Budgetende, Stufe 1: Ausfall vermerken, Regel je Agent, mitnehmen statt anrennen (#716)
Bisher teilte sich eine Budgetablehnung den Ausgang NichtGelaufen mit dem Herunterfahren. Einmalige Aufgaben wurden den Rest des Tages alle 30 Minuten erneut abgelehnt (jedes Mal ein Beleg) und liefen nach Mitternacht still zu Lasten des neuen Tages. Wiederkehrende verfielen still. - TaskLauf.KeinBudget als eigener Ausgang; der Dispatcher bildet BudgetExceeded darauf ab. - Vier Felder an der Aufgabe: BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending (Spalten werden fuer bestehende Datenbanken nachgeruestet). - Einmalige Aufgaben werden mitgenommen: Der Termin bleibt erhalten, faellig ist die Aufgabe wieder ab der naechsten lokalen Mitternacht, nicht vorher. - Wiederkehrende Aufgaben folgen der Regel des Agenten (budget.whenExhausted): expire vermerkt den Ausfall und laesst den Termin verfallen; catchUpOnce fasst alle verpassten Termine zu einem Nachholauf am naechsten Tag zusammen. Ein regulaer faelliger Termin deckt das Nachholen mit ab, es bleibt bei einem Lauf. - Der neue Tag hat Vorfahrt: Mitgenommenes und Nachholaeufe kommen nach allem regulaer Faelligen, unabhaengig von der Prioritaet. - Beim ersten Takt eines neuen Tages eine Zusammenfassung je Agent im Log. - Sichtbar: Die Aufgabenkarte der WebUI zeigt Ausfaelle, Mitnahme und Nachholen; die Regel ist im Desktop je Agent einstellbar. Nebenbei sind "Next:"/"Last:" auf der Karte ins Sprachpaket gewandert. Stufen 2 bis 4 (Reservierung, Spielraum statt pauschalem Anteil, Vorbeugen) folgen. Tests: BudgetMissTests (9 neu). 1.238 gruen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b53396a754
commit
5f3a85595b
@@ -0,0 +1,257 @@
|
||||
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()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
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");
|
||||
}
|
||||
|
||||
// ═══════════════════ 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user