Entscheidungsinstanz an der Spitze: Mensch, Agent oder Mensch zuerst mit Frist (#690)

- cases.top in instance.json: Human | Agent | HumanFirst, agentId, humanFirstHours
- TopRole: der entscheidende Agent steht in keiner Einheit, liest keine Fremdinhalte
  und hat nur Taskboard, Escalation, Memory, FileRW, AgentComm; geprueft beim Setzen,
  bei jeder Zustellung und vor jeder Entscheidung
- CaseDesk: was bisher an der Spitze beim Menschen landete, geht nach Einstellung an
  den Agenten; Tagesdeckel und Frist gehen weiter an den Menschen. handle verwehrt,
  forward geht an den Menschen. Mensch zuerst: Uebergabe nach Ablauf, eigene Frist.
  Der Mensch kann einen Fall des Agenten selbst entscheiden.
- Beschluesse des Agenten: ByTopAgent, zum Lesen vorgelegt; jeder Beschluss laesst
  sich mit Begruendung zuruecknehmen und bindet dann niemanden mehr
- Briefing fuer den Agenten an der Spitze, auch ohne Einheiten
- OrgAdministration.SetTop mit Audit; Werkzeugsperre und Verschiebesperre
- WebUI Vorgaenge: Reiter Spitze, Lesen/Zuruecknehmen, Zaehler; i18n en/de
- Tests: TopRoleTests (15), OrgAdministrationTests (+2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-29 09:27:26 +02:00
co-authored by Claude Opus 5.5
parent e22edb418a
commit e8964e3c74
16 changed files with 1020 additions and 26 deletions
+26 -2
View File
@@ -20,7 +20,11 @@ public sealed record CaseDto(
IReadOnlyList<string> Informed,
IReadOnlyList<string> WaitingFor,
IReadOnlyList<CaseEntryDto> Trail,
string? DecisionId);
string? DecisionId,
// #690: the deciding agent at the top holds it — the human can still decide it.
bool HeldByTop = false,
// #690, human first: when the deciding agent takes over without an answer.
DateTime? TopAgentAt = null);
public sealed record CaseEntryDto(DateTime At, string Author, string Kind, string Text, string? RunId);
@@ -36,8 +40,28 @@ public sealed record DecisionDto(
// "Purchasing / Team A"; null: binds the whole instance.
string? ScopePath,
string CaseId,
string? RunId);
string? RunId,
// #690: taken by the deciding agent at the top, and whether the human has read or withdrawn it.
bool ByTopAgent = false,
DateTime? ReviewedAt = null,
DateTime? RevokedAt = null,
string? RevokedReason = null);
public sealed record CaseDecisionInputDto(string? Answer, string? Reasoning);
public sealed record DecisionRevokeInputDto(string? Reason);
/// <summary>#690: Who decides at the top, and which agents could.</summary>
public sealed record TopDto(
string Mode,
string? AgentId,
double HumanFirstHours,
// Why the named agent does not take cases now; null when it does, or no agent is named.
string? Problem,
IReadOnlyList<TopCandidateDto> Candidates);
public sealed record TopCandidateDto(string AgentId, string DisplayName, string? Unfit);
public sealed record TopInputDto(string? Mode, string? AgentId, double? HumanFirstHours, string? Reason);
public sealed record CaseStatementInputDto(string? AgentId, string? Question);
+38 -5
View File
@@ -22,12 +22,13 @@ internal static class CaseEndpoints
if (host.Cases is not { } repo)
return Results.Ok(Array.Empty<CaseDto>());
var list = all == true ? await repo.ListRecentAsync(200, ct) : await repo.ListOpenAsync(ct);
return Results.Ok(list.OrderByDescending(c => c.OpenedAt).Select(ToDto).ToList());
var top = host.CaseDesk?.TopAgentId;
return Results.Ok(list.OrderByDescending(c => c.OpenedAt).Select(c => ToDto(c, top)).ToList());
});
cases.MapGet("/{caseId}", async (AppHost host, string caseId, CancellationToken ct) =>
host.Cases is { } repo && await repo.GetAsync(caseId, ct) is { } record
? Results.Ok(ToDto(record))
? Results.Ok(ToDto(record, host.CaseDesk?.TopAgentId))
: Results.NotFound(new OutcomeDto(false, $"No case '{caseId}'.")));
cases.MapPost("/{caseId}/decide", async (AppHost host, string caseId, CaseDecisionInputDto input, CancellationToken ct) =>
@@ -53,8 +54,38 @@ internal static class CaseEndpoints
var org = host.Organisation.Current();
return Results.Ok((await repo.ListDecisionsAsync(ct)).Select(d => new DecisionDto(
d.Id, d.Subject, d.Question, d.Answer, d.Reasoning, d.DecidedBy, d.DecidedAt,
d.ScopeUnitId, d.ScopeUnitId is null ? null : org.PathOf(d.ScopeUnitId), d.CaseId, d.RunId)).ToList());
d.ScopeUnitId, d.ScopeUnitId is null ? null : org.PathOf(d.ScopeUnitId), d.CaseId, d.RunId,
d.ByTopAgent, d.ReviewedAt, d.RevokedAt, d.RevokedReason)).ToList());
});
// #690: The human reads what the deciding agent decided — and withdraws what does not hold.
api.MapPost("/decisions/{decisionId}/review", async (AppHost host, string decisionId, CancellationToken ct) =>
host.CaseDesk is { } desk
? Respond(await desk.ReviewDecisionAsync(decisionId, ct))
: Results.BadRequest(new OutcomeDto(false, "Cases are not available in this instance.")));
api.MapPost("/decisions/{decisionId}/revoke", async (AppHost host, string decisionId, DecisionRevokeInputDto input, CancellationToken ct) =>
host.CaseDesk is { } desk
? Respond(await desk.RevokeDecisionAsync(decisionId, input.Reason ?? "", ct))
: Results.BadRequest(new OutcomeDto(false, "Cases are not available in this instance.")));
// #690: Who decides at the top.
cases.MapGet("/top", (AppHost host) =>
{
var top = host.Instance.Cases.Top;
var org = host.Organisation.Current();
return Results.Ok(new TopDto(
top.Mode.ToString(), top.AgentId, top.HumanFirstHours,
TopRole.Resolve(top, org).Problem,
org.Agents.OrderBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase)
.Select(a => new TopCandidateDto(a.AgentId, a.DisplayName ?? a.AgentId, TopRole.Unfit(a, a.AgentId)))
.ToList()));
});
cases.MapPut("/top", (AppHost host, TopInputDto input) =>
Enum.TryParse<TopMode>(input.Mode, ignoreCase: true, out var mode)
? host.Organisation.SetTop(mode, input.AgentId, input.HumanFirstHours, input.Reason ?? "").ToResponse()
: Results.BadRequest(new OutcomeDto(false, $"Unknown mode '{input.Mode}' — human, agent or humanFirst.")));
}
private static IResult Respond(CaseOutcome outcome)
@@ -62,10 +93,12 @@ internal static class CaseEndpoints
? Results.Ok(new OutcomeDto(true, outcome.Message))
: Results.BadRequest(new OutcomeDto(false, outcome.Message));
private static CaseDto ToDto(CaseRecord c) => new(
private static CaseDto ToDto(CaseRecord c, string? topAgentId) => new(
c.Id, c.Kind.ToString().ToLowerInvariant(), c.Status.ToString().ToLowerInvariant(), c.Subject,
c.OpenedBy, c.OpenedAt, c.DeadlineUtc, c.HumanCalledAt, c.HeldByHuman ? null : c.Holder, c.HolderTaskId,
c.Level, c.OriginTaskId, c.OriginRunId, c.Parties, c.Informed, c.PendingStatements.Keys.ToList(),
c.Trail.Select(e => new CaseEntryDto(e.At, e.Author, e.Kind, e.Text, e.RunId)).ToList(),
c.DecisionId);
c.DecisionId,
!c.HeldByHuman && string.Equals(c.Holder, topAgentId, StringComparison.OrdinalIgnoreCase),
c.TopAgentAt);
}