feat(usage): split throttle thresholds per bucket, add draggable gauge markers
This commit is contained in:
@@ -50,7 +50,12 @@ public record AppSettingsDto(
|
||||
List<ModelPresetDto>? ModelPresets = null,
|
||||
int UsageGateFiveHourPct = 80,
|
||||
int UsageGateSevenDayPct = 90,
|
||||
int MaxTurnsCeiling = 80);
|
||||
int MaxTurnsCeiling = 80,
|
||||
// Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings.
|
||||
int UsageThrottleFiveHourSoftPct = 50,
|
||||
int UsageThrottleFiveHourHardPct = 65,
|
||||
int UsageThrottleSevenDaySoftPct = 50,
|
||||
int UsageThrottleSevenDayHardPct = 65);
|
||||
|
||||
// Per-model run defaults (effort + turn budget) edited in Settings -> General.
|
||||
public record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
||||
@@ -137,7 +142,12 @@ public record UsageSnapshotDto(
|
||||
string? LastError,
|
||||
int ConfiguredSlots,
|
||||
int EffectiveSlots,
|
||||
string? ThrottleBucket);
|
||||
string? ThrottleBucket,
|
||||
// Throttle stages per bucket, so the usage monitor can draw (and drag) them on each gauge.
|
||||
int ThrottleFiveHourSoftPct,
|
||||
int ThrottleFiveHourHardPct,
|
||||
int ThrottleSevenDaySoftPct,
|
||||
int ThrottleSevenDayHardPct);
|
||||
|
||||
public record ModelUsageRowDto(
|
||||
DateOnly Date,
|
||||
@@ -446,7 +456,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
.Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList(),
|
||||
row.UsageGateFiveHourPct,
|
||||
row.UsageGateSevenDayPct,
|
||||
row.MaxTurnsCeiling);
|
||||
row.MaxTurnsCeiling,
|
||||
row.UsageThrottleFiveHourSoftPct,
|
||||
row.UsageThrottleFiveHourHardPct,
|
||||
row.UsageThrottleSevenDaySoftPct,
|
||||
row.UsageThrottleSevenDayHardPct);
|
||||
}
|
||||
|
||||
public async Task UpdateAppSettings(AppSettingsDto dto)
|
||||
@@ -477,6 +491,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
UsageGateFiveHourPct = dto.UsageGateFiveHourPct,
|
||||
UsageGateSevenDayPct = dto.UsageGateSevenDayPct,
|
||||
MaxTurnsCeiling = dto.MaxTurnsCeiling,
|
||||
UsageThrottleFiveHourSoftPct = dto.UsageThrottleFiveHourSoftPct,
|
||||
UsageThrottleFiveHourHardPct = dto.UsageThrottleFiveHourHardPct,
|
||||
UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct,
|
||||
UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -242,16 +242,18 @@ public sealed class QueueService : BackgroundService
|
||||
public async Task<(int Configured, int Effective)> GetSlotCountsAsync(CancellationToken ct)
|
||||
{
|
||||
int configured;
|
||||
int softPct, hardPct, gateFivePct, gateSevenPct;
|
||||
UsageThresholds fiveHour, sevenDay;
|
||||
try
|
||||
{
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var settings = await new AppSettingsRepository(context).GetAsync(ct);
|
||||
configured = Math.Max(1, settings.MaxParallelExecutions);
|
||||
softPct = settings.UsageThrottleSoftPct;
|
||||
hardPct = settings.UsageThrottleHardPct;
|
||||
gateFivePct = settings.UsageGateFiveHourPct;
|
||||
gateSevenPct = settings.UsageGateSevenDayPct;
|
||||
fiveHour = new UsageThresholds(
|
||||
settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct,
|
||||
settings.UsageGateFiveHourPct);
|
||||
sevenDay = new UsageThresholds(
|
||||
settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct,
|
||||
settings.UsageGateSevenDayPct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -267,8 +269,9 @@ public sealed class QueueService : BackgroundService
|
||||
}
|
||||
|
||||
var effective = UsageThrottle.EffectiveSlots(
|
||||
configured, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization,
|
||||
softPct, hardPct, gateFivePct, gateSevenPct);
|
||||
configured,
|
||||
snapshot.FiveHour?.Utilization, fiveHour,
|
||||
snapshot.SevenDay?.Utilization, sevenDay);
|
||||
|
||||
ReportThrottleTransition(configured, effective, snapshot);
|
||||
return (configured, effective);
|
||||
|
||||
@@ -28,9 +28,15 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
|
||||
|
||||
if (Directory.Exists(_projectsRoot))
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(_projectsRoot, "*.jsonl", SearchOption.AllDirectories))
|
||||
// A transcript last written before the window began cannot hold a record inside it, so
|
||||
// it is skipped unread — that is what keeps a 7-day range off the full history (hundreds
|
||||
// of MB). One day of slack absorbs local-vs-UTC skew between mtime and record stamps.
|
||||
var mtimeCutoff = start.ToDateTime(TimeOnly.MinValue).AddDays(-1);
|
||||
|
||||
foreach (var file in new DirectoryInfo(_projectsRoot).EnumerateFiles("*.jsonl", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (file.LastWriteTime < mtimeCutoff) continue;
|
||||
|
||||
foreach (var record in GetOrReadFile(file))
|
||||
{
|
||||
@@ -67,8 +73,8 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
|
||||
if (string.IsNullOrWhiteSpace(sessionId) || !Directory.Exists(_projectsRoot))
|
||||
return Task.FromResult<SessionUsageTotals?>(null);
|
||||
|
||||
var file = Directory
|
||||
.EnumerateFiles(_projectsRoot, $"{sessionId}.jsonl", SearchOption.AllDirectories)
|
||||
var file = new DirectoryInfo(_projectsRoot)
|
||||
.EnumerateFiles($"{sessionId}.jsonl", SearchOption.AllDirectories)
|
||||
.FirstOrDefault();
|
||||
if (file is null) return Task.FromResult<SessionUsageTotals?>(null);
|
||||
|
||||
@@ -89,17 +95,16 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
|
||||
new SessionUsageTotals(input, output, cacheRead, cacheCreation));
|
||||
}
|
||||
|
||||
private List<UsageMessageRecord> GetOrReadFile(string file)
|
||||
private List<UsageMessageRecord> GetOrReadFile(FileInfo info)
|
||||
{
|
||||
var info = new FileInfo(file);
|
||||
if (_cache.TryGetValue(file, out var cached) &&
|
||||
if (_cache.TryGetValue(info.FullName, out var cached) &&
|
||||
cached.Length == info.Length && cached.LastWriteUtc == info.LastWriteTimeUtc)
|
||||
{
|
||||
return cached.Records;
|
||||
}
|
||||
|
||||
var records = ReadFile(file);
|
||||
_cache[file] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records);
|
||||
var records = ReadFile(info.FullName);
|
||||
_cache[info.FullName] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records);
|
||||
return records;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,13 +49,18 @@ public sealed class UsageSnapshotBuilder
|
||||
.Select(l => new UsageLimitDto(l.Kind, l.Group, l.Percent, l.Severity, l.ResetsAt, l.ScopeModelDisplayName, l.IsActive))
|
||||
.ToList();
|
||||
|
||||
var fiveHourThresholds = new UsageThresholds(
|
||||
settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct, settings.UsageGateFiveHourPct);
|
||||
var sevenDayThresholds = new UsageThresholds(
|
||||
settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct, settings.UsageGateSevenDayPct);
|
||||
|
||||
var configuredSlots = Math.Max(1, settings.MaxParallelExecutions);
|
||||
var effectiveSlots = snapshot is null || lastError is not null
|
||||
? configuredSlots
|
||||
: UsageThrottle.EffectiveSlots(
|
||||
configuredSlots, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization,
|
||||
settings.UsageThrottleSoftPct, settings.UsageThrottleHardPct,
|
||||
settings.UsageGateFiveHourPct, settings.UsageGateSevenDayPct);
|
||||
configuredSlots,
|
||||
snapshot.FiveHour?.Utilization, fiveHourThresholds,
|
||||
snapshot.SevenDay?.Utilization, sevenDayThresholds);
|
||||
var throttleBucket = effectiveSlots < configuredSlots
|
||||
? DecisiveBucket(snapshot?.FiveHour?.Utilization, snapshot?.SevenDay?.Utilization)
|
||||
: null;
|
||||
@@ -75,7 +80,11 @@ public sealed class UsageSnapshotBuilder
|
||||
lastError,
|
||||
configuredSlots,
|
||||
effectiveSlots,
|
||||
throttleBucket);
|
||||
throttleBucket,
|
||||
fiveHourThresholds.SoftPct,
|
||||
fiveHourThresholds.HardPct,
|
||||
sevenDayThresholds.SoftPct,
|
||||
sevenDayThresholds.HardPct);
|
||||
}
|
||||
|
||||
private static string? DecisiveBucket(double? fiveHourPct, double? sevenDayPct)
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
namespace ClaudeDo.Worker.Usage;
|
||||
|
||||
/// <summary>
|
||||
/// Pure staged throttle sitting ahead of <see cref="UsageGate"/>'s hard pause: as the 5h/7d usage
|
||||
/// window fills up, the queue's effective parallelism steps down before it hits zero, instead of
|
||||
/// running at full tilt right up to the gate threshold. Whichever of the two buckets is more
|
||||
/// utilized decides the stage. A missing bucket (null) is treated as 0% for that bucket only —
|
||||
/// callers with no snapshot at all should skip this and use <paramref name="configuredSlots"/>
|
||||
/// directly (fail-open), same policy as <see cref="UsageGate"/>.
|
||||
/// The soft/hard/gate percentages of a single usage bucket. Soft caps parallelism at 2 slots, hard
|
||||
/// at 1, gate blocks the queue refill entirely. A threshold of 0 disables that stage.
|
||||
/// </summary>
|
||||
public readonly record struct UsageThresholds(int SoftPct, int HardPct, int GatePct);
|
||||
|
||||
/// <summary>
|
||||
/// Pure staged throttle sitting ahead of <see cref="UsageGate"/>'s hard pause: as a usage window
|
||||
/// fills up, the queue's effective parallelism steps down before it hits zero, instead of running
|
||||
/// at full tilt right up to the gate threshold. Each bucket carries its own thresholds (the 5h and
|
||||
/// 7d windows fill at very different rates) and the strictest bucket decides. A missing bucket
|
||||
/// (null) never throttles — callers with no snapshot at all should skip this and use
|
||||
/// <paramref name="configuredSlots"/> directly (fail-open), same policy as <see cref="UsageGate"/>.
|
||||
/// </summary>
|
||||
public static class UsageThrottle
|
||||
{
|
||||
public static int EffectiveSlots(
|
||||
int configuredSlots,
|
||||
double? fiveHourPct,
|
||||
UsageThresholds fiveHour,
|
||||
double? sevenDayPct,
|
||||
int softPct,
|
||||
int hardPct,
|
||||
int gateFiveHourPct,
|
||||
int gateSevenDayPct)
|
||||
UsageThresholds sevenDay)
|
||||
{
|
||||
var slots = Math.Max(1, configuredSlots);
|
||||
|
||||
if (gateFiveHourPct > 0 && fiveHourPct is { } five && five >= gateFiveHourPct)
|
||||
return 0;
|
||||
if (gateSevenDayPct > 0 && sevenDayPct is { } seven && seven >= gateSevenDayPct)
|
||||
return 0;
|
||||
return Math.Min(
|
||||
BucketSlots(slots, fiveHourPct, fiveHour),
|
||||
BucketSlots(slots, sevenDayPct, sevenDay));
|
||||
}
|
||||
|
||||
var worst = Math.Max(fiveHourPct ?? 0, sevenDayPct ?? 0);
|
||||
private static int BucketSlots(int slots, double? pct, UsageThresholds thresholds)
|
||||
{
|
||||
if (pct is not { } utilization) return slots;
|
||||
|
||||
if (hardPct > 0 && worst >= hardPct)
|
||||
return Math.Min(slots, 1);
|
||||
if (softPct > 0 && worst >= softPct)
|
||||
return Math.Min(slots, 2);
|
||||
if (thresholds.GatePct > 0 && utilization >= thresholds.GatePct) return 0;
|
||||
if (thresholds.HardPct > 0 && utilization >= thresholds.HardPct) return Math.Min(slots, 1);
|
||||
if (thresholds.SoftPct > 0 && utilization >= thresholds.SoftPct) return Math.Min(slots, 2);
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user