Files
RichardandClaude Opus 5.5 9cf9a2cfc2 Budgetende, Stufe 3: Nachholen nur im Spielraum des Tages, keine Kappung bei 500 (#716)
- TaskSchedule.OccurrencesBetween zaehlt, wie oft eine Aufgabe bis zu einem
  Zeitpunkt noch laeuft, einschliesslich eines jetzt faelligen Laufs.
- BudgetHeadroom (ILateRunGate): Ein mitgenommener oder nachzuholender Lauf
  startet nur, wenn er in den Spielraum passt: Grenze minus Verbrauch minus
  Reservierung minus Bedarf der restlichen geplanten Laeufe bis Mitternacht
  minus 10 % Reserve. Das gilt je Agent und je Instanz, fuer Kosten und Tokens.
  Anderes Spaetwerk zaehlt nicht als Bedarf, sonst blockierten sich
  mitgenommene Aufgaben gegenseitig.
- Der Scanner fragt den Spielraum nur fuer Spaetwerk und protokolliert, wenn es
  warten muss. Die Engine gibt ihren BudgetGuard heraus, damit Scanner und
  Engine dieselben Reservierungen sehen.
- Ersetzt den pauschalen 25-%-Anteil aus dem Konzept (dort nachgezogen): Ein
  fester Anteil ist an vollen Tagen zu viel, an ruhigen zu wenig.

Drei weitere stille Kappungen bei 500 beseitigt (ListAllAsync, ungekappt):
Kostenprognose (Aufgaben ueber 500 fehlten), Waisenabgleich beim Import und
task_find aus #674, wo Dubletten unter aelteren Aufgaben mit niedriger
Prioritaet unsichtbar wurden.

Tests: BudgetHeadroomTests (9), Scanner-Pforte (1), Dublette hinter 500
Aufgaben (1). 1.257 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:10:40 +02:00

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()
{
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");
}
[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));
}
}
}