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
@@ -63,6 +63,60 @@ public sealed class UnitConfig
|
||||
/// </summary>
|
||||
[JsonPropertyName("inspection")]
|
||||
public UnitInspection? Inspection { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// #702: Operations that are put to a blind vote instead of the lead; <c>null</c> means none.
|
||||
/// Set only by a human — a vote is a deliberate loosening, not a default.
|
||||
/// </summary>
|
||||
[JsonPropertyName("votes")]
|
||||
public UnitVotes? Votes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #702: A vote — several agents check independently and vote blind before something happens.
|
||||
/// Not opinions are counted but independent checks enforced: every voter looks at its own data.
|
||||
/// </summary>
|
||||
public sealed class UnitVotes
|
||||
{
|
||||
/// <summary>The operations put to the vote: <c>Tool.operation</c>, or <c>Tool</c> for all of it.</summary>
|
||||
[JsonPropertyName("operations")]
|
||||
public List<string> Operations { get; set; } = [];
|
||||
|
||||
/// <summary>The agents that vote. Whoever proposes the call does not vote on it.</summary>
|
||||
[JsonPropertyName("voters")]
|
||||
public List<string> Voters { get; set; } = [];
|
||||
|
||||
/// <summary>Yes votes from which the call is approved.</summary>
|
||||
[JsonPropertyName("approveAt")]
|
||||
public int ApproveAt { get; set; } = 3;
|
||||
|
||||
/// <summary>At this many yes votes or fewer, the call is rejected. Between the two, a human decides.</summary>
|
||||
[JsonPropertyName("rejectAt")]
|
||||
public int RejectAt { get; set; } = 1;
|
||||
|
||||
/// <summary>Votes that must be cast for any result; 0 means <see cref="ApproveAt"/>. A missing vote is no yes.</summary>
|
||||
[JsonPropertyName("quorum")]
|
||||
public int Quorum { get; set; }
|
||||
|
||||
/// <summary>How long the voters have; after that, what was cast is counted.</summary>
|
||||
[JsonPropertyName("hours")]
|
||||
public double Hours { get; set; } = 4;
|
||||
|
||||
/// <summary>Votes a day for the unit — every vote costs a run per voter; beyond, the lead or a human decides.</summary>
|
||||
[JsonPropertyName("perDay")]
|
||||
public int PerDay { get; set; } = 10;
|
||||
|
||||
[JsonIgnore]
|
||||
public TimeSpan Window => TimeSpan.FromHours(Math.Clamp(Hours, 0.25, 72));
|
||||
|
||||
[JsonIgnore]
|
||||
public int EffectiveQuorum => Quorum > 0 ? Quorum : ApproveAt;
|
||||
|
||||
/// <summary>Whether the vote covers the operation.</summary>
|
||||
public bool Covers(string tool, string? operation)
|
||||
=> Operations.Any(o => string.Equals(o, tool, StringComparison.OrdinalIgnoreCase)
|
||||
|| (!string.IsNullOrWhiteSpace(operation)
|
||||
&& string.Equals(o, $"{tool}.{operation}", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -63,6 +63,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
/// <summary>#693: The leads' spot checks; set by the host.</summary>
|
||||
public Inspection.ISpotChecks? SpotChecks { get; set; }
|
||||
|
||||
/// <summary>#702: Blind votes on staged calls; set by the host.</summary>
|
||||
public Staging.IVotes? Votes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// #717: Special budgets. A run on a task in a funded tree draws on the pot instead of the
|
||||
/// daily limits; without this, every run counts against the day.
|
||||
@@ -1878,6 +1881,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
Approvals = LeadApprovals,
|
||||
Doctrines = Doctrines,
|
||||
SpotChecks = SpotChecks,
|
||||
Votes = Votes,
|
||||
SpecialBudgets = SpecialBudgets,
|
||||
Sandbox = Sandbox,
|
||||
Files = Files,
|
||||
|
||||
@@ -179,6 +179,7 @@ public sealed class LeadApprovals(
|
||||
{
|
||||
var now = Now;
|
||||
return (await repo.ListRoutedSinceAsync(now.AddDays(-Math.Max(1, days)), ct))
|
||||
.Where(c => !c.Approver!.StartsWith(Votes.DecidedByPrefix, StringComparison.Ordinal))
|
||||
.GroupBy(c => c.Approver!, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Staging;
|
||||
|
||||
/// <summary>One voter's ballot. <see cref="Yes"/> is <c>null</c> while it is not cast.</summary>
|
||||
public sealed record Ballot(long VoteId, string Voter, string? TaskId, bool? Yes, string Reasoning, string Evidence, string? RunId, DateTime? CastAt)
|
||||
{
|
||||
public bool Cast => Yes is not null;
|
||||
}
|
||||
|
||||
/// <summary>#702: A vote on one staged call, with every ballot and what came of it.</summary>
|
||||
public sealed record VoteRecord(
|
||||
long Id, long CallId, string UnitId, string Proposer, string Operation,
|
||||
int ApproveAt, int RejectAt, int Quorum, DateTime Until, DateTime CreatedAt,
|
||||
string? Outcome, DateTime? DecidedAt, IReadOnlyList<Ballot> Ballots)
|
||||
{
|
||||
public int YesVotes => Ballots.Count(b => b.Yes == true);
|
||||
public int NoVotes => Ballots.Count(b => b.Yes == false);
|
||||
public int Outstanding => Ballots.Count(b => !b.Cast);
|
||||
public bool Open => Outcome is null;
|
||||
}
|
||||
|
||||
/// <summary>The voter's side, as the Escalation tool sees it.</summary>
|
||||
public interface IVotes
|
||||
{
|
||||
/// <summary>The votes waiting for this voter — the call only, never another ballot.</summary>
|
||||
Task<IReadOnlyList<(VoteRecord Vote, StagedCall Call)>> BallotsForAsync(string voter, CancellationToken ct);
|
||||
|
||||
Task<LeadDecision> CastAsync(long voteId, string voter, AgentTrust trust, bool yes, string reasoning, string evidence, string? runId, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #702: Several agents check independently and vote blind before something happens
|
||||
/// (Organisation-Konzept, section 8).
|
||||
///
|
||||
/// <para><b>Not opinions, independent checks.</b> Every voter gets its own task with the call
|
||||
/// and nothing else; none sees another ballot before casting its own. Otherwise a cheap model
|
||||
/// agrees with what it just read, and four checks become one check and three echoes. That is
|
||||
/// the opposite of the meeting (#686), where every contribution is visible on purpose.</para>
|
||||
///
|
||||
/// <para><b>Rules, not the model, decide the count.</b> Approved from <c>approveAt</c> yes
|
||||
/// votes, rejected at <c>rejectAt</c> or fewer, a human in between. A missing vote is no yes:
|
||||
/// without the quorum, a human decides. Whoever proposes the call does not vote, and a voter
|
||||
/// that reads content from outside gets no ballot (#715) — its vote would be the most
|
||||
/// rewarding target an injected text can have.</para>
|
||||
///
|
||||
/// <para><b>The human stays above it</b> — it sees the call and can decide it first, and what
|
||||
/// changes agents or budgets is never put to a vote (<see cref="FreigabeRegeln.IstImmerFreigabe"/>).
|
||||
/// Every ballot is kept with reasoning, evidence and run, so the costs of a vote can be set
|
||||
/// against what it prevented.</para>
|
||||
/// </summary>
|
||||
public sealed class Votes(
|
||||
SqliteStorage storage,
|
||||
IStagingRepository repo,
|
||||
TaskboardService board,
|
||||
Func<OrgChart?> organisation,
|
||||
Func<AgentConfig, AgentTrust> trustOf,
|
||||
ILogger logger,
|
||||
TimeProvider? clock = null) : IVotes, IAsyncDisposable
|
||||
{
|
||||
public const string DecidedByPrefix = "vote:";
|
||||
|
||||
public static readonly TimeSpan Interval = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _loop;
|
||||
|
||||
/// <summary>Set once the service exists — it needs the gate, the gate needs this.</summary>
|
||||
public StagingService? Service { get; set; }
|
||||
|
||||
/// <summary>Where the voters' runs are recorded — for what a vote cost.</summary>
|
||||
public Audit.IAuditRepository? Audit { get; init; }
|
||||
|
||||
/// <summary>What the voters' runs cost so far; <c>null</c> while no cost is known.</summary>
|
||||
public async Task<decimal?> CostAsync(VoteRecord vote, CancellationToken ct)
|
||||
{
|
||||
decimal? cost = null;
|
||||
if (Audit is null)
|
||||
return null;
|
||||
foreach (var runId in vote.Ballots.Select(b => b.RunId).OfType<string>())
|
||||
if (await Audit.GetReceiptForRunAsync(runId, ct) is { CostIsKnown: true } receipt)
|
||||
cost = (cost ?? 0) + receipt.CostUsd;
|
||||
return cost;
|
||||
}
|
||||
|
||||
private DateTime Now => _clock.GetUtcNow().UtcDateTime;
|
||||
|
||||
/// <summary>
|
||||
/// The vote this call goes to — the nearest unit of the proposer, upwards, whose rule covers
|
||||
/// the operation — with the agents that may vote on it, or <c>null</c>.
|
||||
/// </summary>
|
||||
public static (UnitConfig Unit, UnitVotes Rule, IReadOnlyList<string> Voters)? RuleFor(
|
||||
OrgChart? org, string proposer, string tool, string? operation, Func<AgentConfig, AgentTrust> trustOf)
|
||||
{
|
||||
if (org is not { HasUnits: true } || FreigabeRegeln.IstImmerFreigabe(tool, operation) || org.UnitOf(proposer) is not { } unit)
|
||||
return null;
|
||||
|
||||
foreach (var candidate in new[] { unit }.Concat(org.Ancestors(unit.Id)))
|
||||
{
|
||||
if (candidate.Votes is not { } rule || !rule.Covers(tool, operation))
|
||||
continue;
|
||||
var voters = rule.Voters
|
||||
.Where(v => !string.Equals(v, proposer, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(org.Agent).OfType<AgentConfig>()
|
||||
.Where(a => !trustOf(a).ReadsForeignContent)
|
||||
.Select(a => a.AgentId)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
// A vote that cannot reach its quorum is no vote — the lead or a human decides.
|
||||
return voters.Count >= rule.EffectiveQuorum && voters.Count >= rule.ApproveAt ? (candidate, rule, voters) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts a freshly staged call to the vote, if a rule covers it and the unit has room today.
|
||||
/// Returns the line the proposing agent reads, or <c>null</c>.
|
||||
/// </summary>
|
||||
public async Task<string?> RouteAsync(StagedCall call, CancellationToken ct)
|
||||
{
|
||||
if (RuleFor(organisation(), call.AgentId, call.Tool, call.Action, trustOf) is not var (unit, rule, voters))
|
||||
return null;
|
||||
|
||||
if (await CountSinceAsync(unit.Id, Now.AddDays(-1), ct) >= Math.Max(0, rule.PerDay))
|
||||
{
|
||||
logger.LogInformation("Approval #{Id}: '{Unit}' has held its {Cap} votes for today — not put to a vote", call.Id, unit.Id, rule.PerDay);
|
||||
return null;
|
||||
}
|
||||
|
||||
var until = Now + rule.Window;
|
||||
var label = Label(call);
|
||||
var voteId = await storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Votes (CallId, UnitId, Proposer, Operation, ApproveAt, RejectAt, Quorum, Until, CreatedAt)
|
||||
VALUES (@call, @unit, @proposer, @op, @approve, @reject, @quorum, @until, @at)
|
||||
RETURNING Id
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@call", call.Id);
|
||||
cmd.Parameters.AddWithValue("@unit", unit.Id);
|
||||
cmd.Parameters.AddWithValue("@proposer", call.AgentId);
|
||||
cmd.Parameters.AddWithValue("@op", label);
|
||||
cmd.Parameters.AddWithValue("@approve", rule.ApproveAt);
|
||||
cmd.Parameters.AddWithValue("@reject", rule.RejectAt);
|
||||
cmd.Parameters.AddWithValue("@quorum", rule.EffectiveQuorum);
|
||||
cmd.Parameters.AddWithValue("@until", until.ToString("O"));
|
||||
cmd.Parameters.AddWithValue("@at", Now.ToString("O"));
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
|
||||
}, ct);
|
||||
|
||||
foreach (var voter in voters)
|
||||
{
|
||||
var task = await board.CreateAsync(new TaskItem
|
||||
{
|
||||
Title = $"Vote #{voteId}: {label} from {call.AgentId}",
|
||||
Body = Request(voteId, call, until),
|
||||
Assignee = "@" + voter,
|
||||
Priority = 4,
|
||||
Acceptance = "Your vote is cast with reasoning and evidence from your own data.",
|
||||
OriginRunId = call.RunId
|
||||
}, ct);
|
||||
await storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "INSERT INTO VoteBallots (VoteId, Voter, TaskId) VALUES (@vote, @voter, @task)";
|
||||
cmd.Parameters.AddWithValue("@vote", voteId);
|
||||
cmd.Parameters.AddWithValue("@voter", voter);
|
||||
cmd.Parameters.AddWithValue("@task", task.Id);
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
// The call waits for the vote, not for the human — the human can still decide it first.
|
||||
await repo.SetApproverAsync(call.Id, DecidedByPrefix + voteId, until, "", ct);
|
||||
logger.LogInformation("Approval #{Id} ({Op}) of {Agent} put to vote #{Vote}: {Voters}", call.Id, label, call.AgentId, voteId, string.Join(", ", voters));
|
||||
return $"{voters.Count} agents check it independently and vote (vote #{voteId}, until {until:yyyy-MM-dd HH:mm} UTC); without a clear result a human decides.";
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<(VoteRecord Vote, StagedCall Call)>> BallotsForAsync(string voter, CancellationToken ct)
|
||||
{
|
||||
var result = new List<(VoteRecord, StagedCall)>();
|
||||
foreach (var vote in await QueryAsync("Outcome IS NULL AND Id IN (SELECT VoteId FROM VoteBallots WHERE Voter = @p AND Yes IS NULL)", voter, ct))
|
||||
if (await repo.GetAsync(vote.CallId, ct) is { Status: StagingStatus.Pending } call)
|
||||
result.Add((vote, call));
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<LeadDecision> CastAsync(long voteId, string voter, AgentTrust trust, bool yes, string reasoning, string evidence, string? runId, CancellationToken ct)
|
||||
{
|
||||
if (await GetAsync(voteId, ct) is not { } vote)
|
||||
return LeadDecision.Fail($"There is no vote #{voteId}.");
|
||||
var ballot = vote.Ballots.FirstOrDefault(b => string.Equals(b.Voter, voter, StringComparison.OrdinalIgnoreCase));
|
||||
if (ballot is null)
|
||||
return LeadDecision.Fail($"You do not vote on #{voteId}.");
|
||||
if (ballot.Cast)
|
||||
return LeadDecision.Fail($"You voted on #{voteId} already — a vote is not changed.");
|
||||
if (!vote.Open)
|
||||
return LeadDecision.Fail($"Vote #{voteId} is closed ({vote.Outcome}).");
|
||||
if (trust.ReadsForeignContent)
|
||||
return LeadDecision.Fail("You read content from outside, so you do not vote — your ballot stays empty.");
|
||||
if (string.IsNullOrWhiteSpace(reasoning))
|
||||
return LeadDecision.Fail("'reasoning' is required — why yes or no.");
|
||||
if (string.IsNullOrWhiteSpace(evidence))
|
||||
return LeadDecision.Fail("'evidence' is required — what you checked, with source and time. Without it a vote is an opinion.");
|
||||
|
||||
var cast = await storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE VoteBallots SET Yes = @yes, Reasoning = @reasoning, Evidence = @evidence, RunId = @run, CastAt = @at
|
||||
WHERE VoteId = @vote AND Voter = @voter AND Yes IS NULL
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@yes", yes ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@reasoning", reasoning.Trim());
|
||||
cmd.Parameters.AddWithValue("@evidence", evidence.Trim());
|
||||
cmd.Parameters.AddWithValue("@run", (object?)runId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@at", Now.ToString("O"));
|
||||
cmd.Parameters.AddWithValue("@vote", voteId);
|
||||
cmd.Parameters.AddWithValue("@voter", ballot.Voter);
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
if (cast == 0)
|
||||
return LeadDecision.Fail($"You voted on #{voteId} already.");
|
||||
|
||||
await CloseTaskAsync(ballot.TaskId, TaskItemStatus.Done, null, ct);
|
||||
var outcome = await ConcludeAsync(voteId, ct);
|
||||
return new LeadDecision(true, outcome is null
|
||||
? $"Your vote on #{voteId} is cast. You will not see the others' votes."
|
||||
: $"Your vote on #{voteId} is cast. The vote is concluded: {Describe(outcome)}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the vote if its result is settled — early when the rest can no longer change it,
|
||||
/// otherwise once every ballot is in or the time is up. Returns the outcome, or <c>null</c>
|
||||
/// while it is open.
|
||||
/// </summary>
|
||||
public async Task<string?> ConcludeAsync(long voteId, CancellationToken ct)
|
||||
{
|
||||
if (await GetAsync(voteId, ct) is not { Open: true } vote)
|
||||
return null;
|
||||
|
||||
var call = await repo.GetAsync(vote.CallId, ct);
|
||||
string? outcome;
|
||||
if (call is not { Status: StagingStatus.Pending })
|
||||
outcome = "superseded";
|
||||
else
|
||||
{
|
||||
var cast = vote.YesVotes + vote.NoVotes;
|
||||
var over = vote.Outstanding == 0 || Now >= vote.Until;
|
||||
if (vote.YesVotes >= vote.ApproveAt && cast >= vote.Quorum)
|
||||
outcome = "approved";
|
||||
else if (cast >= vote.Quorum && vote.YesVotes + vote.Outstanding <= vote.RejectAt)
|
||||
outcome = "rejected";
|
||||
else if (!over)
|
||||
outcome = null;
|
||||
else
|
||||
outcome = cast < vote.Quorum ? "human:quorum" : "human:undecided";
|
||||
}
|
||||
if (outcome is null)
|
||||
return null;
|
||||
|
||||
// Only one pass concludes a vote, however many ballots arrive at once.
|
||||
var won = await storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE Votes SET Outcome = @o, DecidedAt = @at WHERE Id = @id AND Outcome IS NULL";
|
||||
cmd.Parameters.AddWithValue("@o", outcome);
|
||||
cmd.Parameters.AddWithValue("@at", Now.ToString("O"));
|
||||
cmd.Parameters.AddWithValue("@id", voteId);
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
if (won == 0)
|
||||
return null;
|
||||
|
||||
foreach (var open in vote.Ballots.Where(b => !b.Cast))
|
||||
await CloseTaskAsync(open.TaskId, TaskItemStatus.Canceled, $"Vote #{voteId} is concluded ({outcome}) — your vote is no longer needed.", ct);
|
||||
|
||||
var decidedBy = DecidedByPrefix + voteId;
|
||||
switch (outcome)
|
||||
{
|
||||
case "approved" when Service is { } service:
|
||||
await service.ApproveAsync(vote.CallId, decidedBy, ct);
|
||||
break;
|
||||
case "rejected" when Service is { } service:
|
||||
await service.RejectAsync(vote.CallId, decidedBy, Reasons(vote), ct);
|
||||
break;
|
||||
case "human:quorum" or "human:undecided" when call is not null:
|
||||
// The window ends now: the call is the human's, with every ballot beside it.
|
||||
await repo.SetApproverAsync(vote.CallId, decidedBy, Now, "", ct);
|
||||
break;
|
||||
}
|
||||
|
||||
logger.LogInformation("Vote #{Vote} on approval #{Call}: {Outcome} ({Yes} yes, {No} no, {Missing} missing)",
|
||||
voteId, vote.CallId, outcome, vote.YesVotes, vote.NoVotes, vote.Outstanding);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/// <summary>Concludes what is due: votes whose time is up or whose call a human decided meanwhile.</summary>
|
||||
public async Task<int> SweepAsync(CancellationToken ct)
|
||||
{
|
||||
var concluded = 0;
|
||||
foreach (var vote in await QueryAsync("Outcome IS NULL", "", ct))
|
||||
if (await ConcludeAsync(vote.Id, ct) is not null)
|
||||
concluded++;
|
||||
return concluded;
|
||||
}
|
||||
|
||||
public async Task<VoteRecord?> GetAsync(long voteId, CancellationToken ct)
|
||||
=> (await QueryAsync("Id = @p", voteId.ToString(CultureInfo.InvariantCulture), ct)).FirstOrDefault();
|
||||
|
||||
/// <summary>The vote of each call — for the human's view of the approvals.</summary>
|
||||
public async Task<IReadOnlyDictionary<long, VoteRecord>> ForCallsAsync(IEnumerable<long> callIds, CancellationToken ct)
|
||||
{
|
||||
var result = new Dictionary<long, VoteRecord>();
|
||||
foreach (var id in callIds.Distinct())
|
||||
if ((await QueryAsync("CallId = @p", id.ToString(CultureInfo.InvariantCulture), ct)).FirstOrDefault() is { } vote)
|
||||
result[id] = vote;
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<VoteRecord>> RecentAsync(string unitId, int limit, CancellationToken ct)
|
||||
=> QueryAsync("UnitId = @p", unitId, ct, limit);
|
||||
|
||||
public static string Describe(string? outcome) => outcome switch
|
||||
{
|
||||
"approved" => "approved",
|
||||
"rejected" => "rejected",
|
||||
"human:quorum" => "too few votes — a human decides",
|
||||
"human:undecided" => "no clear result — a human decides",
|
||||
"superseded" => "decided by a human before the vote ended",
|
||||
_ => "open"
|
||||
};
|
||||
|
||||
private static string Reasons(VoteRecord vote)
|
||||
=> "Rejected by vote: " + string.Join(" | ", vote.Ballots.Where(b => b.Yes == false).Select(b => $"{b.Voter}: {b.Reasoning} ({b.Evidence})"));
|
||||
|
||||
private static string Request(long voteId, StagedCall call, DateTime until)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"{call.AgentId} proposes {Label(call)}. Before it runs, several agents check it independently — you are one of them (vote #{voteId}, until {until:yyyy-MM-dd HH:mm} UTC).");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("The call, exactly as it would run:");
|
||||
sb.AppendLine(Engine.UntrustedContent.Rahmen(call.Tool, string.IsNullOrWhiteSpace(call.ArgumentsJson) ? call.Proposal : call.ArgumentsJson));
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Check it against your own data and your own area — not against what the proposer says about it. " +
|
||||
"You do not see the other votes, and they do not see yours: that is the point.");
|
||||
sb.AppendLine($"Then use the Escalation tool: cast_vote (vote {voteId}, choice yes or no, reasoning, evidence — what you checked, with source and time). " +
|
||||
"A vote without evidence is an opinion. Not voting is no yes.");
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string Label(StagedCall call)
|
||||
=> string.IsNullOrWhiteSpace(call.Action) ? call.Tool : $"{call.Tool}.{call.Action}";
|
||||
|
||||
private async Task CloseTaskAsync(string? taskId, TaskItemStatus status, string? note, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(taskId))
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (await board.GetAsync(taskId, ct) is { Status: not (TaskItemStatus.Done or TaskItemStatus.Canceled or TaskItemStatus.Archived) })
|
||||
{
|
||||
if (note is not null)
|
||||
await board.AddCommentAsync(taskId, "system", note, ct);
|
||||
await board.UpdateAsync(taskId, t => t with { Status = status }, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "The voter's task {Task} could not be closed", taskId);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── storage ───
|
||||
|
||||
private async Task<int> CountSinceAsync(string unitId, DateTime since, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM Votes WHERE UnitId = @unit AND CreatedAt >= @since";
|
||||
cmd.Parameters.AddWithValue("@unit", unitId);
|
||||
cmd.Parameters.AddWithValue("@since", since.ToString("O"));
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<VoteRecord>> QueryAsync(string where, string value, CancellationToken ct, int limit = 200)
|
||||
{
|
||||
await using var conn = await storage.OpenConnectionAsync(ct);
|
||||
var votes = new List<VoteRecord>();
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"SELECT Id, CallId, UnitId, Proposer, Operation, ApproveAt, RejectAt, Quorum, Until, CreatedAt, Outcome, DecidedAt FROM Votes WHERE {where} ORDER BY Id DESC LIMIT @limit";
|
||||
cmd.Parameters.AddWithValue("@p", value);
|
||||
cmd.Parameters.AddWithValue("@limit", limit);
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await r.ReadAsync(ct))
|
||||
votes.Add(new VoteRecord(r.GetInt64(0), r.GetInt64(1), r.GetString(2), r.GetString(3), r.GetString(4),
|
||||
r.GetInt32(5), r.GetInt32(6), r.GetInt32(7), Time(r, 8)!.Value, Time(r, 9)!.Value,
|
||||
r.IsDBNull(10) ? null : r.GetString(10), Time(r, 11), []));
|
||||
}
|
||||
|
||||
for (var i = 0; i < votes.Count; i++)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT VoteId, Voter, TaskId, Yes, Reasoning, Evidence, RunId, CastAt FROM VoteBallots WHERE VoteId = @vote ORDER BY Voter";
|
||||
cmd.Parameters.AddWithValue("@vote", votes[i].Id);
|
||||
var ballots = new List<Ballot>();
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await r.ReadAsync(ct))
|
||||
ballots.Add(new Ballot(r.GetInt64(0), r.GetString(1), r.IsDBNull(2) ? null : r.GetString(2),
|
||||
r.IsDBNull(3) ? null : r.GetInt64(3) == 1, r.GetString(4), r.GetString(5),
|
||||
r.IsDBNull(6) ? null : r.GetString(6), Time(r, 7)));
|
||||
votes[i] = votes[i] with { Ballots = ballots };
|
||||
}
|
||||
return votes;
|
||||
}
|
||||
|
||||
private static DateTime? Time(SqliteDataReader r, int i)
|
||||
=> r.IsDBNull(i) ? null : DateTime.Parse(r.GetString(i), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
|
||||
// ─── loop ───
|
||||
|
||||
public void Start() => _loop ??= RunAsync(_cts.Token);
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(Interval, ct); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
try { await SweepAsync(ct); }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "Counting the votes failed — next attempt in {Minutes} min", Interval.TotalMinutes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
if (_loop is not null)
|
||||
{
|
||||
try { await _loop; }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,37 @@ public sealed class SqliteStorage
|
||||
TaskId TEXT NULL
|
||||
);
|
||||
|
||||
-- #702: blind votes on staged calls, one ballot per voter with reasoning, evidence and run.
|
||||
CREATE TABLE IF NOT EXISTS Votes (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
CallId INTEGER NOT NULL,
|
||||
UnitId TEXT NOT NULL,
|
||||
Proposer TEXT NOT NULL,
|
||||
Operation TEXT NOT NULL,
|
||||
ApproveAt INTEGER NOT NULL,
|
||||
RejectAt INTEGER NOT NULL,
|
||||
Quorum INTEGER NOT NULL,
|
||||
Until TEXT NOT NULL,
|
||||
CreatedAt TEXT NOT NULL,
|
||||
Outcome TEXT NULL,
|
||||
DecidedAt TEXT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_Votes_Call ON Votes (CallId);
|
||||
CREATE INDEX IF NOT EXISTS IX_Votes_Open ON Votes (Outcome);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS VoteBallots (
|
||||
VoteId INTEGER NOT NULL,
|
||||
Voter TEXT NOT NULL,
|
||||
TaskId TEXT NULL,
|
||||
Yes INTEGER NULL,
|
||||
Reasoning TEXT NOT NULL DEFAULT '',
|
||||
Evidence TEXT NOT NULL DEFAULT '',
|
||||
RunId TEXT NULL,
|
||||
CastAt TEXT NULL,
|
||||
PRIMARY KEY (VoteId, Voter)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS StagedCalls (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
RunId TEXT NOT NULL,
|
||||
|
||||
@@ -58,6 +58,9 @@ public sealed record AgentToolContext(
|
||||
/// <summary>#693: The spot checks of a lead. <c>null</c> outside the engine.</summary>
|
||||
public Inspection.ISpotChecks? SpotChecks { get; init; }
|
||||
|
||||
/// <summary>#702: The ballots of a voter. <c>null</c> outside the engine.</summary>
|
||||
public Staging.IVotes? Votes { get; init; }
|
||||
|
||||
/// <summary>#654: Where code runs — never in the main process. Unavailable under Windows.</summary>
|
||||
public Sandbox.ISandboxRunner? Sandbox { get; init; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user