Grossaufgaben und Sonderbudget (#717, Stufe 2+3)

Eine Aufgabe, deren eigene Schaetzung (gemessen, Geschwister oder Autor)
ueber der Haelfte der Tageskostengrenze liegt, bekommt zuerst einen
Planungslauf: teilen (Teile unter der Elternaufgabe) oder Sonderbudget
beantragen. Danach wartet sie auf ihre Teile bzw. die Freigabe und laeuft
einmal zum Zusammenfuehren.

Sonderbudget ueber Escalation.request_budget, ausnahmslos freigabepflichtig
(auch gegen Werkzeugkonfiguration und Dauerfreigabe). Laeufe im Baum ziehen
den Topf statt der Tagesgrenzen, tragen BudgetPot in Verbrauch und Beleg und
sind in der Kostenanalyse (nach Budget) und auf der Aufgabenkarte gesondert
sichtbar. Aufgebraucht zaehlen sie wieder gegen den Tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 15:47:41 +02:00
co-authored by Claude Opus 5.5
parent 183e922278
commit 67649559f5
30 changed files with 1123 additions and 55 deletions
+3
View File
@@ -135,6 +135,9 @@ public sealed record TaskDto(
decimal? EstimateUsd = null,
DateTime? FillAlertedAt = null);
/// <summary>#717: A special budget — the amount a human approved for a task tree, and what it used.</summary>
public sealed record SpecialBudgetDto(string TaskId, decimal AmountUsd, decimal SpentUsd, string Reason, string RequestedBy, DateTime ApprovedAt);
/// <summary>
/// #716: Runs of this task the budget refused — only present while something is open
/// (misses since the last run that took place, a pending catch-up or a deferral).
@@ -55,7 +55,7 @@ public static class AnalyticsEndpoints
var points = await insights.Analytics.SeriesAsync(
ParseQuery(request),
bucket == "day" ? "day" : "hour",
groupBy is "model" or "source" ? groupBy : "agent",
groupBy is "model" or "source" or "budget" ? groupBy : "agent",
TimeSpan.FromMinutes(Math.Clamp(tzOffset, -14 * 60, 14 * 60)),
ct);
return Results.Ok(points.Select(p => new SeriesPointDto(p.BucketStart, p.Group, p.Runs, p.Tokens, p.CostUsd)).ToList());
@@ -187,7 +187,7 @@ public static class AnalyticsEndpoints
private static RunRowDto Row(Core.Audit.RunReceipt r, string? taskTitle)
=> new(r.RunId, r.AgentId, r.Model, r.Source, r.TaskId, taskTitle, r.Status,
r.StepCount, r.PromptTokens, r.CompletionTokens, r.CachedTokens, r.CostUsd, r.CostIsKnown,
r.DurationMs, r.ResultRef, r.OccurredAt, r.CallerRunId);
r.DurationMs, r.ResultRef, r.OccurredAt, r.CallerRunId, r.BudgetPot);
private static RunDetailDto ToDto(RunDetail d) => new(
d.RunId,
@@ -222,7 +222,9 @@ public sealed record RunRowDto(
string Status, int StepCount, int PromptTokens, int CompletionTokens, int CachedTokens,
decimal CostUsd, bool CostIsKnown, long DurationMs, string Result, DateTime OccurredAt,
// #697: the run this one was delegated from.
string? CallerRunId = null);
string? CallerRunId = null,
// #717: the task whose special budget the run drew on.
string? BudgetPot = null);
public sealed record RunTotalsDto(
int Runs, long PromptTokens, long CompletionTokens, long CachedTokens,
@@ -287,6 +287,18 @@ internal static class ApiEndpoints
tasks.MapPost("/{id}/run", async (AppHost host, string id, CancellationToken ct) =>
(await host.TaskAdmin.RunNowAsync(id, ct)).ToResponse());
// #717: Special budgets with what they have used — shown on the tasks they are for.
api.MapGet("/special-budgets", async (AppHost host, CancellationToken ct) =>
{
if (host.SpecialBudgets is not { } pots)
return Results.Ok(Array.Empty<SpecialBudgetDto>());
var list = new List<SpecialBudgetDto>();
foreach (var pot in await pots.ListAsync(ct))
list.Add(new SpecialBudgetDto(pot.TaskId, pot.AmountUsd, await pots.SpentAsync(pot.TaskId, ct),
pot.Reason, pot.RequestedBy, pot.ApprovedAt));
return Results.Ok(list);
});
tasks.MapGet("/{id}", async (AppHost host, string id, CancellationToken ct) =>
{
if (host.Board is not { } board)