using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Budget;
///
/// #716, stage 3: late work — carried-over tasks and catch-ups — only uses budget the rest
/// of the day does not need. The new day has right of way.
///
public sealed class BudgetHeadroomTests
{
// 2027-03-10 14:00 UTC; the test clock's local zone is UTC, so the day ends at 24:00 UTC.
private static readonly DateTime Now = new(2027, 3, 10, 14, 0, 0, DateTimeKind.Utc);
private static readonly DateTime CreatedEarlier = new(2027, 3, 1, 0, 0, 0, DateTimeKind.Utc);
private static AgentConfig Agent(decimal dailyCost = 1.00m)
=> new() { AgentId = "worker", Budget = new AgentBudget { DailyCostUsd = dailyCost } };
/// Runs averaging 0.05 USD.
private static List History(decimal cost = 0.05m)
=> [.. Enumerable.Range(0, 5).Select(_ => new RunUsage { AgentId = "worker", PromptTokens = 1_000, CostUsd = cost, CostIsKnown = true })];
private static TaskItem Cron(string id, string expression, string? lastOccurrence = null) => new()
{
Id = id, Title = id, Assignee = "@worker", CreatedAt = CreatedEarlier, LastOccurrence = lastOccurrence,
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = expression, TimeZone = "UTC" }
};
private static TaskItem Carried(string id) => new()
{
Id = id, Title = id, Assignee = "@worker", CreatedAt = CreatedEarlier, BudgetMissCount = 1
};
private static BudgetHeadroom Headroom(decimal used, AgentConfig agent, InstanceBudget? instance = null, decimal perRun = 0.05m)
=> new(new BudgetGuard(new FakeUsage(used, History(perRun))),
() => [agent], () => instance ?? InstanceBudget.Unlimited, new UtcClock(Now));
// ═══════════════════ counting the rest of the day ═══════════════════
[Fact]
public void Hourly_from_14_30_until_midnight_is_nine_more_runs()
{
// 15:00 … 23:00. The 14:00 run is done (marker), so nothing is due right now.
var task = Cron("t", "0 * * * *", lastOccurrence: "2027-03-10T14:00:00.0000000Z");
TaskSchedule.OccurrencesBetween(task, Now.AddMinutes(30), Now.Date.AddDays(1)).ShouldBe(9);
}
[Fact]
public void A_run_due_right_now_counts_as_well()
{
// Last run 13:00, so the 14:00 run is due now; then 15:00 … 23:00.
var task = Cron("t", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
TaskSchedule.OccurrencesBetween(task, Now.AddMinutes(1), Now.Date.AddDays(1)).ShouldBe(10);
}
[Fact]
public void A_one_time_task_counts_once_until_it_ran()
{
var open = new TaskItem { Id = "t", CreatedAt = CreatedEarlier };
var done = open with { LastOccurrence = "2027-03-10T09:00:00.0000000Z" };
TaskSchedule.OccurrencesBetween(open, Now, Now.AddHours(1)).ShouldBe(1);
TaskSchedule.OccurrencesBetween(done, Now, Now.AddHours(1)).ShouldBe(0);
}
[Fact]
public void An_interval_counts_its_remaining_ticks()
{
var task = new TaskItem
{
Id = "t", CreatedAt = CreatedEarlier, LastOccurrence = "2027-03-10T13:45:00.0000000Z",
When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" }
};
// 14:15, 14:45, 15:15, 15:45 before 16:00.
TaskSchedule.OccurrencesBetween(task, Now, Now.AddHours(2)).ShouldBe(4);
}
// ═══════════════════ headroom ═══════════════════
[Fact]
public async Task Late_work_fits_when_the_rest_of_the_day_leaves_room()
{
// Limit 1.00, used 0.30, margin 0.10. Rest of the day: 10 hourly runs à 0.05 = 0.50.
// Left: 1.00 - 0.30 - 0.50 - 0.10 = 0.10 ≥ 0.05 for the late run.
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
var late = Carried("t-late");
var room = await Headroom(used: 0.30m, Agent()).HasRoomAsync(late, [hourly, late], Now, default);
room.ShouldBeTrue();
}
[Fact]
public async Task Late_work_waits_when_the_rest_of_the_day_needs_the_budget()
{
// Same, but 0.40 used: 1.00 - 0.40 - 0.50 - 0.10 = 0.00 < 0.05.
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
var late = Carried("t-late");
var room = await Headroom(used: 0.40m, Agent()).HasRoomAsync(late, [hourly, late], Now, default);
room.ShouldBeFalse("the day's own schedule has right of way");
}
[Fact]
public async Task Other_late_work_does_not_count_as_the_days_demand()
{
// Ten carried-over tasks do not reserve budget against each other — they are what
// waits for headroom, not what headroom is kept for.
var late = Carried("t-late");
var others = Enumerable.Range(0, 10).Select(i => Carried($"t-other-{i}")).ToList();
var room = await Headroom(used: 0.50m, Agent()).HasRoomAsync(late, [late, .. others], Now, default);
room.ShouldBeTrue();
}
[Fact]
public async Task The_instance_limit_counts_the_schedules_of_all_agents()
{
var agent = Agent(dailyCost: 0); // no limit of its own
var instance = new InstanceBudget { DailyCostUsd = 1.00m };
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
var late = Carried("t-late");
(await Headroom(used: 0.30m, agent, instance).HasRoomAsync(late, [hourly, late], Now, default)).ShouldBeTrue();
(await Headroom(used: 0.40m, agent, instance).HasRoomAsync(late, [hourly, late], Now, default)).ShouldBeFalse();
}
[Fact]
public async Task Without_any_limit_there_is_always_room()
{
var late = Carried("t-late");
(await Headroom(used: 99m, Agent(dailyCost: 0)).HasRoomAsync(late, [late], Now, default)).ShouldBeTrue();
}
// ═══════════════════ test doubles ═══════════════════
private sealed class UtcClock(DateTime now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(now, TimeSpan.Zero);
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
private sealed class FakeUsage(decimal usedCost, IReadOnlyList recent) : IUsageRepository
{
public Task RecordAsync(RunUsage usage, CancellationToken ct) => Task.CompletedTask;
public Task GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
=> Task.FromResult(new DailyUsage(date, 0, 0, usedCost, true, 0));
public Task> GetRecentAsync(int limit, CancellationToken ct) => Task.FromResult(recent);
public Task> GetRecentForAgentAsync(string agentId, int limit, CancellationToken ct)
=> Task.FromResult(recent);
public Task PurgeBeforeAsync(DateOnly date, CancellationToken ct) => Task.FromResult(0);
}
}