feat(claude-do): Worker: UsageGate — Queue ab Schwelle pausieren
> **Stand 2026-08-05 (List-Handler):** Der Roadblock aus dem letzten Lauf ist erledigt. Beide Voraussetzungen sind jetzt auf `main` gemerged: die `app_settings`-Schwellen `UsageGateFiveHourPct`/`UsageGateSevenDayPct` (Merge-Commit `b1efcdc`) und `UsageState`/`IUsageClient`/`UsageMonitorService` unter `src/ClaudeDo.Worker/Usage/` (Merge-Commit `b126a21`). Dein Worktree ist frisch von diesem `main` ClaudeDo-Task: 06a7cc32-6ab7-4758-98f4-bee77149b2bf
This commit is contained in:
@@ -2,8 +2,11 @@ using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.Usage;
|
||||
using ClaudeDo.Worker.Usage.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
@@ -20,9 +23,13 @@ public sealed class QueueService : BackgroundService
|
||||
private readonly OverrideSlotService _override;
|
||||
private readonly ITaskStateService _state;
|
||||
private readonly RunCancellationRegistry _runCancels;
|
||||
private readonly IUsageGate _usageGate;
|
||||
private readonly UsageState _usageState;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<string, QueueSlotState> _queueSlots = new();
|
||||
private bool _usageGateBlocked;
|
||||
|
||||
public QueueService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
@@ -33,7 +40,10 @@ public sealed class QueueService : BackgroundService
|
||||
IQueuePicker picker,
|
||||
OverrideSlotService overrideSlot,
|
||||
ITaskStateService state,
|
||||
RunCancellationRegistry runCancels)
|
||||
RunCancellationRegistry runCancels,
|
||||
IUsageGate usageGate,
|
||||
UsageState usageState,
|
||||
HubBroadcaster broadcaster)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_runner = runner;
|
||||
@@ -44,6 +54,9 @@ public sealed class QueueService : BackgroundService
|
||||
_override = overrideSlot;
|
||||
_state = state;
|
||||
_runCancels = runCancels;
|
||||
_usageGate = usageGate;
|
||||
_usageState = usageState;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
public IReadOnlyList<(string slot, string taskId, DateTime startedAt)> GetActive()
|
||||
@@ -113,32 +126,40 @@ public sealed class QueueService : BackgroundService
|
||||
|
||||
var maxParallel = await GetMaxParallelAsync(stoppingToken);
|
||||
|
||||
// Fill as many free slots as the limit allows.
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
var gateDecision = await _usageGate.EvaluateAsync(stoppingToken);
|
||||
await ReportUsageGateTransitionAsync(gateDecision);
|
||||
|
||||
// Only queue refill is gated. Runs already in flight (RunNow, ContinueTask,
|
||||
// interactive sessions, planning, daily prep) keep going regardless.
|
||||
if (!gateDecision.IsBlocked)
|
||||
{
|
||||
lock (_lock)
|
||||
// Fill as many free slots as the limit allows.
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_queueSlots.Count >= maxParallel) break;
|
||||
}
|
||||
|
||||
var task = await _picker.ClaimNextAsync(DateTime.UtcNow, stoppingToken);
|
||||
if (task is null) break;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
_queueSlots[task.Id] = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts };
|
||||
_runCancels.Register(task.Id, cts);
|
||||
|
||||
_ = RunInSlotAsync(task.Id, cts.Token).ContinueWith(t =>
|
||||
lock (_lock)
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id);
|
||||
lock (_lock) { _queueSlots.Remove(task.Id); }
|
||||
_runCancels.Unregister(task.Id, cts);
|
||||
cts.Dispose();
|
||||
_waker.Wake(); // Check for next task immediately.
|
||||
}, TaskScheduler.Default);
|
||||
if (_queueSlots.Count >= maxParallel) break;
|
||||
}
|
||||
|
||||
var task = await _picker.ClaimNextAsync(DateTime.UtcNow, stoppingToken);
|
||||
if (task is null) break;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
_queueSlots[task.Id] = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts };
|
||||
_runCancels.Register(task.Id, cts);
|
||||
|
||||
_ = RunInSlotAsync(task.Id, cts.Token).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id);
|
||||
lock (_lock) { _queueSlots.Remove(task.Id); }
|
||||
_runCancels.Unregister(task.Id, cts);
|
||||
cts.Dispose();
|
||||
_waker.Wake(); // Check for next task immediately.
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +176,28 @@ public sealed class QueueService : BackgroundService
|
||||
_logger.LogInformation("QueueService stopping");
|
||||
}
|
||||
|
||||
private async Task ReportUsageGateTransitionAsync(UsageGateDecision decision)
|
||||
{
|
||||
if (decision.IsBlocked == _usageGateBlocked) return;
|
||||
_usageGateBlocked = decision.IsBlocked;
|
||||
|
||||
if (decision.IsBlocked)
|
||||
{
|
||||
_logger.LogInformation("QueueService: usage gate blocking queue refill ({Reason})", decision.Reason);
|
||||
await _broadcaster.WorkerLog($"Queue pausiert: {decision.Reason}", WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
var snapshot = _usageState.Snapshot;
|
||||
var message = snapshot?.FiveHour is not null && snapshot.SevenDay is not null
|
||||
? $"Queue fortgesetzt: 5h {snapshot.FiveHour.Utilization:0}%, 7d {snapshot.SevenDay.Utilization:0}%"
|
||||
: "Queue fortgesetzt";
|
||||
|
||||
_logger.LogInformation("QueueService: usage gate cleared, queue refill resumed");
|
||||
await _broadcaster.WorkerLog(message, WorkerLogLevel.Info, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> GetMaxParallelAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user