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:
mika kuns
2026-08-05 11:10:30 +02:00
parent 334cf1e1d2
commit 8f8c2a65b2
12 changed files with 375 additions and 33 deletions
@@ -7,6 +7,7 @@ using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Usage;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -44,12 +45,16 @@ public sealed class QueueServiceTests : IDisposable
}
private QueueWaker _waker = null!;
private FakeUsageGate _usageGate = null!;
private CapturingHubContext _hubContext = null!;
private (QueueService service, FakeClaudeProcess fakeProcess) CreateService(
Func<string, string, IReadOnlyList<string>, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null)
Func<string, string, IReadOnlyList<string>, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null,
FakeUsageGate? usageGate = null)
{
var fake = new FakeClaudeProcess(handler);
var broadcaster = new HubBroadcaster(new CapturingHubContext());
_hubContext = new CapturingHubContext();
var broadcaster = new HubBroadcaster(_hubContext);
var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder();
@@ -60,7 +65,9 @@ public sealed class QueueServiceTests : IDisposable
_waker = new QueueWaker();
var picker = new QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, built.RunCancels);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot, state, built.RunCancels);
_usageGate = usageGate ?? new FakeUsageGate();
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot, state, built.RunCancels,
_usageGate, new UsageState(), broadcaster);
return (service, fake);
}
@@ -342,4 +349,107 @@ public sealed class QueueServiceTests : IDisposable
tcs.SetResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" });
}
[Fact]
public async Task Blocked_UsageGate_Skips_Queue_Refill()
{
var listId = await SeedListAsync();
await SeedQueuedTask(listId);
var gate = new FakeUsageGate { Decision = new UsageGateDecision(true, "5h-Limit 90% >= 80%") };
var (service, fake) = CreateService(
(_, _, _, _, _) => Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }), gate);
using var cts = new CancellationTokenSource();
await service.StartAsync(cts.Token);
_waker.Wake();
await Task.Delay(200);
cts.Cancel();
Assert.Equal(0, fake.CallCount);
}
[Fact]
public async Task UsageGate_Clearing_Resumes_QueueRefill_OnNextTick()
{
var listId = await SeedListAsync();
await SeedQueuedTask(listId);
var gate = new FakeUsageGate { Decision = new UsageGateDecision(true, "5h-Limit 90% >= 80%") };
var done = new TaskCompletionSource();
var (service, fake) = CreateService((_, _, _, _, _) =>
{
done.TrySetResult();
return Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" });
}, gate);
using var cts = new CancellationTokenSource();
await service.StartAsync(cts.Token);
_waker.Wake();
await Task.Delay(150);
Assert.Equal(0, fake.CallCount);
// Clear the gate; the 50ms backstop timer in this test's config picks it up.
gate.Decision = new UsageGateDecision(false, null);
await done.Task.WaitAsync(TimeSpan.FromSeconds(5));
cts.Cancel();
Assert.Equal(1, fake.CallCount);
}
[Fact]
public async Task Blocked_UsageGate_Does_Not_Cancel_AlreadyRunning_Slot()
{
var listId = await SeedListAsync();
await SeedQueuedTask(listId);
var running = new TaskCompletionSource();
var cancelled = false;
var gate = new FakeUsageGate();
var (service, _) = CreateService(async (_, _, _, _, ct) =>
{
running.SetResult();
try
{
await Task.Delay(Timeout.Infinite, ct);
}
catch (OperationCanceledException)
{
cancelled = true;
throw;
}
return new RunResult { ExitCode = 0, ResultMarkdown = "ok" };
}, gate);
using var cts = new CancellationTokenSource();
await service.StartAsync(cts.Token);
_waker.Wake();
await running.Task.WaitAsync(TimeSpan.FromSeconds(5));
// Block after the slot is already running — several backstop ticks pass.
gate.Decision = new UsageGateDecision(true, "5h-Limit 90% >= 80%");
await Task.Delay(200);
Assert.False(cancelled);
cts.Cancel();
}
[Fact]
public async Task UsageGate_TransitionLogging_FiresOncePerChange()
{
var gate = new FakeUsageGate { Decision = new UsageGateDecision(true, "5h-Limit 90% >= 80%") };
var (service, _) = CreateService(
(_, _, _, _, _) => Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }), gate);
using var cts = new CancellationTokenSource();
await service.StartAsync(cts.Token);
// Several backstop ticks (50ms interval) all observe the same blocked state.
await Task.Delay(200);
cts.Cancel();
var warnCalls = _hubContext.Proxy.Calls
.Count(c => c.Method == "WorkerLog" && (WorkerLogLevel)c.Args[1]! == WorkerLogLevel.Warn);
Assert.Equal(1, warnCalls);
}
}