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
+54
View File
@@ -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 =>
{
+459
View File
@@ -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; }