feat(usage): split throttle thresholds per bucket, add draggable gauge markers

This commit is contained in:
mika kuns
2026-08-07 11:01:45 +02:00
parent 231b063751
commit 7eeb8f5086
33 changed files with 2289 additions and 147 deletions
+50 -11
View File
@@ -1,7 +1,8 @@
# Usage monitoring, gate & throttle # Usage monitoring, gate & throttle
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative. > **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `f6cb825` (2026-08-05). > Last verified against commit `f6cb825` (2026-08-05), plus the uncommitted per-bucket-throttle /
> draggable-gauge change of 2026-08-06 (this note already describes that newer state).
> Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs` > Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs`
> Stable structure only (no line numbers). See docs/explore-notes/README.md. > Stable structure only (no line numbers). See docs/explore-notes/README.md.
@@ -52,21 +53,26 @@ on block / Info on resume) exactly **once per change**, not every tick.
## The throttle (staged parallelism) ## The throttle (staged parallelism)
`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct, `UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, fiveHourThresholds, sevenDayPct,
gateFiveHourPct, gateSevenDayPct)` — pure static, no state. sevenDayThresholds)` — pure static, no state. `UsageThresholds(SoftPct, HardPct, GatePct)` is the
per-bucket triple (same file).
Thresholds `usage_throttle_soft_pct` / `usage_throttle_hard_pct` (defaults 50/65). Thresholds are **per bucket** (`usage_throttle_five_hour_{soft,hard}_pct` /
Whichever of 5h/7d is **more utilized** decides the stage: `usage_throttle_seven_day_{soft,hard}_pct`, defaults 50/65 each) because the 5h and 7d windows fill
at very different rates. Each bucket is staged independently and the **strictest** bucket wins —
not "whichever is more utilized", so a bucket that is lower but tightly configured can be the one
that throttles:
| Utilization | Effective slots | | Utilization (per bucket) | That bucket's slots |
|---|---| |---|---|
| below soft | full configured `max_parallel_executions` | | below soft | full configured `max_parallel_executions` |
| `>= softPct` | capped at 2 | | `>= softPct` | capped at 2 |
| `>= hardPct` | capped at 1 | | `>= hardPct` | capped at 1 |
| `>=` either gate threshold | 0 — same hard block as `UsageGate` | | `>= gatePct` | 0 — same hard block as `UsageGate` |
A threshold of `0` disables that stage. The `0` return is deliberately kept in sync with A threshold of `0` disables that stage for that bucket, and a bucket with no reading (null) never
`UsageGate`'s hard block because both read the same gate thresholds — change one, change both. throttles. The `0` return is deliberately kept in sync with `UsageGate`'s hard block because both
read the same gate thresholds — change one, change both.
Only **new** slot fills are affected; a run already occupying a slot when the stage tightens Only **new** slot fills are affected; a run already occupying a slot when the stage tightens
runs to completion. Same fail-open policy: no snapshot means no throttling. runs to completion. Same fail-open policy: no snapshot means no throttling.
@@ -108,6 +114,12 @@ A missing/unreadable transcript leaves all four fields `null`; it never fails th
Reads `~/.claude/projects/**/*.jsonl`, aggregating by date / model / scope (ClaudeDo vs Reads `~/.claude/projects/**/*.jsonl`, aggregating by date / model / scope (ClaudeDo vs
Other), deduped by `requestId`, with a per-file length+mtime cache. Other), deduped by `requestId`, with a per-file length+mtime cache.
`ReadAsync` **skips any file whose mtime predates the window start minus one day** — it cannot hold
a record inside the range, and the full history is large (measured 2026-08-06: 501 files / 230 MB /
77k lines ≈ 1.7 s to parse cold; a 7-day range touches ~190 files / ~106 MB). The one-day slack
absorbs local-vs-UTC skew between mtime and record timestamps. `ReadSessionTotalsAsync` is
unaffected — it looks up a single `{sessionId}.jsonl`.
`<synthetic>`-model lines are skipped **everywhere** — they are not real API calls. `<synthetic>`-model lines are skipped **everywhere** — they are not real API calls.
## UI surfaces ## UI surfaces
@@ -117,6 +129,25 @@ Other), deduped by `requestId`, with a per-file length+mtime cache.
`IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive: `IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive:
**blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and **blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and
not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket. not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket.
The pill's click handler (`IslandsShellViewModel.OpenUsageMonitor`) **shows the window before
loading** (`BeginLoad`) — awaiting the load first made the pill feel like a dead click, because
the first `GetModelUsage` per worker process scans the whole transcript history.
- **Draggable stage markers** — each of the two real gauges carries three markers (soft/hard/gate).
`UsageGaugeBar` (`Views/Controls`) draws them against its own width and does the pointer work;
the math is a pure static, `UsageThresholdDrag` (in the modal VM's file), which keeps
soft ≤ hard ≤ gate and treats a neighbour of `0` as off. Release fires the row's
`CommitCommand` → read-modify-write via `GetAppSettings` + `UpdateAppSettings`, so only the
dragged bucket's three fields change. Plan-dependent `weekly_scoped` gauges are read-only.
- **Legend = numeric editor.** Under each adjustable bar sit three legend rows whose colour swatches
match the markers (soft `TextDimBrush`, hard `StatusReviewBrush`, gate `StatusErrorBrush`), each
with a `NumericUpDown`. `NumericUpDown` has no commit command, so the box's `Tag`
(`soft`/`hard`/`gate`) plus two code-behind handlers (`LostFocus`, Enter) call the row's
`CommitSoft`/`CommitHard`/`CommitGate` command. Those run the typed value through the **same**
`UsageThresholdDrag.Apply` clamp as a drag, so a box can't invert the order and only the edited
stage moves. ⚠️ The `KeepLastNumber` converter is mandatory on those bindings — see the
`NumericUpDown` null gotcha in `src/ClaudeDo.Ui/CLAUDE.md`.
Rows are updated **in place** on each snapshot (keyed by limit kind) so a poll landing mid-drag
doesn't replace the bound instance.
- **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in - **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in
`UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` / `UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` /
`seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back `seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back
@@ -138,5 +169,13 @@ Other), deduped by `requestId`, with a per-file length+mtime cache.
## Settings columns ## Settings columns
`app_settings`: `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` (80/90), `app_settings`: `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` (80/90),
`usage_throttle_soft_pct` / `usage_throttle_hard_pct` (50/65). All four clamped 0..100 by `usage_throttle_five_hour_{soft,hard}_pct` / `usage_throttle_seven_day_{soft,hard}_pct` (50/65 per
`AppSettingsRepository.UpdateAsync`. Worker config: `usage_poll_interval_seconds`. bucket). All six clamped 0..100 by `AppSettingsRepository.UpdateAsync`, which does **not** enforce
soft ≤ hard ≤ gate — the ordering is a UI-side drag constraint, and an out-of-order stored config
degrades instead of throwing. Worker config: `usage_poll_interval_active_seconds` /
`usage_poll_interval_idle_seconds`.
The gate percentages are editable in **two** places that both write the same `app_settings` row:
Settings → General (typed) and the usage-monitor gauges (dragged). The throttle stages are
gauge-only — `SettingsModalViewModel` therefore carries them load→save verbatim so saving Settings
can't reset a dragged value.
+1 -1
View File
@@ -29,7 +29,7 @@ Beyond the basics it carries:
| `MaxTurnsCeiling` | `max_turns_ceiling` | 80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. `UpdateAsync` clamps to min 1. | | `MaxTurnsCeiling` | `max_turns_ceiling` | 80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. `UpdateAsync` clamps to min 1. |
| `ModelPresets` | `model_presets` | seeded | JSON array of `ModelPreset` rows. ⚠️ `AppSettingsRepository.GetAsync` **backfills shipping defaults on the first read after it's null**, so it's never null once a run has started. | | `ModelPresets` | `model_presets` | seeded | JSON array of `ModelPreset` rows. ⚠️ `AppSettingsRepository.GetAsync` **backfills shipping defaults on the first read after it's null**, so it's never null once a run has started. |
| `UsageGateFiveHourPct` / `UsageGateSevenDayPct` | `usage_gate_*_pct` | 80 / 90 | Queue pause thresholds; `0` = off. | | `UsageGateFiveHourPct` / `UsageGateSevenDayPct` | `usage_gate_*_pct` | 80 / 90 | Queue pause thresholds; `0` = off. |
| `UsageThrottleSoftPct` / `UsageThrottleHardPct` | `usage_throttle_*_pct` | 50 / 65 | Staged parallelism below the hard gate; `0` = that stage off. | | `UsageThrottle{FiveHour,SevenDay}{Soft,Hard}Pct` | `usage_throttle_{five_hour,seven_day}_{soft,hard}_pct` | 50 / 65 per bucket | Staged parallelism below the hard gate, **per bucket**; `0` = that stage off. Edited by dragging the usage-monitor gauges. |
| `DailyPrepMaxTasks` | `daily_prep_max_tasks` | 5 | Hard cap on MyDay tasks the daily prep may place. | | `DailyPrepMaxTasks` | `daily_prep_max_tasks` | 5 | Hard cap on MyDay tasks the daily prep may place. |
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. | | `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. | | `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
@@ -55,10 +55,14 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.UsageGateSevenDayPct) builder.Property(s => s.UsageGateSevenDayPct)
.HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90); .HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90);
builder.Property(s => s.UsageThrottleSoftPct) builder.Property(s => s.UsageThrottleFiveHourSoftPct)
.HasColumnName("usage_throttle_soft_pct").IsRequired().HasDefaultValue(50); .HasColumnName("usage_throttle_five_hour_soft_pct").IsRequired().HasDefaultValue(50);
builder.Property(s => s.UsageThrottleHardPct) builder.Property(s => s.UsageThrottleFiveHourHardPct)
.HasColumnName("usage_throttle_hard_pct").IsRequired().HasDefaultValue(65); .HasColumnName("usage_throttle_five_hour_hard_pct").IsRequired().HasDefaultValue(65);
builder.Property(s => s.UsageThrottleSevenDaySoftPct)
.HasColumnName("usage_throttle_seven_day_soft_pct").IsRequired().HasDefaultValue(50);
builder.Property(s => s.UsageThrottleSevenDayHardPct)
.HasColumnName("usage_throttle_seven_day_hard_pct").IsRequired().HasDefaultValue(65);
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
} }
@@ -0,0 +1,883 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260806141710_SplitUsageThrottlePerBucket")]
partial class SplitUsageThrottlePerBucket
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class SplitUsageThrottlePerBucket : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "usage_throttle_soft_pct",
table: "app_settings",
newName: "usage_throttle_seven_day_soft_pct");
migrationBuilder.RenameColumn(
name: "usage_throttle_hard_pct",
table: "app_settings",
newName: "usage_throttle_seven_day_hard_pct");
migrationBuilder.AddColumn<int>(
name: "usage_throttle_five_hour_hard_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 65);
migrationBuilder.AddColumn<int>(
name: "usage_throttle_five_hour_soft_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 50);
// The old single soft/hard pair was compared against whichever bucket was more utilized,
// so carrying it into BOTH buckets keeps an existing install behaving exactly as before
// the split — the rename above already preserved it for the 7d side.
migrationBuilder.Sql(
"""
UPDATE app_settings
SET usage_throttle_five_hour_soft_pct = usage_throttle_seven_day_soft_pct,
usage_throttle_five_hour_hard_pct = usage_throttle_seven_day_hard_pct;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "usage_throttle_five_hour_hard_pct",
table: "app_settings");
migrationBuilder.DropColumn(
name: "usage_throttle_five_hour_soft_pct",
table: "app_settings");
migrationBuilder.RenameColumn(
name: "usage_throttle_seven_day_soft_pct",
table: "app_settings",
newName: "usage_throttle_soft_pct");
migrationBuilder.RenameColumn(
name: "usage_throttle_seven_day_hard_pct",
table: "app_settings",
newName: "usage_throttle_hard_pct");
}
}
}
@@ -106,17 +106,29 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(90) .HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct"); .HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleHardPct") b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasDefaultValue(65) .HasDefaultValue(65)
.HasColumnName("usage_throttle_hard_pct"); .HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleSoftPct") b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasDefaultValue(50) .HasDefaultValue(50)
.HasColumnName("usage_throttle_soft_pct"); .HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays") b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -155,8 +167,10 @@ namespace ClaudeDo.Data.Migrations
StandupWeekday = 3, StandupWeekday = 3,
UsageGateFiveHourPct = 80, UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90, UsageGateSevenDayPct = 90,
UsageThrottleHardPct = 65, UsageThrottleFiveHourHardPct = 65,
UsageThrottleSoftPct = 50, UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7, WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false, WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling" WorktreeStrategy = "sibling"
@@ -43,8 +43,11 @@ public sealed class AppSettingsEntity
public int UsageGateFiveHourPct { get; set; } = 80; public int UsageGateFiveHourPct { get; set; } = 80;
public int UsageGateSevenDayPct { get; set; } = 90; public int UsageGateSevenDayPct { get; set; } = 90;
// Percentage of the 5h/7d Claude usage window at which the queue starts throttling // Percentage at which the queue starts throttling parallelism ahead of the hard gate above.
// parallelism ahead of the hard gate above. 0 = that stage off. // Tracked per bucket, because the 5h and 7d windows fill at very different rates — soft caps
public int UsageThrottleSoftPct { get; set; } = 50; // parallelism at 2 slots, hard at 1. 0 = that stage off for that bucket.
public int UsageThrottleHardPct { get; set; } = 65; public int UsageThrottleFiveHourSoftPct { get; set; } = 50;
public int UsageThrottleFiveHourHardPct { get; set; } = 65;
public int UsageThrottleSevenDaySoftPct { get; set; } = 50;
public int UsageThrottleSevenDayHardPct { get; set; } = 65;
} }
@@ -88,8 +88,10 @@ public sealed class AppSettingsRepository
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills; row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100); row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100);
row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100); row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100);
row.UsageThrottleSoftPct = Math.Clamp(updated.UsageThrottleSoftPct, 0, 100); row.UsageThrottleFiveHourSoftPct = Math.Clamp(updated.UsageThrottleFiveHourSoftPct, 0, 100);
row.UsageThrottleHardPct = Math.Clamp(updated.UsageThrottleHardPct, 0, 100); row.UsageThrottleFiveHourHardPct = Math.Clamp(updated.UsageThrottleFiveHourHardPct, 0, 100);
row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100);
row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100);
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
+6 -1
View File
@@ -433,6 +433,10 @@
"gateBlockedFormat": "Queue pausiert — {0}", "gateBlockedFormat": "Queue pausiert — {0}",
"throttleFormat": "Queue gedrosselt: {0}/{1} Slots ({2})", "throttleFormat": "Queue gedrosselt: {0}/{1} Slots ({2})",
"resetIn": "Reset in {0}", "resetIn": "Reset in {0}",
"dragHint": "Marker ziehen oder Wert unten eintragen.",
"legendSoft": "Soft · 2 Slots",
"legendHard": "Hard · 1 Slot",
"legendGate": "Gate · Pause",
"gaugeSession": "Session (5 Std.)", "gaugeSession": "Session (5 Std.)",
"gaugeWeeklyAll": "Woche (alle Modelle)", "gaugeWeeklyAll": "Woche (alle Modelle)",
"gaugeWeeklyScopedFormat": "Woche ({0})", "gaugeWeeklyScopedFormat": "Woche ({0})",
@@ -657,7 +661,8 @@
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" }, "weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
"usageMonitor": { "usageMonitor": {
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}", "loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}" "refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}",
"thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}"
}, },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." },
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, "sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
+6 -1
View File
@@ -433,6 +433,10 @@
"gateBlockedFormat": "Queue paused — {0}", "gateBlockedFormat": "Queue paused — {0}",
"throttleFormat": "Queue throttled: {0}/{1} slots ({2})", "throttleFormat": "Queue throttled: {0}/{1} slots ({2})",
"resetIn": "Reset in {0}", "resetIn": "Reset in {0}",
"dragHint": "Drag a marker, or type the value below.",
"legendSoft": "Soft · 2 slots",
"legendHard": "Hard · 1 slot",
"legendGate": "Gate · pause",
"gaugeSession": "Session (5h)", "gaugeSession": "Session (5h)",
"gaugeWeeklyAll": "Week (all models)", "gaugeWeeklyAll": "Week (all models)",
"gaugeWeeklyScopedFormat": "Week ({0})", "gaugeWeeklyScopedFormat": "Week ({0})",
@@ -657,7 +661,8 @@
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" }, "weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
"usageMonitor": { "usageMonitor": {
"loadFailed": "Couldn't load usage data: {0}", "loadFailed": "Couldn't load usage data: {0}",
"refreshFailed": "Couldn't refresh usage: {0}" "refreshFailed": "Couldn't refresh usage: {0}",
"thresholdSaveFailed": "Couldn't save the threshold: {0}"
}, },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." },
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" }, "sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
+2 -2
View File
@@ -32,7 +32,7 @@ ViewModels/
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar, Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
DescriptionStepsCard, WorkConsole; plus SessionTerminalView DescriptionStepsCard, WorkConsole; plus SessionTerminalView
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge, Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge,
AgentConfigEditor AgentConfigEditor, UsagePill, UsageGaugeBar
Design/ — Tokens.axaml (design tokens; merged before styles) Design/ — Tokens.axaml (design tokens; merged before styles)
+ IslandStyles.axaml (component styles + the filled icon geometry library) + IslandStyles.axaml (component styles + the filled icon geometry library)
``` ```
@@ -58,7 +58,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. | | `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. | | `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). | | `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
| `UsageMonitorModalViewModel` | Opened from the usage pill; gauges are **dynamic** per `UsageSnapshotDto.Limits` row. | | `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). |
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`, repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
+13 -2
View File
@@ -670,7 +670,12 @@ public sealed record AppSettingsDto(
List<ModelPresetDto>? ModelPresets = null, List<ModelPresetDto>? ModelPresets = null,
int UsageGateFiveHourPct = 80, int UsageGateFiveHourPct = 80,
int UsageGateSevenDayPct = 90, 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. // Per-model run defaults (effort + turn budget) edited in Settings → General.
public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns); public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns);
@@ -764,7 +769,13 @@ public sealed record UsageSnapshotDto(
string? LastError, string? LastError,
int ConfiguredSlots, int ConfiguredSlots,
int EffectiveSlots, int EffectiveSlots,
string? ThrottleBucket); string? ThrottleBucket,
// Throttle stages per bucket, drawn (and dragged) on the usage-monitor gauges. Defaults match
// the DB defaults so an older worker that doesn't send them yet still yields sane markers.
int ThrottleFiveHourSoftPct = 50,
int ThrottleFiveHourHardPct = 65,
int ThrottleSevenDaySoftPct = 50,
int ThrottleSevenDayHardPct = 65);
public sealed record ModelUsageRowDto( public sealed record ModelUsageRowDto(
DateOnly Date, DateOnly Date,
@@ -570,7 +570,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{ {
var vm = _usageMonitorVmFactory(); var vm = _usageMonitorVmFactory();
vm.ErrorReported += FlashFooterError; vm.ErrorReported += FlashFooterError;
await vm.LoadAsync(); // Show first, load after: the initial transcript scan takes seconds, and awaiting it
// here left the pill looking unresponsive until the window finally appeared.
vm.BeginLoad();
await Dialogs.ShowUsageMonitorAsync(vm); await Dialogs.ShowUsageMonitorAsync(vm);
} }
finally { _usageMonitorOpen = false; } finally { _usageMonitorOpen = false; }
@@ -20,6 +20,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public OnlineInboxSettingsViewModel OnlineInbox { get; } public OnlineInboxSettingsViewModel OnlineInbox { get; }
public SessionSkillsSettingsTabViewModel SessionSkills { get; } public SessionSkillsSettingsTabViewModel SessionSkills { get; }
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
public bool ShowOnlineInbox => false;
[ObservableProperty] private string _validationError = ""; [ObservableProperty] private string _validationError = "";
[ObservableProperty] private bool _isBusy; [ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _statusMessage = ""; [ObservableProperty] private string _statusMessage = "";
@@ -48,6 +51,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
SessionSkills = new SessionSkillsSettingsTabViewModel(worker); SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
} }
// Throttle stages are edited by dragging the usage-monitor gauges, not on any Settings tab —
// carried through load→save verbatim so saving Settings can never reset a dragged value.
private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65);
public async Task LoadAsync() public async Task LoadAsync()
{ {
IsBusy = true; IsBusy = true;
@@ -64,6 +71,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.MaxParallelExecutions = dto.MaxParallelExecutions; General.MaxParallelExecutions = dto.MaxParallelExecutions;
General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct; General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct;
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct; General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
_throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling"; Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot; Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled; Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
@@ -115,7 +125,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.ModelPresetDtos(), General.ModelPresetDtos(),
General.UsageGateFiveHourPct, General.UsageGateFiveHourPct,
General.UsageGateSevenDayPct, General.UsageGateSevenDayPct,
General.MaxTurnsCeiling); General.MaxTurnsCeiling,
_throttleStages.FiveSoft,
_throttleStages.FiveHard,
_throttleStages.SevenSoft,
_throttleStages.SevenHard);
await _worker.UpdateAppSettingsAsync(dto); await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync(); await Prime.SaveAsync();
await OnlineInbox.SaveAsync(); await OnlineInbox.SaveAsync();
@@ -20,7 +20,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
public event Action<string>? ErrorReported; public event Action<string>? ErrorReported;
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(GaugeRows))]
[NotifyPropertyChangedFor(nameof(IsStale))] [NotifyPropertyChangedFor(nameof(IsStale))]
[NotifyPropertyChangedFor(nameof(LastError))] [NotifyPropertyChangedFor(nameof(LastError))]
[NotifyPropertyChangedFor(nameof(IsGateBlocked))] [NotifyPropertyChangedFor(nameof(IsGateBlocked))]
@@ -53,8 +52,8 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0; public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
public bool TasksEmpty => !IsBusy && TaskRows.Count == 0; public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;
public IReadOnlyList<UsageGaugeRowViewModel> GaugeRows => [ObservableProperty]
Snapshot is null ? Array.Empty<UsageGaugeRowViewModel>() : Snapshot.Limits.Select(BuildGaugeRow).ToList(); private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
public bool IsStale => Snapshot?.IsStale == true; public bool IsStale => Snapshot?.IsStale == true;
public string? LastError => Snapshot?.LastError; public string? LastError => Snapshot?.LastError;
@@ -83,7 +82,18 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
CloseAction?.Invoke(); CloseAction?.Invoke();
} }
/// <summary>
/// Starts the load without blocking the caller, so the host can show the window right away and
/// let it fill in behind the busy spinner. The first load per worker process pays a full scan of
/// <c>~/.claude/projects</c> (hundreds of MB of transcripts) — awaiting it before showing the
/// window made the usage pill look like it swallowed the click.
/// </summary>
public void BeginLoad() => _ = LoadAsync();
public async Task LoadAsync() public async Task LoadAsync()
{
IsBusy = true;
try
{ {
Snapshot = await _worker.GetUsageSnapshotAsync(); Snapshot = await _worker.GetUsageSnapshotAsync();
_worker.UsageUpdatedEvent -= OnUsageUpdated; _worker.UsageUpdatedEvent -= OnUsageUpdated;
@@ -91,6 +101,12 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
ApplyPresetRange(SelectedPresetDays); ApplyPresetRange(SelectedPresetDays);
await LoadUsageDataAsync(); await LoadUsageDataAsync();
} }
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message));
}
finally { IsBusy = false; }
}
private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot; private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot;
@@ -173,6 +189,99 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
finally { IsBusy = false; } finally { IsBusy = false; }
} }
partial void OnSnapshotChanged(UsageSnapshotDto? value) => SyncGaugeRows();
/// <summary>
/// Folds a fresh snapshot into the existing rows instead of rebuilding them, so a poll landing
/// while the user works the markers doesn't swap the instances out from under the drag.
/// </summary>
private void SyncGaugeRows()
{
var limits = Snapshot?.Limits ?? (IReadOnlyList<UsageLimitDto>)Array.Empty<UsageLimitDto>();
var existing = GaugeRows.ToDictionary(r => r.Key);
var rows = new List<UsageGaugeRowViewModel>(limits.Count);
foreach (var limit in limits)
{
var key = GaugeKey(limit);
var bucket = GaugeBucket(limit);
var (soft, hard, gate) = StagesFor(bucket);
var label = BuildGaugeLabel(limit);
if (existing.TryGetValue(key, out var row))
{
row.Update(label, limit.Percent, limit.Severity, limit.ResetsAt, soft, hard, gate);
rows.Add(row);
}
else
{
rows.Add(new UsageGaugeRowViewModel(
key, bucket, label, limit.Percent, limit.Severity, limit.ResetsAt,
soft, hard, gate, SaveStagesAsync));
}
}
if (!rows.SequenceEqual(GaugeRows)) GaugeRows = rows;
}
// Scoped weekly buckets are plan-dependent and share no settings row, so they stay read-only.
private static string? GaugeBucket(UsageLimitDto limit) => limit.Kind switch
{
"session" => "five_hour",
"weekly_all" => "seven_day",
_ => null,
};
private static string GaugeKey(UsageLimitDto limit) =>
limit.Kind == "weekly_scoped" ? $"weekly_scoped:{limit.ScopeModelDisplayName}" : limit.Kind;
private (int? Soft, int? Hard, int? Gate) StagesFor(string? bucket) => (bucket, Snapshot) switch
{
("five_hour", { } s) => (s.ThrottleFiveHourSoftPct, s.ThrottleFiveHourHardPct, s.FiveHourThresholdPct),
("seven_day", { } s) => (s.ThrottleSevenDaySoftPct, s.ThrottleSevenDayHardPct, s.SevenDayThresholdPct),
_ => (null, null, null),
};
/// <summary>
/// Persists one gauge's stages after a drag. Read-modify-write against the current settings, so
/// this never clobbers a field the usage monitor doesn't own.
/// </summary>
private async Task SaveStagesAsync(UsageGaugeRowViewModel row)
{
if (row.Bucket is null || row.SoftPct is not { } soft || row.HardPct is not { } hard || row.GatePct is not { } gate)
return;
try
{
var settings = await _worker.GetAppSettingsAsync();
if (settings is null)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
return;
}
var updated = row.Bucket == "five_hour"
? settings with
{
UsageThrottleFiveHourSoftPct = soft,
UsageThrottleFiveHourHardPct = hard,
UsageGateFiveHourPct = gate,
}
: settings with
{
UsageThrottleSevenDaySoftPct = soft,
UsageThrottleSevenDayHardPct = hard,
UsageGateSevenDayPct = gate,
};
await _worker.UpdateAppSettingsAsync(updated);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", ex.Message));
}
}
private static string BuildGaugeLabel(UsageLimitDto limit) => limit.Kind switch private static string BuildGaugeLabel(UsageLimitDto limit) => limit.Kind switch
{ {
"session" => Loc.T("modals.usageMonitor.gaugeSession"), "session" => Loc.T("modals.usageMonitor.gaugeSession"),
@@ -182,17 +291,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
_ => limit.Kind, _ => limit.Kind,
}; };
private UsageGaugeRowViewModel BuildGaugeRow(UsageLimitDto limit)
{
int? threshold = limit.Kind switch
{
"session" => Snapshot?.FiveHourThresholdPct,
"weekly_all" => Snapshot?.SevenDayThresholdPct,
_ => null,
};
return new UsageGaugeRowViewModel(BuildGaugeLabel(limit), limit.Percent, limit.Severity, limit.ResetsAt, threshold);
}
private static IReadOnlyList<ModelUsageDisplayRow> BuildModelDisplayRows(IReadOnlyList<ModelUsageRowDto> rows) private static IReadOnlyList<ModelUsageDisplayRow> BuildModelDisplayRows(IReadOnlyList<ModelUsageRowDto> rows)
{ {
var built = new List<ModelUsageDisplayRow>(); var built = new List<ModelUsageDisplayRow>();
@@ -222,22 +320,115 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
} }
} }
public sealed record UsageGaugeRowViewModel( /// <summary>
string Label, /// One usage gauge. The two real buckets (5h session, 7d week) carry their three stage thresholds
double Percent, /// and are adjustable by dragging; plan-dependent scoped buckets render as a plain bar.
string Severity, /// </summary>
DateTimeOffset? ResetsAt, public sealed partial class UsageGaugeRowViewModel : ObservableObject
int? ThresholdPercent)
{ {
private readonly Func<UsageGaugeRowViewModel, Task>? _commit;
public UsageGaugeRowViewModel(
string key,
string? bucket,
string label,
double percent,
string severity,
DateTimeOffset? resetsAt,
int? softPct,
int? hardPct,
int? gatePct,
Func<UsageGaugeRowViewModel, Task>? commit = null)
{
Key = key;
Bucket = bucket;
_label = label;
_percent = percent;
_severity = severity;
_resetsAt = resetsAt;
_softPct = softPct;
_hardPct = hardPct;
_gatePct = gatePct;
_commit = commit;
}
/// <summary>Identity across snapshot updates, so a live poll updates rows instead of replacing them.</summary>
public string Key { get; }
/// <summary>Which settings bucket a drag writes to: <c>five_hour</c>, <c>seven_day</c>, or null.</summary>
public string? Bucket { get; }
[ObservableProperty] private string _label;
[ObservableProperty] private double _percent;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsWarnSeverity))]
private string _severity;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ResetText))]
private DateTimeOffset? _resetsAt;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
private int? _softPct;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
private int? _hardPct;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
private int? _gatePct;
public bool IsAdjustable => Bucket is not null && SoftPct is not null && HardPct is not null && GatePct is not null;
public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase); public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase);
public string ResetText => ResetsAt is { } r ? Loc.T("modals.usageMonitor.resetIn", FormatRemaining(r)) : ""; public string ResetText => ResetsAt is { } r ? Loc.T("modals.usageMonitor.resetIn", FormatRemaining(r)) : "";
// Matches the gauge card's inner track width in the view (240 card width - 12*2 padding). /// <summary>Live values from a fresh snapshot, without replacing the row instance mid-view.</summary>
private const double GaugeTrackWidthPx = 216; public void Update(string label, double percent, string severity, DateTimeOffset? resetsAt,
int? softPct, int? hardPct, int? gatePct)
{
Label = label;
Percent = percent;
Severity = severity;
ResetsAt = resetsAt;
SoftPct = softPct;
HardPct = hardPct;
GatePct = gatePct;
}
public double ThresholdMarkerLeftPx => /// <summary>Raised by the gauge control when a drag ends — that is when the value is persisted.</summary>
ThresholdPercent is { } t ? GaugeTrackWidthPx * Math.Clamp(t, 0, 100) / 100.0 : 0; [RelayCommand]
private Task Commit() => _commit?.Invoke(this) ?? Task.CompletedTask;
// One per legend input box. A typed value goes through the same clamp as a dragged one, so a box
// can't invert the order — and only the edited stage moves, never its neighbours.
[RelayCommand] private Task CommitSoft() => CommitStage(UsageThresholdDrag.Stage.Soft);
[RelayCommand] private Task CommitHard() => CommitStage(UsageThresholdDrag.Stage.Hard);
[RelayCommand] private Task CommitGate() => CommitStage(UsageThresholdDrag.Stage.Gate);
private Task CommitStage(UsageThresholdDrag.Stage stage)
{
if (!IsAdjustable) return Task.CompletedTask;
var edited = stage switch
{
UsageThresholdDrag.Stage.Soft => SoftPct!.Value,
UsageThresholdDrag.Stage.Hard => HardPct!.Value,
_ => GatePct!.Value,
};
var (soft, hard, gate) = UsageThresholdDrag.Apply(
SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, edited);
SoftPct = soft;
HardPct = hard;
GatePct = gate;
return Commit();
}
private static string FormatRemaining(DateTimeOffset resetsAt) private static string FormatRemaining(DateTimeOffset resetsAt)
{ {
@@ -251,6 +442,54 @@ public sealed record UsageGaugeRowViewModel(
} }
} }
/// <summary>
/// Drag math for the gauge stage markers, kept out of the control so it can be tested directly:
/// every stage stays inside 0..100 and never crosses a neighbour (soft ≤ hard ≤ gate). A neighbour
/// at 0 means "that stage is off" and therefore does not constrain anything.
/// </summary>
public static class UsageThresholdDrag
{
public enum Stage { Soft, Hard, Gate }
/// <summary>Pointer reach for grabbing a marker, as a share of the bar width.</summary>
public static Stage? Nearest(int soft, int hard, int gate, double percent, double tolerancePercent)
{
Stage? best = null;
var bestDistance = double.MaxValue;
foreach (var (stage, value) in new[] { (Stage.Soft, soft), (Stage.Hard, hard), (Stage.Gate, gate) })
{
var distance = Math.Abs(percent - value);
if (distance > tolerancePercent || distance >= bestDistance) continue;
best = stage;
bestDistance = distance;
}
return best;
}
public static (int Soft, int Hard, int Gate) Apply(int soft, int hard, int gate, Stage stage, double rawPercent)
{
var value = (int)Math.Round(Math.Clamp(rawPercent, 0, 100));
return stage switch
{
Stage.Soft => (ClampRange(value, 0, UpperBound(hard, gate)), hard, gate),
Stage.Hard => (soft, ClampRange(value, soft, UpperBound(gate, 100)), gate),
Stage.Gate => (soft, hard, ClampRange(value, Math.Max(soft, hard), 100)),
_ => (soft, hard, gate),
};
}
// A neighbour of 0 is switched off and must not pin the dragged marker to 0.
private static int UpperBound(int nearest, int fallback) =>
nearest > 0 ? nearest : (fallback > 0 ? fallback : 100);
// An already-inconsistent stored config (min above max) must not throw mid-drag.
private static int ClampRange(int value, int min, int max) =>
max < min ? max : Math.Clamp(value, min, max);
}
public sealed record ModelUsageDisplayRow( public sealed record ModelUsageDisplayRow(
string Model, string Model,
long ClaudeDoInputTokens, long ClaudeDoInputTokens,
@@ -0,0 +1,250 @@
using System;
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Rendering;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>
/// Usage bar with three draggable stage markers: soft (throttle to 2 slots), hard (1 slot) and gate
/// (queue paused). Positions are computed against the control's real width — no hardcoded track
/// size — and the drag math lives in <see cref="UsageThresholdDrag"/> so it stays testable.
/// Values are written back through TwoWay bindings while dragging; <see cref="CommitCommand"/>
/// fires once on release, which is when the host persists them.
/// A row without thresholds (plan-dependent scoped buckets) renders as a plain read-only bar.
/// </summary>
public sealed class UsageGaugeBar : Control, ICustomHitTest
{
/// <summary>How close the pointer has to be to grab a marker.</summary>
private const double GrabRadiusPx = 12;
private const double TrackHeightPx = 10;
private const double MarkerWidthPx = 2;
public static readonly StyledProperty<double> PercentProperty =
AvaloniaProperty.Register<UsageGaugeBar, double>(nameof(Percent));
public static readonly StyledProperty<bool> IsWarnProperty =
AvaloniaProperty.Register<UsageGaugeBar, bool>(nameof(IsWarn));
public static readonly StyledProperty<int?> SoftPctProperty =
AvaloniaProperty.Register<UsageGaugeBar, int?>(
nameof(SoftPct), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<int?> HardPctProperty =
AvaloniaProperty.Register<UsageGaugeBar, int?>(
nameof(HardPct), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<int?> GatePctProperty =
AvaloniaProperty.Register<UsageGaugeBar, int?>(
nameof(GatePct), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<IBrush?> TrackBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(TrackBrush));
public static readonly StyledProperty<IBrush?> FillBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(FillBrush));
public static readonly StyledProperty<IBrush?> WarnFillBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(WarnFillBrush));
public static readonly StyledProperty<IBrush?> SoftMarkerBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(SoftMarkerBrush));
public static readonly StyledProperty<IBrush?> HardMarkerBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(HardMarkerBrush));
public static readonly StyledProperty<IBrush?> GateMarkerBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(GateMarkerBrush));
public static readonly StyledProperty<ICommand?> CommitCommandProperty =
AvaloniaProperty.Register<UsageGaugeBar, ICommand?>(nameof(CommitCommand));
static UsageGaugeBar()
{
AffectsRender<UsageGaugeBar>(
PercentProperty, IsWarnProperty, SoftPctProperty, HardPctProperty, GatePctProperty,
TrackBrushProperty, FillBrushProperty, WarnFillBrushProperty,
SoftMarkerBrushProperty, HardMarkerBrushProperty, GateMarkerBrushProperty);
}
private UsageThresholdDrag.Stage? _dragging;
public double Percent
{
get => GetValue(PercentProperty);
set => SetValue(PercentProperty, value);
}
public bool IsWarn
{
get => GetValue(IsWarnProperty);
set => SetValue(IsWarnProperty, value);
}
public int? SoftPct
{
get => GetValue(SoftPctProperty);
set => SetValue(SoftPctProperty, value);
}
public int? HardPct
{
get => GetValue(HardPctProperty);
set => SetValue(HardPctProperty, value);
}
public int? GatePct
{
get => GetValue(GatePctProperty);
set => SetValue(GatePctProperty, value);
}
public IBrush? TrackBrush
{
get => GetValue(TrackBrushProperty);
set => SetValue(TrackBrushProperty, value);
}
public IBrush? FillBrush
{
get => GetValue(FillBrushProperty);
set => SetValue(FillBrushProperty, value);
}
public IBrush? WarnFillBrush
{
get => GetValue(WarnFillBrushProperty);
set => SetValue(WarnFillBrushProperty, value);
}
public IBrush? SoftMarkerBrush
{
get => GetValue(SoftMarkerBrushProperty);
set => SetValue(SoftMarkerBrushProperty, value);
}
public IBrush? HardMarkerBrush
{
get => GetValue(HardMarkerBrushProperty);
set => SetValue(HardMarkerBrushProperty, value);
}
public IBrush? GateMarkerBrush
{
get => GetValue(GateMarkerBrushProperty);
set => SetValue(GateMarkerBrushProperty, value);
}
public ICommand? CommitCommand
{
get => GetValue(CommitCommandProperty);
set => SetValue(CommitCommandProperty, value);
}
private bool IsAdjustable => SoftPct is not null && HardPct is not null && GatePct is not null;
// Custom hit test (point is in local coordinates): the control draws itself, so the whole
// rectangle takes the pointer — not just the pixels the track happens to cover.
public bool HitTest(Point point) => new Rect(Bounds.Size).Contains(point);
public override void Render(DrawingContext context)
{
var width = Bounds.Width;
var height = Bounds.Height;
if (width <= 0 || height <= 0) return;
var top = Math.Max(0, (height - TrackHeightPx) / 2);
var trackHeight = Math.Min(TrackHeightPx, height);
var radius = trackHeight / 2;
// Transparent full-bounds fill keeps the grab area the whole control, not just the track.
context.FillRectangle(Brushes.Transparent, new Rect(0, 0, width, height));
if (TrackBrush is { } track)
context.DrawRectangle(track, null, new RoundedRect(new Rect(0, top, width, trackHeight), radius));
var fillWidth = width * Math.Clamp(Percent, 0, 100) / 100.0;
var fill = IsWarn ? WarnFillBrush ?? FillBrush : FillBrush;
if (fillWidth > 0 && fill is not null)
context.DrawRectangle(fill, null, new RoundedRect(new Rect(0, top, fillWidth, trackHeight), radius));
DrawMarker(context, SoftPct, SoftMarkerBrush, width, height);
DrawMarker(context, HardPct, HardMarkerBrush, width, height);
DrawMarker(context, GatePct, GateMarkerBrush, width, height);
}
private static void DrawMarker(DrawingContext context, int? percent, IBrush? brush, double width, double height)
{
if (percent is not { } value || brush is null) return;
var x = Math.Clamp(width * Math.Clamp(value, 0, 100) / 100.0 - MarkerWidthPx / 2, 0, Math.Max(0, width - MarkerWidthPx));
context.FillRectangle(brush, new Rect(x, 0, MarkerWidthPx, height));
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
if (!IsAdjustable) return;
var percent = PercentAt(e.GetPosition(this).X);
_dragging = UsageThresholdDrag.Nearest(
SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent());
if (_dragging is null) return;
e.Pointer.Capture(this);
ApplyDrag(_dragging.Value, percent);
e.Handled = true;
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
if (!IsAdjustable) return;
var percent = PercentAt(e.GetPosition(this).X);
if (_dragging is { } stage)
{
ApplyDrag(stage, percent);
e.Handled = true;
return;
}
var hover = UsageThresholdDrag.Nearest(
SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent());
Cursor = new Cursor(hover is null ? StandardCursorType.Arrow : StandardCursorType.SizeWestEast);
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
if (_dragging is null) return;
_dragging = null;
e.Pointer.Capture(null);
e.Handled = true;
if (CommitCommand is { } command && command.CanExecute(null))
command.Execute(null);
}
private void ApplyDrag(UsageThresholdDrag.Stage stage, double percent)
{
var (soft, hard, gate) = UsageThresholdDrag.Apply(
SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, percent);
SoftPct = soft;
HardPct = hard;
GatePct = gate;
}
private double PercentAt(double x) => Bounds.Width <= 0 ? 0 : Math.Clamp(x / Bounds.Width * 100.0, 0, 100);
private double GrabTolerancePercent() => Bounds.Width <= 0 ? 0 : GrabRadiusPx / Bounds.Width * 100.0;
}
@@ -390,7 +390,8 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}"> <TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}"
IsVisible="{Binding ShowOnlineInbox}">
<ScrollViewer> <ScrollViewer>
<StackPanel Spacing="14" Margin="0,8,0,0"> <StackPanel Spacing="14" Margin="0,8,0,0">
@@ -8,6 +8,7 @@ public partial class SettingsModalView : Window
public SettingsModalView() public SettingsModalView()
{ {
InitializeComponent(); InitializeComponent();
} }
protected override void OnDataContextChanged(EventArgs e) protected override void OnDataContextChanged(EventArgs e)
@@ -18,19 +18,6 @@
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/> <KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings> </Window.KeyBindings>
<Window.Styles>
<Style Selector="ProgressBar.usage-gauge">
<Setter Property="Height" Value="10"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="Minimum" Value="0"/>
<Setter Property="Maximum" Value="100"/>
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}"/>
</Style>
<Style Selector="ProgressBar.usage-gauge.warn">
<Setter Property="Foreground" Value="{DynamicResource StatusReviewBrush}"/>
</Style>
</Window.Styles>
<ctl:ModalShell Title="{loc:Tr modals.usageMonitor.title}" CloseCommand="{Binding CloseCommand}"> <ctl:ModalShell Title="{loc:Tr modals.usageMonitor.title}" CloseCommand="{Binding CloseCommand}">
<DockPanel> <DockPanel>
@@ -82,19 +69,60 @@
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UsageGaugeRowViewModel"> <DataTemplate x:DataType="vm:UsageGaugeRowViewModel">
<Border Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}" <Border Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="240"> BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="270">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="eyebrow" Text="{Binding Label}"/> <TextBlock Classes="eyebrow" Text="{Binding Label}"/>
<TextBlock Classes="meta" Text="{Binding Percent, StringFormat={}{0:0}%}" HorizontalAlignment="Right"/> <TextBlock Classes="meta" Text="{Binding Percent, StringFormat={}{0:0}%}" HorizontalAlignment="Right"/>
</StackPanel> </StackPanel>
<Grid Height="10"> <ctl:UsageGaugeBar Height="16"
<ProgressBar Classes="usage-gauge" Classes.warn="{Binding IsWarnSeverity}" Value="{Binding Percent}"/> Percent="{Binding Percent}"
<Canvas IsHitTestVisible="False"> IsWarn="{Binding IsWarnSeverity}"
<Rectangle Canvas.Left="{Binding ThresholdMarkerLeftPx}" Width="2" Height="10" SoftPct="{Binding SoftPct, Mode=TwoWay}"
Fill="{DynamicResource TextDimBrush}" HardPct="{Binding HardPct, Mode=TwoWay}"
IsVisible="{Binding ThresholdPercent, Converter={x:Static conv:ObjectConverters.IsNotNull}}"/> GatePct="{Binding GatePct, Mode=TwoWay}"
</Canvas> CommitCommand="{Binding CommitCommand}"
TrackBrush="{DynamicResource LineBrush}"
FillBrush="{DynamicResource AccentBrush}"
WarnFillBrush="{DynamicResource StatusReviewBrush}"
SoftMarkerBrush="{DynamicResource TextDimBrush}"
HardMarkerBrush="{DynamicResource StatusReviewBrush}"
GateMarkerBrush="{DynamicResource StatusErrorBrush}"
ToolTip.Tip="{loc:Tr modals.usageMonitor.dragHint}"/>
<!-- Legend doubles as the numeric editor: swatch colours match the bar's markers,
and each box commits on Enter / focus loss (handlers in the code-behind). -->
<Grid ColumnDefinitions="10,*,62" RowDefinitions="Auto,Auto,Auto"
IsVisible="{Binding IsAdjustable}" Margin="0,2,0,0">
<Rectangle Grid.Row="0" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
VerticalAlignment="Center" Fill="{DynamicResource TextDimBrush}"/>
<TextBlock Grid.Row="0" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendSoft}"/>
<NumericUpDown Grid.Row="0" Grid.Column="2" Tag="soft"
Value="{Binding SoftPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
<Rectangle Grid.Row="1" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
VerticalAlignment="Center" Fill="{DynamicResource StatusReviewBrush}"/>
<TextBlock Grid.Row="1" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendHard}"/>
<NumericUpDown Grid.Row="1" Grid.Column="2" Tag="hard"
Value="{Binding HardPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
<Rectangle Grid.Row="2" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
VerticalAlignment="Center" Fill="{DynamicResource StatusErrorBrush}"/>
<TextBlock Grid.Row="2" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendGate}"/>
<NumericUpDown Grid.Row="2" Grid.Column="2" Tag="gate"
Value="{Binding GatePct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
</Grid> </Grid>
<TextBlock Classes="meta" Text="{Binding ResetText}" IsVisible="{Binding ResetText, Converter={x:Static conv:StringConverters.IsNotNullOrEmpty}}"/> <TextBlock Classes="meta" Text="{Binding ResetText}" IsVisible="{Binding ResetText, Converter={x:Static conv:StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
@@ -1,8 +1,36 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals; namespace ClaudeDo.Ui.Views.Modals;
public partial class UsageMonitorModalView : Window public partial class UsageMonitorModalView : Window
{ {
public UsageMonitorModalView() => InitializeComponent(); public UsageMonitorModalView() => InitializeComponent();
/// <summary>
/// Persists a stage typed into a gauge's legend box. `NumericUpDown` has no commit command, so
/// the box's <c>Tag</c> names the stage and the row's matching command does the clamp + save.
/// </summary>
private void OnStageBoxCommit(object? sender, RoutedEventArgs e)
{
if (sender is not Control { Tag: string stage, DataContext: UsageGaugeRowViewModel row }) return;
var command = stage switch
{
"soft" => row.CommitSoftCommand,
"hard" => row.CommitHardCommand,
_ => row.CommitGateCommand,
};
if (command.CanExecute(null)) command.Execute(null);
}
private void OnStageBoxKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
OnStageBoxCommit(sender, e);
e.Handled = true;
}
} }
+3 -1
View File
@@ -62,7 +62,9 @@ public sealed class WindowDialogService : IDialogService
{ {
var dlg = new UsageMonitorModalView { DataContext = vm }; var dlg = new UsageMonitorModalView { DataContext = vm };
vm.CloseAction = () => dlg.Close(); vm.CloseAction = () => dlg.Close();
await dlg.ShowDialog(_owner); // The pill sits in both the footer and the Mission Control header, so own the dialog to
// whichever window is active — otherwise it opens behind Mission Control.
await dlg.ShowDialog(ActiveOwner());
} }
public async Task ShowSettingsAsync(SettingsModalViewModel vm) public async Task ShowSettingsAsync(SettingsModalViewModel vm)
+21 -3
View File
@@ -50,7 +50,12 @@ public record AppSettingsDto(
List<ModelPresetDto>? ModelPresets = null, List<ModelPresetDto>? ModelPresets = null,
int UsageGateFiveHourPct = 80, int UsageGateFiveHourPct = 80,
int UsageGateSevenDayPct = 90, 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. // Per-model run defaults (effort + turn budget) edited in Settings -> General.
public record ModelPresetDto(string Model, string Effort, int MaxTurns); public record ModelPresetDto(string Model, string Effort, int MaxTurns);
@@ -137,7 +142,12 @@ public record UsageSnapshotDto(
string? LastError, string? LastError,
int ConfiguredSlots, int ConfiguredSlots,
int EffectiveSlots, 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( public record ModelUsageRowDto(
DateOnly Date, 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(), .Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList(),
row.UsageGateFiveHourPct, row.UsageGateFiveHourPct,
row.UsageGateSevenDayPct, row.UsageGateSevenDayPct,
row.MaxTurnsCeiling); row.MaxTurnsCeiling,
row.UsageThrottleFiveHourSoftPct,
row.UsageThrottleFiveHourHardPct,
row.UsageThrottleSevenDaySoftPct,
row.UsageThrottleSevenDayHardPct);
} }
public async Task UpdateAppSettings(AppSettingsDto dto) public async Task UpdateAppSettings(AppSettingsDto dto)
@@ -477,6 +491,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
UsageGateFiveHourPct = dto.UsageGateFiveHourPct, UsageGateFiveHourPct = dto.UsageGateFiveHourPct,
UsageGateSevenDayPct = dto.UsageGateSevenDayPct, UsageGateSevenDayPct = dto.UsageGateSevenDayPct,
MaxTurnsCeiling = dto.MaxTurnsCeiling, MaxTurnsCeiling = dto.MaxTurnsCeiling,
UsageThrottleFiveHourSoftPct = dto.UsageThrottleFiveHourSoftPct,
UsageThrottleFiveHourHardPct = dto.UsageThrottleFiveHourHardPct,
UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct,
UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct,
}); });
} }
+10 -7
View File
@@ -242,16 +242,18 @@ public sealed class QueueService : BackgroundService
public async Task<(int Configured, int Effective)> GetSlotCountsAsync(CancellationToken ct) public async Task<(int Configured, int Effective)> GetSlotCountsAsync(CancellationToken ct)
{ {
int configured; int configured;
int softPct, hardPct, gateFivePct, gateSevenPct; UsageThresholds fiveHour, sevenDay;
try try
{ {
using var context = _dbFactory.CreateDbContext(); using var context = _dbFactory.CreateDbContext();
var settings = await new AppSettingsRepository(context).GetAsync(ct); var settings = await new AppSettingsRepository(context).GetAsync(ct);
configured = Math.Max(1, settings.MaxParallelExecutions); configured = Math.Max(1, settings.MaxParallelExecutions);
softPct = settings.UsageThrottleSoftPct; fiveHour = new UsageThresholds(
hardPct = settings.UsageThrottleHardPct; settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct,
gateFivePct = settings.UsageGateFiveHourPct; settings.UsageGateFiveHourPct);
gateSevenPct = settings.UsageGateSevenDayPct; sevenDay = new UsageThresholds(
settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct,
settings.UsageGateSevenDayPct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -267,8 +269,9 @@ public sealed class QueueService : BackgroundService
} }
var effective = UsageThrottle.EffectiveSlots( var effective = UsageThrottle.EffectiveSlots(
configured, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization, configured,
softPct, hardPct, gateFivePct, gateSevenPct); snapshot.FiveHour?.Utilization, fiveHour,
snapshot.SevenDay?.Utilization, sevenDay);
ReportThrottleTransition(configured, effective, snapshot); ReportThrottleTransition(configured, effective, snapshot);
return (configured, effective); return (configured, effective);
@@ -28,9 +28,15 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
if (Directory.Exists(_projectsRoot)) 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(); ct.ThrowIfCancellationRequested();
if (file.LastWriteTime < mtimeCutoff) continue;
foreach (var record in GetOrReadFile(file)) foreach (var record in GetOrReadFile(file))
{ {
@@ -67,8 +73,8 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
if (string.IsNullOrWhiteSpace(sessionId) || !Directory.Exists(_projectsRoot)) if (string.IsNullOrWhiteSpace(sessionId) || !Directory.Exists(_projectsRoot))
return Task.FromResult<SessionUsageTotals?>(null); return Task.FromResult<SessionUsageTotals?>(null);
var file = Directory var file = new DirectoryInfo(_projectsRoot)
.EnumerateFiles(_projectsRoot, $"{sessionId}.jsonl", SearchOption.AllDirectories) .EnumerateFiles($"{sessionId}.jsonl", SearchOption.AllDirectories)
.FirstOrDefault(); .FirstOrDefault();
if (file is null) return Task.FromResult<SessionUsageTotals?>(null); if (file is null) return Task.FromResult<SessionUsageTotals?>(null);
@@ -89,17 +95,16 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
new SessionUsageTotals(input, output, cacheRead, cacheCreation)); 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(info.FullName, out var cached) &&
if (_cache.TryGetValue(file, out var cached) &&
cached.Length == info.Length && cached.LastWriteUtc == info.LastWriteTimeUtc) cached.Length == info.Length && cached.LastWriteUtc == info.LastWriteTimeUtc)
{ {
return cached.Records; return cached.Records;
} }
var records = ReadFile(file); var records = ReadFile(info.FullName);
_cache[file] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records); _cache[info.FullName] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records);
return 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)) .Select(l => new UsageLimitDto(l.Kind, l.Group, l.Percent, l.Severity, l.ResetsAt, l.ScopeModelDisplayName, l.IsActive))
.ToList(); .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 configuredSlots = Math.Max(1, settings.MaxParallelExecutions);
var effectiveSlots = snapshot is null || lastError is not null var effectiveSlots = snapshot is null || lastError is not null
? configuredSlots ? configuredSlots
: UsageThrottle.EffectiveSlots( : UsageThrottle.EffectiveSlots(
configuredSlots, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization, configuredSlots,
settings.UsageThrottleSoftPct, settings.UsageThrottleHardPct, snapshot.FiveHour?.Utilization, fiveHourThresholds,
settings.UsageGateFiveHourPct, settings.UsageGateSevenDayPct); snapshot.SevenDay?.Utilization, sevenDayThresholds);
var throttleBucket = effectiveSlots < configuredSlots var throttleBucket = effectiveSlots < configuredSlots
? DecisiveBucket(snapshot?.FiveHour?.Utilization, snapshot?.SevenDay?.Utilization) ? DecisiveBucket(snapshot?.FiveHour?.Utilization, snapshot?.SevenDay?.Utilization)
: null; : null;
@@ -75,7 +80,11 @@ public sealed class UsageSnapshotBuilder
lastError, lastError,
configuredSlots, configuredSlots,
effectiveSlots, effectiveSlots,
throttleBucket); throttleBucket,
fiveHourThresholds.SoftPct,
fiveHourThresholds.HardPct,
sevenDayThresholds.SoftPct,
sevenDayThresholds.HardPct);
} }
private static string? DecisiveBucket(double? fiveHourPct, double? sevenDayPct) private static string? DecisiveBucket(double? fiveHourPct, double? sevenDayPct)
+24 -19
View File
@@ -1,37 +1,42 @@
namespace ClaudeDo.Worker.Usage; namespace ClaudeDo.Worker.Usage;
/// <summary> /// <summary>
/// Pure staged throttle sitting ahead of <see cref="UsageGate"/>'s hard pause: as the 5h/7d usage /// The soft/hard/gate percentages of a single usage bucket. Soft caps parallelism at 2 slots, hard
/// window fills up, the queue's effective parallelism steps down before it hits zero, instead of /// at 1, gate blocks the queue refill entirely. A threshold of 0 disables that stage.
/// running at full tilt right up to the gate threshold. Whichever of the two buckets is more /// </summary>
/// utilized decides the stage. A missing bucket (null) is treated as 0% for that bucket only — public readonly record struct UsageThresholds(int SoftPct, int HardPct, int GatePct);
/// callers with no snapshot at all should skip this and use <paramref name="configuredSlots"/>
/// directly (fail-open), same policy as <see cref="UsageGate"/>. /// <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> /// </summary>
public static class UsageThrottle public static class UsageThrottle
{ {
public static int EffectiveSlots( public static int EffectiveSlots(
int configuredSlots, int configuredSlots,
double? fiveHourPct, double? fiveHourPct,
UsageThresholds fiveHour,
double? sevenDayPct, double? sevenDayPct,
int softPct, UsageThresholds sevenDay)
int hardPct,
int gateFiveHourPct,
int gateSevenDayPct)
{ {
var slots = Math.Max(1, configuredSlots); var slots = Math.Max(1, configuredSlots);
if (gateFiveHourPct > 0 && fiveHourPct is { } five && five >= gateFiveHourPct) return Math.Min(
return 0; BucketSlots(slots, fiveHourPct, fiveHour),
if (gateSevenDayPct > 0 && sevenDayPct is { } seven && seven >= gateSevenDayPct) BucketSlots(slots, sevenDayPct, sevenDay));
return 0; }
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) if (thresholds.GatePct > 0 && utilization >= thresholds.GatePct) return 0;
return Math.Min(slots, 1); if (thresholds.HardPct > 0 && utilization >= thresholds.HardPct) return Math.Min(slots, 1);
if (softPct > 0 && worst >= softPct) if (thresholds.SoftPct > 0 && utilization >= thresholds.SoftPct) return Math.Min(slots, 2);
return Math.Min(slots, 2);
return slots; return slots;
} }
@@ -43,6 +43,33 @@ public class SettingsModalViewModelTests
SessionSkills: null, ModelPresets: null, SessionSkills: null, ModelPresets: null,
UsageGateFiveHourPct: fiveHourPct, UsageGateSevenDayPct: sevenDayPct); UsageGateFiveHourPct: fiveHourPct, UsageGateSevenDayPct: sevenDayPct);
[Fact]
public async Task Save_carries_dragged_throttle_stages_through_untouched()
{
// The throttle stages are only editable by dragging the usage-monitor gauges. Saving the
// Settings modal rebuilds the whole DTO, so it must not reset them to the defaults.
var worker = new FakeWorker
{
AppToReturn = DtoWith(65, 95) with
{
UsageThrottleFiveHourSoftPct = 42,
UsageThrottleFiveHourHardPct = 58,
UsageThrottleSevenDaySoftPct = 71,
UsageThrottleSevenDayHardPct = 88,
},
};
var vm = MakeVm(worker);
await vm.LoadAsync();
await vm.SaveCommand.ExecuteAsync(null);
Assert.NotNull(worker.Saved);
Assert.Equal(42, worker.Saved!.UsageThrottleFiveHourSoftPct);
Assert.Equal(58, worker.Saved.UsageThrottleFiveHourHardPct);
Assert.Equal(71, worker.Saved.UsageThrottleSevenDaySoftPct);
Assert.Equal(88, worker.Saved.UsageThrottleSevenDayHardPct);
}
private static SettingsModalViewModel MakeVm(FakeWorker worker) => private static SettingsModalViewModel MakeVm(FakeWorker worker) =>
new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(), new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(),
MakeLocalizer(), new AppSettings()); MakeLocalizer(), new AppSettings());
@@ -33,7 +33,16 @@ public class UsageMonitorModalViewModelTests
public int RefreshCalls; public int RefreshCalls;
public Exception? RefreshThrows; public Exception? RefreshThrows;
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult(Snapshot); /// <summary>When set, the snapshot fetch never completes — stands in for the slow first
/// transcript scan on the worker side.</summary>
public TaskCompletionSource<UsageSnapshotDto?>? SnapshotGate;
public Exception? SnapshotThrows;
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
{
if (SnapshotThrows is not null) throw SnapshotThrows;
return SnapshotGate?.Task ?? Task.FromResult(Snapshot);
}
public override Task<UsageSnapshotDto?> RefreshUsageAsync() public override Task<UsageSnapshotDto?> RefreshUsageAsync()
{ {
@@ -53,7 +62,24 @@ public class UsageMonitorModalViewModelTests
TaskUsageCalls++; TaskUsageCalls++;
return Task.FromResult(TaskRows); return Task.FromResult(TaskRows);
} }
public AppSettingsDto? AppSettings;
public AppSettingsDto? SavedSettings;
public override Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult(AppSettings);
public override Task UpdateAppSettingsAsync(AppSettingsDto dto)
{
SavedSettings = dto;
return Task.CompletedTask;
} }
}
private static AppSettingsDto AppSettings() =>
new(DefaultClaudeInstructions: "", DefaultModel: "sonnet", DefaultMaxTurns: 30,
DefaultPermissionMode: "auto", MaxParallelExecutions: 3, WorktreeStrategy: "sibling",
CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 7,
ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5);
private static UsageLimitDto Limit( private static UsageLimitDto Limit(
string kind, double percent = 10, string severity = "normal", string kind, double percent = 10, string severity = "normal",
@@ -71,13 +97,63 @@ public class UsageMonitorModalViewModelTests
DateTime? fetchedAtUtc = null, DateTime? fetchedAtUtc = null,
int configuredSlots = 1, int configuredSlots = 1,
int effectiveSlots = 1, int effectiveSlots = 1,
string? throttleBucket = null) string? throttleBucket = null,
int throttleFiveHourSoftPct = 50,
int throttleFiveHourHardPct = 65,
int throttleSevenDaySoftPct = 50,
int throttleSevenDayHardPct = 65)
=> new( => new(
null, null, null, null, null, null, null, null,
limits ?? Array.Empty<UsageLimitDto>(), limits ?? Array.Empty<UsageLimitDto>(),
fiveHourThresholdPct, sevenDayThresholdPct, fiveHourThresholdPct, sevenDayThresholdPct,
isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError, isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError,
configuredSlots, effectiveSlots, throttleBucket); configuredSlots, effectiveSlots, throttleBucket,
throttleFiveHourSoftPct, throttleFiveHourHardPct,
throttleSevenDaySoftPct, throttleSevenDayHardPct);
// ── BeginLoad: the modal must open before the data lands ────────────────
[Fact]
public void BeginLoad_ReturnsWhileWorkerStillPending_AndShowsBusy()
{
var worker = new FakeWorker { SnapshotGate = new TaskCompletionSource<UsageSnapshotDto?>() };
var vm = new UsageMonitorModalViewModel(worker);
vm.BeginLoad();
Assert.True(vm.IsBusy);
Assert.False(vm.ModelsEmpty);
Assert.False(vm.TasksEmpty);
}
[Fact]
public void BeginLoad_WorkerThrows_ReportsErrorInsteadOfCrashing()
{
var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") };
var vm = new UsageMonitorModalViewModel(worker);
string? reported = null;
vm.ErrorReported += m => reported = m;
vm.BeginLoad();
Assert.NotNull(reported);
Assert.Contains("worker offline", reported);
Assert.False(vm.IsBusy);
}
[Fact]
public async Task LoadAsync_WorkerThrows_ReportsErrorAndClearsBusy()
{
var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") };
var vm = new UsageMonitorModalViewModel(worker);
string? reported = null;
vm.ErrorReported += m => reported = m;
await vm.LoadAsync();
Assert.NotNull(reported);
Assert.False(vm.IsBusy);
}
// ── Manual refresh ────────────────────────────────────────────────────── // ── Manual refresh ──────────────────────────────────────────────────────
@@ -210,7 +286,7 @@ public class UsageMonitorModalViewModelTests
var vm = new UsageMonitorModalViewModel(worker); var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync(); await vm.LoadAsync();
Assert.Equal(80, vm.GaugeRows[0].ThresholdPercent); Assert.Equal(80, vm.GaugeRows[0].GatePct);
} }
[Fact] [Fact]
@@ -220,7 +296,276 @@ public class UsageMonitorModalViewModelTests
var vm = new UsageMonitorModalViewModel(worker); var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync(); await vm.LoadAsync();
Assert.Null(vm.GaugeRows[0].ThresholdPercent); Assert.Null(vm.GaugeRows[0].GatePct);
Assert.False(vm.GaugeRows[0].IsAdjustable);
}
// ── Draggable stage markers ─────────────────────────────────────────────
[Fact]
public async Task GaugeRow_Session_CarriesPerBucketThrottleStages()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80,
throttleFiveHourSoftPct: 45, throttleFiveHourHardPct: 60),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var row = vm.GaugeRows[0];
Assert.Equal("five_hour", row.Bucket);
Assert.Equal(45, row.SoftPct);
Assert.Equal(60, row.HardPct);
Assert.Equal(80, row.GatePct);
Assert.True(row.IsAdjustable);
}
[Fact]
public async Task GaugeRow_WeeklyAll_CarriesSevenDayStages()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("weekly_all") }, sevenDayThresholdPct: 90,
throttleSevenDaySoftPct: 70, throttleSevenDayHardPct: 85),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var row = vm.GaugeRows[0];
Assert.Equal("seven_day", row.Bucket);
Assert.Equal(70, row.SoftPct);
Assert.Equal(85, row.HardPct);
Assert.Equal(90, row.GatePct);
}
[Fact]
public async Task Commit_WritesOnlyTheDraggedBucket_AndKeepsEverythingElse()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80,
throttleFiveHourSoftPct: 50, throttleFiveHourHardPct: 65),
AppSettings = AppSettings(),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var row = vm.GaugeRows[0];
row.SoftPct = 40;
row.HardPct = 55;
row.GatePct = 75;
await row.CommitCommand.ExecuteAsync(null);
Assert.NotNull(worker.SavedSettings);
Assert.Equal(40, worker.SavedSettings!.UsageThrottleFiveHourSoftPct);
Assert.Equal(55, worker.SavedSettings.UsageThrottleFiveHourHardPct);
Assert.Equal(75, worker.SavedSettings.UsageGateFiveHourPct);
// The 7d bucket and unrelated settings ride along untouched.
Assert.Equal(50, worker.SavedSettings.UsageThrottleSevenDaySoftPct);
Assert.Equal(65, worker.SavedSettings.UsageThrottleSevenDayHardPct);
Assert.Equal(90, worker.SavedSettings.UsageGateSevenDayPct);
Assert.Equal(3, worker.SavedSettings.MaxParallelExecutions);
}
[Fact]
public async Task Commit_WorkerOffline_ReportsErrorAndSavesNothing()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("session") }),
AppSettings = null,
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
string? reported = null;
vm.ErrorReported += m => reported = m;
await vm.GaugeRows[0].CommitCommand.ExecuteAsync(null);
Assert.NotNull(reported);
Assert.Null(worker.SavedSettings);
}
[Fact]
public async Task Commit_NonAdjustableRow_SavesNothing()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Opus") }),
AppSettings = AppSettings(),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
await vm.GaugeRows[0].CommitCommand.ExecuteAsync(null);
Assert.Null(worker.SavedSettings);
}
[Fact]
public async Task LiveSnapshot_UpdatesRowsInPlace_WithoutReplacingInstances()
{
// A poll landing mid-interaction must not swap the row the gauge is bound to.
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session", percent: 20) }) };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var before = vm.GaugeRows[0];
vm.Snapshot = Snapshot(new[] { Limit("session", percent: 55) }, throttleFiveHourSoftPct: 44);
Assert.Same(before, vm.GaugeRows[0]);
Assert.Equal(55, vm.GaugeRows[0].Percent);
Assert.Equal(44, vm.GaugeRows[0].SoftPct);
}
[Fact]
public async Task LiveSnapshot_NewLimitKind_AddsARow()
{
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }) };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
vm.Snapshot = Snapshot(new[] { Limit("session"), Limit("weekly_all") });
Assert.Equal(2, vm.GaugeRows.Count);
}
// ── Legend input boxes ───────────────────────────────────────────────────
[Fact]
public async Task TypedStage_SavesTheEditedBucket()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80),
AppSettings = AppSettings(),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var row = vm.GaugeRows[0];
row.HardPct = 58;
await row.CommitHardCommand.ExecuteAsync(null);
Assert.Equal(58, worker.SavedSettings!.UsageThrottleFiveHourHardPct);
Assert.Equal(50, worker.SavedSettings.UsageThrottleFiveHourSoftPct);
Assert.Equal(80, worker.SavedSettings.UsageGateFiveHourPct);
}
[Fact]
public async Task TypedStage_OutOfOrder_IsPinned_AndLeavesNeighboursAlone()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80,
throttleFiveHourSoftPct: 50, throttleFiveHourHardPct: 65),
AppSettings = AppSettings(),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
// Typing 95 into the soft box may not push past hard — and must not drag hard along.
var row = vm.GaugeRows[0];
row.SoftPct = 95;
await row.CommitSoftCommand.ExecuteAsync(null);
Assert.Equal(65, row.SoftPct);
Assert.Equal(65, row.HardPct);
Assert.Equal(80, row.GatePct);
Assert.Equal(65, worker.SavedSettings!.UsageThrottleFiveHourSoftPct);
}
[Fact]
public async Task TypedGate_BelowHard_IsPinnedToHard()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("weekly_all") }, sevenDayThresholdPct: 90,
throttleSevenDaySoftPct: 50, throttleSevenDayHardPct: 65),
AppSettings = AppSettings(),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var row = vm.GaugeRows[0];
row.GatePct = 20;
await row.CommitGateCommand.ExecuteAsync(null);
Assert.Equal(65, row.GatePct);
Assert.Equal(65, worker.SavedSettings!.UsageGateSevenDayPct);
}
[Fact]
public async Task TypedStage_OnNonAdjustableRow_SavesNothing()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Opus") }),
AppSettings = AppSettings(),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
await vm.GaugeRows[0].CommitSoftCommand.ExecuteAsync(null);
Assert.Null(worker.SavedSettings);
}
// ── Drag math ────────────────────────────────────────────────────────────
[Theory]
[InlineData(30, UsageThresholdDrag.Stage.Soft, 30, 65, 80)] // free move below hard
[InlineData(90, UsageThresholdDrag.Stage.Soft, 65, 65, 80)] // pinned to hard
[InlineData(-5, UsageThresholdDrag.Stage.Soft, 0, 65, 80)] // clamped at 0
[InlineData(70, UsageThresholdDrag.Stage.Hard, 50, 70, 80)] // free move between soft and gate
[InlineData(10, UsageThresholdDrag.Stage.Hard, 50, 50, 80)] // pinned to soft
[InlineData(95, UsageThresholdDrag.Stage.Hard, 50, 80, 80)] // pinned to gate
[InlineData(120, UsageThresholdDrag.Stage.Gate, 50, 65, 100)] // clamped at 100
[InlineData(20, UsageThresholdDrag.Stage.Gate, 50, 65, 65)] // pinned to hard
public void Drag_KeepsStagesOrderedAndInRange(
double dragTo, UsageThresholdDrag.Stage stage, int expectedSoft, int expectedHard, int expectedGate)
{
var result = UsageThresholdDrag.Apply(50, 65, 80, stage, dragTo);
Assert.Equal((expectedSoft, expectedHard, expectedGate), result);
}
[Fact]
public void Drag_RoundsToWholePercent()
{
Assert.Equal((37, 65, 80), UsageThresholdDrag.Apply(50, 65, 80, UsageThresholdDrag.Stage.Soft, 36.7));
}
[Fact]
public void Drag_NeighbourAtZeroIsOff_AndDoesNotPinTheMarker()
{
// hard = 0 means "hard stage off" — soft must still be draggable up to the gate.
Assert.Equal((70, 0, 80), UsageThresholdDrag.Apply(50, 0, 80, UsageThresholdDrag.Stage.Soft, 70));
}
[Fact]
public void Drag_InconsistentStoredConfig_DoesNotThrow()
{
// soft above gate (only reachable by hand-editing the DB) must degrade, not crash.
var result = UsageThresholdDrag.Apply(90, 95, 50, UsageThresholdDrag.Stage.Hard, 60);
Assert.Equal(50, result.Hard);
}
[Theory]
[InlineData(50, UsageThresholdDrag.Stage.Soft)]
[InlineData(63, UsageThresholdDrag.Stage.Hard)]
[InlineData(82, UsageThresholdDrag.Stage.Gate)]
public void Nearest_PicksTheClosestMarkerInReach(double percent, UsageThresholdDrag.Stage expected)
{
Assert.Equal(expected, UsageThresholdDrag.Nearest(50, 65, 80, percent, tolerancePercent: 5));
}
[Fact]
public void Nearest_OutOfReach_GrabsNothing()
{
Assert.Null(UsageThresholdDrag.Nearest(50, 65, 80, percent: 20, tolerancePercent: 5));
} }
// ── Stale / gate bands ─────────────────────────────────────────────────── // ── Stale / gate bands ───────────────────────────────────────────────────
@@ -100,8 +100,10 @@ public sealed class QueueStateMcpToolsTests : IDisposable
var repo = new AppSettingsRepository(ctx); var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync(); var settings = await repo.GetAsync();
settings.MaxParallelExecutions = maxParallel; settings.MaxParallelExecutions = maxParallel;
settings.UsageThrottleSoftPct = softPct; settings.UsageThrottleFiveHourSoftPct = softPct;
settings.UsageThrottleHardPct = hardPct; settings.UsageThrottleFiveHourHardPct = hardPct;
settings.UsageThrottleSevenDaySoftPct = softPct;
settings.UsageThrottleSevenDayHardPct = hardPct;
await repo.UpdateAsync(settings); await repo.UpdateAsync(settings);
} }
@@ -81,8 +81,10 @@ public sealed class QueueServiceTests : IDisposable
var repo = new AppSettingsRepository(ctx); var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync(); var settings = await repo.GetAsync();
settings.MaxParallelExecutions = maxParallel; settings.MaxParallelExecutions = maxParallel;
settings.UsageThrottleSoftPct = softPct; settings.UsageThrottleFiveHourSoftPct = softPct;
settings.UsageThrottleHardPct = hardPct; settings.UsageThrottleFiveHourHardPct = hardPct;
settings.UsageThrottleSevenDaySoftPct = softPct;
settings.UsageThrottleSevenDayHardPct = hardPct;
settings.UsageGateFiveHourPct = gateFive; settings.UsageGateFiveHourPct = gateFive;
settings.UsageGateSevenDayPct = gateSeven; settings.UsageGateSevenDayPct = gateSeven;
await repo.UpdateAsync(settings); await repo.UpdateAsync(settings);
@@ -139,6 +139,35 @@ public class TranscriptUsageReaderTests : IDisposable
Assert.Equal(1, row.Messages); Assert.Equal(1, row.Messages);
} }
[Fact]
public async Task Files_Last_Written_Before_The_Window_Are_Not_Read()
{
// Deliberate heuristic: a transcript whose mtime predates the window cannot contain a
// record inside it, so it is skipped unread. Here the content would match the window —
// proving the file was never opened, which is what keeps a 7-day range off the full history.
var path = WriteSession("proj", "old.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
File.SetLastWriteTime(path, new DateTime(2026, 5, 1, 12, 0, 0));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
Assert.Empty(result);
}
[Fact]
public async Task File_Written_On_The_Window_Start_Day_Is_Still_Read()
{
var path = WriteSession("proj", "edge.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
File.SetLastWriteTime(path, new DateTime(2026, 6, 1, 0, 5, 0));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
Assert.Single(result);
}
[Fact] [Fact]
public async Task Malformed_Line_Does_Not_Abort_The_Run() public async Task Malformed_Line_Does_Not_Abort_The_Run()
{ {
@@ -149,11 +149,61 @@ public sealed class UsageSnapshotBuilderTests : IDisposable
var repo = new AppSettingsRepository(ctx); var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync(); var settings = await repo.GetAsync();
settings.MaxParallelExecutions = maxParallel; settings.MaxParallelExecutions = maxParallel;
settings.UsageThrottleSoftPct = softPct; settings.UsageThrottleFiveHourSoftPct = softPct;
settings.UsageThrottleHardPct = hardPct; settings.UsageThrottleFiveHourHardPct = hardPct;
settings.UsageThrottleSevenDaySoftPct = softPct;
settings.UsageThrottleSevenDayHardPct = hardPct;
await repo.UpdateAsync(settings); await repo.UpdateAsync(settings);
} }
private async Task SetPerBucketThrottleAsync(
int maxParallel, int fiveSoft, int fiveHard, int sevenSoft, int sevenHard)
{
using var ctx = _db.CreateContext();
var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync();
settings.MaxParallelExecutions = maxParallel;
settings.UsageThrottleFiveHourSoftPct = fiveSoft;
settings.UsageThrottleFiveHourHardPct = fiveHard;
settings.UsageThrottleSevenDaySoftPct = sevenSoft;
settings.UsageThrottleSevenDayHardPct = sevenHard;
await repo.UpdateAsync(settings);
}
[Fact]
public async Task Per_bucket_throttle_stages_are_reported_for_the_gauges()
{
await SetThresholdsAsync(80, 90);
await SetPerBucketThrottleAsync(maxParallel: 3, fiveSoft: 45, fiveHard: 60, sevenSoft: 70, sevenHard: 85);
var state = new UsageState();
state.ReportSuccess(new UsageSnapshot(
new UsageBucket(10, null), new UsageBucket(10, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync();
Assert.Equal(45, dto.ThrottleFiveHourSoftPct);
Assert.Equal(60, dto.ThrottleFiveHourHardPct);
Assert.Equal(70, dto.ThrottleSevenDaySoftPct);
Assert.Equal(85, dto.ThrottleSevenDayHardPct);
}
[Fact]
public async Task Per_bucket_stages_apply_independently_to_effective_slots()
{
await SetThresholdsAsync(80, 90);
// Both buckets sit at 60%: past the 5h soft stage (45) but below every 7d stage (70/85).
await SetPerBucketThrottleAsync(maxParallel: 3, fiveSoft: 45, fiveHard: 90, sevenSoft: 70, sevenHard: 85);
var state = new UsageState();
state.ReportSuccess(new UsageSnapshot(
new UsageBucket(60, null), new UsageBucket(60, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync();
Assert.Equal(2, dto.EffectiveSlots);
}
[Fact] [Fact]
public async Task Throttled_slots_and_decisive_bucket_reported() public async Task Throttled_slots_and_decisive_bucket_reported()
{ {
@@ -4,13 +4,11 @@ namespace ClaudeDo.Worker.Tests.Usage;
public sealed class UsageThrottleTests public sealed class UsageThrottleTests
{ {
private const int Soft = 50; private static readonly UsageThresholds FiveHour = new(SoftPct: 50, HardPct: 65, GatePct: 80);
private const int Hard = 65; private static readonly UsageThresholds SevenDay = new(SoftPct: 50, HardPct: 65, GatePct: 90);
private const int GateFive = 80;
private const int GateSeven = 90;
private static int Effective(double? five, double? seven, int configured = 3) => private static int Effective(double? five, double? seven, int configured = 3) =>
UsageThrottle.EffectiveSlots(configured, five, seven, Soft, Hard, GateFive, GateSeven); UsageThrottle.EffectiveSlots(configured, five, FiveHour, seven, SevenDay);
[Fact] [Fact]
public void BelowSoftThreshold_ReturnsFullConfiguredSlots() public void BelowSoftThreshold_ReturnsFullConfiguredSlots()
@@ -94,13 +92,19 @@ public sealed class UsageThrottleTests
[Fact] [Fact]
public void ZeroSoftAndHardThresholds_NeverThrottleBelowGate() public void ZeroSoftAndHardThresholds_NeverThrottleBelowGate()
{ {
Assert.Equal(3, UsageThrottle.EffectiveSlots(3, 79, 89, softPct: 0, hardPct: 0, gateFiveHourPct: GateFive, gateSevenDayPct: GateSeven)); var five = new UsageThresholds(0, 0, 80);
var seven = new UsageThresholds(0, 0, 90);
Assert.Equal(3, UsageThrottle.EffectiveSlots(3, 79, five, 89, seven));
} }
[Fact] [Fact]
public void ZeroGateThresholds_NeverHardBlock() public void ZeroGateThresholds_NeverHardBlock()
{ {
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 99, 99, softPct: Soft, hardPct: Hard, gateFiveHourPct: 0, gateSevenDayPct: 0)); var five = new UsageThresholds(50, 65, 0);
var seven = new UsageThresholds(50, 65, 0);
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 99, five, 99, seven));
} }
[Fact] [Fact]
@@ -108,4 +112,46 @@ public sealed class UsageThrottleTests
{ {
Assert.Equal(1, Effective(10, 10, configured: 0)); Assert.Equal(1, Effective(10, 10, configured: 0));
} }
// ── Per-bucket thresholds are independent ───────────────────────────────
[Fact]
public void PerBucket_SameUtilization_DifferentStagesPerBucket()
{
// 60% is past the 5h soft (50) but still under the 7d soft (70): the 5h bucket decides.
var five = new UsageThresholds(50, 65, 80);
var seven = new UsageThresholds(70, 85, 90);
Assert.Equal(2, UsageThrottle.EffectiveSlots(3, 60, five, 60, seven));
}
[Fact]
public void PerBucket_LessUtilizedBucketCanStillBeTheStricterOne()
{
// 7d sits lower (40%) but has the tighter thresholds, so it — not the busier 5h — throttles.
var five = new UsageThresholds(90, 95, 99);
var seven = new UsageThresholds(20, 35, 90);
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 80, five, 40, seven));
}
[Fact]
public void PerBucket_StrictestStageWins()
{
// 5h is only in its soft stage (2 slots), 7d is past its hard stage (1 slot) → 1 wins.
var five = new UsageThresholds(50, 65, 80);
var seven = new UsageThresholds(30, 40, 90);
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 55, five, 45, seven));
}
[Fact]
public void PerBucket_MissingBucketNeverThrottles()
{
// No 7d reading at all: only the 5h bucket may step parallelism down.
var five = new UsageThresholds(50, 65, 80);
var seven = new UsageThresholds(1, 2, 3);
Assert.Equal(2, UsageThrottle.EffectiveSlots(3, 55, five, null, seven));
}
} }