Freigabe durch die Leitung innerhalb von Grenzen (#687)

- UnitConfig.Approvals: Operationen, Hoechstzahl je Tag, Stunden je Freigabe; nur der
  Mensch setzt sie (OrgAdministration.SetApprovals mit Audit, Teilmengenregel nach oben,
  Senken oben senkt unten mit; Agentenaenderungen/Sonderbudget nie freigebbar)
- LeadApprovals: Vorlage geht an die naechste Leitung, deren Einheit die Operation
  erlaubt; nie eigene Aufrufe, nie eine Leitung mit Fremdinhalt, Tagesdeckel, Frist.
  Entscheidung ueber Escalation approve_call/reject_call, im Audit als lead:<id>
- Der Mensch sieht alles und kann zuerst entscheiden; danach wird die Leitungsaufgabe
  geschlossen. Nach der Frist nur noch der Mensch.
- StagedCalls: Approver, ApproverUntil, ApproverTaskId (Nachruestung)
- WebUI: Hinweis "bei Leitung" in der Freigabestelle, Zaehler ohne diese, Quote je
  Leitung; Karte in der Organisationsansicht; i18n en/de
- Tests: LeadApprovalsTests (13), OrgAdministrationTests (+2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-30 20:12:18 +02:00
co-authored by Claude Opus 5.5
parent dafb8241a0
commit f51186269c
27 changed files with 948 additions and 20 deletions
+13
View File
@@ -1457,6 +1457,19 @@ der Versuch macht ihn zum Flaschenhals.
Agenten kann ein Mensch die Läufe lesen. Bei fünfzig muss etwas anderes ihm sagen, wo Agenten kann ein Mensch die Läufe lesen. Bei fünfzig muss etwas anderes ihm sagen, wo
er hinsehen soll. er hinsehen soll.
> **Umgesetzt (#687).** Die Grenze steht an der Einheit (`units[].approvals`: Operationen
> `Tool.operation` oder `Tool`, Höchstzahl je Tag, Stunden je Freigabe) und setzt nur der
> Mensch — WebUI *Organisation › Einheit › Freigaben durch die Leitung*, mit Audit. Ein
> vorgelegter Aufruf geht an die nächste Leitung über dem Agenten, deren Einheit die
> Operation erlaubt; sie entscheidet mit dem Escalation-Werkzeug (`approve_call`,
> `reject_call` mit Grund). Der Mensch sieht ihn die ganze Zeit und kann zuerst
> entscheiden; nach Ablauf entscheidet nur er. Nie: eigene Aufrufe, Änderungen an Agenten
> und Sonderbudgets (auch nicht über eine Freigabe des ganzen Werkzeugs), eine Leitung, die
> Fremdinhalte liest. Eine Untereinheit erlaubt nie mehr als die Einheiten darüber; wird
> oben gesenkt, sinkt es unten mit. Im Audit steht `lead:<id>`, die Freigabestelle zeigt
> die Quote je Leitung. **Offen:** Beträge je Vorgang und Tag — kein Werkzeug meldet
> heute einen Betrag; das kommt mit dem ersten Werkzeug, das Geld bewegt.
--- ---
## 10a — Das Lagebild ## 10a — Das Lagebild
+10 -1
View File
@@ -642,7 +642,7 @@ public sealed class AppHost : IAsyncDisposable
var (scanner, staging, board) = BuildTaskboard( var (scanner, staging, board) = BuildTaskboard(
instance, taskRepository, engine, tools, stateStore, audit, instance, taskRepository, engine, tools, stateStore, audit,
stagingRepository, dauerfreigaben, cases, loggerFactory, logger); stagingRepository, dauerfreigaben, stagingGate, cases, loggerFactory, logger);
var host = new AppHost var host = new AppHost
{ {
@@ -800,6 +800,7 @@ public sealed class AppHost : IAsyncDisposable
SqliteAuditRepository audit, SqliteAuditRepository audit,
SqliteStagingRepository stagingRepository, SqliteStagingRepository stagingRepository,
SqliteDauerfreigabeRepository dauerfreigaben, SqliteDauerfreigabeRepository dauerfreigaben,
StagingGate stagingGate,
Core.Cases.ICaseRepository cases, Core.Cases.ICaseRepository cases,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
ILogger logger) ILogger logger)
@@ -816,6 +817,14 @@ public sealed class AppHost : IAsyncDisposable
var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit, var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit,
dauerfreigaben: dauerfreigaben); dauerfreigaben: dauerfreigaben);
// #687: A lead within its unit's limits decides instead of the human.
var leadApprovals = new LeadApprovals(
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;
var dispatcher = new EngineTaskDispatcher( var dispatcher = new EngineTaskDispatcher(
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory, engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory,
tasks: taskRepository); tasks: taskRepository);
@@ -3,6 +3,7 @@ using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Cases; using ClawdDotNet.Core.Cases;
using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services; namespace ClawdDotNet.App.Services;
@@ -201,6 +202,84 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
return Preview([agentId], NullIfEmpty(targetUnitId), movingUnit: null, problems); return Preview([agentId], NullIfEmpty(targetUnitId), movingUnit: null, problems);
} }
// ─── approvals by the lead (#687) ───
/// <summary>
/// #687: What the lead of a unit may approve instead of the human. Only a human sets it.
/// Changes to agents are never grantable; a sub-unit gets no more than every unit above it
/// that grants anything; lowering a unit's grant lowers its sub-units' with it. <c>null</c>
/// or an empty list takes the grant away.
/// </summary>
public AdminResult SetApprovals(string unitId, IReadOnlyCollection<string>? operations, int? perDay = null, double? hours = 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();
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.");
// A whole tool that holds such an operation is refused too — "AgentEditor" would read as a grant for it.
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.");
}
if (perDay is < 0)
return AdminResult.Failure("The daily cap cannot be negative.");
if (hours is <= 0)
return AdminResult.Failure("The lead's time comes in hours, above zero.");
// A sub-unit can grant no more than the units above it.
foreach (var above in Chart.Ancestors(unit.Id))
{
if (above.Approvals is not { Operations.Count: > 0 } granted)
continue;
var beyond = ops.Where(op => !Within(granted, op)).ToList();
if (beyond.Count > 0)
return AdminResult.Failure($"'{above.Name}' above grants less: {string.Join(", ", beyond)} is beyond it. Raise it there first.");
}
unit.Approvals = ops.Count == 0 ? null : new UnitApprovals
{
Operations = ops,
PerDay = perDay ?? unit.Approvals?.PerDay ?? 20,
Hours = hours ?? unit.Approvals?.Hours ?? 4
};
// What the units below grant beyond this goes with it.
var trimmed = new List<string>();
foreach (var below in Subtree(Chart, unit.Id).Where(u => !Same(u.Id, unit.Id)))
{
if (FindUnit(below.Id) is not { Approvals: { } theirs } child)
continue;
var keep = unit.Approvals is null ? [] : theirs.Operations.Where(op => Within(unit.Approvals, op)).ToList();
if (keep.Count == theirs.Operations.Count)
continue;
trimmed.Add(child.Name);
child.Approvals = keep.Count == 0 ? null : new UnitApprovals { Operations = keep, PerDay = theirs.PerDay, Hours = theirs.Hours };
}
Save(null, "unit_approvals", new { unitId, operations = ops, perDay = unit.Approvals?.PerDay, hours = unit.Approvals?.Hours, trimmed }, reason);
var message = unit.Approvals is null
? $"The lead of '{unit.Name}' approves nothing now; a human decides."
: $"The lead of '{unit.Name}' may approve {string.Join(", ", ops)} (up to {unit.Approvals.PerDay} a day, {unit.Approvals.Window.TotalHours:0.##} h each).";
return AdminResult.Success(trimmed.Count == 0 ? message : message + $" Lowered with it: {string.Join(", ", trimmed)}.");
}
/// <summary>Whether a grant covers an operation string — "Tool" covers every "Tool.x".</summary>
private static bool Within(UnitApprovals granted, string op)
{
var dot = op.IndexOf('.');
return dot < 0
? granted.Operations.Any(g => Same(g, op))
: granted.Covers(op[..dot], op[(dot + 1)..]);
}
// ─── the top (#690) ─── // ─── the top (#690) ───
private bool IsTopAgent(string agentId) private bool IsTopAgent(string agentId)
@@ -806,7 +885,7 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
private static UnitConfig Clone(UnitConfig u) => new() private static UnitConfig Clone(UnitConfig u) => new()
{ {
Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId, Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId,
Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults, Approvals = u.Approvals
}; };
private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
+39
View File
@@ -49,6 +49,45 @@ public sealed class UnitConfig
/// <summary>Starting values for agents that join this unit.</summary> /// <summary>Starting values for agents that join this unit.</summary>
[JsonPropertyName("defaults")] [JsonPropertyName("defaults")]
public UnitDefaults Defaults { get; set; } = new(); public UnitDefaults Defaults { get; set; } = new();
/// <summary>
/// #687: What the lead of this unit may approve for the agents below it; <c>null</c> means
/// nothing — every approval goes to a human. Set only by a human.
/// </summary>
[JsonPropertyName("approvals")]
public UnitApprovals? Approvals { get; set; }
}
/// <summary>
/// #687: The limits within which a unit's lead approves instead of the human. They belong to
/// the unit, not to the lead as a person — otherwise the power moves with an agent that changes
/// its post, and a vacant lead would have none.
/// </summary>
public sealed class UnitApprovals
{
/// <summary>
/// The operations the lead may approve: <c>Tool.operation</c>, or <c>Tool</c> for all of its
/// operations. A sub-unit can grant no more than every unit above it that grants anything.
/// </summary>
[JsonPropertyName("operations")]
public List<string> Operations { get; set; } = [];
/// <summary>Approvals the lead may give in 24 hours; beyond that the human decides.</summary>
[JsonPropertyName("perDay")]
public int PerDay { get; set; } = 20;
/// <summary>How long the lead has; after that the call is the human's alone.</summary>
[JsonPropertyName("hours")]
public double Hours { get; set; } = 4;
[JsonIgnore]
public TimeSpan Window => TimeSpan.FromHours(Math.Clamp(Hours, 0.25, 72));
/// <summary>Whether this grant 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> /// <summary>
@@ -54,6 +54,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>#689: Escalations, conflicts and decisions; set by the host. Tools reach it through their context.</summary> /// <summary>#689: Escalations, conflicts and decisions; set by the host. Tools reach it through their context.</summary>
public Cases.ICaseDesk? CaseDesk { get; set; } public Cases.ICaseDesk? CaseDesk { get; set; }
/// <summary>#687: Approvals by leads within their unit's limits; set by the host.</summary>
public Staging.ILeadApprovals? LeadApprovals { get; set; }
/// <summary> /// <summary>
/// #717: Special budgets. A run on a task in a funded tree draws on the pot instead of the /// #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. /// daily limits; without this, every run counts against the day.
@@ -1864,6 +1867,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Organisation = CurrentOrganisation(), Organisation = CurrentOrganisation(),
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get), Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
Cases = CaseDesk, Cases = CaseDesk,
Approvals = LeadApprovals,
SpecialBudgets = SpecialBudgets, SpecialBudgets = SpecialBudgets,
Sandbox = Sandbox, Sandbox = Sandbox,
Files = Files, Files = Files,
@@ -66,6 +66,12 @@ public static class OrgBriefing
if (org.LedBy(agentId) is { } led) if (org.LedBy(agentId) is { } led)
{ {
sb.AppendLine($"You lead the unit \"{led.Name}\". Delegate within it, and hand what goes beyond it to your own lead."); sb.AppendLine($"You lead the unit \"{led.Name}\". Delegate within it, and hand what goes beyond it to your own lead.");
// #687: What the unit lets its lead approve — told, so an approval task is no surprise.
if (led.Approvals is { Operations.Count: > 0 } approvals && canEscalate)
sb.AppendLine($"Your unit lets you approve, for the agents below you: {string.Join(", ", approvals.Operations)} " +
$"(up to {approvals.PerDay} a day; you have {approvals.Window.TotalHours:0.##} h each, then a human decides). " +
"Check each call against the agent's task and run, not its own account. Never your own calls. " +
"An approval you give is recorded as yours, and how often you approve is watched.");
var subUnits = org.Children(led.Id); var subUnits = org.Children(led.Id);
if (subUnits.Count > 0) if (subUnits.Count > 0)
{ {
@@ -0,0 +1,216 @@
using System.Text;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Tasks;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Staging;
/// <summary>#687: How one lead has approved — the rate is itself a finding.</summary>
public sealed record LeadApprovalStats(string LeadId, int Routed, int Approved, int Rejected, int Lapsed)
{
/// <summary>Share of its decisions that were approvals; <c>null</c> before the first.</summary>
public double? ApprovalRate => Approved + Rejected == 0 ? null : (double)Approved / (Approved + Rejected);
}
/// <summary>What a lead's decision came to.</summary>
public sealed record LeadDecision(bool Ok, string Message)
{
public static LeadDecision Fail(string message) => new(false, message);
}
/// <summary>The lead's side of an approval: what it decides with, and what it gets back.</summary>
public interface ILeadApprovals
{
Task<LeadDecision> DecideAsync(long callId, string leadId, AgentTrust trust, bool approve, string reason, CancellationToken ct);
/// <summary>The calls waiting for this lead now.</summary>
Task<IReadOnlyList<StagedCall>> PendingForAsync(string leadId, CancellationToken ct);
/// <summary>How each lead decided what it was given, over the last days.</summary>
Task<IReadOnlyList<LeadApprovalStats>> StatsAsync(int days, CancellationToken ct);
}
/// <summary>
/// #687: Approval by the lead within limits — the human must not be the bottleneck of fifty
/// agents (Organisation-Konzept, section 10).
///
/// <para><b>The limits belong to the unit</b> (<see cref="UnitApprovals"/>), and only a human
/// sets them. A staged call goes to the nearest lead above the agent whose unit grants the
/// operation — for a window of hours. The human sees it all the time and can decide it first;
/// after the window it is the human's alone. Nothing is approved by waiting.</para>
///
/// <para><b>Two hard rules.</b> A lead never approves its own calls — otherwise approval is a
/// formality with extra cost. What changes the structure — agents, their texts and rights —
/// always stays with the human (<see cref="FreigabeRegeln.IstImmerFreigabe"/>).</para>
///
/// <para><b>A lead that reads foreign content does not approve.</b> Its judgement is the
/// most rewarding target an injected text can have (#715).</para>
///
/// <para><b>A lead that approves is a model that approves.</b> Every decision stands in the
/// audit as <c>lead:&lt;id&gt;</c>, and the approval rate per lead is shown: a lead that
/// approves everything does nothing but cost.</para>
/// </summary>
public sealed class LeadApprovals(
IStagingRepository repo,
TaskboardService board,
Func<OrgChart?> organisation,
Func<AgentConfig, AgentTrust> trustOf,
ILogger logger,
TimeProvider? clock = null) : ILeadApprovals
{
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
/// <summary>Set once the service exists — it needs the gate, the gate needs this.</summary>
public StagingService? Service { get; set; }
public const string DecidedByPrefix = "lead:";
private DateTime Now => _clock.GetUtcNow().UtcDateTime;
/// <summary>
/// The lead that may decide this call, with the unit that grants it — or <c>null</c>: the
/// nearest lead above the agent whose unit covers the operation, never the agent itself, never
/// one that reads foreign content.
/// </summary>
public static (AgentConfig Lead, UnitConfig Unit)? ApproverFor(
OrgChart? org, string agentId, string tool, string? operation, Func<AgentConfig, AgentTrust> trustOf)
{
if (org is not { HasUnits: true } || FreigabeRegeln.IstImmerFreigabe(tool, operation))
return null;
if (org.UnitOf(agentId) is not { } unit)
return null;
foreach (var candidate in new[] { unit }.Concat(org.Ancestors(unit.Id)))
{
if (org.LeaderOf(candidate.Id) is not { } lead
|| string.Equals(lead.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
continue;
if (candidate.Approvals?.Covers(tool, operation) != true)
continue;
if (trustOf(lead).ReadsForeignContent)
continue;
return (lead, candidate);
}
return null;
}
/// <summary>
/// Gives a freshly staged call to its lead, if one may decide it and has room today.
/// Returns the line the proposing agent reads, or <c>null</c>: the human decides.
/// </summary>
public async Task<string?> RouteAsync(StagedCall call, CancellationToken ct)
{
var org = organisation();
if (ApproverFor(org, call.AgentId, call.Tool, call.Action, trustOf) is not var (lead, unit))
return null;
var today = (await repo.ListRoutedSinceAsync(Now.AddDays(-1), ct))
.Count(c => string.Equals(c.Approver, lead.AgentId, StringComparison.OrdinalIgnoreCase));
if (today >= Math.Max(0, unit.Approvals!.PerDay))
{
logger.LogInformation("Approval #{Id}: lead {Lead} has reached its {Cap} for today — a human decides", call.Id, lead.AgentId, unit.Approvals.PerDay);
return null;
}
var until = Now + unit.Approvals.Window;
var task = await board.CreateAsync(new TaskItem
{
Title = $"Approval #{call.Id}: {Label(call)} from {call.AgentId}",
Body = Request(call, until),
Assignee = "@" + lead.AgentId,
Priority = 4,
Acceptance = "The call is approved or rejected with a reason — or left to the human.",
OriginRunId = call.RunId
}, ct);
await repo.SetApproverAsync(call.Id, lead.AgentId, until, task.Id, ct);
logger.LogInformation("Approval #{Id} ({Tool}) of {Agent} goes to its lead {Lead} until {Until:u}",
call.Id, Label(call), call.AgentId, lead.AgentId, until);
return $"Your lead {lead.AgentId} decides it (until {until:yyyy-MM-dd HH:mm} UTC), otherwise a human.";
}
public async Task<LeadDecision> DecideAsync(long callId, string leadId, AgentTrust trust, bool approve, string reason, CancellationToken ct)
{
if (Service is not { } service)
return LeadDecision.Fail("Approvals are not available in this instance.");
if (await repo.GetAsync(callId, ct) is not { } call)
return LeadDecision.Fail($"There is no approval #{callId}.");
if (call.Status != StagingStatus.Pending)
return LeadDecision.Fail($"Approval #{callId} is already decided ({call.Status}).");
if (!string.Equals(call.Approver, leadId, StringComparison.OrdinalIgnoreCase))
return LeadDecision.Fail($"Approval #{callId} is not yours to decide.");
if (!call.WithLead(Now))
return LeadDecision.Fail($"Your time for approval #{callId} has run out — a human decides it now.");
if (string.Equals(call.AgentId, leadId, StringComparison.OrdinalIgnoreCase))
return LeadDecision.Fail("You never approve your own calls.");
if (trust.ReadsForeignContent)
return LeadDecision.Fail("You read content from outside, so you do not approve — a human decides it.");
// The structure may have changed since: the grant must still hold, and still be this lead's.
var now = ApproverFor(organisation(), call.AgentId, call.Tool, call.Action, trustOf);
if (now is not var (lead, _) || !string.Equals(lead.AgentId, leadId, StringComparison.OrdinalIgnoreCase))
return LeadDecision.Fail($"Your unit no longer grants you approval #{callId} — a human decides it.");
if (!approve && string.IsNullOrWhiteSpace(reason))
return LeadDecision.Fail("A rejection needs its reason — the agent must know what to change.");
var decidedBy = DecidedByPrefix + leadId;
var result = approve
? await service.ApproveAsync(callId, decidedBy, ct)
: await service.RejectAsync(callId, decidedBy, reason.Trim(), ct);
return result.Kind switch
{
StagingResultKind.Executed => new LeadDecision(true, $"Approval #{callId} approved and carried out: {Clip(result.Message, 300)}"),
StagingResultKind.Failed => new LeadDecision(true, $"Approval #{callId} approved, but it failed: {Clip(result.Message, 300)}"),
StagingResultKind.Rejected => new LeadDecision(true, $"Approval #{callId} rejected. The agent is told why."),
_ => LeadDecision.Fail(result.Message)
};
}
public async Task<IReadOnlyList<StagedCall>> PendingForAsync(string leadId, CancellationToken ct)
=> (await repo.ListPendingAsync(ct))
.Where(c => string.Equals(c.Approver, leadId, StringComparison.OrdinalIgnoreCase) && c.WithLead(Now))
.ToList();
/// <summary>How each lead decided what it was given, over the last <paramref name="days"/> days.</summary>
public async Task<IReadOnlyList<LeadApprovalStats>> StatsAsync(int days, CancellationToken ct)
{
var now = Now;
return (await repo.ListRoutedSinceAsync(now.AddDays(-Math.Max(1, days)), ct))
.GroupBy(c => c.Approver!, StringComparer.OrdinalIgnoreCase)
.Select(g =>
{
var byLead = g.Where(c => string.Equals(c.DecidedBy, DecidedByPrefix + g.Key, StringComparison.OrdinalIgnoreCase)).ToList();
return new LeadApprovalStats(
g.Key,
g.Count(),
byLead.Count(c => c.Status is StagingStatus.Executed or StagingStatus.Failed or StagingStatus.Approved),
byLead.Count(c => c.Status == StagingStatus.Rejected),
g.Count(c => !byLead.Contains(c) && !c.WithLead(now)));
})
.OrderBy(s => s.LeadId, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string Request(StagedCall call, DateTime until)
{
var sb = new StringBuilder();
sb.AppendLine($"{call.AgentId} proposes {Label(call)} and needs an approval (#{call.Id}). Your unit lets you decide it " +
$"until {until:yyyy-MM-dd HH:mm} UTC; after that a human does. A human may also decide it before you.");
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 what the agent was asked to do — look at its task and run, not at what it says about itself. " +
$"Then use the Escalation tool: approve_call (call {call.Id}) or reject_call (call {call.Id}, reason). " +
"If you are unsure, leave it: the human decides. Approving everything is not approving.");
return sb.ToString().TrimEnd();
}
private static string Label(StagedCall call)
=> string.IsNullOrWhiteSpace(call.Action) ? call.Tool : $"{call.Tool}.{call.Action}";
private static string Clip(string text, int max) => text.Length <= max ? text : text[..max] + "…";
}
@@ -168,11 +168,40 @@ public sealed class SqliteStagingRepository : IStagingRepository
return await cmd.ExecuteNonQueryAsync(ct); return await cmd.ExecuteNonQueryAsync(ct);
}, ct); }, ct);
public Task SetApproverAsync(long id, string approver, DateTime until, string taskId, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE StagedCalls SET Approver = @approver, ApproverUntil = @until, ApproverTaskId = @task
WHERE Id = @id AND Status = 'Pending'
""";
cmd.Parameters.AddWithValue("@approver", approver);
cmd.Parameters.AddWithValue("@until", Format(until));
cmd.Parameters.AddWithValue("@task", taskId);
cmd.Parameters.AddWithValue("@id", id);
await cmd.ExecuteNonQueryAsync(ct);
}, ct);
public async Task<IReadOnlyList<StagedCall>> ListRoutedSinceAsync(DateTime since, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT " + Columns + " FROM StagedCalls WHERE Approver IS NOT NULL AND CreatedAt >= @since ORDER BY Id";
cmd.Parameters.AddWithValue("@since", Format(since));
var results = new List<StagedCall>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
results.Add(Read(reader));
return results;
}
// ─── Hilfsfunktionen ─── // ─── Hilfsfunktionen ───
private const string Columns = private const string Columns =
"Id, RunId, AgentId, InstanceId, Tool, Action, ArgumentsJson, Proposal, Status, " + "Id, RunId, AgentId, InstanceId, Tool, Action, ArgumentsJson, Proposal, Status, " +
"CreatedAt, DecidedAt, DecidedBy, ResultRef, RejectionReason"; "CreatedAt, DecidedAt, DecidedBy, ResultRef, RejectionReason, Approver, ApproverUntil, ApproverTaskId";
private static StagedCall Read(SqliteDataReader r) => new() private static StagedCall Read(SqliteDataReader r) => new()
{ {
@@ -189,7 +218,10 @@ public sealed class SqliteStagingRepository : IStagingRepository
DecidedAt = r.IsDBNull(10) ? null : Parse(r.GetString(10)), DecidedAt = r.IsDBNull(10) ? null : Parse(r.GetString(10)),
DecidedBy = r.IsDBNull(11) ? null : r.GetString(11), DecidedBy = r.IsDBNull(11) ? null : r.GetString(11),
ResultRef = r.IsDBNull(12) ? null : r.GetString(12), ResultRef = r.IsDBNull(12) ? null : r.GetString(12),
RejectionReason = r.IsDBNull(13) ? null : r.GetString(13) RejectionReason = r.IsDBNull(13) ? null : r.GetString(13),
Approver = r.IsDBNull(14) ? null : r.GetString(14),
ApproverUntil = r.IsDBNull(15) ? null : Parse(r.GetString(15)),
ApproverTaskId = r.IsDBNull(16) ? null : r.GetString(16)
}; };
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O"); private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
+22 -4
View File
@@ -52,6 +52,12 @@ public sealed class StagingGate
_clock = clock ?? TimeProvider.System; _clock = clock ?? TimeProvider.System;
} }
/// <summary>
/// #687: Hands a freshly staged call to the lead that may decide it. Returns the line the
/// agent reads, or <c>null</c> when a human decides.
/// </summary>
public Func<StagedCall, CancellationToken, Task<string?>>? Route { get; set; }
/// <summary>Verwirft offene Vorschlaege, die aelter als die Verfallsfrist sind. Gibt die Anzahl zurueck.</summary> /// <summary>Verwirft offene Vorschlaege, die aelter als die Verfallsfrist sind. Gibt die Anzahl zurueck.</summary>
public Task<int> ExpireAsync(CancellationToken ct) public Task<int> ExpireAsync(CancellationToken ct)
{ {
@@ -115,7 +121,7 @@ public sealed class StagingGate
vorhanden.Id); vorhanden.Id);
} }
var id = await _repo.AppendAsync(new StagedCall var staged = new StagedCall
{ {
RunId = runId, RunId = runId,
AgentId = agentId, AgentId = agentId,
@@ -124,13 +130,25 @@ public sealed class StagingGate
Action = action, Action = action,
ArgumentsJson = argumentsJson, ArgumentsJson = argumentsJson,
Proposal = BuildProposal(tool, action, argumentsJson) Proposal = BuildProposal(tool, action, argumentsJson)
}, ct); };
var id = await _repo.AppendAsync(staged, ct);
// #687: A lead within its unit's limits decides instead of the human.
string? beiLeitung = null;
if (Route is { } route)
{
// Routing is a relief for the human, not a condition: if it fails, the human decides.
try { beiLeitung = await route(staged with { Id = id }, ct); }
catch (Exception ex) when (ex is not OperationCanceledException) { beiLeitung = null; }
}
return new StagingInterception( return new StagingInterception(
StagingOutcome.Staged, StagingOutcome.Staged,
$"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. {einstufung.Grund} " + $"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. {einstufung.Grund} " +
"Die Aktion wird erst nach menschlicher Freigabe ausgeführt. Scheitert sie oder wird sie " + (beiLeitung is null
"abgelehnt, wirst du geweckt. Fahre mit anderer Arbeit fort oder schließe ab.", ? "Die Aktion wird erst nach menschlicher Freigabe ausgeführt. "
: $"Die Aktion wird erst nach Freigabe ausgeführt. {beiLeitung} ") +
"Scheitert sie oder wird sie abgelehnt, wirst du geweckt. Fahre mit anderer Arbeit fort oder schließe ab.",
id); id);
default: default:
@@ -73,6 +73,21 @@ public sealed record StagedCall
/// <summary>Bei Ablehnung: der Grund.</summary> /// <summary>Bei Ablehnung: der Grund.</summary>
public string? RejectionReason { get; init; } public string? RejectionReason { get; init; }
/// <summary>
/// #687: The lead that may decide this call instead of the human, until
/// <see cref="ApproverUntil"/>. The human can decide it all the time; after that only the human.
/// </summary>
public string? Approver { get; init; }
public DateTime? ApproverUntil { get; init; }
/// <summary>#687: The task in the lead's inbox — closed once the call is decided.</summary>
public string? ApproverTaskId { get; init; }
/// <summary>#687: Whether the lead may still decide it at <paramref name="nowUtc"/>.</summary>
public bool WithLead(DateTime nowUtc)
=> Status == StagingStatus.Pending && !string.IsNullOrWhiteSpace(Approver) && ApproverUntil is { } until && nowUtc < until;
} }
public interface IStagingRepository public interface IStagingRepository
@@ -116,4 +131,10 @@ public interface IStagingRepository
/// <summary>#660: Everything a run proposed for approval - for the run detail view.</summary> /// <summary>#660: Everything a run proposed for approval - for the run detail view.</summary>
Task<IReadOnlyList<StagedCall>> ListForRunAsync(string runId, CancellationToken ct); Task<IReadOnlyList<StagedCall>> ListForRunAsync(string runId, CancellationToken ct);
/// <summary>#687: Hands a pending call to a lead until a point in time, with its inbox task.</summary>
Task SetApproverAsync(long id, string approver, DateTime until, string taskId, CancellationToken ct);
/// <summary>#687: Calls routed to a lead since <paramref name="since"/> — decided or not, for its approval rate and daily cap.</summary>
Task<IReadOnlyList<StagedCall>> ListRoutedSinceAsync(DateTime since, CancellationToken ct);
} }
+27 -1
View File
@@ -141,6 +141,7 @@ public sealed class StagingService
// ausgeführt wird. // ausgeführt wird.
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, decidedBy, null, DateTime.UtcNow, ct)) if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, decidedBy, null, DateTime.UtcNow, ct))
return StagingResult.AlreadyDecided; return StagingResult.AlreadyDecided;
await CloseLeadTaskAsync(call, decidedBy, ct);
string result; string result;
StagingStatus final; StagingStatus final;
@@ -202,6 +203,7 @@ public sealed class StagingService
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Rejected, decidedBy, reason, DateTime.UtcNow, ct)) if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Rejected, decidedBy, reason, DateTime.UtcNow, ct))
return StagingResult.AlreadyDecided; return StagingResult.AlreadyDecided;
await CloseLeadTaskAsync(call, decidedBy, ct);
await RecordAuditAsync(call, decidedBy, AuditStatus.Denied, $"Abgelehnt von {decidedBy}: {reason}", ct); await RecordAuditAsync(call, decidedBy, AuditStatus.Denied, $"Abgelehnt von {decidedBy}: {reason}", ct);
await SammelnAsync(call, "abgelehnt", $"Grund: {reason}", weckt: true, ct); await SammelnAsync(call, "abgelehnt", $"Grund: {reason}", weckt: true, ct);
@@ -211,6 +213,30 @@ public sealed class StagingService
// ─── Hilfsfunktionen ─── // ─── Hilfsfunktionen ───
/// <summary>
/// #687: Once a call is decided — by its lead or by the human first — the lead's task for
/// it is done; a lead must not be woken for something already settled.
/// </summary>
private async Task CloseLeadTaskAsync(StagedCall call, string decidedBy, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(call.ApproverTaskId))
return;
try
{
if (await _board.GetAsync(call.ApproverTaskId, ct) is { Status: not (TaskItemStatus.Done or TaskItemStatus.Canceled) } task)
{
var byLead = decidedBy.StartsWith(LeadApprovals.DecidedByPrefix, StringComparison.Ordinal);
if (!byLead)
await _board.AddCommentAsync(task.Id, "system", $"Approval #{call.Id} was decided by a human ({decidedBy}) — nothing left to do.", ct);
await _board.UpdateAsync(task.Id, t => t with { Status = byLead ? TaskItemStatus.Done : TaskItemStatus.Canceled }, ct);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "The lead's task for approval #{Id} could not be closed", call.Id);
}
}
/// <summary> /// <summary>
/// Trägt eine Entscheidung in die offene Sammelaufgabe des Agenten ein und schiebt ihren /// Trägt eine Entscheidung in die offene Sammelaufgabe des Agenten ein und schiebt ihren
/// Termin um das Bündelfenster nach hinten. Gibt es keine offene, entsteht eine — aber /// Termin um das Bündelfenster nach hinten. Gibt es keine offene, entsteht eine — aber
@@ -267,7 +293,7 @@ public sealed class StagingService
} }
internal const string SammelKopf = internal const string SammelKopf =
"[Freigabe-Ergebnisse] Ein Mensch hat über Aktionen entschieden, die du zur Freigabe " "[Freigabe-Ergebnisse] Ein Mensch oder deine Leitung hat über Aktionen entschieden, die du zur Freigabe "
+ "vorgelegt hattest. Freigegebene Aktionen wurden genau so ausgeführt, wie du sie vorgelegt " + "vorgelegt hattest. Freigegebene Aktionen wurden genau so ausgeführt, wie du sie vorgelegt "
+ "hast, abgelehnte gar nicht.\n" + "hast, abgelehnte gar nicht.\n"
+ "- Gescheitert: Prüfe die Ursache. Liegt sie bei dir (falscher Name, falsche Spalte, " + "- Gescheitert: Prüfe die Ursache. Liegt sie bei dir (falscher Name, falsche Spalte, "
@@ -491,6 +491,10 @@ public sealed class SqliteStorage
SpalteErgaenzen(connection, "RunUsage", "Runtime", "TEXT NOT NULL DEFAULT ''"); SpalteErgaenzen(connection, "RunUsage", "Runtime", "TEXT NOT NULL DEFAULT ''");
SpalteErgaenzen(connection, "RunUsage", "OnSubscription", "INTEGER NOT NULL DEFAULT 0"); SpalteErgaenzen(connection, "RunUsage", "OnSubscription", "INTEGER NOT NULL DEFAULT 0");
SpalteErgaenzen(connection, "RunReceipts", "BudgetPot", "TEXT NULL"); SpalteErgaenzen(connection, "RunReceipts", "BudgetPot", "TEXT NULL");
// #687: a lead that may decide a staged call instead of the human, until when, and its inbox task.
SpalteErgaenzen(connection, "StagedCalls", "Approver", "TEXT NULL");
SpalteErgaenzen(connection, "StagedCalls", "ApproverUntil", "TEXT NULL");
SpalteErgaenzen(connection, "StagedCalls", "ApproverTaskId", "TEXT NULL");
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen. // Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
using var index = connection.CreateCommand(); using var index = connection.CreateCommand();
@@ -49,6 +49,9 @@ public sealed record AgentToolContext(
/// <summary>#689: The desk for escalations, conflicts and decisions. <c>null</c> outside the engine.</summary> /// <summary>#689: The desk for escalations, conflicts and decisions. <c>null</c> outside the engine.</summary>
public Cases.ICaseDesk? Cases { get; init; } public Cases.ICaseDesk? Cases { get; init; }
/// <summary>#687: Approvals a lead decides for the agents below it. <c>null</c> outside the engine.</summary>
public Staging.ILeadApprovals? Approvals { get; init; }
/// <summary>#654: Where code runs — never in the main process. Unavailable under Windows.</summary> /// <summary>#654: Where code runs — never in the main process. Unavailable under Windows.</summary>
public Sandbox.ISandboxRunner? Sandbox { get; init; } public Sandbox.ISandboxRunner? Sandbox { get; init; }
@@ -573,6 +573,16 @@ button.klein {
background: var(--surface-hover); background: var(--surface-hover);
} }
/* #687: a lead that approves everything */
.daten-tabelle tr.zeile-warnung td {
color: var(--warnung);
}
/* #687: a call a lead may still decide — the human's too, but not yet the human's alone */
.staging-bei-leitung {
border-left: 3px solid var(--accent);
}
/* ─── Datenlisten (dl/dt/dd) ─── */ /* ─── Datenlisten (dl/dt/dd) ─── */
.datenliste { .datenliste {
@@ -95,6 +95,11 @@
"org.ceiling": "Rechte-Obergrenze", "org.ceiling": "Rechte-Obergrenze",
"org.ceilingHint": "Was die Einheit nicht darf, kann ein Mitglied nicht bekommen. Senken entzieht den Mitgliedern Werkzeuge — nach einer Bestätigung.", "org.ceilingHint": "Was die Einheit nicht darf, kann ein Mitglied nicht bekommen. Senken entzieht den Mitgliedern Werkzeuge — nach einer Bestätigung.",
"org.ceilingLimit": "Werkzeuge dieser Einheit begrenzen", "org.ceilingLimit": "Werkzeuge dieser Einheit begrenzen",
"org.approvals": "Freigaben durch die Leitung",
"org.approvalsHint": "Was die Leitung dieser Einheit für die Agenten darunter an deiner Stelle freigeben darf. Eine Operation je Zeile: Werkzeug.operation, oder Werkzeug für alle. Änderungen an Agenten bleiben immer bei dir; eine Leitung gibt nie ihre eigenen Aufrufe frei. Eine Untereinheit darf nicht mehr freigeben als die Einheiten darüber. Leer: du entscheidest alles.",
"org.approvalsOperations": "Operationen",
"org.approvalsPerDay": "Höchstens je Tag",
"org.approvalsHours": "Stunden je Freigabe",
"org.ceilingInherited": "Von oben begrenzt auf: {tools}", "org.ceilingInherited": "Von oben begrenzt auf: {tools}",
"org.settings": "Sammelwerte", "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.", "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.",
@@ -608,6 +613,16 @@
"staging.rejectButton": "Aufruf ablehnen", "staging.rejectButton": "Aufruf ablehnen",
"staging.rejected": "Aufruf wurde abgelehnt.", "staging.rejected": "Aufruf wurde abgelehnt.",
"staging.rejectFailed": "Ablehnen fehlgeschlagen: {error}", "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.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",
"staging.leadRouted": "Vorgelegt",
"staging.leadApproved": "Freigegeben",
"staging.leadRejected": "Abgelehnt",
"staging.leadLapsed": "Dir überlassen",
"staging.leadRate": "Freigabequote",
"staging.leadAllApproved": "Alles freigegeben — prüfe, ob diese Leitung wirklich hinsieht.",
"agentChats.title": "Agenten-Kommunikation (A2A)", "agentChats.title": "Agenten-Kommunikation (A2A)",
"agentChats.allAgents": "Alle Agenten", "agentChats.allAgents": "Alle Agenten",
@@ -95,6 +95,11 @@
"org.ceiling": "Tool ceiling", "org.ceiling": "Tool ceiling",
"org.ceilingHint": "What the unit may not have, a member cannot be granted. Lowering it removes tools from the members — after a confirmation.", "org.ceilingHint": "What the unit may not have, a member cannot be granted. Lowering it removes tools from the members — after a confirmation.",
"org.ceilingLimit": "Limit the tools of this unit", "org.ceilingLimit": "Limit the tools of this unit",
"org.approvals": "Approvals by the lead",
"org.approvalsHint": "What the lead of this unit may approve for the agents below it, instead of you. One operation per line: Tool.operation, or Tool for all of it. Changes to agents always stay with you; a lead never approves its own calls. A sub-unit can grant no more than the units above it. Empty: you decide everything.",
"org.approvalsOperations": "Operations",
"org.approvalsPerDay": "Per day at most",
"org.approvalsHours": "Hours per approval",
"org.ceilingInherited": "Limited from above to: {tools}", "org.ceilingInherited": "Limited from above to: {tools}",
"org.settings": "Unit-wide values", "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.", "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.",
@@ -608,6 +613,16 @@
"staging.rejectButton": "Reject Call", "staging.rejectButton": "Reject Call",
"staging.rejected": "Call was rejected.", "staging.rejected": "Call was rejected.",
"staging.rejectFailed": "Rejection failed: {error}", "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.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",
"staging.leadRouted": "Given",
"staging.leadApproved": "Approved",
"staging.leadRejected": "Rejected",
"staging.leadLapsed": "Left to you",
"staging.leadRate": "Approval rate",
"staging.leadAllApproved": "Everything approved — check whether this lead really looks.",
"agentChats.title": "Agent Communication (A2A)", "agentChats.title": "Agent Communication (A2A)",
"agentChats.allAgents": "All Agents", "agentChats.allAgents": "All Agents",
@@ -366,6 +366,7 @@ function unitPanel(unit) {
cards.push(unitWorkspaceCard(unit)); cards.push(unitWorkspaceCard(unit));
cards.push(ceilingCard(unit)); cards.push(ceilingCard(unit));
cards.push(approvalsCard(unit));
cards.push(settingsCard(unit)); cards.push(settingsCard(unit));
// Members. // Members.
@@ -432,6 +433,35 @@ function ceilingCard(unit) {
]); ]);
} }
/**
* #687: What the unit's lead may approve instead of the human. One operation per line —
* "Tool.operation", or "Tool" for all of it. Changes to agents are refused by the server.
*/
function approvalsCard(unit) {
const current = unit.approvals;
const ops = erstelleElement("textarea", { rows: 3, placeholder: "Database.insert\nFileRW.delete" });
ops.value = (current?.operations ?? []).join("\n");
const perDay = erstelleElement("input", { type: "number", min: "0", step: "1", value: String(current?.perDay ?? 20) });
const hours = erstelleElement("input", { type: "number", min: "0.25", step: "0.25", value: String(current?.hours ?? 4) });
return erstelleKarte(t("org.approvals"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.approvalsHint") }),
erstelleElement("div", { klasse: "formular-raster" },
erstelleElement("label", { text: t("org.approvalsOperations") }), ops,
erstelleElement("label", { text: t("org.approvalsPerDay") }), perDay,
erstelleElement("label", { text: t("org.approvalsHours") }), hours),
erstelleElement("button", {
type: "button", klasse: "klein", text: t("common.save"),
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/approvals`, "PUT", {
operations: ops.value.split(/[\n,]/).map((o) => o.trim()).filter(Boolean),
perDay: Number(perDay.value),
hours: Number(hours.value),
reason: reason()
})
})
]);
}
function settingsCard(unit) { function settingsCard(unit) {
const setting = erstelleElement("select", {}, const setting = erstelleElement("select", {},
...SETTINGS.map((s) => erstelleElement("option", { value: s, text: t(`org.setting.${s}`) }))); ...SETTINGS.map((s) => erstelleElement("option", { value: s, text: t(`org.setting.${s}`) })));
@@ -16,10 +16,15 @@ import {
let wartendeAufrufe = []; let wartendeAufrufe = [];
// #687: What a lead may still decide is not yet the human's to do — it is shown, not counted.
function menschWartet(anfrage) {
return !anfrage.withLead;
}
export async function holeWartendeAnzahl() { export async function holeWartendeAnzahl() {
try { try {
const liste = await api("/staging"); const liste = await api("/staging");
return Array.isArray(liste) ? liste.length : 0; return Array.isArray(liste) ? liste.filter(menschWartet).length : 0;
} catch { } catch {
return 0; return 0;
} }
@@ -48,7 +53,8 @@ export async function rendereStaging() {
const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" }); const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" });
const dauerBereich = erstelleElement("div", { id: "staging-dauer-bereich" }); const dauerBereich = erstelleElement("div", { id: "staging-dauer-bereich" });
layout.append(kopfZeile, listeBereich, dauerBereich); const leitungBereich = erstelleElement("div", { id: "staging-leitung-bereich" });
layout.append(kopfZeile, listeBereich, dauerBereich, leitungBereich);
container.append(layout); container.append(layout);
await ladeFreigaben(); await ladeFreigaben();
@@ -67,8 +73,9 @@ async function ladeFreigaben() {
container.replaceChildren(); container.replaceChildren();
// Aktualisiere Badge in Sidebar // Aktualisiere Badge in Sidebar
aktualisiereStagingBadge(wartendeAufrufe.length); aktualisiereStagingBadge(wartendeAufrufe.filter(menschWartet).length);
ladeDauerfreigaben(); ladeDauerfreigaben();
ladeLeitungen();
if (!wartendeAufrufe || wartendeAufrufe.length === 0) { if (!wartendeAufrufe || wartendeAufrufe.length === 0) {
container.append( container.append(
@@ -137,6 +144,14 @@ async function ladeFreigaben() {
const teile = [datenliste, argBox]; const teile = [datenliste, argBox];
// #687: A lead decides it within its unit's limits — the human can still decide first.
if (anfrage.withLead) {
teile.unshift(erstelleElement("div", {
klasse: "hinweis-box",
text: t("staging.withLead", { lead: anfrage.withLead, until: formatiereDatumZeit(anfrage.leadUntil) })
}));
}
if (anfrage.suggestAllowSimilar) { if (anfrage.suggestAllowSimilar) {
teile.push( teile.push(
erstelleElement("div", { erstelleElement("div", {
@@ -169,7 +184,8 @@ async function ladeFreigaben() {
} }
const karte = erstelleKarte(`${anfrage.tool}.${anfrage.action || "?"}`, teile, { const karte = erstelleKarte(`${anfrage.tool}.${anfrage.action || "?"}`, teile, {
aktionen: kopfAktionen aktionen: kopfAktionen,
klasse: anfrage.withLead ? "staging-bei-leitung" : undefined
}); });
container.append(karte); container.append(karte);
@@ -226,6 +242,44 @@ async function bestaetigeFreigabe(id, gleichartig = false) {
} }
} }
/**
* #687: How each lead decided what it was given. A lead that approves everything does
* nothing but cost — the rate is the finding, so it is shown, not hidden in the audit.
*/
async function ladeLeitungen() {
const bereich = document.getElementById("staging-leitung-bereich");
if (!bereich) return;
let liste = [];
try { liste = await api("/staging/leads"); } catch { liste = []; }
if (!Array.isArray(liste) || liste.length === 0) {
bereich.replaceChildren();
return;
}
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
tabelle.append(erstelleElement("thead", {}, erstelleElement("tr", {},
...["staging.leadName", "staging.leadRouted", "staging.leadApproved", "staging.leadRejected", "staging.leadLapsed", "staging.leadRate"]
.map((k) => erstelleElement("th", { text: t(k) })))));
const koerper = erstelleElement("tbody");
for (const l of liste) {
const quote = l.approvalRate == null ? "—" : `${Math.round(l.approvalRate * 100)} %`;
// Everything approved, with enough decisions to mean something, is worth a look.
const auffaellig = l.approvalRate === 1 && l.approved >= 5;
koerper.append(erstelleElement("tr", { klasse: auffaellig ? "zeile-warnung" : undefined },
erstelleElement("td", { text: l.leadId }),
erstelleElement("td", { text: String(l.routed) }),
erstelleElement("td", { text: String(l.approved) }),
erstelleElement("td", { text: String(l.rejected) }),
erstelleElement("td", { text: String(l.lapsed) }),
erstelleElement("td", { text: quote, title: auffaellig ? t("staging.leadAllApproved") : "" })));
}
tabelle.append(koerper);
bereich.replaceChildren(erstelleKarte(t("staging.leadsTitle"), [
erstelleElement("p", { klasse: "gedaempft", text: t("staging.leadsHint") }),
tabelle
]));
}
/** /**
* #641: Die geltenden Dauerfreigaben - "Gleichartiges kuenftig erlauben" - mit Widerruf. * #641: Die geltenden Dauerfreigaben - "Gleichartiges kuenftig erlauben" - mit Widerruf.
*/ */
@@ -31,6 +31,7 @@ public sealed class EscalationTool : IAgentTool
- state_position: answer a request for your position or a statement. - state_position: answer a request for your position or a statement.
After the deadline a human decides. After the deadline a human decides.
- request_budget: a special budget for one large task and its parts (task, amount_usd, reasoning). A human always decides; it does not raise your daily limit. - request_budget: a special budget for one large task and its parts (task, amount_usd, reasoning). A human always decides; it does not raise your daily limit.
As a lead whose unit may approve: list_approvals shows the calls waiting for you; approve_call (call) or reject_call (call, reason). Never your own calls; if unsure, leave it to the human.
"""; """;
public JsonElement InputSchema { get; } = JsonDocument.Parse(""" public JsonElement InputSchema { get; } = JsonDocument.Parse("""
@@ -39,7 +40,7 @@ public sealed class EscalationTool : IAgentTool
"properties": { "properties": {
"action": { "action": {
"type": "string", "type": "string",
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget"] "enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call"]
}, },
"amount_usd": { "type": "number", "description": "request_budget: the amount for the task and all its parts, in USD." }, "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." }, "subject": { "type": "string", "description": "What it is about, in a few words — like a title. escalate, open_conflict, find_decisions." },
@@ -54,7 +55,9 @@ public sealed class EscalationTool : IAgentTool
"note": { "type": "string", "description": "handle: what you did about it." }, "note": { "type": "string", "description": "handle: what you did about it." },
"assessment": { "type": "string", "description": "forward: your view, added to the case." }, "assessment": { "type": "string", "description": "forward: your view, added to the case." },
"with": { "type": "string", "description": "open_conflict: the agent you disagree with." }, "with": { "type": "string", "description": "open_conflict: the agent you disagree with." },
"position": { "type": "string", "description": "open_conflict, state_position: your side, with reasons." } "position": { "type": "string", "description": "open_conflict, state_position: your side, with reasons." },
"call": { "type": "integer", "description": "approve_call, reject_call: the approval number (#…)." },
"reason": { "type": "string", "description": "reject_call: why — the agent must know what to change." }
}, },
"required": ["action"] "required": ["action"]
} }
@@ -70,6 +73,10 @@ public sealed class EscalationTool : IAgentTool
if (Get(input, "action") == "request_budget") if (Get(input, "action") == "request_budget")
return await SetUpBudgetAsync(input, context, ct); return await SetUpBudgetAsync(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);
if (context.Cases is not { } desk) if (context.Cases is not { } desk)
return ToolResult.Fail("Escalation is not available in this instance."); return ToolResult.Fail("Escalation is not available in this instance.");
@@ -138,7 +145,7 @@ public sealed class EscalationTool : IAgentTool
break; break;
default: default:
return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget."); 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.");
} }
return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message); return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message);
@@ -196,6 +203,30 @@ public sealed class EscalationTool : IAgentTool
"Runs in that task tree draw on it, not on your daily budget; once it is used up, they count against the day again."); "Runs in that task tree draw on it, not on your daily budget; once it is used up, they count against the day again.");
} }
private static async Task<ToolResult> ApprovalAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
if (context.Approvals is not { } approvals)
return ToolResult.Fail("Approvals by leads are not available in this instance.");
var action = Get(input, "action");
if (action == "list_approvals")
{
var waiting = await approvals.PendingForAsync(context.AgentId, ct);
if (waiting.Count == 0)
return ToolResult.Ok("No approval waits for you.");
var sb = new StringBuilder($"{waiting.Count} approval(s) wait for you:\n");
foreach (var c in waiting)
sb.AppendLine($"- #{c.Id} {c.Tool}{(string.IsNullOrWhiteSpace(c.Action) ? "" : "." + c.Action)} from {c.AgentId}, " +
$"until {c.ApproverUntil:yyyy-MM-dd HH:mm} UTC — task {c.ApproverTaskId}");
return ToolResult.Ok(sb.ToString().TrimEnd());
}
if (!input.TryGetProperty("call", out var n) || !n.TryGetInt64(out var callId))
return ToolResult.Fail("'call' is required — the approval number.");
var decided = await approvals.DecideAsync(callId, context.AgentId, context.Trust, action == "approve_call", Get(input, "reason") ?? "", ct);
return decided.Ok ? ToolResult.Ok(decided.Message) : ToolResult.Fail(decided.Message);
}
private static string Describe(Decision d) private static string Describe(Decision d)
=> CaseDesk.DecisionLine(d) + (string.IsNullOrWhiteSpace(d.Reasoning) ? "" : $"\n Reasoning: {d.Reasoning}"); => CaseDesk.DecisionLine(d) + (string.IsNullOrWhiteSpace(d.Reasoning) ? "" : $"\n Reasoning: {d.Reasoning}");
+7 -1
View File
@@ -307,7 +307,13 @@ public sealed record StagedCallDto(
DateTime CreatedAt, DateTime CreatedAt,
int PreviousApprovals = 0, int PreviousApprovals = 0,
bool CanAllowSimilar = false, bool CanAllowSimilar = false,
bool SuggestAllowSimilar = false); bool SuggestAllowSimilar = false,
// #687: the lead that may decide it instead of the human, and until when.
string? WithLead = null,
DateTime? LeadUntil = 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);
/// <summary>#641: Eine Dauerfreigabe - "Gleichartiges kuenftig erlauben".</summary> /// <summary>#641: Eine Dauerfreigabe - "Gleichartiges kuenftig erlauben".</summary>
public sealed record StandingApprovalDto( public sealed record StandingApprovalDto(
+4 -1
View File
@@ -220,7 +220,10 @@ internal static class Mapping
call.CreatedAt, call.CreatedAt,
bisherFreigegeben, bisherFreigegeben,
erlaubbar, erlaubbar,
erlaubbar && bisherFreigegeben >= StagingService.VorschlagAb); erlaubbar && bisherFreigegeben >= StagingService.VorschlagAb,
// #687: with a lead until then — the human can still decide it first.
call.WithLead(DateTime.UtcNow) ? call.Approver : null,
call.WithLead(DateTime.UtcNow) ? call.ApproverUntil : null);
} }
public static StandingApprovalDto ToDto(this Dauerfreigabe d) public static StandingApprovalDto ToDto(this Dauerfreigabe d)
@@ -18,7 +18,13 @@ public sealed record UnitDto(
IReadOnlyList<string>? EffectiveToolCeiling, IReadOnlyList<string>? EffectiveToolCeiling,
UnitDefaultsDto Defaults, UnitDefaultsDto Defaults,
string Path, string Path,
int Depth); int Depth,
// #687: what the lead may approve instead of the human; null: nothing.
UnitApprovalsDto? Approvals = null);
public sealed record UnitApprovalsDto(IReadOnlyList<string> Operations, int PerDay, double Hours);
public sealed record ApprovalsInputDto(IReadOnlyList<string>? Operations, int? PerDay, double? Hours, string Reason = "");
public sealed record UnitDefaultsDto(string? Model, decimal? DailyCostUsd, long? DailyTokens, string? WhenExhausted); public sealed record UnitDefaultsDto(string? Model, decimal? DailyCostUsd, long? DailyTokens, string? WhenExhausted);
@@ -365,6 +365,15 @@ internal static class ApiEndpoints
result.Kind is Core.Staging.StagingResultKind.Executed, result.Message)); result.Kind is Core.Staging.StagingResultKind.Executed, result.Message));
}); });
// #687: How often each lead approved what it was given — a lead that approves everything does nothing.
staging.MapGet("/leads", async (AppHost host, CancellationToken ct, int days = 30) =>
{
if (host.Engine?.LeadApprovals is not { } leads)
return Results.Ok(Array.Empty<LeadApprovalStatsDto>());
var stats = await leads.StatsAsync(days, ct);
return Results.Ok(stats.Select(x => new LeadApprovalStatsDto(x.LeadId, x.Routed, x.Approved, x.Rejected, x.Lapsed, x.ApprovalRate)).ToList());
});
staging.MapGet("/standing", async (AppHost host, CancellationToken ct, bool revoked = false) => staging.MapGet("/standing", async (AppHost host, CancellationToken ct, bool revoked = false) =>
{ {
if (host.Staging is not { } service) if (host.Staging is not { } service)
@@ -27,7 +27,8 @@ internal static class OrganisationEndpoints
u.Id, u.Name, u.ParentId, u.LeaderAgentId, u.Goal, u.ToolCeiling, u.Id, u.Name, u.ParentId, u.LeaderAgentId, u.Goal, u.ToolCeiling,
chart.ToolCeiling(u.Id)?.Order(StringComparer.OrdinalIgnoreCase).ToList(), chart.ToolCeiling(u.Id)?.Order(StringComparer.OrdinalIgnoreCase).ToList(),
new UnitDefaultsDto(u.Defaults.Model, u.Defaults.DailyCostUsd, u.Defaults.DailyTokens, u.Defaults.WhenExhausted), new UnitDefaultsDto(u.Defaults.Model, u.Defaults.DailyCostUsd, u.Defaults.DailyTokens, u.Defaults.WhenExhausted),
chart.PathOf(u.Id), chart.Depth(u.Id))) chart.PathOf(u.Id), chart.Depth(u.Id),
u.Approvals is { } ap ? new UnitApprovalsDto(ap.Operations, ap.PerDay, ap.Hours) : null))
.ToList(); .ToList();
var agents = host.Instance.Agents var agents = host.Instance.Agents
@@ -80,6 +81,10 @@ internal static class OrganisationEndpoints
? Results.Ok(ToDto(host.Organisation.PreviewMoveAgent(agentId, input.Target))) ? Results.Ok(ToDto(host.Organisation.PreviewMoveAgent(agentId, input.Target)))
: host.Organisation.MoveAgent(agentId, input.Target, input.Reason, input.DropExcessTools).ToResponse()); : host.Organisation.MoveAgent(agentId, input.Target, input.Reason, input.DropExcessTools).ToResponse());
// #687: What the lead may approve instead of the human.
org.MapPut("/units/{unitId}/approvals", (AppHost host, string unitId, ApprovalsInputDto input) =>
host.Organisation.SetApprovals(unitId, input.Operations, input.PerDay, input.Hours, input.Reason).ToResponse());
org.MapPut("/units/{unitId}/ceiling", (AppHost host, string unitId, CeilingInputDto input) => org.MapPut("/units/{unitId}/ceiling", (AppHost host, string unitId, CeilingInputDto input) =>
input.Preview input.Preview
? Results.Ok(ToDto(host.Organisation.PreviewToolCeiling(unitId, input.Tools))) ? Results.Ok(ToDto(host.Organisation.PreviewToolCeiling(unitId, input.Tools)))
@@ -251,6 +251,43 @@ public sealed class OrgAdministrationTests : IDisposable
Find("newbie").UnitId.ShouldBe("amazon"); Find("newbie").UnitId.ShouldBe("amazon");
} }
// ═══════════════════ approvals by the lead (#687) ═══════════════════
private UnitConfig Unit(string id) => _test.Host.Instance.Units.Single(u => u.Id == id);
[Fact]
public void A_unit_grants_its_lead_approvals_but_never_changes_to_agents()
{
BuildMarketing();
Org.SetApprovals("marketing", ["AgentEditor.update_soul"]).Message.ShouldContain("always stays with a human");
Org.SetApprovals("marketing", ["AgentEditor"]).Ok.ShouldBeFalse("a whole tool with such an operation reads as a grant for it");
Org.SetApprovals("marketing", [".x"]).Ok.ShouldBeFalse();
var set = Org.SetApprovals("marketing", ["Database.insert", "FileRW.delete"], perDay: 5, hours: 2, reason: "night shift");
set.Ok.ShouldBeTrue(set.Message);
Unit("marketing").Approvals!.Operations.ShouldBe(["Database.insert", "FileRW.delete"]);
Unit("marketing").Approvals!.PerDay.ShouldBe(5);
Org.SetApprovals("marketing", []).Ok.ShouldBeTrue();
Unit("marketing").Approvals.ShouldBeNull();
}
[Fact]
public void A_sub_unit_grants_no_more_than_the_units_above_and_shrinks_with_them()
{
BuildMarketing();
Org.SetApprovals("marketing", ["Database"]).Ok.ShouldBeTrue();
Org.SetApprovals("amazon", ["Mail.send"]).Message.ShouldContain("Marketing");
Org.SetApprovals("amazon", ["Database.insert", "Database.update"]).Ok.ShouldBeTrue("'Database' above covers each of its operations");
var lowered = Org.SetApprovals("marketing", ["Database.insert"]);
lowered.Message.ShouldContain("Amazon");
Unit("amazon").Approvals!.Operations.ShouldBe(["Database.insert"]);
}
// ═══════════════════ the top (#690) ═══════════════════ // ═══════════════════ the top (#690) ═══════════════════
private void MakeDecider(string id) private void MakeDecider(string id)
@@ -75,6 +75,20 @@ internal sealed class InMemoryStagingRepository : IStagingRepository
} }
} }
public Task SetApproverAsync(long id, string approver, DateTime until, string taskId, CancellationToken ct)
{
lock (_lock)
{
if (_calls.TryGetValue(id, out var call) && call.Status == StagingStatus.Pending)
_calls[id] = call with { Approver = approver, ApproverUntil = until, ApproverTaskId = taskId };
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<StagedCall>> ListRoutedSinceAsync(DateTime since, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<StagedCall>>(
_calls.Values.Where(c => c.Approver is not null && c.CreatedAt >= since).OrderBy(c => c.Id).ToList());
/// <summary>Verschiebt den Anlagezeitpunkt eines Vorschlags in die Vergangenheit - fuer Verfallstests.</summary> /// <summary>Verschiebt den Anlagezeitpunkt eines Vorschlags in die Vergangenheit - fuer Verfallstests.</summary>
public void Altern(long id, TimeSpan um) public void Altern(long id, TimeSpan um)
{ {
@@ -0,0 +1,223 @@
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>
/// #687: A lead approves within the limits its unit grants — never its own calls, never a
/// change to agents, never while it reads foreign content — and the human stays above it.
/// </summary>
public sealed class LeadApprovalsTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "leads-" + 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 LeadApprovals _leads;
private readonly HashSet<string> _foreign = new(StringComparer.OrdinalIgnoreCase);
/// <summary>ops (ops-lead; grants Database.insert, 2 a day, 1 h) › team (team-lead, grants nothing; worker).</summary>
private readonly UnitConfig _ops = new()
{
Id = "ops", Name = "Operations", LeaderAgentId = "ops-lead",
Approvals = new UnitApprovals { Operations = ["Database.insert", "AgentEditor"], PerDay = 2, Hours = 1 }
};
private OrgChart Org => new(
[_ops, new UnitConfig { Id = "team", Name = "Team", ParentId = "ops", LeaderAgentId = "team-lead" }],
[
new AgentConfig { AgentId = "ops-lead", UnitId = "ops" },
new AgentConfig { AgentId = "team-lead", UnitId = "team" },
new AgentConfig { AgentId = "worker", UnitId = "team" }
]);
public LeadApprovalsTests()
{
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);
_leads = new LeadApprovals(_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)> Stage(string agent, string tool = "Database", string action = "insert")
{
var call = new StagedCall { RunId = "run-1", AgentId = agent, Tool = tool, Action = action, ArgumentsJson = $"{{\"action\":\"{action}\"}}" };
var id = await _staging.AppendAsync(call, default);
var line = await _leads.RouteAsync(call with { Id = id }, default);
return ((await _staging.GetAsync(id, default))!, line);
}
// ═══════════════════ where it goes ═══════════════════
[Fact]
public async Task A_call_goes_to_the_nearest_lead_whose_unit_grants_it()
{
var (call, line) = await Stage("worker");
call.Approver.ShouldBe("ops-lead", "team-lead's unit grants nothing — the grant above counts");
line!.ShouldContain("ops-lead");
var inbox = (await _tasks.GetAsync(call.ApproverTaskId!, default))!;
inbox.Assignee.ShouldBe("@ops-lead");
inbox.Body.ShouldContain("<untrusted_content", Case.Sensitive, customMessage: "the frozen call is data for the lead");
inbox.Body.ShouldContain("approve_call");
}
[Fact]
public async Task What_the_unit_does_not_grant_goes_to_the_human()
=> (await Stage("worker", "Mail", "send")).Call.Approver.ShouldBeNull();
[Fact]
public async Task A_change_to_agents_stays_with_the_human_even_under_a_whole_tool_grant()
=> (await Stage("worker", "AgentEditor", "update_soul")).Call.Approver.ShouldBeNull();
[Fact]
public async Task A_lead_never_gets_its_own_call()
{
(await Stage("ops-lead")).Call.Approver.ShouldBeNull("nobody above ops-lead grants anything");
(await Stage("team-lead")).Call.Approver.ShouldBe("ops-lead");
}
[Fact]
public async Task A_lead_that_reads_foreign_content_does_not_approve()
{
_foreign.Add("ops-lead");
(await Stage("worker")).Call.Approver.ShouldBeNull();
}
[Fact]
public async Task Beyond_the_daily_cap_the_human_decides()
{
(await Stage("worker")).Call.Approver.ShouldBe("ops-lead");
(await Stage("worker")).Call.Approver.ShouldBe("ops-lead");
(await Stage("worker")).Call.Approver.ShouldBeNull();
}
// ═══════════════════ how it is decided ═══════════════════
[Fact]
public async Task The_lead_approves_the_frozen_call_and_the_decision_is_marked_as_the_leads()
{
var (call, _) = await Stage("worker");
var decided = await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, approve: true, "", default);
decided.Ok.ShouldBeTrue(decided.Message);
_executor.Calls.ShouldBe(1);
var after = (await _staging.GetAsync(call.Id, default))!;
after.Status.ShouldBe(StagingStatus.Executed);
after.DecidedBy.ShouldBe("lead:ops-lead");
(await _tasks.GetAsync(call.ApproverTaskId!, default))!.Status.ShouldBe(TaskItemStatus.Done);
}
[Fact]
public async Task A_rejection_needs_its_reason()
{
var (call, _) = await Stage("worker");
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, approve: false, " ", default)).Ok.ShouldBeFalse();
var rejected = await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, approve: false, "Wrong table.", default);
rejected.Ok.ShouldBeTrue(rejected.Message);
(await _staging.GetAsync(call.Id, default))!.Status.ShouldBe(StagingStatus.Rejected);
_executor.Calls.ShouldBe(0);
}
[Fact]
public async Task Only_the_routed_lead_decides_and_only_in_time()
{
var (call, _) = await Stage("worker");
(await _leads.DecideAsync(call.Id, "team-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("not yours");
(await _leads.DecideAsync(call.Id, "ops-lead", new AgentTrust(TrustTier.ReadsForeignContent, ["Mail"]), true, "", default))
.Message.ShouldContain("outside");
_clock.Advance(TimeSpan.FromHours(1.5));
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("run out");
(await _leads.PendingForAsync("ops-lead", default)).ShouldBeEmpty();
(await _service.ApproveAsync(call.Id, "webui", default)).Kind.ShouldBe(StagingResultKind.Executed, "the human decides it now");
}
[Fact]
public async Task When_the_human_decides_first_the_leads_task_is_closed()
{
var (call, _) = await Stage("worker");
await _service.RejectAsync(call.Id, "webui", "Not now.", default);
(await _tasks.GetAsync(call.ApproverTaskId!, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("already decided");
}
[Fact]
public async Task A_grant_taken_away_ends_the_leads_say()
{
var (call, _) = await Stage("worker");
_ops.Approvals = null;
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("no longer grants");
}
// ═══════════════════ the rate ═══════════════════
[Fact]
public async Task The_approval_rate_per_lead_is_counted()
{
var (first, _) = await Stage("worker");
var (second, _) = await Stage("worker");
await _leads.DecideAsync(first.Id, "ops-lead", AgentTrust.Standard, true, "", default);
await _leads.DecideAsync(second.Id, "ops-lead", AgentTrust.Standard, false, "No.", default);
var stats = (await _leads.StatsAsync(30, default)).Single();
stats.LeadId.ShouldBe("ops-lead");
stats.Routed.ShouldBe(2);
stats.Approved.ShouldBe(1);
stats.Rejected.ShouldBe(1);
stats.ApprovalRate.ShouldBe(0.5);
}
[Fact]
public async Task The_gate_tells_the_agent_who_decides()
{
var gate = new StagingGate(new StagingPolicy(), _staging) { Route = _leads.RouteAsync };
var intercept = await gate.InterceptAsync("worker", "inst", "run-2", "Database", "{\"action\":\"insert\",\"table\":\"orders\"}", default, "insert");
intercept.Outcome.ShouldBe(StagingOutcome.Staged);
intercept.Message.ShouldContain("ops-lead");
intercept.Message.ShouldNotContain("menschlicher Freigabe");
}
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");
}
}
}