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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user