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
+18 -2
View File
@@ -653,7 +653,7 @@ public sealed class AppHost : IAsyncDisposable
var (scanner, staging, board) = BuildTaskboard(
instance, taskRepository, engine, tools, stateStore, audit,
stagingRepository, dauerfreigaben, stagingGate, cases, loggerFactory, logger);
stagingRepository, dauerfreigaben, stagingGate, cases, storage, loggerFactory, logger);
var host = new AppHost
{
@@ -732,6 +732,13 @@ public sealed class AppHost : IAsyncDisposable
spotChecks.Start();
host._shutdown.Add(spotChecks.DisposeAsync);
// #702: votes whose time is up are counted without waiting for the next ballot.
if (engine.Votes is Core.Staging.Votes votes)
{
votes.Start();
host._shutdown.Add(votes.DisposeAsync);
}
// #699: costs per unit, and what changed in them.
var costWatch = new Core.Accounting.CostWatch(
storage, usage, () => Core.Organisation.OrgChart.From(instance), () => instance.CostWatch, board,
@@ -838,6 +845,7 @@ public sealed class AppHost : IAsyncDisposable
SqliteDauerfreigabeRepository dauerfreigaben,
StagingGate stagingGate,
Core.Cases.ICaseRepository cases,
SqliteStorage storage,
ILoggerFactory loggerFactory,
ILogger logger)
{
@@ -858,9 +866,17 @@ public sealed class AppHost : IAsyncDisposable
stagingRepository, board, () => engine.OrganisationProvider?.Invoke(),
agent => Core.Organisation.ReportingLine.TrustOf(agent, tools.Get),
loggerFactory.CreateLogger("ClawdDotNet.Core.Staging.Leads")) { Service = staging };
stagingGate.Route = leadApprovals.RouteAsync;
engine.LeadApprovals = leadApprovals;
// #702: What a unit puts to a blind vote goes there first; otherwise to the lead.
var votes = new Votes(
storage, stagingRepository, board, () => engine.OrganisationProvider?.Invoke(),
agent => Core.Organisation.ReportingLine.TrustOf(agent, tools.Get),
loggerFactory.CreateLogger("ClawdDotNet.Core.Staging.Votes")) { Service = staging, Audit = audit };
stagingGate.Route = async (call, ct) => await votes.RouteAsync(call, ct) ?? await leadApprovals.RouteAsync(call, ct);
engine.Votes = votes;
var dispatcher = new EngineTaskDispatcher(
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory,
tasks: taskRepository);
@@ -271,6 +271,78 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
return AdminResult.Success(trimmed.Count == 0 ? message : message + $" Lowered with it: {string.Join(", ", trimmed)}.");
}
// ─── votes (#702) ───
/// <summary>
/// #702: What a unit puts to a blind vote, and who votes. Only a human sets it. Changes to
/// agents and budgets are never put to a vote; the thresholds must leave room for a human
/// between approval and rejection. <c>null</c> or no operations takes the vote away.
/// </summary>
public AdminResult SetVotes(string unitId, IReadOnlyCollection<string>? operations, IReadOnlyCollection<string>? voters,
int? approveAt = null, int? rejectAt = null, int? quorum = null, double? hours = null, int? perDay = null, string reason = "")
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
var ops = (operations ?? []).Select(o => o.Trim()).Where(o => o.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (ops.Count == 0)
{
unit.Votes = null;
Save(null, "unit_votes", new { unitId, operations = ops }, reason);
return AdminResult.Success($"'{unit.Name}' puts nothing to a vote now.");
}
foreach (var op in ops)
{
var dot = op.IndexOf('.');
var tool = dot < 0 ? op : op[..dot];
var operation = dot < 0 ? null : op[(dot + 1)..];
if (tool.Length == 0 || (dot >= 0 && string.IsNullOrWhiteSpace(operation)))
return AdminResult.Failure($"'{op}' is no operation — write Tool.operation, or Tool for all of its operations.");
if (FreigabeRegeln.IstImmerFreigabe(tool, operation)
|| (operation is null && FreigabeRegeln.ImmerFreigabe.Any(e => e.StartsWith(tool + ".", StringComparison.OrdinalIgnoreCase))))
return AdminResult.Failure($"'{op}' changes agents or budgets — that always stays with a human.");
}
var people = (voters ?? []).Select(v => v.Trim().TrimStart('@')).Where(v => v.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var unknown = people.Where(v => host.AgentAdmin.Find(v) is null).ToList();
if (unknown.Count > 0)
return AdminResult.Failure($"No such agent: {string.Join(", ", unknown)}.");
if (people.Count < 2)
return AdminResult.Failure("A vote needs at least two voters — one is a lead's approval.");
var current = unit.Votes ?? new UnitVotes();
var rule = new UnitVotes
{
Operations = ops,
Voters = people,
ApproveAt = approveAt ?? Math.Min(current.ApproveAt, people.Count),
RejectAt = rejectAt ?? current.RejectAt,
Quorum = quorum ?? current.Quorum,
Hours = hours ?? current.Hours,
PerDay = perDay ?? current.PerDay
};
if (rule.ApproveAt < 1 || rule.ApproveAt > people.Count)
return AdminResult.Failure($"Approval needs between 1 and {people.Count} yes votes.");
if (rule.RejectAt < 0 || rule.RejectAt >= rule.ApproveAt)
return AdminResult.Failure("Rejection must lie below approval — between them a human decides.");
if (rule.Quorum < 0 || rule.Quorum > people.Count)
return AdminResult.Failure($"The quorum lies between 0 (as many as approve) and {people.Count}.");
if (rule.Hours <= 0)
return AdminResult.Failure("The voters' time comes in hours, above zero.");
if (rule.PerDay < 0)
return AdminResult.Failure("The daily cap cannot be negative.");
unit.Votes = rule;
Save(null, "unit_votes", new { unitId, operations = ops, voters = people, rule.ApproveAt, rule.RejectAt, quorum = rule.EffectiveQuorum, rule.Hours, rule.PerDay }, reason);
var foreign = people.Where(v => host.AgentAdmin.Find(v) is { } a && Core.Organisation.ReportingLine.TrustOf(a, host.Tools.Get).ReadsForeignContent).ToList();
var message = $"'{unit.Name}' puts {string.Join(", ", ops)} to a vote of {people.Count}: approved from {rule.ApproveAt} yes, rejected at {rule.RejectAt} or fewer, a human in between; at least {rule.EffectiveQuorum} votes.";
return AdminResult.Success(foreign.Count == 0
? message
: message + $" {string.Join(", ", foreign)} read(s) content from outside and get(s) no ballot while that holds.");
}
// ─── spot checks (#693) ───
/// <summary>
@@ -932,7 +1004,7 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
{
Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId,
Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults, Approvals = u.Approvals,
Inspection = u.Inspection
Inspection = u.Inspection, Votes = u.Votes
};
private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);