using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Core.Budget;
///
/// #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.
///
/// The rule: the new day has right of way. 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.
///
/// 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.
///
/// Estimates, honestly. A run is estimated from the agent's recent runs
/// (). 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.
///
public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
{
/// Share of each limit kept free as a reserve for surprises.
public const decimal SafetyShare = 0.10m;
private readonly BudgetGuard _guard;
private readonly Func> _agents;
private readonly Func _instanceBudget;
private readonly TimeProvider _clock;
public BudgetHeadroom(
BudgetGuard guard,
Func> agents,
Func instanceBudget,
TimeProvider? clock = null)
{
_guard = guard;
_agents = agents;
_instanceBudget = instanceBudget;
_clock = clock ?? TimeProvider.System;
}
public async Task HasRoomAsync(
TaskItem lateTask, IReadOnlyList 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(StringComparer.OrdinalIgnoreCase);
async Task 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;
}
///
/// #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.
///
public async Task> ShortfallsAsync(
IReadOnlyList runnable, DateTime nowUtc, CancellationToken ct)
{
var agents = _agents();
var instanceBudget = _instanceBudget();
var endOfDay = StartOfNextLocalDay(nowUtc);
var result = new List();
// 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(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;
}
///
/// The index of the first run that would find the budget used up, or null.
/// 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.
///
private static int? FirstRefused(
List<(DateTime At, string AgentId)> runs, Dictionary 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;
}
/// Carried-over work and pending catch-ups are the late runs.
public static bool IsLate(TaskItem task) => task.IsCarriedOver || task.CatchUpPending;
/// 0 or less means: no limit of this kind.
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 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);
}
}