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
@@ -142,4 +142,86 @@ public sealed class UsageSnapshotBuilderTests : IDisposable
Assert.True(dto.IsStale);
Assert.Equal("boom", dto.LastError);
}
private async Task SetThrottleAsync(int maxParallel, int softPct, int hardPct)
{
using var ctx = _db.CreateContext();
var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync();
settings.MaxParallelExecutions = maxParallel;
settings.UsageThrottleSoftPct = softPct;
settings.UsageThrottleHardPct = hardPct;
await repo.UpdateAsync(settings);
}
[Fact]
public async Task Throttled_slots_and_decisive_bucket_reported()
{
await SetThresholdsAsync(80, 90);
await SetThrottleAsync(maxParallel: 3, softPct: 50, hardPct: 65);
var state = new UsageState();
state.ReportSuccess(new UsageSnapshot(
new UsageBucket(70, null), new UsageBucket(20, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
var builder = CreateBuilder(state, new UsageGateDecision(false, null));
var dto = await builder.BuildAsync();
Assert.Equal(3, dto.ConfiguredSlots);
Assert.Equal(1, dto.EffectiveSlots);
Assert.Equal("five_hour", dto.ThrottleBucket);
}
[Fact]
public async Task SevenDay_decisive_bucket_reported_when_higher()
{
await SetThresholdsAsync(80, 90);
await SetThrottleAsync(maxParallel: 3, softPct: 50, hardPct: 65);
var state = new UsageState();
state.ReportSuccess(new UsageSnapshot(
new UsageBucket(10, null), new UsageBucket(70, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
var builder = CreateBuilder(state, new UsageGateDecision(false, null));
var dto = await builder.BuildAsync();
Assert.Equal("seven_day", dto.ThrottleBucket);
}
[Fact]
public async Task Not_throttled_reports_null_bucket_and_equal_slots()
{
await SetThresholdsAsync(80, 90);
await SetThrottleAsync(maxParallel: 3, softPct: 50, hardPct: 65);
var state = new UsageState();
state.ReportSuccess(new UsageSnapshot(
new UsageBucket(10, null), new UsageBucket(20, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
var builder = CreateBuilder(state, new UsageGateDecision(false, null));
var dto = await builder.BuildAsync();
Assert.Equal(3, dto.ConfiguredSlots);
Assert.Equal(3, dto.EffectiveSlots);
Assert.Null(dto.ThrottleBucket);
}
[Fact]
public async Task No_snapshot_falls_back_to_configured_slots_without_throttling()
{
await SetThresholdsAsync(80, 90);
await SetThrottleAsync(maxParallel: 3, softPct: 50, hardPct: 65);
var state = new UsageState();
var builder = CreateBuilder(state, new UsageGateDecision(false, null));
var dto = await builder.BuildAsync();
Assert.Equal(3, dto.ConfiguredSlots);
Assert.Equal(3, dto.EffectiveSlots);
Assert.Null(dto.ThrottleBucket);
}
}