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:
co-authored by
Claude Opus 5.5
parent
d834e5ac38
commit
a5dd503d9b
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user