Budgetende, Stufe 4: den Engpass melden, bevor er eintritt (#716)
- TaskSchedule.OccurrenceTimesBetween liefert die Zeitpunkte der restlichen Laeufe; OccurrencesBetween zaehlt sie nur noch. - BudgetHeadroom implementiert IBudgetOutlook: Die restlichen geplanten Laeufe des Tages werden in zeitlicher Reihenfolge durchgegangen, je Agent mit Grenze und fuer die Instanz, bis zum ersten Lauf, der das Budget verbraucht vorfaende. Das spiegelt die Pruefung beim Start eines Laufs. Spaetwerk zaehlt nicht mit, es laeuft ohnehin nur im Spielraum. - Der Scanner schaut alle 30 Minuten voraus und nennt je Agent und Tag einmal, wann das Budget ausgeht und wie viele der geplanten Laeufe dann ausfallen, solange noch Zeit ist, nachzulegen oder zu entscheiden, was wartet. Die Meldung steht vorerst im Protokoll (Warnung). Der Weg zum Menschen ueber einen Kanal gehoert zu #646, die Anzeige ins Lagebild (#711). Tests: 4 neu fuer die Vorschau. 1.261 gruen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9cf9a2cfc2
commit
32383119da
@@ -22,7 +22,7 @@ namespace ClawdDotNet.Core.Budget;
|
||||
/// then only the margin keeps a reserve. That errs towards letting late work through;
|
||||
/// the budget check at the start of the run still refuses once the limit is reached.</para>
|
||||
/// </summary>
|
||||
public sealed class BudgetHeadroom : ILateRunGate
|
||||
public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
|
||||
{
|
||||
/// <summary>Share of each limit kept free as a reserve for surprises.</summary>
|
||||
public const decimal SafetyShare = 0.10m;
|
||||
@@ -108,6 +108,82 @@ public sealed class BudgetHeadroom : ILateRunGate
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #716: Walks through the rest of today's schedule in time order and finds, per agent
|
||||
/// with a limit and for the instance, the first run the budget would no longer cover.
|
||||
/// Late work is left out: it only ever runs in headroom, so it cannot be what makes the
|
||||
/// day's own runs fail.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BudgetShortfall>> ShortfallsAsync(
|
||||
IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
var agents = _agents();
|
||||
var instanceBudget = _instanceBudget();
|
||||
var endOfDay = StartOfNextLocalDay(nowUtc);
|
||||
var result = new List<BudgetShortfall>();
|
||||
|
||||
// Every planned run until midnight, with whose it is.
|
||||
var planned = new List<(DateTime At, string AgentId)>();
|
||||
foreach (var task in runnable.Where(t => !IsLate(t)))
|
||||
{
|
||||
var agent = FindAgent(agents, task);
|
||||
if (agent is null)
|
||||
continue;
|
||||
foreach (var at in TaskSchedule.OccurrenceTimesBetween(task, nowUtc, endOfDay))
|
||||
planned.Add((at, agent.AgentId));
|
||||
}
|
||||
|
||||
planned.Sort((a, b) => a.At.CompareTo(b.At));
|
||||
|
||||
var estimates = new Dictionary<string, RunEstimate>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var agentId in planned.Select(p => p.AgentId).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
estimates[agentId] = await _guard.EstimateRunAsync(agentId, ct);
|
||||
|
||||
foreach (var agent in agents.Where(a => a.Budget.HasAnyLimit))
|
||||
{
|
||||
var runs = planned.Where(p => string.Equals(p.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var used = await _guard.GetUsedTodayAsync(agent.AgentId, ct);
|
||||
var reserved = _guard.Reserved(agent.AgentId);
|
||||
if (FirstRefused(runs, estimates, agent.Budget.DailyCostUsd, agent.Budget.DailyTokens, used, reserved) is { } index)
|
||||
result.Add(new BudgetShortfall(agent.AgentId, runs[index].At, runs.Count - index, runs.Count));
|
||||
}
|
||||
|
||||
if (instanceBudget.HasAnyLimit)
|
||||
{
|
||||
var used = await _guard.GetUsedTodayAsync("", ct);
|
||||
var reserved = _guard.Reserved("");
|
||||
if (FirstRefused(planned, estimates, instanceBudget.DailyCostUsd, instanceBudget.DailyTokens, used, reserved) is { } index)
|
||||
result.Add(new BudgetShortfall("", planned[index].At, planned.Count - index, planned.Count));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The index of the first run that would find the budget used up, or <c>null</c>.
|
||||
/// Mirrors the check at the start of a run: a run is refused once usage has reached
|
||||
/// the limit, not merely because its own estimate would overshoot.
|
||||
/// </summary>
|
||||
private static int? FirstRefused(
|
||||
List<(DateTime At, string AgentId)> runs, Dictionary<string, RunEstimate> estimates,
|
||||
decimal costLimit, long tokenLimit, Accounting.DailyUsage used, RunEstimate reserved)
|
||||
{
|
||||
var cost = used.CostUsd + reserved.CostUsd;
|
||||
long tokens = used.TotalTokens + reserved.Tokens;
|
||||
|
||||
for (var i = 0; i < runs.Count; i++)
|
||||
{
|
||||
if ((costLimit > 0 && cost >= costLimit) || (tokenLimit > 0 && tokens >= tokenLimit))
|
||||
return i;
|
||||
|
||||
var estimate = estimates.GetValueOrDefault(runs[i].AgentId);
|
||||
cost += estimate.CostUsd;
|
||||
tokens += estimate.Tokens;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Carried-over work and pending catch-ups are the late runs.</summary>
|
||||
public static bool IsLate(TaskItem task) => task.IsCarriedOver || task.CatchUpPending;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user