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:
Richard
2026-09-24 12:03:15 +02:00
co-authored by Claude Opus 5.5
parent b53396a754
commit 5f3a85595b
18 changed files with 624 additions and 15 deletions
@@ -193,6 +193,68 @@ public sealed class SqliteTaskRepository : ITaskRepository
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> CarryOverAsync(
string id, string claimToken, string? previousOccurrence, DateTime deferredUntil,
DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE Tasks
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL,
LastOccurrence = @occ, UpdatedAt = @now,
BudgetMissCount = BudgetMissCount + 1, LastBudgetMissAt = @now,
DeferredUntil = @deferred
WHERE Id = @id AND ClaimToken = @token
""";
cmd.Parameters.AddWithValue("@occ", (object?)previousOccurrence ?? DBNull.Value);
cmd.Parameters.AddWithValue("@now", Format(now));
cmd.Parameters.AddWithValue("@deferred", Format(deferredUntil));
cmd.Parameters.AddWithValue("@id", id);
cmd.Parameters.AddWithValue("@token", claimToken);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> RecordBudgetMissAsync(
string id, string claimToken, DateTime? catchUpUntil, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
// A pending catch-up keeps its date: several misses still mean one catch-up run.
cmd.CommandText = """
UPDATE Tasks
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now,
BudgetMissCount = BudgetMissCount + 1, LastBudgetMissAt = @now,
CatchUpPending = CASE WHEN @catchUp = 1 THEN 1 ELSE CatchUpPending END,
DeferredUntil = CASE WHEN @catchUp = 1 AND CatchUpPending = 0 THEN @deferred
ELSE DeferredUntil END
WHERE Id = @id AND ClaimToken = @token
""";
cmd.Parameters.AddWithValue("@now", Format(now));
cmd.Parameters.AddWithValue("@catchUp", catchUpUntil is null ? 0 : 1);
cmd.Parameters.AddWithValue("@deferred", catchUpUntil is { } until ? Format(until) : DBNull.Value);
cmd.Parameters.AddWithValue("@id", id);
cmd.Parameters.AddWithValue("@token", claimToken);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> ClearBudgetMissAsync(string id, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE Tasks
SET BudgetMissCount = 0, CatchUpPending = 0, DeferredUntil = NULL, UpdatedAt = @now
WHERE Id = @id
""";
cmd.Parameters.AddWithValue("@now", Format(now));
cmd.Parameters.AddWithValue("@id", id);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<int> ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
@@ -252,7 +314,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
private const string Columns =
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt";
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, "
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending";
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
{
@@ -318,7 +381,11 @@ public sealed class SqliteTaskRepository : ITaskRepository
ClaimToken = r.IsDBNull(18) ? null : r.GetString(18),
ClaimedAt = r.IsDBNull(19) ? null : Parse(r.GetString(19)),
CreatedAt = Parse(r.GetString(20)),
UpdatedAt = Parse(r.GetString(21))
UpdatedAt = Parse(r.GetString(21)),
BudgetMissCount = r.GetInt32(22),
LastBudgetMissAt = r.IsDBNull(23) ? null : Parse(r.GetString(23)),
DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)),
CatchUpPending = r.GetInt32(25) != 0
};
}