fix(worker): Stuck-Running-Fenster in Continue- und Stop-Pfad schließen

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.
This commit is contained in:
mika kuns
2026-08-20 15:00:14 +02:00
parent 4cf08f8159
commit 389c9126c8
6 changed files with 266 additions and 49 deletions
+25 -2
View File
@@ -348,9 +348,32 @@ public sealed class QueueService : BackgroundService
}
catch (OperationCanceledException)
{
// Cancellation is driven by the cancel path, which already wrote the terminal status.
// Marking the task Failed here would be a regression (it would stomp Cancelled).
// Most cancellation origins (TaskStateService.CancelAsync, the runner's own
// OCE handling) already write a terminal status before the CTS is actually
// cancelled, so by the time we get here the task has already left Running.
// A plain Stop (QueueService.CancelTask cancels the slot's CTS directly, with no
// TaskStateService write) landing during the pre-dispatch reads above is the one
// gap nothing else covers — the picker's claim already committed status='running'
// and nothing since has moved it off that. Only close that specific gap: write
// Cancelled when (and only when) the task is still Running, so an origin that
// already wrote its own terminal status is never stomped.
_logger.LogInformation("Slot runner cancelled for task {TaskId}", taskId);
try
{
TaskStatus current;
using (var context = _dbFactory.CreateDbContext())
current = await context.Tasks.AsNoTracking()
.Where(t => t.Id == taskId)
.Select(t => t.Status)
.FirstAsync(CancellationToken.None);
if (current == TaskStatus.Running)
await _state.CancelAsync(taskId, DateTime.UtcNow, CancellationToken.None);
}
catch (Exception cancelEx)
{
_logger.LogError(cancelEx, "Could not finalize cancelled task {TaskId} after slot cancellation", taskId);
}
}
catch (Exception ex)
{