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>
This commit is contained in:
Richard
2026-09-24 12:10:40 +02:00
co-authored by Claude Opus 5.5
parent 8a83a67fb4
commit 9cf9a2cfc2
15 changed files with 524 additions and 11 deletions
@@ -238,6 +238,42 @@ public sealed class BudgetMissTests : IDisposable
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