From 7eeb8f508623be2d1b2671c4d94351b9df5b0495 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 11:01:45 +0200 Subject: [PATCH] feat(usage): split throttle thresholds per bucket, add draggable gauge markers --- docs/explore-notes/usage-monitoring.md | 61 +- src/ClaudeDo.Data/CLAUDE.md | 2 +- .../AppSettingsEntityConfiguration.cs | 12 +- ...10_SplitUsageThrottlePerBucket.Designer.cs | 883 ++++++++++++++++++ ...60806141710_SplitUsageThrottlePerBucket.cs | 70 ++ .../ClaudeDoDbContextModelSnapshot.cs | 26 +- src/ClaudeDo.Data/Models/AppSettingsEntity.cs | 11 +- .../Repositories/AppSettingsRepository.cs | 6 +- src/ClaudeDo.Localization/locales/de.json | 7 +- src/ClaudeDo.Localization/locales/en.json | 7 +- src/ClaudeDo.Ui/CLAUDE.md | 4 +- src/ClaudeDo.Ui/Services/WorkerClient.cs | 15 +- .../ViewModels/IslandsShellViewModel.cs | 4 +- .../Modals/SettingsModalViewModel.cs | 16 +- .../Modals/UsageMonitorModalViewModel.cs | 297 +++++- .../Views/Controls/UsageGaugeBar.cs | 250 +++++ .../Views/Modals/SettingsModalView.axaml | 3 +- .../Views/Modals/SettingsModalView.axaml.cs | 1 + .../Views/Modals/UsageMonitorModalView.axaml | 70 +- .../Modals/UsageMonitorModalView.axaml.cs | 28 + src/ClaudeDo.Ui/Views/WindowDialogService.cs | 4 +- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 24 +- src/ClaudeDo.Worker/Queue/QueueService.cs | 17 +- .../Usage/TranscriptUsageReader.cs | 21 +- .../Usage/UsageSnapshotBuilder.cs | 17 +- src/ClaudeDo.Worker/Usage/UsageThrottle.cs | 43 +- .../ViewModels/SettingsModalViewModelTests.cs | 27 + .../UsageMonitorModalViewModelTests.cs | 355 ++++++- .../External/QueueStateMcpToolsTests.cs | 6 +- .../Services/QueueServiceTests.cs | 6 +- .../Usage/TranscriptUsageReaderTests.cs | 29 + .../Usage/UsageSnapshotBuilderTests.cs | 54 +- .../Usage/UsageThrottleTests.cs | 60 +- 33 files changed, 2289 insertions(+), 147 deletions(-) create mode 100644 src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.cs create mode 100644 src/ClaudeDo.Ui/Views/Controls/UsageGaugeBar.cs diff --git a/docs/explore-notes/usage-monitoring.md b/docs/explore-notes/usage-monitoring.md index 9b37575a..3d913ee4 100644 --- a/docs/explore-notes/usage-monitoring.md +++ b/docs/explore-notes/usage-monitoring.md @@ -1,7 +1,8 @@ # Usage monitoring, gate & throttle > **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` > 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) -`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct, -gateFiveHourPct, gateSevenDayPct)` — pure static, no state. +`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, fiveHourThresholds, sevenDayPct, +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). -Whichever of 5h/7d is **more utilized** decides the stage: +Thresholds are **per bucket** (`usage_throttle_five_hour_{soft,hard}_pct` / +`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` | | `>= softPct` | capped at 2 | | `>= 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 -`UsageGate`'s hard block because both read the same gate thresholds — change one, change both. +A threshold of `0` disables that stage for that bucket, and a bucket with no reading (null) never +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 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 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`. + ``-model lines are skipped **everywhere** — they are not real API calls. ## 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: **blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and 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 `UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` / `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 `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 -`AppSettingsRepository.UpdateAsync`. Worker config: `usage_poll_interval_seconds`. +`usage_throttle_five_hour_{soft,hard}_pct` / `usage_throttle_seven_day_{soft,hard}_pct` (50/65 per +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. diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index e83d865f..357f20d1 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -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. | | `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. | -| `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. | | `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. | | `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. | diff --git a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs index 13404a7e..dbf294ba 100644 --- a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs @@ -55,10 +55,14 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration s.UsageGateSevenDayPct) .HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90); - builder.Property(s => s.UsageThrottleSoftPct) - .HasColumnName("usage_throttle_soft_pct").IsRequired().HasDefaultValue(50); - builder.Property(s => s.UsageThrottleHardPct) - .HasColumnName("usage_throttle_hard_pct").IsRequired().HasDefaultValue(65); + builder.Property(s => s.UsageThrottleFiveHourSoftPct) + .HasColumnName("usage_throttle_five_hour_soft_pct").IsRequired().HasDefaultValue(50); + builder.Property(s => s.UsageThrottleFiveHourHardPct) + .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 }); } diff --git a/src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.Designer.cs b/src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.Designer.cs new file mode 100644 index 00000000..545c4758 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.Designer.cs @@ -0,0 +1,883 @@ +// +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 + { + /// + 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("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(40) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("UsageThrottleFiveHourHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_five_hour_hard_pct"); + + b.Property("UsageThrottleFiveHourSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_five_hour_soft_pct"); + + b.Property("UsageThrottleSevenDayHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_seven_day_hard_pct"); + + b.Property("UsageThrottleSevenDaySoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_seven_day_soft_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("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("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("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("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CacheReadTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_read_tokens"); + + b.Property("CacheWriteTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_write_tokens"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("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("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("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 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.cs b/src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.cs new file mode 100644 index 00000000..25f18668 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class SplitUsageThrottlePerBucket : Migration + { + /// + 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( + name: "usage_throttle_five_hour_hard_pct", + table: "app_settings", + type: "INTEGER", + nullable: false, + defaultValue: 65); + + migrationBuilder.AddColumn( + 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; + """); + } + + /// + 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"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index ef7c07ad..0137ba73 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -106,17 +106,29 @@ namespace ClaudeDo.Data.Migrations .HasDefaultValue(90) .HasColumnName("usage_gate_seven_day_pct"); - b.Property("UsageThrottleHardPct") + b.Property("UsageThrottleFiveHourHardPct") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") .HasDefaultValue(65) - .HasColumnName("usage_throttle_hard_pct"); + .HasColumnName("usage_throttle_five_hour_hard_pct"); - b.Property("UsageThrottleSoftPct") + b.Property("UsageThrottleFiveHourSoftPct") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") .HasDefaultValue(50) - .HasColumnName("usage_throttle_soft_pct"); + .HasColumnName("usage_throttle_five_hour_soft_pct"); + + b.Property("UsageThrottleSevenDayHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_seven_day_hard_pct"); + + b.Property("UsageThrottleSevenDaySoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_seven_day_soft_pct"); b.Property("WorktreeAutoCleanupDays") .ValueGeneratedOnAdd() @@ -155,8 +167,10 @@ namespace ClaudeDo.Data.Migrations StandupWeekday = 3, UsageGateFiveHourPct = 80, UsageGateSevenDayPct = 90, - UsageThrottleHardPct = 65, - UsageThrottleSoftPct = 50, + UsageThrottleFiveHourHardPct = 65, + UsageThrottleFiveHourSoftPct = 50, + UsageThrottleSevenDayHardPct = 65, + UsageThrottleSevenDaySoftPct = 50, WorktreeAutoCleanupDays = 7, WorktreeAutoCleanupEnabled = false, WorktreeStrategy = "sibling" diff --git a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs index 39cf67cf..122344bf 100644 --- a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs +++ b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs @@ -43,8 +43,11 @@ public sealed class AppSettingsEntity public int UsageGateFiveHourPct { get; set; } = 80; public int UsageGateSevenDayPct { get; set; } = 90; - // Percentage of the 5h/7d Claude usage window at which the queue starts throttling - // parallelism ahead of the hard gate above. 0 = that stage off. - public int UsageThrottleSoftPct { get; set; } = 50; - public int UsageThrottleHardPct { get; set; } = 65; + // Percentage at which the queue starts throttling parallelism ahead of the hard gate above. + // Tracked per bucket, because the 5h and 7d windows fill at very different rates — soft caps + // parallelism at 2 slots, hard at 1. 0 = that stage off for that bucket. + public int UsageThrottleFiveHourSoftPct { get; set; } = 50; + public int UsageThrottleFiveHourHardPct { get; set; } = 65; + public int UsageThrottleSevenDaySoftPct { get; set; } = 50; + public int UsageThrottleSevenDayHardPct { get; set; } = 65; } diff --git a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs index 7461389e..e35bb1ca 100644 --- a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs +++ b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs @@ -88,8 +88,10 @@ public sealed class AppSettingsRepository row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills; row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100); row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100); - row.UsageThrottleSoftPct = Math.Clamp(updated.UsageThrottleSoftPct, 0, 100); - row.UsageThrottleHardPct = Math.Clamp(updated.UsageThrottleHardPct, 0, 100); + row.UsageThrottleFiveHourSoftPct = Math.Clamp(updated.UsageThrottleFiveHourSoftPct, 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); } diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 8921d76e..035fbc23 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -433,6 +433,10 @@ "gateBlockedFormat": "Queue pausiert — {0}", "throttleFormat": "Queue gedrosselt: {0}/{1} Slots ({2})", "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.)", "gaugeWeeklyAll": "Woche (alle Modelle)", "gaugeWeeklyScopedFormat": "Woche ({0})", @@ -657,7 +661,8 @@ "weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" }, "usageMonitor": { "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." }, "sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 372d2d9a..185584bb 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -433,6 +433,10 @@ "gateBlockedFormat": "Queue paused — {0}", "throttleFormat": "Queue throttled: {0}/{1} slots ({2})", "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)", "gaugeWeeklyAll": "Week (all models)", "gaugeWeeklyScopedFormat": "Week ({0})", @@ -657,7 +661,8 @@ "weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" }, "usageMonitor": { "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." }, "sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" }, diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index 542e0688..1fc391e1 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -32,7 +32,7 @@ ViewModels/ Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar, DescriptionStepsCard, WorkConsole; plus SessionTerminalView Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge, - AgentConfigEditor + AgentConfigEditor, UsagePill, UsageGaugeBar Design/ — Tokens.axaml (design tokens; merged before styles) + 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. | | `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). | -| `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 repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`, diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 1fd45b7e..33bb3c25 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -670,7 +670,12 @@ public sealed record AppSettingsDto( List? ModelPresets = null, int UsageGateFiveHourPct = 80, int UsageGateSevenDayPct = 90, - int MaxTurnsCeiling = 80); + int MaxTurnsCeiling = 80, + // Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings. + int UsageThrottleFiveHourSoftPct = 50, + int UsageThrottleFiveHourHardPct = 65, + int UsageThrottleSevenDaySoftPct = 50, + int UsageThrottleSevenDayHardPct = 65); // Per-model run defaults (effort + turn budget) edited in Settings → General. public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns); @@ -764,7 +769,13 @@ public sealed record UsageSnapshotDto( string? LastError, int ConfiguredSlots, 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( DateOnly Date, diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 06b0f55f..b6c35395 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -570,7 +570,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable { var vm = _usageMonitorVmFactory(); 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); } finally { _usageMonitorOpen = false; } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs index 18bb830b..5d3f38f6 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -19,6 +19,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase public PrimeClaudeTabViewModel Prime { get; } public OnlineInboxSettingsViewModel OnlineInbox { 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 bool _isBusy; @@ -48,6 +51,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase 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() { IsBusy = true; @@ -64,6 +71,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase General.MaxParallelExecutions = dto.MaxParallelExecutions; General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct; General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct; + _throttleStages = ( + dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct, + dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct); Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling"; Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot; Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled; @@ -115,7 +125,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase General.ModelPresetDtos(), General.UsageGateFiveHourPct, General.UsageGateSevenDayPct, - General.MaxTurnsCeiling); + General.MaxTurnsCeiling, + _throttleStages.FiveSoft, + _throttleStages.FiveHard, + _throttleStages.SevenSoft, + _throttleStages.SevenHard); await _worker.UpdateAppSettingsAsync(dto); await Prime.SaveAsync(); await OnlineInbox.SaveAsync(); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs index 3d4d7de9..388d2da9 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs @@ -20,7 +20,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase public event Action? ErrorReported; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(GaugeRows))] [NotifyPropertyChangedFor(nameof(IsStale))] [NotifyPropertyChangedFor(nameof(LastError))] [NotifyPropertyChangedFor(nameof(IsGateBlocked))] @@ -53,8 +52,8 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0; public bool TasksEmpty => !IsBusy && TaskRows.Count == 0; - public IReadOnlyList GaugeRows => - Snapshot is null ? Array.Empty() : Snapshot.Limits.Select(BuildGaugeRow).ToList(); + [ObservableProperty] + private IReadOnlyList _gaugeRows = Array.Empty(); public bool IsStale => Snapshot?.IsStale == true; public string? LastError => Snapshot?.LastError; @@ -83,13 +82,30 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase CloseAction?.Invoke(); } + /// + /// 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 + /// ~/.claude/projects (hundreds of MB of transcripts) — awaiting it before showing the + /// window made the usage pill look like it swallowed the click. + /// + public void BeginLoad() => _ = LoadAsync(); + public async Task LoadAsync() { - Snapshot = await _worker.GetUsageSnapshotAsync(); - _worker.UsageUpdatedEvent -= OnUsageUpdated; - _worker.UsageUpdatedEvent += OnUsageUpdated; - ApplyPresetRange(SelectedPresetDays); - await LoadUsageDataAsync(); + IsBusy = true; + try + { + Snapshot = await _worker.GetUsageSnapshotAsync(); + _worker.UsageUpdatedEvent -= OnUsageUpdated; + _worker.UsageUpdatedEvent += OnUsageUpdated; + ApplyPresetRange(SelectedPresetDays); + await LoadUsageDataAsync(); + } + catch (Exception ex) + { + ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message)); + } + finally { IsBusy = false; } } private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot; @@ -173,6 +189,99 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase finally { IsBusy = false; } } + partial void OnSnapshotChanged(UsageSnapshotDto? value) => SyncGaugeRows(); + + /// + /// 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. + /// + private void SyncGaugeRows() + { + var limits = Snapshot?.Limits ?? (IReadOnlyList)Array.Empty(); + var existing = GaugeRows.ToDictionary(r => r.Key); + var rows = new List(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), + }; + + /// + /// 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. + /// + 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 { "session" => Loc.T("modals.usageMonitor.gaugeSession"), @@ -182,17 +291,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase _ => 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 BuildModelDisplayRows(IReadOnlyList rows) { var built = new List(); @@ -222,22 +320,115 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase } } -public sealed record UsageGaugeRowViewModel( - string Label, - double Percent, - string Severity, - DateTimeOffset? ResetsAt, - int? ThresholdPercent) +/// +/// One usage gauge. The two real buckets (5h session, 7d week) carry their three stage thresholds +/// and are adjustable by dragging; plan-dependent scoped buckets render as a plain bar. +/// +public sealed partial class UsageGaugeRowViewModel : ObservableObject { + private readonly Func? _commit; + + public UsageGaugeRowViewModel( + string key, + string? bucket, + string label, + double percent, + string severity, + DateTimeOffset? resetsAt, + int? softPct, + int? hardPct, + int? gatePct, + Func? commit = null) + { + Key = key; + Bucket = bucket; + _label = label; + _percent = percent; + _severity = severity; + _resetsAt = resetsAt; + _softPct = softPct; + _hardPct = hardPct; + _gatePct = gatePct; + _commit = commit; + } + + /// Identity across snapshot updates, so a live poll updates rows instead of replacing them. + public string Key { get; } + + /// Which settings bucket a drag writes to: five_hour, seven_day, or null. + 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 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). - private const double GaugeTrackWidthPx = 216; + /// Live values from a fresh snapshot, without replacing the row instance mid-view. + 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 => - ThresholdPercent is { } t ? GaugeTrackWidthPx * Math.Clamp(t, 0, 100) / 100.0 : 0; + /// Raised by the gauge control when a drag ends — that is when the value is persisted. + [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) { @@ -251,6 +442,54 @@ public sealed record UsageGaugeRowViewModel( } } +/// +/// 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. +/// +public static class UsageThresholdDrag +{ + public enum Stage { Soft, Hard, Gate } + + /// Pointer reach for grabbing a marker, as a share of the bar width. + 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( string Model, long ClaudeDoInputTokens, diff --git a/src/ClaudeDo.Ui/Views/Controls/UsageGaugeBar.cs b/src/ClaudeDo.Ui/Views/Controls/UsageGaugeBar.cs new file mode 100644 index 00000000..f3deb5f9 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/UsageGaugeBar.cs @@ -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; + +/// +/// 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 so it stays testable. +/// Values are written back through TwoWay bindings while dragging; +/// 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. +/// +public sealed class UsageGaugeBar : Control, ICustomHitTest +{ + /// How close the pointer has to be to grab a marker. + private const double GrabRadiusPx = 12; + + private const double TrackHeightPx = 10; + private const double MarkerWidthPx = 2; + + public static readonly StyledProperty PercentProperty = + AvaloniaProperty.Register(nameof(Percent)); + + public static readonly StyledProperty IsWarnProperty = + AvaloniaProperty.Register(nameof(IsWarn)); + + public static readonly StyledProperty SoftPctProperty = + AvaloniaProperty.Register( + nameof(SoftPct), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty HardPctProperty = + AvaloniaProperty.Register( + nameof(HardPct), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty GatePctProperty = + AvaloniaProperty.Register( + nameof(GatePct), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty TrackBrushProperty = + AvaloniaProperty.Register(nameof(TrackBrush)); + + public static readonly StyledProperty FillBrushProperty = + AvaloniaProperty.Register(nameof(FillBrush)); + + public static readonly StyledProperty WarnFillBrushProperty = + AvaloniaProperty.Register(nameof(WarnFillBrush)); + + public static readonly StyledProperty SoftMarkerBrushProperty = + AvaloniaProperty.Register(nameof(SoftMarkerBrush)); + + public static readonly StyledProperty HardMarkerBrushProperty = + AvaloniaProperty.Register(nameof(HardMarkerBrush)); + + public static readonly StyledProperty GateMarkerBrushProperty = + AvaloniaProperty.Register(nameof(GateMarkerBrush)); + + public static readonly StyledProperty CommitCommandProperty = + AvaloniaProperty.Register(nameof(CommitCommand)); + + static UsageGaugeBar() + { + AffectsRender( + 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; +} diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml index 55ce88db..d42970ed 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -390,7 +390,8 @@ - + diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs index 5b2a63cb..92110a15 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs @@ -8,6 +8,7 @@ public partial class SettingsModalView : Window public SettingsModalView() { InitializeComponent(); + } protected override void OnDataContextChanged(EventArgs e) diff --git a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml index d14d2690..082383dc 100644 --- a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml @@ -18,19 +18,6 @@ - - - - - @@ -82,19 +69,60 @@ + BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="270"> - - - - - + + + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml.cs b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml.cs index 079306bf..11546480 100644 --- a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml.cs @@ -1,8 +1,36 @@ using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using ClaudeDo.Ui.ViewModels.Modals; namespace ClaudeDo.Ui.Views.Modals; public partial class UsageMonitorModalView : Window { public UsageMonitorModalView() => InitializeComponent(); + + /// + /// Persists a stage typed into a gauge's legend box. `NumericUpDown` has no commit command, so + /// the box's Tag names the stage and the row's matching command does the clamp + save. + /// + 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; + } } diff --git a/src/ClaudeDo.Ui/Views/WindowDialogService.cs b/src/ClaudeDo.Ui/Views/WindowDialogService.cs index beda84ef..96a826af 100644 --- a/src/ClaudeDo.Ui/Views/WindowDialogService.cs +++ b/src/ClaudeDo.Ui/Views/WindowDialogService.cs @@ -62,7 +62,9 @@ public sealed class WindowDialogService : IDialogService { var dlg = new UsageMonitorModalView { DataContext = vm }; 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) diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 1fc3f6bf..fccea1a1 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -50,7 +50,12 @@ public record AppSettingsDto( List? ModelPresets = null, int UsageGateFiveHourPct = 80, int UsageGateSevenDayPct = 90, - int MaxTurnsCeiling = 80); + int MaxTurnsCeiling = 80, + // Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings. + int UsageThrottleFiveHourSoftPct = 50, + int UsageThrottleFiveHourHardPct = 65, + int UsageThrottleSevenDaySoftPct = 50, + int UsageThrottleSevenDayHardPct = 65); // Per-model run defaults (effort + turn budget) edited in Settings -> General. public record ModelPresetDto(string Model, string Effort, int MaxTurns); @@ -137,7 +142,12 @@ public record UsageSnapshotDto( string? LastError, int ConfiguredSlots, int EffectiveSlots, - string? ThrottleBucket); + string? ThrottleBucket, + // Throttle stages per bucket, so the usage monitor can draw (and drag) them on each gauge. + int ThrottleFiveHourSoftPct, + int ThrottleFiveHourHardPct, + int ThrottleSevenDaySoftPct, + int ThrottleSevenDayHardPct); public record ModelUsageRowDto( DateOnly Date, @@ -446,7 +456,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub .Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList(), row.UsageGateFiveHourPct, row.UsageGateSevenDayPct, - row.MaxTurnsCeiling); + row.MaxTurnsCeiling, + row.UsageThrottleFiveHourSoftPct, + row.UsageThrottleFiveHourHardPct, + row.UsageThrottleSevenDaySoftPct, + row.UsageThrottleSevenDayHardPct); } public async Task UpdateAppSettings(AppSettingsDto dto) @@ -477,6 +491,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub UsageGateFiveHourPct = dto.UsageGateFiveHourPct, UsageGateSevenDayPct = dto.UsageGateSevenDayPct, MaxTurnsCeiling = dto.MaxTurnsCeiling, + UsageThrottleFiveHourSoftPct = dto.UsageThrottleFiveHourSoftPct, + UsageThrottleFiveHourHardPct = dto.UsageThrottleFiveHourHardPct, + UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct, + UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct, }); } diff --git a/src/ClaudeDo.Worker/Queue/QueueService.cs b/src/ClaudeDo.Worker/Queue/QueueService.cs index b01a6ec9..6167ddba 100644 --- a/src/ClaudeDo.Worker/Queue/QueueService.cs +++ b/src/ClaudeDo.Worker/Queue/QueueService.cs @@ -242,16 +242,18 @@ public sealed class QueueService : BackgroundService public async Task<(int Configured, int Effective)> GetSlotCountsAsync(CancellationToken ct) { int configured; - int softPct, hardPct, gateFivePct, gateSevenPct; + UsageThresholds fiveHour, sevenDay; try { using var context = _dbFactory.CreateDbContext(); var settings = await new AppSettingsRepository(context).GetAsync(ct); configured = Math.Max(1, settings.MaxParallelExecutions); - softPct = settings.UsageThrottleSoftPct; - hardPct = settings.UsageThrottleHardPct; - gateFivePct = settings.UsageGateFiveHourPct; - gateSevenPct = settings.UsageGateSevenDayPct; + fiveHour = new UsageThresholds( + settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct, + settings.UsageGateFiveHourPct); + sevenDay = new UsageThresholds( + settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct, + settings.UsageGateSevenDayPct); } catch (Exception ex) { @@ -267,8 +269,9 @@ public sealed class QueueService : BackgroundService } var effective = UsageThrottle.EffectiveSlots( - configured, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization, - softPct, hardPct, gateFivePct, gateSevenPct); + configured, + snapshot.FiveHour?.Utilization, fiveHour, + snapshot.SevenDay?.Utilization, sevenDay); ReportThrottleTransition(configured, effective, snapshot); return (configured, effective); diff --git a/src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs b/src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs index 52db99fe..b1722d7b 100644 --- a/src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs +++ b/src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs @@ -28,9 +28,15 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader if (Directory.Exists(_projectsRoot)) { - foreach (var file in Directory.EnumerateFiles(_projectsRoot, "*.jsonl", SearchOption.AllDirectories)) + // A transcript last written before the window began cannot hold a record inside it, so + // it is skipped unread — that is what keeps a 7-day range off the full history (hundreds + // of MB). One day of slack absorbs local-vs-UTC skew between mtime and record stamps. + var mtimeCutoff = start.ToDateTime(TimeOnly.MinValue).AddDays(-1); + + foreach (var file in new DirectoryInfo(_projectsRoot).EnumerateFiles("*.jsonl", SearchOption.AllDirectories)) { ct.ThrowIfCancellationRequested(); + if (file.LastWriteTime < mtimeCutoff) continue; foreach (var record in GetOrReadFile(file)) { @@ -67,8 +73,8 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader if (string.IsNullOrWhiteSpace(sessionId) || !Directory.Exists(_projectsRoot)) return Task.FromResult(null); - var file = Directory - .EnumerateFiles(_projectsRoot, $"{sessionId}.jsonl", SearchOption.AllDirectories) + var file = new DirectoryInfo(_projectsRoot) + .EnumerateFiles($"{sessionId}.jsonl", SearchOption.AllDirectories) .FirstOrDefault(); if (file is null) return Task.FromResult(null); @@ -89,17 +95,16 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader new SessionUsageTotals(input, output, cacheRead, cacheCreation)); } - private List GetOrReadFile(string file) + private List GetOrReadFile(FileInfo info) { - var info = new FileInfo(file); - if (_cache.TryGetValue(file, out var cached) && + if (_cache.TryGetValue(info.FullName, out var cached) && cached.Length == info.Length && cached.LastWriteUtc == info.LastWriteTimeUtc) { return cached.Records; } - var records = ReadFile(file); - _cache[file] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records); + var records = ReadFile(info.FullName); + _cache[info.FullName] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records); return records; } diff --git a/src/ClaudeDo.Worker/Usage/UsageSnapshotBuilder.cs b/src/ClaudeDo.Worker/Usage/UsageSnapshotBuilder.cs index 2fdbd133..d224b572 100644 --- a/src/ClaudeDo.Worker/Usage/UsageSnapshotBuilder.cs +++ b/src/ClaudeDo.Worker/Usage/UsageSnapshotBuilder.cs @@ -49,13 +49,18 @@ public sealed class UsageSnapshotBuilder .Select(l => new UsageLimitDto(l.Kind, l.Group, l.Percent, l.Severity, l.ResetsAt, l.ScopeModelDisplayName, l.IsActive)) .ToList(); + var fiveHourThresholds = new UsageThresholds( + settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct, settings.UsageGateFiveHourPct); + var sevenDayThresholds = new UsageThresholds( + settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct, settings.UsageGateSevenDayPct); + var configuredSlots = Math.Max(1, settings.MaxParallelExecutions); var effectiveSlots = snapshot is null || lastError is not null ? configuredSlots : UsageThrottle.EffectiveSlots( - configuredSlots, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization, - settings.UsageThrottleSoftPct, settings.UsageThrottleHardPct, - settings.UsageGateFiveHourPct, settings.UsageGateSevenDayPct); + configuredSlots, + snapshot.FiveHour?.Utilization, fiveHourThresholds, + snapshot.SevenDay?.Utilization, sevenDayThresholds); var throttleBucket = effectiveSlots < configuredSlots ? DecisiveBucket(snapshot?.FiveHour?.Utilization, snapshot?.SevenDay?.Utilization) : null; @@ -75,7 +80,11 @@ public sealed class UsageSnapshotBuilder lastError, configuredSlots, effectiveSlots, - throttleBucket); + throttleBucket, + fiveHourThresholds.SoftPct, + fiveHourThresholds.HardPct, + sevenDayThresholds.SoftPct, + sevenDayThresholds.HardPct); } private static string? DecisiveBucket(double? fiveHourPct, double? sevenDayPct) diff --git a/src/ClaudeDo.Worker/Usage/UsageThrottle.cs b/src/ClaudeDo.Worker/Usage/UsageThrottle.cs index da8d349b..c761a2b4 100644 --- a/src/ClaudeDo.Worker/Usage/UsageThrottle.cs +++ b/src/ClaudeDo.Worker/Usage/UsageThrottle.cs @@ -1,37 +1,42 @@ namespace ClaudeDo.Worker.Usage; /// -/// Pure staged throttle sitting ahead of 's hard pause: as the 5h/7d usage -/// window fills up, the queue's effective parallelism steps down before it hits zero, instead of -/// running at full tilt right up to the gate threshold. Whichever of the two buckets is more -/// utilized decides the stage. A missing bucket (null) is treated as 0% for that bucket only — -/// callers with no snapshot at all should skip this and use -/// directly (fail-open), same policy as . +/// The soft/hard/gate percentages of a single usage bucket. Soft caps parallelism at 2 slots, hard +/// at 1, gate blocks the queue refill entirely. A threshold of 0 disables that stage. +/// +public readonly record struct UsageThresholds(int SoftPct, int HardPct, int GatePct); + +/// +/// Pure staged throttle sitting ahead of '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 +/// directly (fail-open), same policy as . /// public static class UsageThrottle { public static int EffectiveSlots( int configuredSlots, double? fiveHourPct, + UsageThresholds fiveHour, double? sevenDayPct, - int softPct, - int hardPct, - int gateFiveHourPct, - int gateSevenDayPct) + UsageThresholds sevenDay) { var slots = Math.Max(1, configuredSlots); - if (gateFiveHourPct > 0 && fiveHourPct is { } five && five >= gateFiveHourPct) - return 0; - if (gateSevenDayPct > 0 && sevenDayPct is { } seven && seven >= gateSevenDayPct) - return 0; + return Math.Min( + BucketSlots(slots, fiveHourPct, fiveHour), + BucketSlots(slots, sevenDayPct, sevenDay)); + } - var worst = Math.Max(fiveHourPct ?? 0, sevenDayPct ?? 0); + private static int BucketSlots(int slots, double? pct, UsageThresholds thresholds) + { + if (pct is not { } utilization) return slots; - if (hardPct > 0 && worst >= hardPct) - return Math.Min(slots, 1); - if (softPct > 0 && worst >= softPct) - return Math.Min(slots, 2); + if (thresholds.GatePct > 0 && utilization >= thresholds.GatePct) return 0; + if (thresholds.HardPct > 0 && utilization >= thresholds.HardPct) return Math.Min(slots, 1); + if (thresholds.SoftPct > 0 && utilization >= thresholds.SoftPct) return Math.Min(slots, 2); return slots; } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/SettingsModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/SettingsModalViewModelTests.cs index de8ba656..60370ddb 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/SettingsModalViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/SettingsModalViewModelTests.cs @@ -43,6 +43,33 @@ public class SettingsModalViewModelTests SessionSkills: null, ModelPresets: null, 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) => new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(), MakeLocalizer(), new AppSettings()); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs index 316fc57f..7d16b339 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs @@ -33,7 +33,16 @@ public class UsageMonitorModalViewModelTests public int RefreshCalls; public Exception? RefreshThrows; - public override Task GetUsageSnapshotAsync() => Task.FromResult(Snapshot); + /// When set, the snapshot fetch never completes — stands in for the slow first + /// transcript scan on the worker side. + public TaskCompletionSource? SnapshotGate; + public Exception? SnapshotThrows; + + public override Task GetUsageSnapshotAsync() + { + if (SnapshotThrows is not null) throw SnapshotThrows; + return SnapshotGate?.Task ?? Task.FromResult(Snapshot); + } public override Task RefreshUsageAsync() { @@ -53,8 +62,25 @@ public class UsageMonitorModalViewModelTests TaskUsageCalls++; return Task.FromResult(TaskRows); } + + public AppSettingsDto? AppSettings; + public AppSettingsDto? SavedSettings; + + public override Task 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( string kind, double percent = 10, string severity = "normal", DateTimeOffset? resetsAt = null, string? scopeModelDisplayName = null, bool isActive = true) @@ -71,13 +97,63 @@ public class UsageMonitorModalViewModelTests DateTime? fetchedAtUtc = null, int configuredSlots = 1, int effectiveSlots = 1, - string? throttleBucket = null) + string? throttleBucket = null, + int throttleFiveHourSoftPct = 50, + int throttleFiveHourHardPct = 65, + int throttleSevenDaySoftPct = 50, + int throttleSevenDayHardPct = 65) => new( null, null, null, null, limits ?? Array.Empty(), fiveHourThresholdPct, sevenDayThresholdPct, 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() }; + 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 ────────────────────────────────────────────────────── @@ -210,7 +286,7 @@ public class UsageMonitorModalViewModelTests var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); - Assert.Equal(80, vm.GaugeRows[0].ThresholdPercent); + Assert.Equal(80, vm.GaugeRows[0].GatePct); } [Fact] @@ -220,7 +296,276 @@ public class UsageMonitorModalViewModelTests var vm = new UsageMonitorModalViewModel(worker); 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 ─────────────────────────────────────────────────── diff --git a/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs index 230c8d39..69ef045f 100644 --- a/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs @@ -100,8 +100,10 @@ public sealed class QueueStateMcpToolsTests : IDisposable var repo = new AppSettingsRepository(ctx); var settings = await repo.GetAsync(); settings.MaxParallelExecutions = maxParallel; - settings.UsageThrottleSoftPct = softPct; - settings.UsageThrottleHardPct = hardPct; + settings.UsageThrottleFiveHourSoftPct = softPct; + settings.UsageThrottleFiveHourHardPct = hardPct; + settings.UsageThrottleSevenDaySoftPct = softPct; + settings.UsageThrottleSevenDayHardPct = hardPct; await repo.UpdateAsync(settings); } diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs index 527166dc..e0cf4a92 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs @@ -81,8 +81,10 @@ public sealed class QueueServiceTests : IDisposable var repo = new AppSettingsRepository(ctx); var settings = await repo.GetAsync(); settings.MaxParallelExecutions = maxParallel; - settings.UsageThrottleSoftPct = softPct; - settings.UsageThrottleHardPct = hardPct; + settings.UsageThrottleFiveHourSoftPct = softPct; + settings.UsageThrottleFiveHourHardPct = hardPct; + settings.UsageThrottleSevenDaySoftPct = softPct; + settings.UsageThrottleSevenDayHardPct = hardPct; settings.UsageGateFiveHourPct = gateFive; settings.UsageGateSevenDayPct = gateSeven; await repo.UpdateAsync(settings); diff --git a/tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs index 6a6cdf20..d18ff72e 100644 --- a/tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs @@ -139,6 +139,35 @@ public class TranscriptUsageReaderTests : IDisposable 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] public async Task Malformed_Line_Does_Not_Abort_The_Run() { diff --git a/tests/ClaudeDo.Worker.Tests/Usage/UsageSnapshotBuilderTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/UsageSnapshotBuilderTests.cs index 4ff5ef7e..f2e5b4ab 100644 --- a/tests/ClaudeDo.Worker.Tests/Usage/UsageSnapshotBuilderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Usage/UsageSnapshotBuilderTests.cs @@ -149,11 +149,61 @@ public sealed class UsageSnapshotBuilderTests : IDisposable var repo = new AppSettingsRepository(ctx); var settings = await repo.GetAsync(); settings.MaxParallelExecutions = maxParallel; - settings.UsageThrottleSoftPct = softPct; - settings.UsageThrottleHardPct = hardPct; + settings.UsageThrottleFiveHourSoftPct = softPct; + settings.UsageThrottleFiveHourHardPct = hardPct; + settings.UsageThrottleSevenDaySoftPct = softPct; + settings.UsageThrottleSevenDayHardPct = hardPct; 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(), 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(), DateTime.UtcNow)); + + var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync(); + + Assert.Equal(2, dto.EffectiveSlots); + } + [Fact] public async Task Throttled_slots_and_decisive_bucket_reported() { diff --git a/tests/ClaudeDo.Worker.Tests/Usage/UsageThrottleTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/UsageThrottleTests.cs index fb82db1c..1a1e683e 100644 --- a/tests/ClaudeDo.Worker.Tests/Usage/UsageThrottleTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Usage/UsageThrottleTests.cs @@ -4,13 +4,11 @@ namespace ClaudeDo.Worker.Tests.Usage; public sealed class UsageThrottleTests { - private const int Soft = 50; - private const int Hard = 65; - private const int GateFive = 80; - private const int GateSeven = 90; + private static readonly UsageThresholds FiveHour = new(SoftPct: 50, HardPct: 65, GatePct: 80); + private static readonly UsageThresholds SevenDay = new(SoftPct: 50, HardPct: 65, GatePct: 90); 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] public void BelowSoftThreshold_ReturnsFullConfiguredSlots() @@ -94,13 +92,19 @@ public sealed class UsageThrottleTests [Fact] 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] 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] @@ -108,4 +112,46 @@ public sealed class UsageThrottleTests { 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)); + } }