feat(usage): split throttle thresholds per bucket, add draggable gauge markers
This commit is contained in:
@@ -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