Die zufaelligen roten Laeufe der vollen Suite (jedes Mal ein anderer Test, zuletzt PromptHistoryTests mit einer reinen Funktion) hatten eine Ursache: Zwanzig Testklassen riefen beim Aufraeumen SqliteConnection.ClearAllPools() auf. Das ist prozessweit und nahm einem parallel laufenden Test die Verbindung mitten im Oeffnen weg - ObjectDisposedException in ApplyPragmas. - SqliteStorage.ReleasePooledConnections(pfad) leert nur die Pools der Datenbanken unter einer Datei oder einem Verzeichnis. - BackupService oeffnet die Quelle fuer VACUUM INTO ohne Pool: eine einmalige Kopie soll die Datei danach nicht offen halten. - PoolReleaseTests: die freigegebene Datenbank laesst sich loeschen, die andere arbeitet weiter. Vier volle Laeufe hintereinander gruen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
294 lines
12 KiB
C#
294 lines
12 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>
|
|
/// #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.
|
|
/// </summary>
|
|
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<TaskItem> SeedOneTime(string id, int priority = 3)
|
|
=> _repo.UpsertAsync(new TaskItem { Id = id, Title = id, Assignee = "@worker", Priority = priority }, default);
|
|
|
|
private Task<TaskItem> 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<TaskItem> Get(string id) => (await _repo.GetAsync(id, default))!;
|
|
|
|
/// <summary>The next local midnight, in UTC — the budget day follows the local date.</summary>
|
|
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<string> Asked { get; } = [];
|
|
|
|
public Task<bool> HasRoomAsync(TaskItem lateTask, IReadOnlyList<TaskItem> 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<string> Started { get; } = [];
|
|
|
|
public Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
|
|
{
|
|
Calls++;
|
|
Started.Add(task.Id);
|
|
return Task.FromResult(Result ?? (HasBudget ? TaskLauf.Erledigt : TaskLauf.KeinBudget));
|
|
}
|
|
}
|
|
}
|