Compare commits
2
Commits
d834e5ac38
...
11465f3d74
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11465f3d74 | ||
|
|
a5dd503d9b |
@@ -1051,6 +1051,20 @@ Agenten einen neuen Gesprächspartner. Die Stimmberechtigten reden nicht miteina
|
||||
sehen den Antrag und antworten. Deshalb steht sie **nicht** in der Ausnahmeliste aus
|
||||
Abschnitt 3.
|
||||
|
||||
> **Umgesetzt (#702).** Die Regel steht je Einheit (`votes`: Operationen, Stimmberechtigte,
|
||||
> angenommen ab, abgelehnt bei höchstens, Quorum, Frist, Obergrenze je Tag) und wird nur vom
|
||||
> Menschen gesetzt. Eine abgedeckte Freigabe geht **vor** der Leitung (#687) an die
|
||||
> Abstimmung: Jeder Stimmberechtigte bekommt eine eigene Aufgabe mit dem eingefrorenen
|
||||
> Aufruf und sonst nichts, stimmt mit `cast_vote` (ja/nein, Begründung, Beleg — beides
|
||||
> Pflicht) und sieht keine andere Stimme. Gezählt wird, sobald das Ergebnis feststeht: früh,
|
||||
> wenn die restlichen Stimmen es nicht mehr ändern können, sonst wenn alle abgestimmt haben
|
||||
> oder die Frist um ist. Dazwischen und ohne Quorum entscheidet der Mensch, mit allen
|
||||
> Stimmen neben dem Aufruf in der Freigabeansicht. Wer beantragt, stimmt nicht mit; wer
|
||||
> fremde Inhalte liest, bekommt keinen Stimmzettel; Änderungen an Agenten und Budgets kommen
|
||||
> nie zur Abstimmung. Erfasst wird jede Stimme mit Lauf, die Kosten der Abstimmenden stehen
|
||||
> bei jeder Abstimmung. Nicht umgesetzt: Schwellen nach Betrag — ein Freigabeaufruf trägt
|
||||
> keinen allgemein lesbaren Betrag; das braucht eine Betragsangabe je Werkzeug.
|
||||
|
||||
### Die Doctrine: wie eine Leitung steuert, ohne jeden Auftrag anzufassen
|
||||
|
||||
Ein Agent wird heute von zwei Texten geprägt: `Identity.md` sagt, **wer** er ist,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -2637,6 +2637,16 @@ button.org-einheit .org-name {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* #702: a vote beside the call — every ballot with its reasoning and evidence. */
|
||||
.staging-stimmen {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* #693: the lead's spot checks and the measures waiting for the human. */
|
||||
.org-stichproben h4 {
|
||||
margin: 14px 0 6px;
|
||||
|
||||
@@ -138,6 +138,20 @@
|
||||
"org.measure.security": "Sicherheitsverdacht",
|
||||
"org.measureStatus.accepted": "angenommen",
|
||||
"org.measureStatus.dismissed": "abgelehnt",
|
||||
"org.votes": "Abstimmungen",
|
||||
"org.votesHint": "Operationen, die mehrere Agenten vor der Ausführung unabhängig prüfen und blind abstimmen — statt der Leitung. Stimmberechtigte wählen, die auf verschiedene Daten sehen (Konten, Lager, Sortiment): Es geht um unabhängige Prüfungen, nicht um eine Mehrheit. Wer beantragt, stimmt nicht mit; wer fremde Inhalte liest, bekommt keinen Stimmzettel; eine fehlende Stimme ist kein Ja. Zwischen Annahme und Ablehnung und ohne Quorum entscheidest du. Jede Stimme kostet einen Lauf.",
|
||||
"org.votesVoters": "Stimmberechtigte (ein Agent je Zeile)",
|
||||
"org.votesApproveAt": "Angenommen ab Ja-Stimmen",
|
||||
"org.votesRejectAt": "Abgelehnt bei höchstens Ja-Stimmen",
|
||||
"org.votesQuorum": "Mindestens abgegebene Stimmen (0: wie für Annahme)",
|
||||
"org.votesHours": "Stunden für die Abstimmung",
|
||||
"org.votesHeld": "Abgehaltene Abstimmungen",
|
||||
"org.voteWhen": "Wann",
|
||||
"org.voteOperation": "Aufruf",
|
||||
"org.voteTally": "Stimmen",
|
||||
"org.voteTallyText": "{yes} ja · {no} nein · {missing} fehlen",
|
||||
"org.voteOutcome": "Ergebnis",
|
||||
"org.voteCost": "Kosten der Abstimmenden",
|
||||
"org.ceilingInherited": "Von oben begrenzt auf: {tools}",
|
||||
"org.settings": "Sammelwerte",
|
||||
"org.settingsHint": "Werden nach einer Vorschau in die Agenten geschrieben. Die Einheit merkt sich den Wert nur als Vorgabe für neue Mitglieder — zur Laufzeit übertrumpft nichts einen Agenten.",
|
||||
@@ -659,6 +673,14 @@
|
||||
"staging.rejected": "Aufruf wurde abgelehnt.",
|
||||
"staging.rejectFailed": "Ablehnen fehlgeschlagen: {error}",
|
||||
"staging.withLead": "Die Leitung {lead} darf dies bis {until} entscheiden, im Rahmen dessen, was ihre Einheit erlaubt. Du kannst vorher entscheiden; danach liegt es allein bei dir.",
|
||||
"staging.voteRunning": "Abstimmung #{id} läuft bis {until}: {yes} ja, {no} nein, {missing} ausstehend. Du kannst vorher entscheiden.",
|
||||
"staging.voteDone": "Abstimmung #{id}: {outcome} — {yes} ja, {no} nein, {missing} fehlen.",
|
||||
"staging.voteRule": "Angenommen ab {approve} Ja, abgelehnt bei höchstens {reject}, mindestens {quorum} Stimmen",
|
||||
"staging.voteCost": "Läufe der Abstimmenden: ${cost}",
|
||||
"staging.voteYes": "ja",
|
||||
"staging.voteNo": "nein",
|
||||
"staging.voteMissing": "fehlt",
|
||||
"staging.voteEvidence": "Beleg: {evidence}",
|
||||
"staging.leadsTitle": "Freigaben durch Leitungen",
|
||||
"staging.leadsHint": "Was Leitungen in den letzten 30 Tagen an deiner Stelle freigegeben haben. Eine Leitung, die alles freigibt, verursacht nur Kosten — sieh dir ihre Entscheidungen an.",
|
||||
"staging.leadName": "Leitung",
|
||||
|
||||
@@ -138,6 +138,20 @@
|
||||
"org.measure.security": "Security suspicion",
|
||||
"org.measureStatus.accepted": "accepted",
|
||||
"org.measureStatus.dismissed": "dismissed",
|
||||
"org.votes": "Votes",
|
||||
"org.votesHint": "Operations that several agents check independently and vote on blind before they run — instead of the lead. Choose voters that look at different data (accounts, stock, range): independent checks are the point, not a majority. Whoever proposes does not vote; voters that read content from outside get no ballot; a missing vote is no yes. Between approval and rejection, and without the quorum, you decide. Every voter costs a run.",
|
||||
"org.votesVoters": "Voters (one agent per line)",
|
||||
"org.votesApproveAt": "Approved from yes votes",
|
||||
"org.votesRejectAt": "Rejected at yes votes or fewer",
|
||||
"org.votesQuorum": "Votes needed at least (0: as for approval)",
|
||||
"org.votesHours": "Hours for the vote",
|
||||
"org.votesHeld": "Votes held",
|
||||
"org.voteWhen": "When",
|
||||
"org.voteOperation": "Call",
|
||||
"org.voteTally": "Votes",
|
||||
"org.voteTallyText": "{yes} yes · {no} no · {missing} missing",
|
||||
"org.voteOutcome": "Outcome",
|
||||
"org.voteCost": "Cost of the voters",
|
||||
"org.ceilingInherited": "Limited from above to: {tools}",
|
||||
"org.settings": "Unit-wide values",
|
||||
"org.settingsHint": "Written into the agents after a preview. The unit keeps the value only as a default for new members — nothing overrides an agent at run time.",
|
||||
@@ -659,6 +673,14 @@
|
||||
"staging.rejected": "Call was rejected.",
|
||||
"staging.rejectFailed": "Rejection failed: {error}",
|
||||
"staging.withLead": "Its lead {lead} may decide this until {until}, within the limits its unit grants. You can decide it first; after that it is yours alone.",
|
||||
"staging.voteRunning": "Vote #{id} runs until {until}: {yes} yes, {no} no, {missing} outstanding. You can decide it first.",
|
||||
"staging.voteDone": "Vote #{id}: {outcome} — {yes} yes, {no} no, {missing} missing.",
|
||||
"staging.voteRule": "Approved from {approve} yes, rejected at {reject} or fewer, at least {quorum} votes",
|
||||
"staging.voteCost": "voters' runs: ${cost}",
|
||||
"staging.voteYes": "yes",
|
||||
"staging.voteNo": "no",
|
||||
"staging.voteMissing": "missing",
|
||||
"staging.voteEvidence": "Evidence: {evidence}",
|
||||
"staging.leadsTitle": "Approvals by leads",
|
||||
"staging.leadsHint": "What leads approved instead of you in the last 30 days. A lead that approves everything does nothing but cost — look at its decisions.",
|
||||
"staging.leadName": "Lead",
|
||||
|
||||
@@ -369,6 +369,7 @@ function unitPanel(unit) {
|
||||
cards.push(approvalsCard(unit));
|
||||
cards.push(doctrineCard(unit));
|
||||
cards.push(inspectionCard(unit));
|
||||
cards.push(votesCard(unit));
|
||||
cards.push(settingsCard(unit));
|
||||
|
||||
// Members.
|
||||
@@ -504,6 +505,69 @@ function doctrineCard(unit) {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* #702: What the unit puts to a blind vote, who votes, and the thresholds. A vote counts
|
||||
* independent checks, not opinions — the voters should look at different data. The votes held
|
||||
* stand below, with every ballot.
|
||||
*/
|
||||
function votesCard(unit) {
|
||||
const current = unit.votes;
|
||||
const ops = erstelleElement("textarea", { rows: 2, placeholder: "Database.insert" });
|
||||
ops.value = (current?.operations ?? []).join("\n");
|
||||
const voters = erstelleElement("textarea", { rows: 3, placeholder: "accounting\nlogistics\nmarketing" });
|
||||
voters.value = (current?.voters ?? []).join("\n");
|
||||
const number = (value, min) => erstelleElement("input", { type: "number", min: String(min), step: "1", value: String(value) });
|
||||
const approveAt = number(current?.approveAt ?? 3, 1);
|
||||
const rejectAt = number(current?.rejectAt ?? 1, 0);
|
||||
const quorum = number(current?.quorum ?? 0, 0);
|
||||
const hours = erstelleElement("input", { type: "number", min: "0.25", step: "0.25", value: String(current?.hours ?? 4) });
|
||||
const perDay = number(current?.perDay ?? 10, 0);
|
||||
const held = erstelleElement("div", { klasse: "org-stichproben" });
|
||||
|
||||
(async () => {
|
||||
let list = [];
|
||||
try { list = await api(`/organisation/units/${encodeURIComponent(unit.id)}/votes`); } catch { return; }
|
||||
if (!list.length) return;
|
||||
held.append(erstelleElement("h4", { text: t("org.votesHeld") }));
|
||||
held.append(erstelleElement("table", { klasse: "daten-tabelle" },
|
||||
erstelleElement("thead", {}, erstelleElement("tr", {},
|
||||
...[t("org.voteWhen"), t("org.voteOperation"), t("org.voteTally"), t("org.voteOutcome"), t("org.voteCost")].map((h) => erstelleElement("th", { text: h })))),
|
||||
erstelleElement("tbody", {}, ...list.map((v) => erstelleElement("tr", {},
|
||||
erstelleElement("td", { text: formatiereDatumZeit(v.createdAt) }),
|
||||
erstelleElement("td", { text: `${v.operation} · ${v.proposer}` }),
|
||||
erstelleElement("td", { text: t("org.voteTallyText", { yes: v.yes, no: v.no, missing: v.missing }) }),
|
||||
erstelleElement("td", { text: v.outcomeText }),
|
||||
erstelleElement("td", { text: v.costUsd != null ? `$${v.costUsd.toFixed(4)}` : "—" }))))));
|
||||
})();
|
||||
|
||||
const lines = (el) => el.value.split(/[\n,]/).map((o) => o.trim()).filter(Boolean);
|
||||
return erstelleKarte(t("org.votes"), [
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.votesHint") }),
|
||||
erstelleElement("div", { klasse: "formular-raster" },
|
||||
erstelleElement("label", { text: t("org.approvalsOperations") }), ops,
|
||||
erstelleElement("label", { text: t("org.votesVoters") }), voters,
|
||||
erstelleElement("label", { text: t("org.votesApproveAt") }), approveAt,
|
||||
erstelleElement("label", { text: t("org.votesRejectAt") }), rejectAt,
|
||||
erstelleElement("label", { text: t("org.votesQuorum") }), quorum,
|
||||
erstelleElement("label", { text: t("org.votesHours") }), hours,
|
||||
erstelleElement("label", { text: t("org.approvalsPerDay") }), perDay),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "klein", text: t("common.save"),
|
||||
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/votes`, "PUT", {
|
||||
operations: lines(ops),
|
||||
voters: lines(voters),
|
||||
approveAt: Number(approveAt.value),
|
||||
rejectAt: Number(rejectAt.value),
|
||||
quorum: Number(quorum.value),
|
||||
hours: Number(hours.value),
|
||||
perDay: Number(perDay.value),
|
||||
reason: reason()
|
||||
})
|
||||
}),
|
||||
held
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* #693: The lead's spot checks. How often it looks at the runs of the agents it leads, what
|
||||
* came of the last checks, and the measures it proposed — which only a human decides. Nothing
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PFADE,
|
||||
erstelleKarte,
|
||||
erstelleModal,
|
||||
erstellePlakette,
|
||||
formatiereDatumZeit,
|
||||
zeigeMeldung
|
||||
} from "../modules/dom.js";
|
||||
@@ -21,6 +22,28 @@ function menschWartet(anfrage) {
|
||||
return !anfrage.withLead;
|
||||
}
|
||||
|
||||
/**
|
||||
* #702: A vote on the call — the tally, and each ballot with its reasoning and evidence. The
|
||||
* voters never see this; the human decides with all of it in view when the vote is undecided.
|
||||
*/
|
||||
function voteBox(vote, running) {
|
||||
const kopf = running
|
||||
? t("staging.voteRunning", { id: vote.id, until: formatiereDatumZeit(vote.until), yes: vote.yes, no: vote.no, missing: vote.missing })
|
||||
: t("staging.voteDone", { id: vote.id, outcome: vote.outcomeText, yes: vote.yes, no: vote.no, missing: vote.missing });
|
||||
const regeln = t("staging.voteRule", { approve: vote.approveAt, reject: vote.rejectAt, quorum: vote.quorum })
|
||||
+ (vote.costUsd != null ? " · " + t("staging.voteCost", { cost: vote.costUsd.toFixed(4) }) : "");
|
||||
return erstelleElement("div", { klasse: "hinweis-box staging-abstimmung" },
|
||||
erstelleElement("strong", { text: kopf }),
|
||||
erstelleElement("div", { klasse: "gedaempft text-klein", text: regeln }),
|
||||
erstelleElement("ul", { klasse: "staging-stimmen" }, ...vote.ballots.map((b) => erstelleElement("li", {},
|
||||
erstellePlakette(
|
||||
b.choice === "yes" ? t("staging.voteYes") : b.choice === "no" ? t("staging.voteNo") : t("staging.voteMissing"),
|
||||
b.choice === "yes" ? "erfolg" : b.choice === "no" ? "gefahr" : "neutral"),
|
||||
erstelleElement("strong", { text: ` ${b.voter}` }),
|
||||
b.choice ? erstelleElement("div", { klasse: "text-klein", text: b.reasoning }) : "",
|
||||
b.choice ? erstelleElement("div", { klasse: "gedaempft text-klein", text: t("staging.voteEvidence", { evidence: b.evidence }) }) : ""))));
|
||||
}
|
||||
|
||||
export async function holeWartendeAnzahl() {
|
||||
try {
|
||||
const liste = await api("/staging");
|
||||
@@ -145,7 +168,10 @@ async function ladeFreigaben() {
|
||||
const teile = [datenliste, argBox];
|
||||
|
||||
// #687: A lead decides it within its unit's limits — the human can still decide first.
|
||||
if (anfrage.withLead) {
|
||||
// #702: Or a blind vote does; its ballots stand beside the call.
|
||||
if (anfrage.vote) {
|
||||
teile.unshift(voteBox(anfrage.vote, !!anfrage.withLead));
|
||||
} else if (anfrage.withLead) {
|
||||
teile.unshift(erstelleElement("div", {
|
||||
klasse: "hinweis-box",
|
||||
text: t("staging.withLead", { lead: anfrage.withLead, until: formatiereDatumZeit(anfrage.leadUntil) })
|
||||
|
||||
@@ -41,6 +41,7 @@ public sealed class EscalationTool : IAgentTool
|
||||
spot_checks lists the runs picked for you, with what the audit log shows; inspect_run (run) shows one run in full;
|
||||
check_done (check, verdict ok|finding, note) closes a check. propose_measure (agent, measure, reasoning, check) puts a measure to a human:
|
||||
sharpen_task, change_model, adjust_rights, suspend — or security, if you suspect the agent is steered from outside.
|
||||
As a voter: list_ballots shows the calls you are asked to vote on; cast_vote (vote, choice yes|no, reasoning, evidence) — check against your own data, with source and time. You never see the other votes.
|
||||
""";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
@@ -49,7 +50,7 @@ public sealed class EscalationTool : IAgentTool
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set", "spot_checks", "inspect_run", "check_done", "propose_measure"]
|
||||
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set", "spot_checks", "inspect_run", "check_done", "propose_measure", "list_ballots", "cast_vote"]
|
||||
},
|
||||
"amount_usd": { "type": "number", "description": "request_budget: the amount for the task and all its parts, in USD." },
|
||||
"subject": { "type": "string", "description": "What it is about, in a few words — like a title. escalate, open_conflict, find_decisions." },
|
||||
@@ -72,7 +73,10 @@ public sealed class EscalationTool : IAgentTool
|
||||
"check": { "type": "integer", "description": "check_done, propose_measure: the spot check number." },
|
||||
"verdict": { "type": "string", "enum": ["ok", "finding"], "description": "check_done: ok, or finding (then 'note' with the evidence)." },
|
||||
"agent": { "type": "string", "description": "propose_measure: the agent it concerns." },
|
||||
"measure": { "type": "string", "enum": ["sharpen_task", "change_model", "adjust_rights", "suspend", "security"], "description": "propose_measure: what should follow." }
|
||||
"measure": { "type": "string", "enum": ["sharpen_task", "change_model", "adjust_rights", "suspend", "security"], "description": "propose_measure: what should follow." },
|
||||
"vote": { "type": "integer", "description": "cast_vote: the vote number (#…)." },
|
||||
"choice": { "type": "string", "enum": ["yes", "no"], "description": "cast_vote: your vote." },
|
||||
"evidence": { "type": "string", "description": "cast_vote: what you checked — source and time. Required: without it a vote is an opinion." }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
@@ -104,6 +108,10 @@ public sealed class EscalationTool : IAgentTool
|
||||
if (Get(input, "action") is "spot_checks" or "inspect_run" or "check_done" or "propose_measure")
|
||||
return await SpotCheckAsync(input, context, ct);
|
||||
|
||||
// #702: A voter's ballot.
|
||||
if (Get(input, "action") is "list_ballots" or "cast_vote")
|
||||
return await BallotAsync(input, context, ct);
|
||||
|
||||
// #687: A lead deciding the approvals its unit grants it.
|
||||
if (Get(input, "action") is "list_approvals" or "approve_call" or "reject_call")
|
||||
return await ApprovalAsync(input, context, ct);
|
||||
@@ -176,7 +184,7 @@ public sealed class EscalationTool : IAgentTool
|
||||
break;
|
||||
|
||||
default:
|
||||
return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget, list_approvals, approve_call, reject_call, doctrine_read, doctrine_set, spot_checks, inspect_run, check_done, propose_measure.");
|
||||
return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget, list_approvals, approve_call, reject_call, doctrine_read, doctrine_set, spot_checks, inspect_run, check_done, propose_measure, list_ballots, cast_vote.");
|
||||
}
|
||||
|
||||
return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message);
|
||||
@@ -328,6 +336,35 @@ public sealed class EscalationTool : IAgentTool
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> BallotAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
if (context.Votes is not { } votes)
|
||||
return ToolResult.Fail("Votes are not available in this instance.");
|
||||
|
||||
if (Get(input, "action") == "list_ballots")
|
||||
{
|
||||
var open = await votes.BallotsForAsync(context.AgentId, ct);
|
||||
if (open.Count == 0)
|
||||
return ToolResult.Ok("No vote waits for you.");
|
||||
var sb = new StringBuilder($"{open.Count} vote(s) wait for you — check each against your own data:\n");
|
||||
foreach (var (vote, call) in open)
|
||||
{
|
||||
sb.AppendLine($"- vote #{vote.Id}: {vote.Operation} from {vote.Proposer}, until {vote.Until:yyyy-MM-dd HH:mm} UTC. The call:");
|
||||
sb.AppendLine(Core.Engine.UntrustedContent.Rahmen(call.Tool, string.IsNullOrWhiteSpace(call.ArgumentsJson) ? call.Proposal : call.ArgumentsJson));
|
||||
}
|
||||
return ToolResult.Ok(sb.ToString().TrimEnd());
|
||||
}
|
||||
|
||||
if (!input.TryGetProperty("vote", out var v) || v.ValueKind != JsonValueKind.Number || !v.TryGetInt64(out var voteId))
|
||||
return ToolResult.Fail("'vote' is required — the vote number.");
|
||||
var choice = Get(input, "choice");
|
||||
if (choice is not ("yes" or "no"))
|
||||
return ToolResult.Fail("'choice' must be yes or no.");
|
||||
var cast = await votes.CastAsync(voteId, context.AgentId, context.Trust, choice == "yes",
|
||||
Get(input, "reasoning") ?? "", Get(input, "evidence") ?? "", context.RunId, ct);
|
||||
return cast.Ok ? ToolResult.Ok(cast.Message) : ToolResult.Fail(cast.Message);
|
||||
}
|
||||
|
||||
/// <summary>One run as the engine recorded it. The result text is the agent's — marked as such.</summary>
|
||||
private static string Describe(RunInspection run)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -308,6 +308,28 @@ public sealed class OrgAdministrationTests : IDisposable
|
||||
Unit("marketing").Inspection.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ═══════════════════ votes (#702) ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public void A_human_puts_operations_to_a_vote_with_room_for_a_human_between_the_thresholds()
|
||||
{
|
||||
BuildMarketing();
|
||||
|
||||
Org.SetVotes("marketing", ["AgentEditor.update_soul"], ["ads", "listing"]).Message.ShouldContain("always stays with a human");
|
||||
Org.SetVotes("marketing", ["Database.insert"], ["ads"]).Ok.ShouldBeFalse("one voter is a lead's approval");
|
||||
Org.SetVotes("marketing", ["Database.insert"], ["ads", "nobody"]).Message.ShouldContain("nobody");
|
||||
Org.SetVotes("marketing", ["Database.insert"], ["ads", "listing", "specialist"], approveAt: 2, rejectAt: 2).Ok
|
||||
.ShouldBeFalse("rejection must lie below approval");
|
||||
|
||||
var set = Org.SetVotes("marketing", ["Database.insert"], ["ads", "listing", "specialist"], approveAt: 2, rejectAt: 0, reason: "orders");
|
||||
|
||||
set.Ok.ShouldBeTrue(set.Message);
|
||||
Unit("marketing").Votes!.Voters.ShouldBe(["ads", "listing", "specialist"]);
|
||||
Unit("marketing").Votes!.EffectiveQuorum.ShouldBe(2);
|
||||
Org.SetVotes("marketing", [], null).Ok.ShouldBeTrue();
|
||||
Unit("marketing").Votes.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ═══════════════════ the top (#690) ═══════════════════
|
||||
|
||||
private void MakeDecider(string id)
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Staging;
|
||||
|
||||
/// <summary>
|
||||
/// #702: Several agents check independently and vote blind — the rule counts, a missing vote
|
||||
/// is no yes, and a human decides whatever is not clear.
|
||||
/// </summary>
|
||||
public sealed class VotesTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "votes-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
private readonly SqliteStagingRepository _staging;
|
||||
private readonly SqliteTaskRepository _tasks;
|
||||
private readonly TaskboardService _board;
|
||||
private readonly FakeTimeProvider _clock = new(DateTimeOffset.UtcNow);
|
||||
private readonly Executor _executor = new();
|
||||
private readonly StagingService _service;
|
||||
private readonly Votes _votes;
|
||||
private readonly HashSet<string> _foreign = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly UnitConfig _purchasing = new()
|
||||
{
|
||||
Id = "purchasing", Name = "Purchasing", LeaderAgentId = "buy-lead",
|
||||
Votes = new UnitVotes
|
||||
{
|
||||
Operations = ["Database.insert", "AgentEditor"],
|
||||
Voters = ["accounting", "logistics", "marketing", "sales", "buyer"],
|
||||
ApproveAt = 3, RejectAt = 1, Hours = 1, PerDay = 3
|
||||
}
|
||||
};
|
||||
|
||||
private OrgChart Org => new(
|
||||
[_purchasing, new UnitConfig { Id = "staff", Name = "Staff" }],
|
||||
[
|
||||
new AgentConfig { AgentId = "buy-lead", UnitId = "purchasing" },
|
||||
new AgentConfig { AgentId = "buyer", UnitId = "purchasing" },
|
||||
new AgentConfig { AgentId = "accounting", UnitId = "staff" },
|
||||
new AgentConfig { AgentId = "logistics", UnitId = "staff" },
|
||||
new AgentConfig { AgentId = "marketing", UnitId = "staff" },
|
||||
new AgentConfig { AgentId = "sales", UnitId = "staff" }
|
||||
]);
|
||||
|
||||
public VotesTests()
|
||||
{
|
||||
var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_staging = new SqliteStagingRepository(storage);
|
||||
_tasks = new SqliteTaskRepository(storage);
|
||||
_board = new TaskboardService(_tasks, Path.Combine(_directory, "tasks"));
|
||||
_service = new StagingService(_staging, _executor, _board, NullLoggerFactory.Instance);
|
||||
_votes = new Votes(storage, _staging, _board, () => Org,
|
||||
a => _foreign.Contains(a.AgentId) ? new AgentTrust(TrustTier.ReadsForeignContent, ["WebFetch"]) : AgentTrust.Standard,
|
||||
NullLogger.Instance, _clock) { Service = _service };
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
private async Task<(StagedCall Call, string? Line, VoteRecord? Vote)> Stage(string agent = "buyer", string tool = "Database", string action = "insert")
|
||||
{
|
||||
var call = new StagedCall { RunId = "run-1", AgentId = agent, Tool = tool, Action = action, ArgumentsJson = $"{{\"action\":\"{action}\",\"amount\":150}}" };
|
||||
var id = await _staging.AppendAsync(call, default);
|
||||
var line = await _votes.RouteAsync(call with { Id = id }, default);
|
||||
var vote = (await _votes.ForCallsAsync([id], default)).GetValueOrDefault(id);
|
||||
return ((await _staging.GetAsync(id, default))!, line, vote);
|
||||
}
|
||||
|
||||
private Task<LeadDecision> Vote(long vote, string voter, bool yes, string evidence = "Account balance 4,210 EUR, 2026-10-01 08:14")
|
||||
=> _votes.CastAsync(vote, voter, AgentTrust.Standard, yes, yes ? "Fits." : "Does not fit.", evidence, "run-" + voter, default);
|
||||
|
||||
// ═══════════════════ where it goes ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task A_covered_call_goes_to_a_vote_with_one_task_per_voter_and_never_to_the_proposer()
|
||||
{
|
||||
var (call, line, vote) = await Stage();
|
||||
|
||||
vote.ShouldNotBeNull();
|
||||
vote.Ballots.Select(b => b.Voter).ShouldBe(["accounting", "logistics", "marketing", "sales"], ignoreOrder: true,
|
||||
customMessage: "the buyer proposes, so it does not vote");
|
||||
call.Approver.ShouldBe(Votes.DecidedByPrefix + vote.Id);
|
||||
call.WithLead(DateTime.UtcNow).ShouldBeTrue("the vote decides first; the human can still step in");
|
||||
line!.ShouldContain("4 agents");
|
||||
|
||||
var ballot = (await _tasks.GetAsync(vote.Ballots[0].TaskId!, default))!;
|
||||
ballot.Assignee.ShouldBe("@" + vote.Ballots[0].Voter);
|
||||
ballot.Body.ShouldContain("<untrusted_content", Case.Sensitive);
|
||||
ballot.Body.ShouldContain("cast_vote");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task What_changes_agents_is_never_put_to_a_vote()
|
||||
=> (await Stage(tool: "AgentEditor", action: "update_soul")).Vote.ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public async Task A_voter_that_reads_foreign_content_gets_no_ballot_and_a_vote_without_enough_voters_is_none()
|
||||
{
|
||||
_foreign.Add("sales");
|
||||
(await Stage()).Vote!.Ballots.Select(b => b.Voter).ShouldNotContain("sales");
|
||||
|
||||
_foreign.UnionWith(["marketing", "logistics"]);
|
||||
(await Stage()).Vote.ShouldBeNull("one voter cannot reach three yes votes — the lead or a human decides");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_unit_holds_no_more_votes_a_day_than_its_cap()
|
||||
{
|
||||
for (var i = 0; i < 3; i++)
|
||||
(await Stage()).Vote.ShouldNotBeNull();
|
||||
(await Stage()).Vote.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ═══════════════════ blind ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task A_voter_sees_the_call_never_another_ballot_and_votes_once_with_evidence()
|
||||
{
|
||||
var vote = (await Stage()).Vote!;
|
||||
await Vote(vote.Id, "accounting", false);
|
||||
|
||||
var open = await _votes.BallotsForAsync("logistics", default);
|
||||
open.Single().Vote.Id.ShouldBe(vote.Id);
|
||||
(await _votes.BallotsForAsync("accounting", default)).ShouldBeEmpty("it has voted");
|
||||
|
||||
(await Vote(vote.Id, "accounting", true)).Message.ShouldContain("already");
|
||||
(await Vote(vote.Id, "buy-lead", true)).Ok.ShouldBeFalse("not a voter");
|
||||
(await Vote(vote.Id, "logistics", true, evidence: " ")).Message.ShouldContain("'evidence'");
|
||||
(await _votes.CastAsync(vote.Id, "logistics", new AgentTrust(TrustTier.ReadsForeignContent, ["Mail"]), true, "x", "y", null, default))
|
||||
.Ok.ShouldBeFalse("a voter that reads outside content now does not vote");
|
||||
}
|
||||
|
||||
// ═══════════════════ the count ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Three_yes_of_four_approve_it_and_the_remaining_ballot_is_called_off()
|
||||
{
|
||||
var vote = (await Stage()).Vote!;
|
||||
|
||||
await Vote(vote.Id, "accounting", true);
|
||||
await Vote(vote.Id, "logistics", true);
|
||||
(await Vote(vote.Id, "marketing", true)).Message.ShouldContain("approved");
|
||||
|
||||
var call = (await _staging.GetAsync(vote.CallId, default))!;
|
||||
call.Status.ShouldBe(StagingStatus.Executed);
|
||||
call.DecidedBy.ShouldBe(Votes.DecidedByPrefix + vote.Id);
|
||||
_executor.Calls.ShouldBe(1);
|
||||
|
||||
var sales = (await _votes.GetAsync(vote.Id, default))!.Ballots.Single(b => b.Voter == "sales");
|
||||
(await _tasks.GetAsync(sales.TaskId!, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_rejection_is_settled_as_soon_as_the_rest_cannot_change_it()
|
||||
{
|
||||
var vote = (await Stage()).Vote!;
|
||||
|
||||
await Vote(vote.Id, "accounting", false);
|
||||
await Vote(vote.Id, "logistics", false);
|
||||
(await Vote(vote.Id, "marketing", false)).Message.ShouldContain("rejected", customMessage: "even a yes from sales stays at one");
|
||||
|
||||
var call = (await _staging.GetAsync(vote.CallId, default))!;
|
||||
call.Status.ShouldBe(StagingStatus.Rejected);
|
||||
_executor.Calls.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_of_four_go_to_a_human_with_every_ballot()
|
||||
{
|
||||
var vote = (await Stage()).Vote!;
|
||||
|
||||
await Vote(vote.Id, "accounting", true);
|
||||
await Vote(vote.Id, "logistics", true);
|
||||
await Vote(vote.Id, "marketing", false);
|
||||
(await Vote(vote.Id, "sales", false)).Message.ShouldContain("a human decides");
|
||||
|
||||
var call = (await _staging.GetAsync(vote.CallId, default))!;
|
||||
call.Status.ShouldBe(StagingStatus.Pending);
|
||||
call.WithLead(DateTime.UtcNow).ShouldBeFalse("it is the human's now");
|
||||
(await _votes.GetAsync(vote.Id, default))!.Ballots.ShouldAllBe(b => b.Evidence.Length > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_missing_vote_is_no_yes()
|
||||
{
|
||||
var vote = (await Stage()).Vote!;
|
||||
await Vote(vote.Id, "accounting", true);
|
||||
await Vote(vote.Id, "logistics", true);
|
||||
|
||||
(await _votes.SweepAsync(default)).ShouldBe(0, "there is still time");
|
||||
_clock.Advance(TimeSpan.FromHours(2));
|
||||
(await _votes.SweepAsync(default)).ShouldBe(1);
|
||||
|
||||
(await _votes.GetAsync(vote.Id, default))!.Outcome.ShouldBe("human:quorum", "two votes are not the three needed — nothing is waved through");
|
||||
(await _staging.GetAsync(vote.CallId, default))!.Status.ShouldBe(StagingStatus.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_human_who_decides_first_ends_the_vote()
|
||||
{
|
||||
var vote = (await Stage()).Vote!;
|
||||
await _service.RejectAsync(vote.CallId, "webui", "Not now.", default);
|
||||
|
||||
(await _votes.SweepAsync(default)).ShouldBe(1);
|
||||
var ended = (await _votes.GetAsync(vote.Id, default))!;
|
||||
ended.Outcome.ShouldBe("superseded");
|
||||
foreach (var b in ended.Ballots)
|
||||
(await _tasks.GetAsync(b.TaskId!, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
|
||||
(await Vote(vote.Id, "accounting", true)).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private sealed class Executor : IFrozenCallExecutor
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public Task<string> ExecuteApprovedCallAsync(string agentId, string tool, string argumentsJson, string runId, CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult("1 row inserted");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.Taskboard;
|
||||
using ClawdDotNet.Tools.Tests.FileRW;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Tools.Tests.Taskboard;
|
||||
|
||||
/// <summary>#702: A voter's side of a vote through the Escalation tool.</summary>
|
||||
public sealed class VoteToolTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
private readonly SqliteStagingRepository _staging;
|
||||
private readonly Votes _votes;
|
||||
private readonly EscalationTool _tool = new();
|
||||
|
||||
private readonly OrgChart _org = new(
|
||||
[
|
||||
new UnitConfig
|
||||
{
|
||||
Id = "purchasing", Name = "Purchasing",
|
||||
Votes = new UnitVotes { Operations = ["Database.insert"], Voters = ["accounting", "logistics"], ApproveAt = 2, RejectAt = 0 }
|
||||
}
|
||||
],
|
||||
[
|
||||
new AgentConfig { AgentId = "buyer", UnitId = "purchasing" },
|
||||
new AgentConfig { AgentId = "accounting", UnitId = "purchasing" },
|
||||
new AgentConfig { AgentId = "logistics", UnitId = "purchasing" }
|
||||
]);
|
||||
|
||||
public VoteToolTests()
|
||||
{
|
||||
var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_staging = new SqliteStagingRepository(storage);
|
||||
var board = new TaskboardService(new SqliteTaskRepository(storage), Path.Combine(_directory, "tasks"));
|
||||
_votes = new Votes(storage, _staging, board, () => _org, _ => AgentTrust.Standard, NullLogger.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleanup is secondary */ }
|
||||
}
|
||||
|
||||
private Task<ToolResult> Run(string agent, object input)
|
||||
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input),
|
||||
new AgentToolContext(agent, "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance, CancellationToken.None)
|
||||
{
|
||||
Organisation = _org,
|
||||
Votes = _votes,
|
||||
RunId = "run-" + agent
|
||||
}, CancellationToken.None);
|
||||
|
||||
[Fact]
|
||||
public async Task A_voter_lists_its_ballot_and_casts_it_with_evidence()
|
||||
{
|
||||
var call = new StagedCall { RunId = "r", AgentId = "buyer", Tool = "Database", Action = "insert", ArgumentsJson = "{\"action\":\"insert\"}" };
|
||||
var id = await _staging.AppendAsync(call, default);
|
||||
await _votes.RouteAsync(call with { Id = id }, default);
|
||||
var vote = (await _votes.ForCallsAsync([id], default))[id];
|
||||
|
||||
(await Run("accounting", new { action = "list_ballots" })).Content.ShouldContain($"vote #{vote.Id}");
|
||||
(await Run("accounting", new { action = "cast_vote", vote = vote.Id, choice = "maybe", reasoning = "x", evidence = "y" }))
|
||||
.ErrorMessage!.ShouldContain("'choice'");
|
||||
|
||||
var cast = await Run("accounting", new { action = "cast_vote", vote = vote.Id, choice = "no", reasoning = "Over budget.", evidence = "Balance 80 EUR, ledger 2026-10-01 08:10" });
|
||||
cast.Success.ShouldBeTrue(cast.ErrorMessage);
|
||||
cast.Content.ShouldContain("You will not see the others' votes");
|
||||
(await _votes.GetAsync(vote.Id, default))!.Ballots.Single(b => b.Voter == "accounting").RunId.ShouldBe("run-accounting");
|
||||
(await Run("logistics", new { action = "list_ballots" })).Content.ShouldNotContain("Over budget", customMessage: "blind");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user