using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tasks; using ClawdDotNet.Core.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Tasks; /// /// #716: What the scanner does with a run the budget refused. /// /// Before, a one-time task was retried every 30 minutes for the rest of the day — each /// attempt another refusal and another receipt — and then ran after midnight at the /// expense of the new day, without anyone having decided that. A recurring task's /// occurrence lapsed silently. /// public sealed class BudgetMissTests : IDisposable { private readonly string _directory; private readonly SqliteStorage _storage; private readonly SqliteTaskRepository _repo; // 2027: a cron schedule never looks back before the task's creation date. private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero)); public BudgetMissTests() { _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); _repo = new SqliteTaskRepository(_storage); } public void Dispose() { ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is secondary */ } } private TaskScanner Scanner(ITaskDispatcher dispatcher, BudgetMissPolicy policy = BudgetMissPolicy.Expire) => new(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock, budgetMissPolicy: _ => policy); private Task SeedOneTime(string id, int priority = 3) => _repo.UpsertAsync(new TaskItem { Id = id, Title = id, Assignee = "@worker", Priority = priority }, default); private Task SeedHourly(string id) => _repo.UpsertAsync(new TaskItem { Id = id, Title = id, Assignee = "@worker", When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 * * * *", TimeZone = "UTC" } }, default); private async Task Get(string id) => (await _repo.GetAsync(id, default))!; /// The next local midnight, in UTC — the budget day follows the local date. private DateTime NextLocalMidnightUtc() { var zone = _clock.LocalTimeZone; var local = TimeZoneInfo.ConvertTimeFromUtc(_clock.GetUtcNow().UtcDateTime, zone); return TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local.Date.AddDays(1), DateTimeKind.Unspecified), zone); } private void MoveTo(DateTime utc) => _clock.SetUtcNow(new DateTimeOffset(utc, TimeSpan.Zero)); // ═══════════════════ one-time tasks: carried over ═══════════════════ [Fact] public async Task A_refused_one_time_task_is_carried_over_to_the_next_day() { await SeedOneTime("t-report"); var dispatcher = new BudgetDispatcher { HasBudget = false }; var scanner = Scanner(dispatcher); var midnight = NextLocalMidnightUtc(); await scanner.ScanOnceAsync(default); var task = await Get("t-report"); task.Status.ShouldBe(TaskItemStatus.Todo, "it is still work to be done"); task.BudgetMissCount.ShouldBe(1); task.LastBudgetMissAt.ShouldNotBeNull(); task.DeferredUntil.ShouldBe(midnight); task.IsCarriedOver.ShouldBeTrue(); } [Fact] public async Task A_carried_over_task_is_not_retried_the_same_day() { await SeedOneTime("t-report"); var dispatcher = new BudgetDispatcher { HasBudget = false }; var scanner = Scanner(dispatcher); await scanner.ScanOnceAsync(default); // Before #716: again after 30 minutes, and again, until midnight. MoveTo(NextLocalMidnightUtc().AddMinutes(-1)); (await scanner.ScanOnceAsync(default)).ShouldBe(0); dispatcher.Calls.ShouldBe(1, "one refusal, not one every half hour"); } [Fact] public async Task On_the_next_day_it_runs_and_the_miss_is_settled() { await SeedOneTime("t-report"); var dispatcher = new BudgetDispatcher { HasBudget = false }; var scanner = Scanner(dispatcher); await scanner.ScanOnceAsync(default); MoveTo(NextLocalMidnightUtc().AddMinutes(1)); dispatcher.HasBudget = true; (await scanner.ScanOnceAsync(default)).ShouldBe(1); var task = await Get("t-report"); task.Status.ShouldBe(TaskItemStatus.Done); task.BudgetMissCount.ShouldBe(0); task.DeferredUntil.ShouldBeNull(); task.LastBudgetMissAt.ShouldNotBeNull("kept as history"); } [Fact] public async Task The_new_day_has_right_of_way_over_carried_over_work() { await SeedOneTime("t-yesterday", priority: 5); var dispatcher = new BudgetDispatcher { HasBudget = false }; var scanner = Scanner(dispatcher); await scanner.ScanOnceAsync(default); MoveTo(NextLocalMidnightUtc().AddMinutes(1)); await SeedOneTime("t-today", priority: 1); dispatcher.HasBudget = true; dispatcher.Started.Clear(); await scanner.ScanOnceAsync(default); dispatcher.Started.ShouldBe(["t-today", "t-yesterday"], "carried-over work comes after the day's own, even with a higher priority"); } // ═══════════════════ recurring tasks: the agent's policy ═══════════════════ [Fact] public async Task Under_expire_a_refused_occurrence_lapses_and_is_recorded() { await SeedHourly("t-hourly"); var dispatcher = new BudgetDispatcher { HasBudget = false }; var scanner = Scanner(dispatcher, BudgetMissPolicy.Expire); await scanner.ScanOnceAsync(default); var task = await Get("t-hourly"); task.Status.ShouldBe(TaskItemStatus.Todo); task.BudgetMissCount.ShouldBe(1); task.CatchUpPending.ShouldBeFalse(); // Nothing is caught up the next day beyond the regular occurrences. MoveTo(NextLocalMidnightUtc().AddMinutes(30)); dispatcher.HasBudget = true; dispatcher.Started.Clear(); (await scanner.ScanOnceAsync(default)).ShouldBe(1, "only the regular occurrence of this hour"); } [Fact] public async Task Under_catch_up_once_several_misses_leave_one_pending_catch_up() { await SeedHourly("t-hourly"); var dispatcher = new BudgetDispatcher { HasBudget = false }; var scanner = Scanner(dispatcher, BudgetMissPolicy.CatchUpOnce); var midnight = NextLocalMidnightUtc(); // Three hours in a row refused. for (var hour = 0; hour < 3; hour++) { await scanner.ScanOnceAsync(default); _clock.Advance(TimeSpan.FromHours(1)); } var task = await Get("t-hourly"); task.BudgetMissCount.ShouldBe(3); task.CatchUpPending.ShouldBeTrue(); task.DeferredUntil.ShouldBe(midnight, "set by the first miss, not pushed on by the later ones"); } [Fact] public async Task The_catch_up_runs_exactly_once_on_the_next_day() { // A daily report at 22:30: its next regular occurrence is 24 hours away, so here // the catch-up is the only run — the case catchUpOnce exists for. MoveTo(new DateTime(2027, 3, 10, 22, 30, 0, DateTimeKind.Utc)); await _repo.UpsertAsync(new TaskItem { Id = "t-daily", Title = "t-daily", Assignee = "@worker", When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "30 22 * * *", TimeZone = "UTC" } }, default); var dispatcher = new BudgetDispatcher { HasBudget = false }; var scanner = Scanner(dispatcher, BudgetMissPolicy.CatchUpOnce); (await scanner.ScanOnceAsync(default)).ShouldBe(1, "the regular 22:30 run, refused"); MoveTo(NextLocalMidnightUtc().AddMinutes(1)); dispatcher.HasBudget = true; dispatcher.Started.Clear(); (await scanner.ScanOnceAsync(default)).ShouldBe(1, "the catch-up"); _clock.Advance(TimeSpan.FromMinutes(1)); (await scanner.ScanOnceAsync(default)).ShouldBe(0, "once, not on every tick"); dispatcher.Started.ShouldBe(["t-daily"]); var task = await Get("t-daily"); task.CatchUpPending.ShouldBeFalse(); task.BudgetMissCount.ShouldBe(0); task.Status.ShouldBe(TaskItemStatus.Todo, "still recurring"); } [Fact] public async Task A_pending_catch_up_does_not_run_on_the_same_day() { await SeedHourly("t-hourly"); var dispatcher = new BudgetDispatcher { HasBudget = false }; var scanner = Scanner(dispatcher, BudgetMissPolicy.CatchUpOnce); await scanner.ScanOnceAsync(default); // Budget raised by the human in the evening: the regular occurrence runs, and it // covers the pending catch-up — one run, not two. dispatcher.HasBudget = true; dispatcher.Started.Clear(); _clock.Advance(TimeSpan.FromHours(1)); (await scanner.ScanOnceAsync(default)).ShouldBe(1); (await Get("t-hourly")).CatchUpPending.ShouldBeFalse(); } [Fact] public async Task A_shutdown_is_not_a_budget_miss() { await SeedOneTime("t-report"); var dispatcher = new BudgetDispatcher { Result = TaskLauf.NichtGelaufen }; await Scanner(dispatcher).ScanOnceAsync(default); var task = await Get("t-report"); task.BudgetMissCount.ShouldBe(0); task.DeferredUntil.ShouldBeNull("a shutdown keeps the old 30-minute retry"); } [Fact] public async Task Late_work_waits_while_the_headroom_gate_says_no() { await SeedOneTime("t-yesterday"); var dispatcher = new BudgetDispatcher { HasBudget = false }; var gate = new FixedGate { Room = false }; var scanner = new TaskScanner(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock, lateRunGate: gate); await scanner.ScanOnceAsync(default); MoveTo(NextLocalMidnightUtc().AddMinutes(1)); await SeedOneTime("t-today"); dispatcher.HasBudget = true; dispatcher.Started.Clear(); await scanner.ScanOnceAsync(default); dispatcher.Started.ShouldBe(["t-today"], "regular work never asks the gate"); gate.Asked.ShouldBe(["t-yesterday"]); gate.Room = true; await scanner.ScanOnceAsync(default); dispatcher.Started.ShouldBe(["t-today", "t-yesterday"]); } private sealed class FixedGate : ILateRunGate { public bool Room { get; set; } public List Asked { get; } = []; public Task HasRoomAsync(TaskItem lateTask, IReadOnlyList runnable, DateTime nowUtc, CancellationToken ct) { Asked.Add(lateTask.Id); return Task.FromResult(Room); } } // ═══════════════════ test double ═══════════════════ private sealed class BudgetDispatcher : ITaskDispatcher { public bool HasBudget { get; set; } = true; public TaskLauf? Result { get; set; } public int Calls { get; private set; } public List Started { get; } = []; public Task DispatchAsync(TaskItem task, CancellationToken ct) { Calls++; Started.Add(task.Id); return Task.FromResult(Result ?? (HasBudget ? TaskLauf.Erledigt : TaskLauf.KeinBudget)); } } }