Budgetende, Stufe 3: Nachholen nur im Spielraum des Tages, keine Kappung bei 500 (#716)
- TaskSchedule.OccurrencesBetween zaehlt, wie oft eine Aufgabe bis zu einem Zeitpunkt noch laeuft, einschliesslich eines jetzt faelligen Laufs. - BudgetHeadroom (ILateRunGate): Ein mitgenommener oder nachzuholender Lauf startet nur, wenn er in den Spielraum passt: Grenze minus Verbrauch minus Reservierung minus Bedarf der restlichen geplanten Laeufe bis Mitternacht minus 10 % Reserve. Das gilt je Agent und je Instanz, fuer Kosten und Tokens. Anderes Spaetwerk zaehlt nicht als Bedarf, sonst blockierten sich mitgenommene Aufgaben gegenseitig. - Der Scanner fragt den Spielraum nur fuer Spaetwerk und protokolliert, wenn es warten muss. Die Engine gibt ihren BudgetGuard heraus, damit Scanner und Engine dieselben Reservierungen sehen. - Ersetzt den pauschalen 25-%-Anteil aus dem Konzept (dort nachgezogen): Ein fester Anteil ist an vollen Tagen zu viel, an ruhigen zu wenig. Drei weitere stille Kappungen bei 500 beseitigt (ListAllAsync, ungekappt): Kostenprognose (Aufgaben ueber 500 fehlten), Waisenabgleich beim Import und task_find aus #674, wo Dubletten unter aelteren Aufgaben mit niedriger Prioritaet unsichtbar wurden. Tests: BudgetHeadroomTests (9), Scanner-Pforte (1), Dublette hinter 500 Aufgaben (1). 1.257 gruen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8a83a67fb4
commit
9cf9a2cfc2
@@ -117,6 +117,10 @@ public sealed class BudgetGuard
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <summary>What is currently reserved by runs in flight — for the agent, or with an
|
||||
/// empty id for the whole instance.</summary>
|
||||
public RunEstimate Reserved(string agentId)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
|
||||
namespace ClawdDotNet.Core.Budget;
|
||||
|
||||
/// <summary>
|
||||
/// #716: Decides whether a late run — carried-over work or a catch-up — may start now,
|
||||
/// without taking budget the rest of the day still needs.
|
||||
///
|
||||
/// <para><b>The rule: the new day has right of way.</b> Headroom is the limit minus what
|
||||
/// was used, minus what runs in flight have reserved, minus what the day's remaining
|
||||
/// scheduled runs will need, minus a safety margin. A late run starts only if its
|
||||
/// estimate fits into that. Otherwise one overdrawn day would push its shortfall into the
|
||||
/// next, and the next, instead of resolving it.</para>
|
||||
///
|
||||
/// <para>This replaces the flat "at most 25 % of the new day" from the concept: a fixed
|
||||
/// share is either too much on a busy day or needlessly little on a quiet one. The same
|
||||
/// calculation is what fill work (#717) will run on.</para>
|
||||
///
|
||||
/// <para><b>Estimates, honestly.</b> A run is estimated from the agent's recent runs
|
||||
/// (<see cref="BudgetGuard.EstimateRunAsync"/>). Without history the estimate is zero —
|
||||
/// 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
|
||||
{
|
||||
/// <summary>Share of each limit kept free as a reserve for surprises.</summary>
|
||||
public const decimal SafetyShare = 0.10m;
|
||||
|
||||
private readonly BudgetGuard _guard;
|
||||
private readonly Func<IReadOnlyList<AgentConfig>> _agents;
|
||||
private readonly Func<InstanceBudget> _instanceBudget;
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
public BudgetHeadroom(
|
||||
BudgetGuard guard,
|
||||
Func<IReadOnlyList<AgentConfig>> agents,
|
||||
Func<InstanceBudget> instanceBudget,
|
||||
TimeProvider? clock = null)
|
||||
{
|
||||
_guard = guard;
|
||||
_agents = agents;
|
||||
_instanceBudget = instanceBudget;
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<bool> HasRoomAsync(
|
||||
TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
var agents = _agents();
|
||||
var agent = FindAgent(agents, lateTask);
|
||||
var instanceBudget = _instanceBudget();
|
||||
|
||||
// Without a known agent there is nothing to calculate against; the budget check at
|
||||
// the start of the run still applies.
|
||||
if (agent is null || (!agent.Budget.HasAnyLimit && !instanceBudget.HasAnyLimit))
|
||||
return true;
|
||||
|
||||
var endOfDay = StartOfNextLocalDay(nowUtc);
|
||||
var estimates = new Dictionary<string, RunEstimate>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
async Task<RunEstimate> EstimateFor(string agentId)
|
||||
{
|
||||
if (!estimates.TryGetValue(agentId, out var estimate))
|
||||
estimates[agentId] = estimate = await _guard.EstimateRunAsync(agentId, ct);
|
||||
return estimate;
|
||||
}
|
||||
|
||||
var ownEstimate = await EstimateFor(agent.AgentId);
|
||||
|
||||
// Regular work of the rest of the day: everything scheduled except late work
|
||||
// itself — that is what has to wait for headroom, not what headroom is kept for.
|
||||
var regular = runnable.Where(t => t.Id != lateTask.Id && !IsLate(t)).ToList();
|
||||
|
||||
if (agent.Budget.HasAnyLimit)
|
||||
{
|
||||
var demand = default(RunEstimate);
|
||||
foreach (var task in regular.Where(t => IsOf(t, agent.AgentId)))
|
||||
demand += Times(ownEstimate, TaskSchedule.OccurrencesBetween(task, nowUtc, endOfDay));
|
||||
|
||||
var used = await _guard.GetUsedTodayAsync(agent.AgentId, ct);
|
||||
var reserved = _guard.Reserved(agent.AgentId);
|
||||
|
||||
if (!Fits(agent.Budget.DailyCostUsd, used.CostUsd, reserved.CostUsd, demand.CostUsd, ownEstimate.CostUsd)
|
||||
|| !Fits(agent.Budget.DailyTokens, used.TotalTokens, reserved.Tokens, demand.Tokens, ownEstimate.Tokens))
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instanceBudget.HasAnyLimit)
|
||||
{
|
||||
var demand = default(RunEstimate);
|
||||
foreach (var task in regular)
|
||||
{
|
||||
var agentId = TaskAssignee.AgentId(task.Assignee);
|
||||
if (agentId.Length == 0)
|
||||
continue;
|
||||
demand += Times(await EstimateFor(agentId), TaskSchedule.OccurrencesBetween(task, nowUtc, endOfDay));
|
||||
}
|
||||
|
||||
var used = await _guard.GetUsedTodayAsync("", ct);
|
||||
var reserved = _guard.Reserved("");
|
||||
|
||||
if (!Fits(instanceBudget.DailyCostUsd, used.CostUsd, reserved.CostUsd, demand.CostUsd, ownEstimate.CostUsd)
|
||||
|| !Fits(instanceBudget.DailyTokens, used.TotalTokens, reserved.Tokens, demand.Tokens, ownEstimate.Tokens))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Carried-over work and pending catch-ups are the late runs.</summary>
|
||||
public static bool IsLate(TaskItem task) => task.IsCarriedOver || task.CatchUpPending;
|
||||
|
||||
/// <summary>0 or less means: no limit of this kind.</summary>
|
||||
private static bool Fits(decimal limit, decimal used, decimal reserved, decimal demand, decimal estimate)
|
||||
=> limit <= 0 || limit - used - reserved - demand - limit * SafetyShare >= estimate;
|
||||
|
||||
private static bool Fits(long limit, long used, long reserved, long demand, long estimate)
|
||||
=> limit <= 0 || limit - used - reserved - demand - (long)(limit * SafetyShare) >= estimate;
|
||||
|
||||
private static RunEstimate Times(RunEstimate estimate, int runs)
|
||||
=> new(estimate.CostUsd * runs, estimate.Tokens * runs);
|
||||
|
||||
private static bool IsOf(TaskItem task, string agentId)
|
||||
=> string.Equals(TaskAssignee.AgentId(task.Assignee), agentId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static AgentConfig? FindAgent(IReadOnlyList<AgentConfig> agents, TaskItem task)
|
||||
{
|
||||
var agentId = TaskAssignee.AgentId(task.Assignee);
|
||||
return agentId.Length == 0
|
||||
? (agents.Count == 1 ? agents[0] : null)
|
||||
: agents.FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private DateTime StartOfNextLocalDay(DateTime nowUtc)
|
||||
{
|
||||
var zone = _clock.LocalTimeZone;
|
||||
var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc), zone);
|
||||
var midnight = DateTime.SpecifyKind(local.Date.AddDays(1), DateTimeKind.Unspecified);
|
||||
while (zone.IsInvalidTime(midnight))
|
||||
midnight = midnight.AddMinutes(1);
|
||||
return TimeZoneInfo.ConvertTimeToUtc(midnight, zone);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user