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:
@@ -13,7 +13,7 @@ namespace ClaudeDo.Worker.Queue;
|
||||
/// timer, never a crash, never a change from today's behaviour. Called once per
|
||||
/// <see cref="QueueService"/> tick (both from its wake and its 30s backstop):
|
||||
/// <see cref="GetScheduledWakeAtAsync"/> tells the caller when to schedule an exact wake so a
|
||||
/// gate-blocked queue doesn't wait out the backstop, and <see cref="RunAsync"/> fires exactly one
|
||||
/// gate-blocked queue doesn't wait out the backstop, and <see cref="RunAsync"/> fires at most one
|
||||
/// <c>ContinueTask</c> per eligible task once the window has actually reset.
|
||||
/// </summary>
|
||||
public sealed class UsageLimitAutoContinueCoordinator
|
||||
@@ -47,9 +47,10 @@ public sealed class UsageLimitAutoContinueCoordinator
|
||||
return _usageState.Snapshot?.FiveHour?.ResetsAt;
|
||||
}
|
||||
|
||||
/// <summary>Fires exactly one <c>ContinueTask</c> per task failed on the usage limit, once the
|
||||
/// 5h window has actually reset. No-ops (never throws) when the toggle is off, no reset time
|
||||
/// is known, or the window hasn't reset yet.</summary>
|
||||
/// <summary>Fires at most one <c>ContinueTask</c> per task failed on the usage limit, once the
|
||||
/// 5h window has actually reset — and at most one per tick, because <see cref="OverrideSlotService"/>
|
||||
/// has a single slot. Remaining candidates are picked up by later ticks. No-ops (never throws)
|
||||
/// when the toggle is off, no reset time is known, or the window hasn't reset yet.</summary>
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var settings = await TryReadSettingsAsync(ct);
|
||||
@@ -64,6 +65,11 @@ public sealed class UsageLimitAutoContinueCoordinator
|
||||
|
||||
foreach (var task in candidates)
|
||||
{
|
||||
// OverrideSlotService holds exactly ONE slot, and a usage limit typically stops every
|
||||
// running task at once — so most ticks have more candidates than capacity. Stop at the
|
||||
// first occupied slot instead of burning the rest; the queue's backstop retries.
|
||||
if (_override.CurrentSlot is not null) return;
|
||||
|
||||
bool claimed;
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
claimed = await new TaskRepository(context).TryClaimUsageLimitAutoContinueAsync(task.Id, DateTime.UtcNow, ct);
|
||||
@@ -78,11 +84,29 @@ public sealed class UsageLimitAutoContinueCoordinator
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The claim is the dedupe guard, so it has to be taken *before* the run starts —
|
||||
// which means a run that never started has to give it back, or the task is
|
||||
// permanently excluded from the candidate query for a continue that never ran.
|
||||
await ReleaseClaimAsync(task.Id, ct);
|
||||
_logger.LogError(ex, "Failed to auto-continue task {TaskId} after usage-limit reset", task.Id);
|
||||
return; // slot busy or the run was rejected — leave the rest for the next tick.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReleaseClaimAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
await new TaskRepository(context).ReleaseUsageLimitAutoContinueClaimAsync(taskId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to release the usage-limit auto-continue claim for task {TaskId}", taskId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AppSettingsEntity?> TryReadSettingsAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user