Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
285 lines
10 KiB
C#
285 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>A task due immediately (no schedule, never run).</summary>
|
|
private Task<TaskItem> SeedDue(
|
|
string id, string assignee = "@new",
|
|
TaskItemStatus status = TaskItemStatus.Todo,
|
|
bool requireApproval = false, IReadOnlyList<string>? 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<string> Dispatched { get; } = [];
|
|
|
|
public DispatchOutcome? Ergebnis { get; set; }
|
|
|
|
public Task<DispatchOutcome> 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;
|
|
}
|
|
}
|