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; /// /// The scanner core — the delicate part marked in the roadmap for Opus 5/Fable. Tested /// against real SQLite (atomic claiming is the point) with a fake for the /// execution, so no engine is needed. The three invariants are at the center. /// public sealed class TaskScannerTests : IDisposable { private readonly string _directory; private readonly SqliteStorage _storage; private readonly SqliteTaskRepository _repo; private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 7, 31, 12, 0, 0, TimeSpan.Zero)); public TaskScannerTests() { _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 { /* Cleaning up is secondary */ } } private TaskScanner Scanner(ITaskDispatcher dispatcher, IMarketCalendar? market = null) => new(_repo, dispatcher, NullLoggerFactory.Instance, market, _clock); /// A task due immediately (no schedule, never run). private Task SeedDue( string id, string assignee = "@new", TaskItemStatus status = TaskItemStatus.Todo, bool requireApproval = false, IReadOnlyList? blockedBy = null, bool onlyWhenMarketOpen = false) => _repo.UpsertAsync(new TaskItem { Id = id, Title = id, Assignee = assignee, Status = status, RequireApproval = requireApproval, BlockedBy = blockedBy ?? [], OnlyWhenMarketOpen = onlyWhenMarketOpen }, default); // ─── Basic flow ─── [Fact] public async Task A_due_task_is_executed_and_completed() { await SeedDue("t-1"); var dispatcher = new FakeDispatcher(); var dispatched = await Scanner(dispatcher).ScanOnceAsync(default); dispatched.ShouldBe(1); dispatcher.Dispatched.ShouldBe(["t-1"]); (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Done); } [Fact] public async Task With_require_approval_the_result_ends_up_in_review() { await SeedDue("t-1", requireApproval: true); await Scanner(new FakeDispatcher()).ScanOnceAsync(default); (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.InReview); } [Fact] public async Task A_failed_run_goes_back_to_todo_and_is_not_retried() { await SeedDue("t-1"); var dispatcher = new FakeDispatcher(succeed: false); var scanner = Scanner(dispatcher); await scanner.ScanOnceAsync(default); (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo); // Second tick: the marker is set, the same occurrence does not run again. await scanner.ScanOnceAsync(default); dispatcher.Dispatched.Count.ShouldBe(1, "kein Retry-Sturm für denselben Termin"); } // ─── Invariant 2: no dispatch with an open blocker / to humans ─── [Fact] public async Task A_blocked_task_is_not_triggered() { await SeedDue("t-1", status: TaskItemStatus.Blocked, blockedBy: ["t-0"]); var dispatcher = new FakeDispatcher(); (await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0); dispatcher.Dispatched.ShouldBeEmpty(); } [Fact] public async Task A_task_for_a_human_is_not_triggered() { await SeedDue("t-1", assignee: "@human"); var dispatcher = new FakeDispatcher(); (await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0); dispatcher.Dispatched.ShouldBeEmpty(); (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo, "sie wartet unverändert"); } // ─── Invariant 3: double tick = one run ─── [Fact] public async Task Two_concurrent_ticks_trigger_a_task_only_once() { for (var round = 0; round < 20; round++) { var id = $"t-{round}"; await SeedDue(id); var dispatcher = new FakeDispatcher(); var scanner = Scanner(dispatcher); await Task.WhenAll( scanner.ScanOnceAsync(default), scanner.ScanOnceAsync(default)); dispatcher.Dispatched.Count(x => x == id).ShouldBe(1, $"Runde {round}"); } } // ─── Recurring tasks (replaces the legacy scheduler) ─── [Fact] public async Task A_recurring_task_stays_on_todo_after_firing() { _clock.SetUtcNow(new DateTimeOffset(2027, 1, 1, 9, 0, 0, TimeSpan.Zero)); await _repo.UpsertAsync(new TaskItem { Id = "t-rec", Title = "täglich", Assignee = "@new", Status = TaskItemStatus.Todo, When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "UTC" } }, default); await Scanner(new FakeDispatcher()).ScanOnceAsync(default); (await _repo.GetAsync("t-rec", default))!.Status.ShouldBe( TaskItemStatus.Todo, "sonst liefe ein Cron-Task nur ein einziges Mal"); } [Fact] public async Task A_recurring_task_fires_again_at_every_occurrence() { await _repo.UpsertAsync(new TaskItem { Id = "t-rec", Title = "täglich", Assignee = "@new", Status = TaskItemStatus.Todo, When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "UTC" } }, default); var dispatcher = new FakeDispatcher(); var scanner = Scanner(dispatcher); _clock.SetUtcNow(new DateTimeOffset(2027, 1, 1, 9, 0, 0, TimeSpan.Zero)); await scanner.ScanOnceAsync(default); _clock.SetUtcNow(new DateTimeOffset(2027, 1, 2, 9, 0, 0, TimeSpan.Zero)); await scanner.ScanOnceAsync(default); dispatcher.Dispatched.Count(x => x == "t-rec").ShouldBe(2, "zwei Termine, zwei Läufe"); } [Fact] public async Task A_backlog_task_is_not_executed() { await SeedDue("t-1", status: TaskItemStatus.Backlog); var dispatcher = new FakeDispatcher(); (await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0); dispatcher.Dispatched.ShouldBeEmpty(); } // ─── C1 hook: market calendar ─── [Fact] public async Task With_the_market_closed_a_market_dependent_schedule_waits() { await SeedDue("t-1", onlyWhenMarketOpen: true); var dispatcher = new FakeDispatcher(); var scanner = Scanner(dispatcher, new ClosedMarket()); (await scanner.ScanOnceAsync(default)).ShouldBe(0); dispatcher.Dispatched.ShouldBeEmpty(); (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo); } // ─── Auto-dispatch: blocker done → waiting ones released ─── [Fact] public async Task When_the_last_blocker_is_done_the_waiting_task_is_released() { await SeedDue("t-blocker"); await SeedDue("t-wartend", status: TaskItemStatus.Blocked, blockedBy: ["t-blocker"]); await Scanner(new FakeDispatcher()).ScanOnceAsync(default); (await _repo.GetAsync("t-blocker", default))!.Status.ShouldBe(TaskItemStatus.Done); (await _repo.GetAsync("t-wartend", default))!.Status.ShouldBe( TaskItemStatus.Todo, "der Blocker ist erledigt, also ist sie jetzt bereit"); } [Fact] public async Task While_a_blocker_is_open_the_waiting_task_stays_blocked() { await SeedDue("t-b1"); // finishes in this tick await SeedDue("t-b2", assignee: "@human"); // a human — stays open await SeedDue("t-wartend", status: TaskItemStatus.Blocked, blockedBy: ["t-b1", "t-b2"]); await Scanner(new FakeDispatcher()).ScanOnceAsync(default); (await _repo.GetAsync("t-wartend", default))!.Status.ShouldBe( TaskItemStatus.Blocked, "nicht alle Blocker sind erledigt"); } // ─── #643: tasks that did not run ─── [Fact] public async Task A_one_off_task_without_budget_stays_due_instead_of_being_dropped() { await _repo.UpsertAsync(new TaskItem { Id = "t-einmal", Title = "einmal", Assignee = "@new:a", Status = TaskItemStatus.Todo }, default); var dispatcher = new FakeDispatcher { Ergebnis = DispatchOutcome.NichtGelaufen }; var scanner = Scanner(dispatcher); (await scanner.ScanOnceAsync(default)).ShouldBe(1); var task = (await _repo.GetAsync("t-einmal", default))!; task.Status.ShouldBe(TaskItemStatus.Todo); task.LastOccurrence.ShouldBeNull("der Termin gilt als nicht verbraucht"); (await scanner.ScanOnceAsync(default)).ShouldBe(0, "Wiedervorlage erst nach der Pause, nicht im Minutentakt"); _clock.Advance(TaskScanner.RetryDelay + TimeSpan.FromMinutes(1)); dispatcher.Ergebnis = DispatchOutcome.Erledigt; (await scanner.ScanOnceAsync(default)).ShouldBe(1); (await _repo.GetAsync("t-einmal", default))!.Status.ShouldBe(TaskItemStatus.Done); } [Fact] public async Task A_failed_one_off_task_stays_used_up() { await _repo.UpsertAsync(new TaskItem { Id = "t-kaputt", Title = "kaputt", Assignee = "@new:a", Status = TaskItemStatus.Todo }, default); var scanner = Scanner(new FakeDispatcher(succeed: false)); await scanner.ScanOnceAsync(default); (await _repo.GetAsync("t-kaputt", default))!.LastOccurrence.ShouldNotBeNull("kein Retry-Sturm nach echtem Fehlschlag"); } // ─── Fakes ─── private sealed class FakeDispatcher(bool succeed = true) : ITaskDispatcher { private readonly Lock _lock = new(); public List Dispatched { get; } = []; public DispatchOutcome? Ergebnis { get; set; } public Task DispatchAsync(TaskItem task, CancellationToken ct) { lock (_lock) Dispatched.Add(task.Id); return Task.FromResult(Ergebnis ?? (succeed ? DispatchOutcome.Erledigt : DispatchOutcome.Gescheitert)); } } private sealed class ClosedMarket : IMarketCalendar { public bool IsOpen(DateTime nowUtc) => false; } }