Abstimmung: mehrere Agenten pruefen unabhaengig und stimmen blind ab (#702)

A unit can put operations to a blind vote instead of its lead. Not opinions
are counted but independent checks enforced.

- UnitConfig.votes: operations, voters, approveAt, rejectAt, quorum, hours,
  perDay - set only by a human (OrgAdministration.SetVotes).
- Core/Staging/Votes: routes a covered staged call before the lead; one task
  per voter with the frozen call and nothing else; cast_vote needs reasoning
  and evidence. Counted as soon as the result is settled; in between and
  without the quorum a human decides. The proposer does not vote, voters that
  read foreign content get no ballot, changes to agents never go to a vote.
- Escalation tool: list_ballots, cast_vote.
- Approvals view shows each vote with every ballot and the voters' costs;
  organisation view sets the rule and lists the votes held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 12:04:33 +02:00
co-authored by Claude Opus 5.5
parent d834e5ac38
commit a5dd503d9b
23 changed files with 1238 additions and 11 deletions
@@ -0,0 +1,79 @@
using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.Taskboard;
using ClawdDotNet.Tools.Tests.FileRW;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.Taskboard;
/// <summary>#702: A voter's side of a vote through the Escalation tool.</summary>
public sealed class VoteToolTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
private readonly SqliteStagingRepository _staging;
private readonly Votes _votes;
private readonly EscalationTool _tool = new();
private readonly OrgChart _org = new(
[
new UnitConfig
{
Id = "purchasing", Name = "Purchasing",
Votes = new UnitVotes { Operations = ["Database.insert"], Voters = ["accounting", "logistics"], ApproveAt = 2, RejectAt = 0 }
}
],
[
new AgentConfig { AgentId = "buyer", UnitId = "purchasing" },
new AgentConfig { AgentId = "accounting", UnitId = "purchasing" },
new AgentConfig { AgentId = "logistics", UnitId = "purchasing" }
]);
public VoteToolTests()
{
var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_staging = new SqliteStagingRepository(storage);
var board = new TaskboardService(new SqliteTaskRepository(storage), Path.Combine(_directory, "tasks"));
_votes = new Votes(storage, _staging, board, () => _org, _ => AgentTrust.Standard, NullLogger.Instance);
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private Task<ToolResult> Run(string agent, object input)
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input),
new AgentToolContext(agent, "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance, CancellationToken.None)
{
Organisation = _org,
Votes = _votes,
RunId = "run-" + agent
}, CancellationToken.None);
[Fact]
public async Task A_voter_lists_its_ballot_and_casts_it_with_evidence()
{
var call = new StagedCall { RunId = "r", AgentId = "buyer", Tool = "Database", Action = "insert", ArgumentsJson = "{\"action\":\"insert\"}" };
var id = await _staging.AppendAsync(call, default);
await _votes.RouteAsync(call with { Id = id }, default);
var vote = (await _votes.ForCallsAsync([id], default))[id];
(await Run("accounting", new { action = "list_ballots" })).Content.ShouldContain($"vote #{vote.Id}");
(await Run("accounting", new { action = "cast_vote", vote = vote.Id, choice = "maybe", reasoning = "x", evidence = "y" }))
.ErrorMessage!.ShouldContain("'choice'");
var cast = await Run("accounting", new { action = "cast_vote", vote = vote.Id, choice = "no", reasoning = "Over budget.", evidence = "Balance 80 EUR, ledger 2026-10-01 08:10" });
cast.Success.ShouldBeTrue(cast.ErrorMessage);
cast.Content.ShouldContain("You will not see the others' votes");
(await _votes.GetAsync(vote.Id, default))!.Ballots.Single(b => b.Voter == "accounting").RunId.ShouldBe("run-accounting");
(await Run("logistics", new { action = "list_ballots" })).Content.ShouldNotContain("Over budget", customMessage: "blind");
}
}