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
+50 -7
View File
@@ -54,6 +54,9 @@ public sealed class BudgetGuard
private readonly Dictionary<string, RunEstimate> _reservedByAgent = new(StringComparer.OrdinalIgnoreCase);
private RunEstimate _reservedInstance;
/// <summary>#717: What runs in flight have reserved of each special budget.</summary>
private readonly Dictionary<string, RunEstimate> _reservedByPot = new(StringComparer.OrdinalIgnoreCase);
public BudgetGuard(IUsageRepository usage, Func<DateTime>? now = null)
{
_usage = usage;
@@ -109,7 +112,7 @@ public sealed class BudgetGuard
_reservedInstance += estimate;
}
return (status, new Reservation(this, agent.AgentId, estimate));
return (status, new Reservation(() => Release(agent.AgentId, estimate)));
}
finally
{
@@ -117,9 +120,49 @@ public sealed class BudgetGuard
}
}
/// <summary>
/// #717: Reserves a run on a special budget instead of the daily limits. Refused once what
/// the pot's runs used, plus what runs in flight reserved, has reached the amount — the
/// same rule as for the daily limit: a run is not refused for its own estimate.
/// </summary>
public async Task<IDisposable?> ReservePotAsync(SpecialBudget pot, decimal spent, string agentId, CancellationToken ct)
{
var estimate = await EstimateRunAsync(agentId, ct);
await _reserveGate.WaitAsync(ct);
try
{
lock (_reservedLock)
{
var reserved = _reservedByPot.GetValueOrDefault(pot.TaskId);
if (spent + reserved.CostUsd >= pot.AmountUsd)
return null;
_reservedByPot[pot.TaskId] = reserved + estimate;
}
return new Reservation(() => ReleasePot(pot.TaskId, estimate));
}
finally
{
_reserveGate.Release();
}
}
private void ReleasePot(string potId, RunEstimate estimate)
{
lock (_reservedLock)
{
var left = _reservedByPot.GetValueOrDefault(potId) - estimate;
if (left.CostUsd <= 0 && left.Tokens <= 0)
_reservedByPot.Remove(potId);
else
_reservedByPot[potId] = left;
}
}
/// <summary>Today's usage of an agent, or with an empty id of the whole instance.</summary>
public Task<DailyUsage> GetUsedTodayAsync(string agentId, CancellationToken ct)
=> _usage.GetDailyAsync(DateOnly.FromDateTime(_now()), agentId, ct);
=> _usage.GetDailyForBudgetAsync(DateOnly.FromDateTime(_now()), agentId, ct);
/// <summary>What is currently reserved by runs in flight — for the agent, or with an
/// empty id for the whole instance.</summary>
@@ -159,14 +202,14 @@ public sealed class BudgetGuard
}
}
private sealed class Reservation(BudgetGuard guard, string agentId, RunEstimate estimate) : IDisposable
private sealed class Reservation(Action release) : IDisposable
{
private int _disposed;
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
guard.Release(agentId, estimate);
release();
}
}
@@ -180,7 +223,7 @@ public sealed class BudgetGuard
var agentBudget = agent.Budget;
if (agentBudget.HasAnyLimit)
{
var used = await _usage.GetDailyAsync(today, agent.AgentId, ct);
var used = await _usage.GetDailyForBudgetAsync(today, agent.AgentId, ct);
if (Exceeds(agentBudget.DailyCostUsd, used.CostUsd + agentReserved.CostUsd))
{
@@ -203,7 +246,7 @@ public sealed class BudgetGuard
// Instanzgrenze — gilt für alle Agenten zusammen
if (instanceBudget.HasAnyLimit)
{
var used = await _usage.GetDailyAsync(today, agentId: "", ct);
var used = await _usage.GetDailyForBudgetAsync(today, agentId: "", ct);
if (Exceeds(instanceBudget.DailyCostUsd, used.CostUsd + instanceReserved.CostUsd))
{
@@ -232,7 +275,7 @@ public sealed class BudgetGuard
if (agent.Budget.DailyCostUsd <= 0)
return null;
var used = await _usage.GetDailyAsync(DateOnly.FromDateTime(_now()), agent.AgentId, ct);
var used = await _usage.GetDailyForBudgetAsync(DateOnly.FromDateTime(_now()), agent.AgentId, ct);
return Math.Max(0m, agent.Budget.DailyCostUsd - used.CostUsd);
}