fix(worker): Usage-Limit-Auto-Continue verbrennt nicht mehr alle Kandidaten außer dem ersten

OverrideSlotService hält genau einen Slot, und ein Usage-Limit stoppt
naturgemäß alle laufenden Tasks gleichzeitig — "mehr Kandidaten als
Kapazität" ist also der Normalfall. Der Coordinator stempelte
UsageLimitAutoContinuedAt aber VOR dem ContinueTask (der Marker ist die
Dedupe-Guard), sodass Kandidat 2..n den Marker bekamen, deren
ContinueTask mit "override slot busy" in den catch flog und sie danach
dauerhaft aus GetUsageLimitAutoContinueCandidatesAsync ausgeschlossen
waren — ein Continue, der nie lief.

- Vorab-Check auf CurrentSlot: bei belegtem Slot bricht der Tick ab,
  statt die restlichen Kandidaten zu verbrennen.
- Neuer TaskRepository.ReleaseUsageLimitAutoContinueClaimAsync gibt den
  Claim zurück, wenn ContinueTask wirft; danach return, der 30s-Backstop
  holt den Rest im nächsten Tick.
- Regressionstest mit zwei Kandidaten, deterministisch über eine
  TaskCompletionSource im FakeClaudeProcess (StartInSlot setzt _slot
  synchron unter dem Lock, bevor die Arbeit startet).
This commit is contained in:
mika kuns
2026-08-24 09:05:20 +02:00
parent 55bfb765e6
commit 3dfae30fff
3 changed files with 106 additions and 14 deletions
@@ -235,6 +235,16 @@ public sealed class TaskRepository
return affected > 0;
}
/// Undoes a claim whose ContinueTask never actually started (the single override slot was
/// busy, the run was rejected). Without this the task keeps a marker for a run that never
/// happened and the candidate query excludes it forever.
public async Task ReleaseUsageLimitAutoContinueClaimAsync(string taskId, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.UsageLimitAutoContinuedAt, (DateTime?)null), ct);
}
internal async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
{
var resultText = "[stale] " + reason;