Merge branch 'claudedo/87105f5ec4f44af4ae6089cd2e153e3c'

This commit is contained in:
mika kuns
2026-08-05 16:10:37 +02:00
24 changed files with 1466 additions and 19 deletions
+14 -2
View File
@@ -21,7 +21,7 @@ Worker/
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (OIDC discovery + refresh-token flow), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message, broadcasts `HubBroadcaster.UsageUpdated` after every tick via `UsageSnapshotBuilder`), UsageSnapshotBuilder (builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings` thresholds — the one place `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` share the stale/threshold/gate logic), TranscriptUsageReader (aggregates Claude Code transcript token usage from `~/.claude/projects/**/*.jsonl` by date/model/scope (ClaudeDo vs Other), deduped by requestId, with a per-file length+mtime cache; `<synthetic>`-model lines are skipped everywhere, not real API calls; also exposes `ReadSessionTotalsAsync(sessionId)` — cumulative raw input/output/cache-read/cache-write totals for one session, located by `{sessionId}.jsonl`, used by `TaskRunner` to populate `task_runs`' per-run token fields), UsageGate (reads `UsageState` + `AppSettings.UsageGateFiveHourPct`/`UsageGateSevenDayPct`, returns a `UsageGateDecision(IsBlocked, Reason)`; `Utilization` from `UsageBucket` is already a 0100 percent, compared directly against the threshold with `>=`; threshold `0` = that bucket never gates; fail-open — no snapshot yet, a failed last poll, or a settings-read error all resolve to not-blocked); interfaces in Usage/Interfaces/ (IUsageClient, ITranscriptUsageReader, IUsageGate)
Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message, broadcasts `HubBroadcaster.UsageUpdated` after every tick via `UsageSnapshotBuilder`), UsageSnapshotBuilder (builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings` thresholds — the one place `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` share the stale/threshold/gate logic), TranscriptUsageReader (aggregates Claude Code transcript token usage from `~/.claude/projects/**/*.jsonl` by date/model/scope (ClaudeDo vs Other), deduped by requestId, with a per-file length+mtime cache; `<synthetic>`-model lines are skipped everywhere, not real API calls; also exposes `ReadSessionTotalsAsync(sessionId)` — cumulative raw input/output/cache-read/cache-write totals for one session, located by `{sessionId}.jsonl`, used by `TaskRunner` to populate `task_runs`' per-run token fields), UsageGate (reads `UsageState` + `AppSettings.UsageGateFiveHourPct`/`UsageGateSevenDayPct`, returns a `UsageGateDecision(IsBlocked, Reason)`; `Utilization` from `UsageBucket` is already a 0100 percent, compared directly against the threshold with `>=`; threshold `0` = that bucket never gates; fail-open — no snapshot yet, a failed last poll, or a settings-read error all resolve to not-blocked), UsageThrottle (pure static `EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct, gateFiveHourPct, gateSevenDayPct)` — stages parallelism down ahead of the hard gate: whichever bucket is more utilized decides the stage, `0` for a threshold disables that stage, `>= softPct` caps at 2 slots, `>= hardPct` caps at 1, `>= either gate threshold` returns 0 — same 0 as `UsageGate`'s hard block, kept in sync since both read the same gate thresholds); interfaces in Usage/Interfaces/ (IUsageClient, ITranscriptUsageReader, IUsageGate)
```
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
@@ -30,7 +30,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
- **TaskStateService** — only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions.
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`. On each loop tick, after computing `maxParallel`, it also asks `IUsageGate.EvaluateAsync` — if blocked, the slot-fill loop is skipped entirely for that tick (already-running slots are untouched; `RunNow`/`ContinueTask`/interactive/planning/daily-prep all bypass the queue and are unaffected). A blocked↔free transition is logged/broadcast (`WorkerLog`, Warn on block / Info on resume) exactly once per change, not on every tick; the 30 s backstop timer re-evaluates the gate on its own even with no wake signal, so the queue self-recovers once usage drops back under the threshold.
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`. On each loop tick, `GetEffectiveMaxParallelAsync` reads `AppSettings.MaxParallelExecutions` and steps it down via `UsageThrottle.EffectiveSlots` against the current `UsageState` snapshot (a missing/failed snapshot fails open to the configured value — never throttles on a broken poll); a stage change (not every tick) logs once via the standard logger. Separately, it also asks `IUsageGate.EvaluateAsync` — if blocked, the slot-fill loop is skipped entirely for that tick (already-running slots are untouched in either case; `RunNow`/`ContinueTask`/interactive/planning/daily-prep all bypass the queue and are unaffected). A blocked↔free transition is logged/broadcast (`WorkerLog`, Warn on block / Info on resume) exactly once per change, not on every tick; the 30 s backstop timer re-evaluates both the throttle and the gate on its own even with no wake signal, so the queue self-recovers once usage drops back under the threshold.
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." **No external tool returns bare `Task` or a nullable payload directly** — an MCP client can't tell an empty/omitted response apart from a dropped one. Write tools return a small confirmation record (`{ ok/deleted/removed/reset/started: true, <id>, ... }`, e.g. `DeleteListResult`, `RunTaskNowResult`, `ResetFailedTaskResult`, `RemoveAttachmentResult`; `SetListConfigResult`/`SetTaskConfigResult` additionally echo the resulting config so the caller can see which fields were set vs. cleared to null); read tools that may have nothing to return use an explicit `Found`/`Available` flag alongside the nullable payload (`TaskConfigResult`, pre-existing `BatchGetTaskResult`, `TaskLogResult`) instead of returning null outright. Organized by concern:
@@ -79,6 +79,18 @@ recovery is just the queue's 30s backstop timer re-evaluating the gate on its ow
drops back under the threshold. See `Usage/` in the folder layout above for the component
breakdown.
Ahead of that hard gate, `UsageThrottle` steps the queue's effective parallelism down in two
stages (thresholds `usage_throttle_soft_pct`/`usage_throttle_hard_pct`, defaults 50/65):
whichever of 5h/7d is more utilized decides the stage — below soft = full configured
`max_parallel_executions`, at/above soft = capped to 2 slots, at/above hard = capped to 1,
at/above either gate threshold = 0 (the pre-existing hard pause, unchanged). Only *new* slot
fills are affected; a run already occupying a slot when the stage tightens keeps running to
completion. Same fail-open policy as the gate — no snapshot yet means no throttling, full
configured parallelism. The effective stage (configured vs. effective slots, decisive bucket)
rides along on `UsageSnapshotDto`/`GetUsageSnapshot` for UI display (`UsagePillViewModel`
tooltip, `UsageMonitorModalViewModel`'s throttle band) — it does not change what the gate
itself gates on.
## Status Model
`TaskEntity` carries three orthogonal fields. Lifecycle, planning hierarchy, and chain blocking are no longer conflated.
+4 -1
View File
@@ -129,7 +129,10 @@ public record UsageSnapshotDto(
string? GateReason,
DateTime? FetchedAtUtc,
bool IsStale,
string? LastError);
string? LastError,
int ConfiguredSlots,
int EffectiveSlots,
string? ThrottleBucket);
public record ModelUsageRowDto(
DateOnly Date,
+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)
@@ -46,6 +46,17 @@ public sealed class UsageSnapshotBuilder
.Select(l => new UsageLimitDto(l.Kind, l.Group, l.Percent, l.Severity, l.ResetsAt, l.ScopeModelDisplayName, l.IsActive))
.ToList();
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);
var throttleBucket = effectiveSlots < configuredSlots
? DecisiveBucket(snapshot?.FiveHour?.Utilization, snapshot?.SevenDay?.Utilization)
: null;
return new UsageSnapshotDto(
snapshot?.FiveHour?.Utilization,
snapshot?.FiveHour?.ResetsAt,
@@ -58,6 +69,15 @@ public sealed class UsageSnapshotBuilder
decision.Reason,
snapshot?.FetchedAtUtc,
isStale,
lastError);
lastError,
configuredSlots,
effectiveSlots,
throttleBucket);
}
private static string? DecisiveBucket(double? fiveHourPct, double? sevenDayPct)
{
if (fiveHourPct is null && sevenDayPct is null) return null;
return (fiveHourPct ?? 0) >= (sevenDayPct ?? 0) ? "five_hour" : "seven_day";
}
}
@@ -0,0 +1,38 @@
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"/>.
/// </summary>
public static class UsageThrottle
{
public static int EffectiveSlots(
int configuredSlots,
double? fiveHourPct,
double? sevenDayPct,
int softPct,
int hardPct,
int gateFiveHourPct,
int gateSevenDayPct)
{
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;
var worst = Math.Max(fiveHourPct ?? 0, sevenDayPct ?? 0);
if (hardPct > 0 && worst >= hardPct)
return Math.Min(slots, 1);
if (softPct > 0 && worst >= softPct)
return Math.Min(slots, 2);
return slots;
}
}