chore(claude-do): UsageGate: Parallelitaet stufenweise drosseln statt erst bei

## Kontext: Limits sind Fenster, nicht Summen

Die Runs laufen ueber das Claude-Abo. Limits greifen pro 5h-Fenster und pro 7 Tage. Nicht die Wochensumme tut weh, sondern dass ein Agent-Burst ein Fenster leerraeumt, in dem Mika selbst interaktiv arbeiten will.

## Messgrundlage (alle Transcripts unter ~/.claude/projects)

Agent-Runs sind ueber die ganze Historie nur **18,4 %** des Account-Verbrauch

ClaudeDo-Task: 87105f5e-c4f4-4af4-ae60-89cd2e153e3c
This commit is contained in:
mika kuns
2026-08-05 15:53:15 +02:00
parent 83ea429b8a
commit a201d3f43d
24 changed files with 1466 additions and 19 deletions
+49 -3
View File
@@ -30,6 +30,7 @@ public sealed class QueueService : BackgroundService
private readonly object _lock = new();
private readonly Dictionary<string, QueueSlotState> _queueSlots = new();
private bool _usageGateBlocked;
private int? _lastEffectiveSlots;
public QueueService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
@@ -124,7 +125,7 @@ public sealed class QueueService : BackgroundService
await Task.WhenAny(wakeTask, timerTask);
var maxParallel = await GetMaxParallelAsync(stoppingToken);
var maxParallel = await GetEffectiveMaxParallelAsync(stoppingToken);
var gateDecision = await _usageGate.EvaluateAsync(stoppingToken);
await ReportUsageGateTransitionAsync(gateDecision);
@@ -198,19 +199,64 @@ public sealed class QueueService : BackgroundService
}
}
private async Task<int> GetMaxParallelAsync(CancellationToken ct)
/// <summary>
/// Configured parallelism, stepped down by <see cref="UsageThrottle"/> ahead of the hard usage
/// gate. A missing snapshot (poll hasn't landed / endpoint unreachable) fails open to the
/// configured value — a broken usage poll must never stall the queue.
/// </summary>
private async Task<int> GetEffectiveMaxParallelAsync(CancellationToken ct)
{
int configured;
int softPct, hardPct, gateFivePct, gateSevenPct;
try
{
using var context = _dbFactory.CreateDbContext();
var settings = await new AppSettingsRepository(context).GetAsync(ct);
return Math.Max(1, settings.MaxParallelExecutions);
configured = Math.Max(1, settings.MaxParallelExecutions);
softPct = settings.UsageThrottleSoftPct;
hardPct = settings.UsageThrottleHardPct;
gateFivePct = settings.UsageGateFiveHourPct;
gateSevenPct = settings.UsageGateSevenDayPct;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read max parallel executions; defaulting to 1");
return 1;
}
var snapshot = _usageState.Snapshot;
if (snapshot is null || _usageState.LastError is not null)
{
_lastEffectiveSlots = configured;
return configured;
}
var effective = UsageThrottle.EffectiveSlots(
configured, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization,
softPct, hardPct, gateFivePct, gateSevenPct);
ReportThrottleTransition(configured, effective, snapshot);
return effective;
}
private void ReportThrottleTransition(int configured, int effective, UsageSnapshot snapshot)
{
if (_lastEffectiveSlots == effective) return;
var previous = _lastEffectiveSlots;
_lastEffectiveSlots = effective;
if (previous is null && effective == configured) return; // baseline, nothing to report
if (effective < configured)
{
_logger.LogInformation(
"QueueService: usage throttle stepped to {Effective}/{Configured} slots (5h={FiveHour}%, 7d={SevenDay}%)",
effective, configured, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization);
}
else
{
_logger.LogInformation("QueueService: usage throttle cleared, back to {Configured} slots", configured);
}
}
private async Task RunInSlotAsync(string taskId, CancellationToken ct)