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
+3 -1
View File
@@ -322,7 +322,9 @@ public sealed record StagedCallDto(
bool SuggestAllowSimilar = false,
// #687: the lead that may decide it instead of the human, and until when.
string? WithLead = null,
DateTime? LeadUntil = null);
DateTime? LeadUntil = null,
// #702: the blind vote on it, if one was held.
VoteDto? Vote = null);
/// <summary>#687: How a lead decided the approvals it was given.</summary>
public sealed record LeadApprovalStatsDto(string LeadId, int Routed, int Approved, int Rejected, int Lapsed, double? ApprovalRate);
@@ -22,7 +22,9 @@ public sealed record UnitDto(
// #687: what the lead may approve instead of the human; null: nothing.
UnitApprovalsDto? Approvals = null,
// #693: the lead's spot checks; null: none.
UnitInspectionDto? Inspection = null);
UnitInspectionDto? Inspection = null,
// #702: what the unit puts to a blind vote; null: nothing.
UnitVotesDto? Votes = null);
/// <summary>#698: One version of a unit's doctrine.</summary>
public sealed record DoctrineDto(int Version, string Text, string SetBy, DateTime SetAt, string? RunId, string Reason);
@@ -40,6 +42,20 @@ public sealed record InspectionInputDto(bool Enabled, double? Rate, double? Dens
public sealed record FindingDto(string Kind, string Text);
/// <summary>#702: What a unit puts to a blind vote, and who votes.</summary>
public sealed record UnitVotesDto(IReadOnlyList<string> Operations, IReadOnlyList<string> Voters, int ApproveAt, int RejectAt, int Quorum, double Hours, int PerDay);
public sealed record VotesInputDto(IReadOnlyList<string>? Operations, IReadOnlyList<string>? Voters, int? ApproveAt, int? RejectAt, int? Quorum, double? Hours, int? PerDay, string Reason = "");
/// <summary>#702: One ballot as the human sees it — the voters never see each other's.</summary>
public sealed record BallotDto(string Voter, string? Choice, string Reasoning, string Evidence, DateTime? CastAt, string? RunId);
/// <summary>#702: A vote on a staged call — the tally, every ballot and what the voters' runs cost.</summary>
public sealed record VoteDto(
long Id, long CallId, string Proposer, string Operation, int ApproveAt, int RejectAt, int Quorum,
DateTime Until, DateTime CreatedAt, string Outcome, string OutcomeText, int Yes, int No, int Missing,
IReadOnlyList<BallotDto> Ballots, decimal? CostUsd);
/// <summary>#693: A run picked for the lead, with what the audit log showed and the lead's verdict.</summary>
public sealed record SpotCheckDto(
long Id, string Lead, string AgentId, string RunId, string Why, IReadOnlyList<FindingDto> Findings,
@@ -0,0 +1,18 @@
using ClawdDotNet.Core.Staging;
namespace ClawdDotNet.Web.Contracts;
/// <summary>#702: A vote for the human — with what the voters' runs cost, so a vote can be set against what it prevented.</summary>
public static class VoteMapping
{
public static async Task<VoteDto> ToDtoAsync(VoteRecord vote, Votes votes, CancellationToken ct)
{
var cost = await votes.CostAsync(vote, ct);
return new VoteDto(
vote.Id, vote.CallId, vote.Proposer, vote.Operation, vote.ApproveAt, vote.RejectAt, vote.Quorum,
vote.Until, vote.CreatedAt, vote.Outcome ?? "open", Votes.Describe(vote.Outcome),
vote.YesVotes, vote.NoVotes, vote.Outstanding,
vote.Ballots.Select(b => new BallotDto(b.Voter, b.Yes is null ? null : b.Yes.Value ? "yes" : "no", b.Reasoning, b.Evidence, b.CastAt, b.RunId)).ToList(),
cost);
}
}
+10 -1
View File
@@ -347,9 +347,18 @@ internal static class ApiEndpoints
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var pending = await service.ListPendingAsync(ct);
// #702: the vote beside each call it was held on — the human decides with every ballot in view.
var votes = host.Engine?.Votes is Core.Staging.Votes v
? await v.ForCallsAsync(pending.Select(c => c.Id), ct)
: new Dictionary<long, Core.Staging.VoteRecord>();
var liste = new List<StagedCallDto>(pending.Count);
foreach (var call in pending)
liste.Add(call.ToDto(await service.BisherFreigegebenAsync(call, ct), service.KenntDauerfreigaben));
{
var dto = call.ToDto(await service.BisherFreigegebenAsync(call, ct), service.KenntDauerfreigaben);
if (votes.TryGetValue(call.Id, out var vote))
dto = dto with { Vote = await VoteMapping.ToDtoAsync(vote, (Core.Staging.Votes)host.Engine!.Votes!, ct) };
liste.Add(dto);
}
return Results.Ok(liste);
});
@@ -29,7 +29,8 @@ internal static class OrganisationEndpoints
new UnitDefaultsDto(u.Defaults.Model, u.Defaults.DailyCostUsd, u.Defaults.DailyTokens, u.Defaults.WhenExhausted),
chart.PathOf(u.Id), chart.Depth(u.Id),
u.Approvals is { } ap ? new UnitApprovalsDto(ap.Operations, ap.PerDay, ap.Hours) : null,
u.Inspection is { } ins ? new UnitInspectionDto(ins.Rate, ins.DenseRate, ins.NewDays, ins.PerDay) : null))
u.Inspection is { } ins ? new UnitInspectionDto(ins.Rate, ins.DenseRate, ins.NewDays, ins.PerDay) : null,
u.Votes is { } v ? new UnitVotesDto(v.Operations, v.Voters, v.ApproveAt, v.RejectAt, v.EffectiveQuorum, v.Hours, v.PerDay) : null))
.ToList();
var agents = host.Instance.Agents
@@ -104,6 +105,20 @@ internal static class OrganisationEndpoints
return Results.Ok(new OutcomeDto(true, set.IsEmpty ? $"Doctrine of '{unit.Name}' removed." : $"Doctrine of '{unit.Name}' set (version {set.Version})."));
});
// #702: What the unit puts to a blind vote, and the votes held.
org.MapPut("/units/{unitId}/votes", (AppHost host, string unitId, VotesInputDto input) =>
host.Organisation.SetVotes(unitId, input.Operations, input.Voters, input.ApproveAt, input.RejectAt, input.Quorum, input.Hours, input.PerDay, input.Reason).ToResponse());
org.MapGet("/units/{unitId}/votes", async (AppHost host, string unitId, CancellationToken ct) =>
{
if (host.Engine?.Votes is not Core.Staging.Votes votes)
return Results.Ok(Array.Empty<VoteDto>());
var list = new List<VoteDto>();
foreach (var vote in await votes.RecentAsync(unitId, 20, ct))
list.Add(await VoteMapping.ToDtoAsync(vote, votes, ct));
return Results.Ok(list);
});
// #693: The lead's spot checks — how often, what came of them, and the measures it proposed.
org.MapPut("/units/{unitId}/inspection", (AppHost host, string unitId, InspectionInputDto input) =>
host.Organisation.SetInspection(unitId, input.Enabled, input.Rate, input.DenseRate, input.NewDays, input.PerDay, input.Reason).ToResponse());