using ClawdDotNet.Core.Accounting; using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Budget; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tasks; using ClawdDotNet.Core.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Budget; /// /// #717: Fill work — large, not urgent — runs on budget the day would otherwise leave unused, /// and never overruns its deadline silently. Whether there is room is decided by the code. /// public sealed class FillWorkTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); private readonly SqliteTaskRepository _repo; private readonly InMemoryAuditRepository _audit = new(); // 2027-03-10 14:00 UTC; the clocks' local zone is UTC. private static readonly DateTime Afternoon = new(2027, 3, 10, 14, 0, 0, DateTimeKind.Utc); private static readonly DateTime Evening = new(2027, 3, 10, 20, 0, 0, DateTimeKind.Utc); public FillWorkTests() { _repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db"))); } public void Dispose() { SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is secondary */ } } private static AgentConfig Agent(decimal dailyCost = 1.00m) => new() { AgentId = "worker", Budget = new AgentBudget { DailyCostUsd = dailyCost } }; /// The agent's runs average 0.05 USD over 1,000 tokens. private static BudgetGuard Guard(decimal used = 0m) => new(new FakeUsage(used, [.. Enumerable.Range(0, 5).Select(_ => new RunUsage { AgentId = "worker", PromptTokens = 1_000, CostUsd = 0.05m, CostIsKnown = true })])); private static TaskItem Fill(string id, DateTime deadline, decimal? estimate = null, string parent = "") => new() { Id = id, Title = id, Assignee = "@worker", FillBy = deadline, EstimateUsd = estimate, ParentId = parent, CreatedAt = Afternoon.AddDays(-1) }; private void Receipt(string taskId, decimal cost, int tokens = 2_000) => _audit.Receipts.Enqueue(new RunReceipt { RunId = Guid.NewGuid().ToString("N"), AgentId = "worker", TaskId = taskId, PromptTokens = tokens, CostUsd = cost, OccurredAt = Afternoon }); // ═══════════════════ estimating, honestly ═══════════════════ [Fact] public async Task Measured_runs_of_the_task_beat_everything_else() { var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3), estimate: 5m), default); Receipt("t-1", 0.40m); Receipt("t-1", 0.60m); var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default); estimate.Source.ShouldBe(EstimateSource.Measured); estimate.Run.CostUsd.ShouldBe(0.50m); estimate.Samples.ShouldBe(2); estimate.IsGuess.ShouldBeFalse(); } [Fact] public async Task A_refusal_without_usage_is_not_a_measurement() { var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3), estimate: 2m), default); Receipt("t-1", 0m, tokens: 0); var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default); estimate.Source.ShouldBe(EstimateSource.Stated, "a budget refusal costs nothing and says nothing"); } [Fact] public async Task After_the_first_part_ran_its_siblings_are_estimated_from_it() { await _repo.UpsertAsync(new TaskItem { Id = "t-big", Title = "big", CreatedAt = Afternoon }, default); await _repo.UpsertAsync(Fill("t-part-1", Evening.AddDays(3), estimate: 3m, parent: "t-big"), default); var second = await _repo.UpsertAsync(Fill("t-part-2", Evening.AddDays(3), estimate: 3m, parent: "t-big"), default); Receipt("t-part-1", 0.80m); var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(second, default); estimate.Source.ShouldBe(EstimateSource.Siblings); estimate.Run.CostUsd.ShouldBe(0.80m, "the measured part replaces the author's guess of 3 USD"); } [Fact] public async Task A_stated_estimate_is_a_guess_and_gets_tokens_at_the_agents_price() { var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3), estimate: 0.50m), default); var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default); estimate.Source.ShouldBe(EstimateSource.Stated); estimate.IsGuess.ShouldBeTrue(); estimate.Run.Tokens.ShouldBe(10_000, "0.50 USD at 0.05 USD per 1,000 tokens"); FillWorkNotice.Describe(estimate).ShouldContain("estimated by its author, not measured"); } [Fact] public async Task Without_anything_the_agents_average_is_used_and_called_a_guess() { var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3)), default); var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default); estimate.Source.ShouldBe(EstimateSource.AgentAverage); estimate.Run.CostUsd.ShouldBe(0.05m); estimate.IsGuess.ShouldBeTrue(); } // ═══════════════════ is there room? ═══════════════════ private BudgetHeadroom Headroom(DateTime now, decimal used, AgentConfig? agent = null, FillWorkSettings? settings = null) { var guard = Guard(used); return new BudgetHeadroom(guard, () => [agent ?? Agent()], () => InstanceBudget.Unlimited, new UtcClock(now), new TaskCostEstimator(_audit, _repo, guard), () => settings ?? new FillWorkSettings()); } [Fact] public async Task In_the_afternoon_fill_work_waits_for_the_evening() { var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.20m); (await Headroom(Afternoon, used: 0m).CheckAsync(fill, [fill], Afternoon, default)) .ShouldBe(FillVerdict.NotYet, "at 14:00 'there is room' is a guess"); } [Fact] public async Task In_the_evening_it_runs_when_its_estimate_fits() { // Limit 1.00, used 0.50, margin 0.10: 0.40 left for an estimate of 0.30. var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.30m); (await Headroom(Evening, used: 0.50m).CheckAsync(fill, [fill], Evening, default)).ShouldBe(FillVerdict.Run); } [Fact] public async Task It_is_measured_with_its_own_estimate_not_the_agents_average() { // 0.40 left — enough for the agent's average run of 0.05, not for this task's 0.60. var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.60m); (await Headroom(Evening, used: 0.50m).CheckAsync(fill, [fill], Evening, default)).ShouldBe(FillVerdict.NoRoom); } [Fact] public async Task The_rest_of_the_days_schedule_comes_first() { // Four hourly runs à 0.05 until midnight (20:00 … 23:00) = 0.20: 1.00 - 0.50 - 0.20 - 0.10 = 0.20 < 0.30. var hourly = new TaskItem { Id = "t-hourly", Title = "hourly", Assignee = "@worker", CreatedAt = Afternoon.AddDays(-1), LastOccurrence = "2027-03-10T19:00:00.0000000Z", When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 * * * *", TimeZone = "UTC" } }; var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.30m); (await Headroom(Evening, used: 0.50m).CheckAsync(fill, [hourly, fill], Evening, default)).ShouldBe(FillVerdict.NoRoom); } [Fact] public async Task Close_to_its_deadline_it_no_longer_waits_for_the_evening() { var fill = Fill("t-fill", Afternoon.AddHours(10), estimate: 0.20m); (await Headroom(Afternoon, used: 0m).CheckAsync(fill, [fill], Afternoon, default)).ShouldBe(FillVerdict.Run); } [Fact] public async Task Without_a_limit_there_is_nothing_to_wait_for() { var fill = Fill("t-fill", Evening.AddDays(3), estimate: 50m); (await Headroom(Afternoon, used: 0m, agent: Agent(dailyCost: 0m)).CheckAsync(fill, [fill], Afternoon, default)) .ShouldBe(FillVerdict.Run); } // ═══════════════════ the scanner ═══════════════════ private readonly FakeTimeProvider _clock = new(new DateTimeOffset(Evening, TimeSpan.Zero)); private TaskScanner Scanner(RecordingDispatcher dispatcher, IFillGate gate, List? asked = null, int maxParallel = 4) => new(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock, maxParallelRuns: maxParallel, fillGate: gate, askAboutFill: (task, _) => { asked?.Add(task); return Task.CompletedTask; }, fillWarnBefore: () => TimeSpan.FromHours(24)); [Fact] public async Task Fill_work_starts_only_when_the_gate_sees_room() { await _repo.UpsertAsync(Fill("t-fill", Evening.AddDays(3)), default); var dispatcher = new RecordingDispatcher(); var gate = new FixedGate(FillVerdict.NoRoom); (await Scanner(dispatcher, gate).ScanOnceAsync(default)).ShouldBe(0); gate.Verdict = FillVerdict.Run; (await Scanner(dispatcher, gate).ScanOnceAsync(default)).ShouldBe(1); dispatcher.Started.ShouldBe(["t-fill"]); } [Fact] public async Task Fill_work_waits_while_other_work_of_its_agent_waits() { // A carried-over task of the same agent waits for headroom. The fill task would fit — // but late work was due and fill work never was, so it does not overtake it. await _repo.UpsertAsync(new TaskItem { Id = "t-carried", Title = "carried", Assignee = "@worker" }, default); await _repo.TryClaimAsync("t-carried", "occ-1", "token", Evening, Evening.AddMinutes(-15), default); await _repo.CarryOverAsync("t-carried", "token", null, Evening.AddMinutes(-1), Evening, default); await _repo.UpsertAsync(Fill("t-fill", Evening.AddDays(3)), default); await _repo.UpsertAsync(Fill("t-fill-other", Evening.AddDays(3)) with { Assignee = "@other" }, default); var dispatcher = new RecordingDispatcher(); var scanner = new TaskScanner(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock, lateRunGate: new NoRoomForLateWork(), fillGate: new FixedGate(FillVerdict.Run)); await scanner.ScanOnceAsync(default); dispatcher.Started.ShouldBe(["t-fill-other"], "another agent's fill work is not held back"); } [Fact] public async Task Regular_work_goes_first_and_the_nearest_deadline_leads_the_fill_work() { await _repo.UpsertAsync(Fill("t-fill-late", Evening.AddDays(5)), default); await _repo.UpsertAsync(Fill("t-fill-soon", Evening.AddDays(2)), default); await _repo.UpsertAsync(new TaskItem { Id = "t-regular", Title = "regular", Assignee = "@worker", Priority = 1 }, default); var dispatcher = new RecordingDispatcher(); await Scanner(dispatcher, new FixedGate(FillVerdict.Run)).ScanOnceAsync(default); dispatcher.Started.ShouldBe(["t-regular", "t-fill-soon", "t-fill-late"]); } [Fact] public async Task A_human_is_asked_once_when_the_deadline_comes_close() { await _repo.UpsertAsync(Fill("t-fill", Evening.AddHours(20)), default); var asked = new List(); var scanner = Scanner(new RecordingDispatcher(), new FixedGate(FillVerdict.NoRoom), asked); await scanner.ScanOnceAsync(default); await scanner.ScanOnceAsync(default); asked.Select(t => t.Id).ShouldBe(["t-fill"], "asked once, not every minute"); (await _repo.GetAsync("t-fill", default))!.FillAlertedAt.ShouldNotBeNull(); } [Fact] public async Task Far_from_the_deadline_nobody_is_bothered() { await _repo.UpsertAsync(Fill("t-fill", Evening.AddDays(3)), default); var asked = new List(); await Scanner(new RecordingDispatcher(), new FixedGate(FillVerdict.NoRoom), asked).ScanOnceAsync(default); asked.ShouldBeEmpty(); } [Fact] public async Task Past_its_deadline_fill_work_runs_as_regular_work() { await _repo.UpsertAsync(Fill("t-fill", Evening.AddHours(2)), default); _clock.SetUtcNow(new DateTimeOffset(Evening.AddHours(3), TimeSpan.Zero)); var dispatcher = new RecordingDispatcher(); (await Scanner(dispatcher, new FixedGate(FillVerdict.NoRoom)).ScanOnceAsync(default)).ShouldBe(1); dispatcher.Started.ShouldBe(["t-fill"], "the deadline was the promise"); } [Fact] public void The_question_to_the_human_hangs_below_the_fill_work() { var question = FillWorkNotice.QuestionFor(Fill("t-fill", Evening.AddDays(1)), new TaskEstimate(new RunEstimate(0.80m, 0), EstimateSource.Measured, 3)); question.Assignee.ShouldBe(TaskAssignee.Human); question.ParentId.ShouldBe("t-fill"); question.Body.ShouldContain("0.80 USD (measured over 3 run(s))"); question.Body.ShouldContain("raise the agent's daily budget"); } // ═══════════════════ the definition ═══════════════════ [Fact] public void Deadline_and_estimate_survive_the_file() { var task = Fill("t-fill", new DateTime(2027, 3, 12, 18, 0, 0, DateTimeKind.Utc), estimate: 1.25m); TaskFrontmatter.TryParse(TaskFrontmatter.Serialize(task), out var read, out _).ShouldBeTrue(); read.FillBy.ShouldBe(task.FillBy); read.EstimateUsd.ShouldBe(1.25m); read.IsFillWork.ShouldBeTrue(); } [Fact] public void A_bare_date_means_the_end_of_that_day() => TaskFrontmatter.ParseUtc("2027-03-12").ShouldBe(new DateTime(2027, 3, 13, 0, 0, 0, DateTimeKind.Utc)); [Fact] public async Task Deadline_and_estimate_survive_the_database() { await _repo.UpsertAsync(Fill("t-fill", new DateTime(2027, 3, 12, 18, 0, 0, DateTimeKind.Utc), estimate: 0.75m), default); var read = (await _repo.GetAsync("t-fill", default))!; read.FillBy.ShouldBe(new DateTime(2027, 3, 12, 18, 0, 0, DateTimeKind.Utc)); read.EstimateUsd.ShouldBe(0.75m); } [Fact] public void A_recurring_task_is_never_fill_work() => (Fill("t", Evening) with { When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "1h", TimeZone = "UTC" } }) .IsFillWork.ShouldBeFalse(); // ═══════════════════ test doubles ═══════════════════ private sealed class FixedGate(FillVerdict verdict) : IFillGate { public FillVerdict Verdict { get; set; } = verdict; public Task CheckAsync(TaskItem fillTask, IReadOnlyList runnable, DateTime nowUtc, CancellationToken ct) => Task.FromResult(Verdict); } private sealed class NoRoomForLateWork : ILateRunGate { public Task HasRoomAsync(TaskItem lateTask, IReadOnlyList runnable, DateTime nowUtc, CancellationToken ct) => Task.FromResult(false); } private sealed class RecordingDispatcher : ITaskDispatcher { public List Started { get; } = []; public Task DispatchAsync(TaskItem task, CancellationToken ct) { lock (Started) Started.Add(task.Id); return Task.FromResult(TaskLauf.Erledigt); } } 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); } }