Klassifiziert einen echten Usage-Limit-Abbruch als eigene FailureReason
"usage_limit" (TaskRunner.ClassifyFailureReason: nur bei terminal_reason
"api_error" plus einem Limit-Muster im gerenderten Fehlertext, nicht an
Status==Failed allein). Neuer Toggle AutoContinueOnUsageLimit (app_settings,
Default aus) unter Settings → General → "Usage limit stop":
- UsageLimitAutoContinueCoordinator feuert pro Task genau einmal ContinueTask
über OverrideSlotService, sobald das 5h-Fenster (UsageState.Snapshot.FiveHour
.ResetsAt) tatsächlich zurückgesetzt ist; ein persistenter Marker
(TaskEntity.UsageLimitAutoContinuedAt) verhindert einen zweiten Anlauf bei
einem erneuten Limit-Treffer.
- QueueService schedult zusätzlich einen exakten Wake-Timer auf den
Reset-Zeitpunkt, statt nur auf den 30s-Backstop zu warten.
- Fail-open durchgängig: kein Snapshot/keine Reset-Zeit → kein Timer, kein
Continue, kein Throw. Toggle aus ändert das heutige Verhalten nicht.
Migration AddUsageLimitAutoContinue fügt beide Spalten hinzu; die von
`dotnet ef migrations add` mitgescaffoldete leere UpdateData auf app_settings
(columns/values: []) erzeugte ungültiges SQL ("near WHERE") und wurde entfernt
— TaskNumberMigrationTests deckte das über den vollen Migrate()-Pfad auf.
TaskRunner.ContinueAsync: Claim, SeedAsync und SetupMcpConfigAsync liefen
vor dem try/catch (anders als RunAsync) - warf einer davon nach dem
Running-Claim, propagierte die Exception ungefangen bis zu
OverrideSlotService.RunContinueInSlotAsync, das nur loggt. Task blieb
Running. Fix: derselbe Aufbau wie RunAsync (Claim+Seed+MCP-Setup im
try, MarkFailed im catch, mcpToken/mcpConfigPath vor dem try auf null).
SetupMcpConfigAsync bekommt zusätzlich einen onTokenRegistered-Callback,
damit die äußere mcpToken-Variable den Token sofort nach dem Register
sieht - sonst hätte ein Fehler zwischen Register und Rückgabe (z.B.
File.WriteAllTextAsync) den Token in der TaskRunTokenRegistry geleakt
(betrifft denselben Aufruf in RunAsync mit, daher dort ebenfalls
verdrahtet - RunAsync-Struktur selbst unverändert).
QueueService.RunInSlotAsync: der Stop-Button (CancelTask) cancelt die
Slot-CTS direkt ohne TaskStateService-Schreibzugriff. Traf das die
Pre-Dispatch-DB-Reads, loggte der OCE-Catch nur und die vom Picker
bereits auf Running geclaimte Task blieb dort für immer hängen. Fix:
Status im Catch neu lesen und nur wenn er noch Running ist über
TaskStateService.CancelAsync auf Cancelled setzen - ein Ursprung, der
bereits selbst einen Terminalstatus geschrieben hat (z.B. CancelReview),
wird nicht überschrieben. Kommentar korrigiert.
TDD: neue Tests in ContinueAsyncExceptionTests (Seed-/MCP-Setup-Fehler
nach Claim -> Failed, kein Token-Leak) und QueueServiceSlotFailureTests
(Stop während Pre-Dispatch -> Cancelled statt Running; ein bereits
terminal geschriebener Status wird nicht gestompt) vorher rot, jetzt
grün. Worker.Tests: 1213/1213 grün, Worker baut in Release.
QueueServiceSlotFailureTests's throwing-slot test broke its poll loop the
instant the DB read observed Status==Failed, but TaskStateService.FailAsync
commits the status flip before calling the broadcaster's TaskUpdated, so the
assertion could race ahead of the broadcast landing in hub.Proxy.Calls
(~1-in-5 failures in isolation). Wait for both signals before breaking.
RunInSlotAsync only logged an unexpected exception, leaving a task stuck
Running in the DB forever with the UI never notified (the raw-SQL queue
claim that put it there never broadcasts). Cancellation is handled
separately and left alone, since the cancel path already wrote the
terminal status.