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:
co-authored by
Claude Opus 5.5
parent
dafb8241a0
commit
f51186269c
@@ -49,6 +49,45 @@ public sealed class UnitConfig
|
||||
/// <summary>Starting values for agents that join this unit.</summary>
|
||||
[JsonPropertyName("defaults")]
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
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>
|
||||
/// #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.
|
||||
@@ -1864,6 +1867,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
Organisation = CurrentOrganisation(),
|
||||
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
|
||||
Cases = CaseDesk,
|
||||
Approvals = LeadApprovals,
|
||||
SpecialBudgets = SpecialBudgets,
|
||||
Sandbox = Sandbox,
|
||||
Files = Files,
|
||||
|
||||
@@ -66,6 +66,12 @@ public static class OrgBriefing
|
||||
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.");
|
||||
// #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);
|
||||
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:<id></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);
|
||||
}, 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 ───
|
||||
|
||||
private const string Columns =
|
||||
"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()
|
||||
{
|
||||
@@ -189,7 +218,10 @@ public sealed class SqliteStagingRepository : IStagingRepository
|
||||
DecidedAt = r.IsDBNull(10) ? null : Parse(r.GetString(10)),
|
||||
DecidedBy = r.IsDBNull(11) ? null : r.GetString(11),
|
||||
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");
|
||||
|
||||
@@ -52,6 +52,12 @@ public sealed class StagingGate
|
||||
_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>
|
||||
public Task<int> ExpireAsync(CancellationToken ct)
|
||||
{
|
||||
@@ -115,7 +121,7 @@ public sealed class StagingGate
|
||||
vorhanden.Id);
|
||||
}
|
||||
|
||||
var id = await _repo.AppendAsync(new StagedCall
|
||||
var staged = new StagedCall
|
||||
{
|
||||
RunId = runId,
|
||||
AgentId = agentId,
|
||||
@@ -124,13 +130,25 @@ public sealed class StagingGate
|
||||
Action = action,
|
||||
ArgumentsJson = 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(
|
||||
StagingOutcome.Staged,
|
||||
$"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. {einstufung.Grund} " +
|
||||
"Die Aktion wird erst nach menschlicher Freigabe ausgeführt. Scheitert sie oder wird sie " +
|
||||
"abgelehnt, wirst du geweckt. Fahre mit anderer Arbeit fort oder schließe ab.",
|
||||
(beiLeitung is null
|
||||
? "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);
|
||||
|
||||
default:
|
||||
|
||||
@@ -73,6 +73,21 @@ public sealed record StagedCall
|
||||
|
||||
/// <summary>Bei Ablehnung: der Grund.</summary>
|
||||
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
|
||||
@@ -116,4 +131,10 @@ public interface IStagingRepository
|
||||
|
||||
/// <summary>#660: Everything a run proposed for approval - for the run detail view.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ public sealed class StagingService
|
||||
// ausgeführt wird.
|
||||
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, decidedBy, null, DateTime.UtcNow, ct))
|
||||
return StagingResult.AlreadyDecided;
|
||||
await CloseLeadTaskAsync(call, decidedBy, ct);
|
||||
|
||||
string result;
|
||||
StagingStatus final;
|
||||
@@ -202,6 +203,7 @@ public sealed class StagingService
|
||||
|
||||
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Rejected, decidedBy, reason, DateTime.UtcNow, ct))
|
||||
return StagingResult.AlreadyDecided;
|
||||
await CloseLeadTaskAsync(call, decidedBy, ct);
|
||||
|
||||
await RecordAuditAsync(call, decidedBy, AuditStatus.Denied, $"Abgelehnt von {decidedBy}: {reason}", ct);
|
||||
await SammelnAsync(call, "abgelehnt", $"Grund: {reason}", weckt: true, ct);
|
||||
@@ -211,6 +213,30 @@ public sealed class StagingService
|
||||
|
||||
// ─── 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>
|
||||
/// 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
|
||||
@@ -267,7 +293,7 @@ public sealed class StagingService
|
||||
}
|
||||
|
||||
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 "
|
||||
+ "hast, abgelehnte gar nicht.\n"
|
||||
+ "- 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", "OnSubscription", "INTEGER NOT NULL DEFAULT 0");
|
||||
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.
|
||||
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>
|
||||
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>
|
||||
public Sandbox.ISandboxRunner? Sandbox { get; init; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user