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
@@ -308,6 +308,28 @@ public sealed class OrgAdministrationTests : IDisposable
Unit("marketing").Inspection.ShouldBeNull();
}
// ═══════════════════ votes (#702) ═══════════════════
[Fact]
public void A_human_puts_operations_to_a_vote_with_room_for_a_human_between_the_thresholds()
{
BuildMarketing();
Org.SetVotes("marketing", ["AgentEditor.update_soul"], ["ads", "listing"]).Message.ShouldContain("always stays with a human");
Org.SetVotes("marketing", ["Database.insert"], ["ads"]).Ok.ShouldBeFalse("one voter is a lead's approval");
Org.SetVotes("marketing", ["Database.insert"], ["ads", "nobody"]).Message.ShouldContain("nobody");
Org.SetVotes("marketing", ["Database.insert"], ["ads", "listing", "specialist"], approveAt: 2, rejectAt: 2).Ok
.ShouldBeFalse("rejection must lie below approval");
var set = Org.SetVotes("marketing", ["Database.insert"], ["ads", "listing", "specialist"], approveAt: 2, rejectAt: 0, reason: "orders");
set.Ok.ShouldBeTrue(set.Message);
Unit("marketing").Votes!.Voters.ShouldBe(["ads", "listing", "specialist"]);
Unit("marketing").Votes!.EffectiveQuorum.ShouldBe(2);
Org.SetVotes("marketing", [], null).Ok.ShouldBeTrue();
Unit("marketing").Votes.ShouldBeNull();
}
// ═══════════════════ the top (#690) ═══════════════════
private void MakeDecider(string id)
@@ -0,0 +1,231 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Staging;
/// <summary>
/// #702: Several agents check independently and vote blind — the rule counts, a missing vote
/// is no yes, and a human decides whatever is not clear.
/// </summary>
public sealed class VotesTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "votes-" + Guid.NewGuid().ToString("N")[..8]);
private readonly SqliteStagingRepository _staging;
private readonly SqliteTaskRepository _tasks;
private readonly TaskboardService _board;
private readonly FakeTimeProvider _clock = new(DateTimeOffset.UtcNow);
private readonly Executor _executor = new();
private readonly StagingService _service;
private readonly Votes _votes;
private readonly HashSet<string> _foreign = new(StringComparer.OrdinalIgnoreCase);
private readonly UnitConfig _purchasing = new()
{
Id = "purchasing", Name = "Purchasing", LeaderAgentId = "buy-lead",
Votes = new UnitVotes
{
Operations = ["Database.insert", "AgentEditor"],
Voters = ["accounting", "logistics", "marketing", "sales", "buyer"],
ApproveAt = 3, RejectAt = 1, Hours = 1, PerDay = 3
}
};
private OrgChart Org => new(
[_purchasing, new UnitConfig { Id = "staff", Name = "Staff" }],
[
new AgentConfig { AgentId = "buy-lead", UnitId = "purchasing" },
new AgentConfig { AgentId = "buyer", UnitId = "purchasing" },
new AgentConfig { AgentId = "accounting", UnitId = "staff" },
new AgentConfig { AgentId = "logistics", UnitId = "staff" },
new AgentConfig { AgentId = "marketing", UnitId = "staff" },
new AgentConfig { AgentId = "sales", UnitId = "staff" }
]);
public VotesTests()
{
var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_staging = new SqliteStagingRepository(storage);
_tasks = new SqliteTaskRepository(storage);
_board = new TaskboardService(_tasks, Path.Combine(_directory, "tasks"));
_service = new StagingService(_staging, _executor, _board, NullLoggerFactory.Instance);
_votes = new Votes(storage, _staging, _board, () => Org,
a => _foreign.Contains(a.AgentId) ? new AgentTrust(TrustTier.ReadsForeignContent, ["WebFetch"]) : AgentTrust.Standard,
NullLogger.Instance, _clock) { Service = _service };
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleaning up is secondary */ }
}
private async Task<(StagedCall Call, string? Line, VoteRecord? Vote)> Stage(string agent = "buyer", string tool = "Database", string action = "insert")
{
var call = new StagedCall { RunId = "run-1", AgentId = agent, Tool = tool, Action = action, ArgumentsJson = $"{{\"action\":\"{action}\",\"amount\":150}}" };
var id = await _staging.AppendAsync(call, default);
var line = await _votes.RouteAsync(call with { Id = id }, default);
var vote = (await _votes.ForCallsAsync([id], default)).GetValueOrDefault(id);
return ((await _staging.GetAsync(id, default))!, line, vote);
}
private Task<LeadDecision> Vote(long vote, string voter, bool yes, string evidence = "Account balance 4,210 EUR, 2026-10-01 08:14")
=> _votes.CastAsync(vote, voter, AgentTrust.Standard, yes, yes ? "Fits." : "Does not fit.", evidence, "run-" + voter, default);
// ═══════════════════ where it goes ═══════════════════
[Fact]
public async Task A_covered_call_goes_to_a_vote_with_one_task_per_voter_and_never_to_the_proposer()
{
var (call, line, vote) = await Stage();
vote.ShouldNotBeNull();
vote.Ballots.Select(b => b.Voter).ShouldBe(["accounting", "logistics", "marketing", "sales"], ignoreOrder: true,
customMessage: "the buyer proposes, so it does not vote");
call.Approver.ShouldBe(Votes.DecidedByPrefix + vote.Id);
call.WithLead(DateTime.UtcNow).ShouldBeTrue("the vote decides first; the human can still step in");
line!.ShouldContain("4 agents");
var ballot = (await _tasks.GetAsync(vote.Ballots[0].TaskId!, default))!;
ballot.Assignee.ShouldBe("@" + vote.Ballots[0].Voter);
ballot.Body.ShouldContain("<untrusted_content", Case.Sensitive);
ballot.Body.ShouldContain("cast_vote");
}
[Fact]
public async Task What_changes_agents_is_never_put_to_a_vote()
=> (await Stage(tool: "AgentEditor", action: "update_soul")).Vote.ShouldBeNull();
[Fact]
public async Task A_voter_that_reads_foreign_content_gets_no_ballot_and_a_vote_without_enough_voters_is_none()
{
_foreign.Add("sales");
(await Stage()).Vote!.Ballots.Select(b => b.Voter).ShouldNotContain("sales");
_foreign.UnionWith(["marketing", "logistics"]);
(await Stage()).Vote.ShouldBeNull("one voter cannot reach three yes votes — the lead or a human decides");
}
[Fact]
public async Task A_unit_holds_no_more_votes_a_day_than_its_cap()
{
for (var i = 0; i < 3; i++)
(await Stage()).Vote.ShouldNotBeNull();
(await Stage()).Vote.ShouldBeNull();
}
// ═══════════════════ blind ═══════════════════
[Fact]
public async Task A_voter_sees_the_call_never_another_ballot_and_votes_once_with_evidence()
{
var vote = (await Stage()).Vote!;
await Vote(vote.Id, "accounting", false);
var open = await _votes.BallotsForAsync("logistics", default);
open.Single().Vote.Id.ShouldBe(vote.Id);
(await _votes.BallotsForAsync("accounting", default)).ShouldBeEmpty("it has voted");
(await Vote(vote.Id, "accounting", true)).Message.ShouldContain("already");
(await Vote(vote.Id, "buy-lead", true)).Ok.ShouldBeFalse("not a voter");
(await Vote(vote.Id, "logistics", true, evidence: " ")).Message.ShouldContain("'evidence'");
(await _votes.CastAsync(vote.Id, "logistics", new AgentTrust(TrustTier.ReadsForeignContent, ["Mail"]), true, "x", "y", null, default))
.Ok.ShouldBeFalse("a voter that reads outside content now does not vote");
}
// ═══════════════════ the count ═══════════════════
[Fact]
public async Task Three_yes_of_four_approve_it_and_the_remaining_ballot_is_called_off()
{
var vote = (await Stage()).Vote!;
await Vote(vote.Id, "accounting", true);
await Vote(vote.Id, "logistics", true);
(await Vote(vote.Id, "marketing", true)).Message.ShouldContain("approved");
var call = (await _staging.GetAsync(vote.CallId, default))!;
call.Status.ShouldBe(StagingStatus.Executed);
call.DecidedBy.ShouldBe(Votes.DecidedByPrefix + vote.Id);
_executor.Calls.ShouldBe(1);
var sales = (await _votes.GetAsync(vote.Id, default))!.Ballots.Single(b => b.Voter == "sales");
(await _tasks.GetAsync(sales.TaskId!, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
}
[Fact]
public async Task A_rejection_is_settled_as_soon_as_the_rest_cannot_change_it()
{
var vote = (await Stage()).Vote!;
await Vote(vote.Id, "accounting", false);
await Vote(vote.Id, "logistics", false);
(await Vote(vote.Id, "marketing", false)).Message.ShouldContain("rejected", customMessage: "even a yes from sales stays at one");
var call = (await _staging.GetAsync(vote.CallId, default))!;
call.Status.ShouldBe(StagingStatus.Rejected);
_executor.Calls.ShouldBe(0);
}
[Fact]
public async Task Two_of_four_go_to_a_human_with_every_ballot()
{
var vote = (await Stage()).Vote!;
await Vote(vote.Id, "accounting", true);
await Vote(vote.Id, "logistics", true);
await Vote(vote.Id, "marketing", false);
(await Vote(vote.Id, "sales", false)).Message.ShouldContain("a human decides");
var call = (await _staging.GetAsync(vote.CallId, default))!;
call.Status.ShouldBe(StagingStatus.Pending);
call.WithLead(DateTime.UtcNow).ShouldBeFalse("it is the human's now");
(await _votes.GetAsync(vote.Id, default))!.Ballots.ShouldAllBe(b => b.Evidence.Length > 0);
}
[Fact]
public async Task A_missing_vote_is_no_yes()
{
var vote = (await Stage()).Vote!;
await Vote(vote.Id, "accounting", true);
await Vote(vote.Id, "logistics", true);
(await _votes.SweepAsync(default)).ShouldBe(0, "there is still time");
_clock.Advance(TimeSpan.FromHours(2));
(await _votes.SweepAsync(default)).ShouldBe(1);
(await _votes.GetAsync(vote.Id, default))!.Outcome.ShouldBe("human:quorum", "two votes are not the three needed — nothing is waved through");
(await _staging.GetAsync(vote.CallId, default))!.Status.ShouldBe(StagingStatus.Pending);
}
[Fact]
public async Task A_human_who_decides_first_ends_the_vote()
{
var vote = (await Stage()).Vote!;
await _service.RejectAsync(vote.CallId, "webui", "Not now.", default);
(await _votes.SweepAsync(default)).ShouldBe(1);
var ended = (await _votes.GetAsync(vote.Id, default))!;
ended.Outcome.ShouldBe("superseded");
foreach (var b in ended.Ballots)
(await _tasks.GetAsync(b.TaskId!, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
(await Vote(vote.Id, "accounting", true)).Ok.ShouldBeFalse();
}
private sealed class Executor : IFrozenCallExecutor
{
public int Calls { get; private set; }
public Task<string> ExecuteApprovedCallAsync(string agentId, string tool, string argumentsJson, string runId, CancellationToken ct)
{
Calls++;
return Task.FromResult("1 row inserted");
}
}
}
@@ -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");
}
}