feat(usage): split throttle thresholds per bucket, add draggable gauge markers
This commit is contained in:
@@ -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`.
|
||||
|
||||
`<synthetic>`-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.
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -55,10 +55,14 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
||||
builder.Property(s => 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 });
|
||||
}
|
||||
|
||||
+883
@@ -0,0 +1,883 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260806141710_SplitUsageThrottlePerBucket")]
|
||||
partial class SplitUsageThrottlePerBucket
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(40)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<int>("MaxTurnsCeiling")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(80)
|
||||
.HasColumnName("max_turns_ceiling");
|
||||
|
||||
b.Property<string>("ModelPresets")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model_presets");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("UsageGateFiveHourPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(80)
|
||||
.HasColumnName("usage_gate_five_hour_pct");
|
||||
|
||||
b.Property<int>("UsageGateSevenDayPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(90)
|
||||
.HasColumnName("usage_gate_seven_day_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleFiveHourHardPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(65)
|
||||
.HasColumnName("usage_throttle_five_hour_hard_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleFiveHourSoftPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(50)
|
||||
.HasColumnName("usage_throttle_five_hour_soft_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleSevenDayHardPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(65)
|
||||
.HasColumnName("usage_throttle_seven_day_hard_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleSevenDaySoftPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(50)
|
||||
.HasColumnName("usage_throttle_seven_day_soft_pct");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 40,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
MaxTurnsCeiling = 80,
|
||||
StandupWeekday = 3,
|
||||
UsageGateFiveHourPct = 80,
|
||||
UsageGateSevenDayPct = 90,
|
||||
UsageThrottleFiveHourHardPct = 65,
|
||||
UsageThrottleFiveHourSoftPct = 50,
|
||||
UsageThrottleSevenDayHardPct = 65,
|
||||
UsageThrottleSevenDaySoftPct = 50,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.ToTable("daily_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("VerifyCommand")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("verify_command");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTimeOffset>("AddedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("added_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("PinnedRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pinned_ref");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("source_url");
|
||||
|
||||
b.Property<string>("Subpath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subpath");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("session_skills", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_attachments_task_id");
|
||||
|
||||
b.ToTable("task_attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<string>("HandlerBaseCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_base_commit");
|
||||
|
||||
b.Property<string>("HandlerHeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_head_commit");
|
||||
|
||||
b.Property<string>("InteractiveSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("interactive_session_id");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<int?>("CacheReadTokens")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("cache_read_tokens");
|
||||
|
||||
b.Property<int?>("CacheWriteTokens")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("cache_write_tokens");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("week_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("MergeCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("merge_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SplitUsageThrottlePerBucket : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "usage_throttle_soft_pct",
|
||||
table: "app_settings",
|
||||
newName: "usage_throttle_seven_day_soft_pct");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "usage_throttle_hard_pct",
|
||||
table: "app_settings",
|
||||
newName: "usage_throttle_seven_day_hard_pct");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "usage_throttle_five_hour_hard_pct",
|
||||
table: "app_settings",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 65);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "usage_throttle_five_hour_soft_pct",
|
||||
table: "app_settings",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 50);
|
||||
|
||||
// The old single soft/hard pair was compared against whichever bucket was more utilized,
|
||||
// so carrying it into BOTH buckets keeps an existing install behaving exactly as before
|
||||
// the split — the rename above already preserved it for the 7d side.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE app_settings
|
||||
SET usage_throttle_five_hour_soft_pct = usage_throttle_seven_day_soft_pct,
|
||||
usage_throttle_five_hour_hard_pct = usage_throttle_seven_day_hard_pct;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "usage_throttle_five_hour_hard_pct",
|
||||
table: "app_settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "usage_throttle_five_hour_soft_pct",
|
||||
table: "app_settings");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "usage_throttle_seven_day_soft_pct",
|
||||
table: "app_settings",
|
||||
newName: "usage_throttle_soft_pct");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "usage_throttle_seven_day_hard_pct",
|
||||
table: "app_settings",
|
||||
newName: "usage_throttle_hard_pct");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,17 +106,29 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasDefaultValue(90)
|
||||
.HasColumnName("usage_gate_seven_day_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleHardPct")
|
||||
b.Property<int>("UsageThrottleFiveHourHardPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(65)
|
||||
.HasColumnName("usage_throttle_hard_pct");
|
||||
.HasColumnName("usage_throttle_five_hour_hard_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleSoftPct")
|
||||
b.Property<int>("UsageThrottleFiveHourSoftPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(50)
|
||||
.HasColumnName("usage_throttle_soft_pct");
|
||||
.HasColumnName("usage_throttle_five_hour_soft_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleSevenDayHardPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(65)
|
||||
.HasColumnName("usage_throttle_seven_day_hard_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleSevenDaySoftPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(50)
|
||||
.HasColumnName("usage_throttle_seven_day_soft_pct");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}" },
|
||||
|
||||
@@ -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}" },
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -670,7 +670,12 @@ public sealed record AppSettingsDto(
|
||||
List<ModelPresetDto>? ModelPresets = null,
|
||||
int UsageGateFiveHourPct = 80,
|
||||
int UsageGateSevenDayPct = 90,
|
||||
int MaxTurnsCeiling = 80);
|
||||
int MaxTurnsCeiling = 80,
|
||||
// Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings.
|
||||
int UsageThrottleFiveHourSoftPct = 50,
|
||||
int UsageThrottleFiveHourHardPct = 65,
|
||||
int UsageThrottleSevenDaySoftPct = 50,
|
||||
int UsageThrottleSevenDayHardPct = 65);
|
||||
|
||||
// Per-model run defaults (effort + turn budget) edited in Settings → General.
|
||||
public 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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -20,7 +20,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
public event Action<string>? 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<UsageGaugeRowViewModel> GaugeRows =>
|
||||
Snapshot is null ? Array.Empty<UsageGaugeRowViewModel>() : Snapshot.Limits.Select(BuildGaugeRow).ToList();
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
|
||||
|
||||
public bool IsStale => Snapshot?.IsStale == true;
|
||||
public string? LastError => Snapshot?.LastError;
|
||||
@@ -83,13 +82,30 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the load without blocking the caller, so the host can show the window right away and
|
||||
/// let it fill in behind the busy spinner. The first load per worker process pays a full scan of
|
||||
/// <c>~/.claude/projects</c> (hundreds of MB of transcripts) — awaiting it before showing the
|
||||
/// window made the usage pill look like it swallowed the click.
|
||||
/// </summary>
|
||||
public void BeginLoad() => _ = LoadAsync();
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// Folds a fresh snapshot into the existing rows instead of rebuilding them, so a poll landing
|
||||
/// while the user works the markers doesn't swap the instances out from under the drag.
|
||||
/// </summary>
|
||||
private void SyncGaugeRows()
|
||||
{
|
||||
var limits = Snapshot?.Limits ?? (IReadOnlyList<UsageLimitDto>)Array.Empty<UsageLimitDto>();
|
||||
var existing = GaugeRows.ToDictionary(r => r.Key);
|
||||
var rows = new List<UsageGaugeRowViewModel>(limits.Count);
|
||||
|
||||
foreach (var limit in limits)
|
||||
{
|
||||
var key = GaugeKey(limit);
|
||||
var bucket = GaugeBucket(limit);
|
||||
var (soft, hard, gate) = StagesFor(bucket);
|
||||
var label = BuildGaugeLabel(limit);
|
||||
|
||||
if (existing.TryGetValue(key, out var row))
|
||||
{
|
||||
row.Update(label, limit.Percent, limit.Severity, limit.ResetsAt, soft, hard, gate);
|
||||
rows.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
rows.Add(new UsageGaugeRowViewModel(
|
||||
key, bucket, label, limit.Percent, limit.Severity, limit.ResetsAt,
|
||||
soft, hard, gate, SaveStagesAsync));
|
||||
}
|
||||
}
|
||||
|
||||
if (!rows.SequenceEqual(GaugeRows)) GaugeRows = rows;
|
||||
}
|
||||
|
||||
// Scoped weekly buckets are plan-dependent and share no settings row, so they stay read-only.
|
||||
private static string? GaugeBucket(UsageLimitDto limit) => limit.Kind switch
|
||||
{
|
||||
"session" => "five_hour",
|
||||
"weekly_all" => "seven_day",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string GaugeKey(UsageLimitDto limit) =>
|
||||
limit.Kind == "weekly_scoped" ? $"weekly_scoped:{limit.ScopeModelDisplayName}" : limit.Kind;
|
||||
|
||||
private (int? Soft, int? Hard, int? Gate) StagesFor(string? bucket) => (bucket, Snapshot) switch
|
||||
{
|
||||
("five_hour", { } s) => (s.ThrottleFiveHourSoftPct, s.ThrottleFiveHourHardPct, s.FiveHourThresholdPct),
|
||||
("seven_day", { } s) => (s.ThrottleSevenDaySoftPct, s.ThrottleSevenDayHardPct, s.SevenDayThresholdPct),
|
||||
_ => (null, null, null),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Persists one gauge's stages after a drag. Read-modify-write against the current settings, so
|
||||
/// this never clobbers a field the usage monitor doesn't own.
|
||||
/// </summary>
|
||||
private async Task SaveStagesAsync(UsageGaugeRowViewModel row)
|
||||
{
|
||||
if (row.Bucket is null || row.SoftPct is not { } soft || row.HardPct is not { } hard || row.GatePct is not { } gate)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var settings = await _worker.GetAppSettingsAsync();
|
||||
if (settings is null)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = row.Bucket == "five_hour"
|
||||
? settings with
|
||||
{
|
||||
UsageThrottleFiveHourSoftPct = soft,
|
||||
UsageThrottleFiveHourHardPct = hard,
|
||||
UsageGateFiveHourPct = gate,
|
||||
}
|
||||
: settings with
|
||||
{
|
||||
UsageThrottleSevenDaySoftPct = soft,
|
||||
UsageThrottleSevenDayHardPct = hard,
|
||||
UsageGateSevenDayPct = gate,
|
||||
};
|
||||
|
||||
await _worker.UpdateAppSettingsAsync(updated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildGaugeLabel(UsageLimitDto limit) => limit.Kind switch
|
||||
{
|
||||
"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<ModelUsageDisplayRow> BuildModelDisplayRows(IReadOnlyList<ModelUsageRowDto> rows)
|
||||
{
|
||||
var built = new List<ModelUsageDisplayRow>();
|
||||
@@ -222,22 +320,115 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UsageGaugeRowViewModel(
|
||||
string Label,
|
||||
double Percent,
|
||||
string Severity,
|
||||
DateTimeOffset? ResetsAt,
|
||||
int? ThresholdPercent)
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed partial class UsageGaugeRowViewModel : ObservableObject
|
||||
{
|
||||
private readonly Func<UsageGaugeRowViewModel, Task>? _commit;
|
||||
|
||||
public UsageGaugeRowViewModel(
|
||||
string key,
|
||||
string? bucket,
|
||||
string label,
|
||||
double percent,
|
||||
string severity,
|
||||
DateTimeOffset? resetsAt,
|
||||
int? softPct,
|
||||
int? hardPct,
|
||||
int? gatePct,
|
||||
Func<UsageGaugeRowViewModel, Task>? commit = null)
|
||||
{
|
||||
Key = key;
|
||||
Bucket = bucket;
|
||||
_label = label;
|
||||
_percent = percent;
|
||||
_severity = severity;
|
||||
_resetsAt = resetsAt;
|
||||
_softPct = softPct;
|
||||
_hardPct = hardPct;
|
||||
_gatePct = gatePct;
|
||||
_commit = commit;
|
||||
}
|
||||
|
||||
/// <summary>Identity across snapshot updates, so a live poll updates rows instead of replacing them.</summary>
|
||||
public string Key { get; }
|
||||
|
||||
/// <summary>Which settings bucket a drag writes to: <c>five_hour</c>, <c>seven_day</c>, or null.</summary>
|
||||
public string? Bucket { get; }
|
||||
|
||||
[ObservableProperty] private string _label;
|
||||
[ObservableProperty] private double _percent;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsWarnSeverity))]
|
||||
private string _severity;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ResetText))]
|
||||
private DateTimeOffset? _resetsAt;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
|
||||
private int? _softPct;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
|
||||
private int? _hardPct;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
|
||||
private int? _gatePct;
|
||||
|
||||
public bool IsAdjustable => Bucket is not null && SoftPct is not null && HardPct is not null && GatePct is not null;
|
||||
|
||||
public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public 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;
|
||||
/// <summary>Live values from a fresh snapshot, without replacing the row instance mid-view.</summary>
|
||||
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;
|
||||
/// <summary>Raised by the gauge control when a drag ends — that is when the value is persisted.</summary>
|
||||
[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(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drag math for the gauge stage markers, kept out of the control so it can be tested directly:
|
||||
/// every stage stays inside 0..100 and never crosses a neighbour (soft ≤ hard ≤ gate). A neighbour
|
||||
/// at 0 means "that stage is off" and therefore does not constrain anything.
|
||||
/// </summary>
|
||||
public static class UsageThresholdDrag
|
||||
{
|
||||
public enum Stage { Soft, Hard, Gate }
|
||||
|
||||
/// <summary>Pointer reach for grabbing a marker, as a share of the bar width.</summary>
|
||||
public static Stage? Nearest(int soft, int hard, int gate, double percent, double tolerancePercent)
|
||||
{
|
||||
Stage? best = null;
|
||||
var bestDistance = double.MaxValue;
|
||||
|
||||
foreach (var (stage, value) in new[] { (Stage.Soft, soft), (Stage.Hard, hard), (Stage.Gate, gate) })
|
||||
{
|
||||
var distance = Math.Abs(percent - value);
|
||||
if (distance > tolerancePercent || distance >= bestDistance) continue;
|
||||
best = stage;
|
||||
bestDistance = distance;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public static (int Soft, int Hard, int Gate) Apply(int soft, int hard, int gate, Stage stage, double rawPercent)
|
||||
{
|
||||
var value = (int)Math.Round(Math.Clamp(rawPercent, 0, 100));
|
||||
|
||||
return stage switch
|
||||
{
|
||||
Stage.Soft => (ClampRange(value, 0, UpperBound(hard, gate)), hard, gate),
|
||||
Stage.Hard => (soft, ClampRange(value, soft, UpperBound(gate, 100)), gate),
|
||||
Stage.Gate => (soft, hard, ClampRange(value, Math.Max(soft, hard), 100)),
|
||||
_ => (soft, hard, gate),
|
||||
};
|
||||
}
|
||||
|
||||
// A neighbour of 0 is switched off and must not pin the dragged marker to 0.
|
||||
private static int UpperBound(int nearest, int fallback) =>
|
||||
nearest > 0 ? nearest : (fallback > 0 ? fallback : 100);
|
||||
|
||||
// An already-inconsistent stored config (min above max) must not throw mid-drag.
|
||||
private static int ClampRange(int value, int min, int max) =>
|
||||
max < min ? max : Math.Clamp(value, min, max);
|
||||
}
|
||||
|
||||
public sealed record ModelUsageDisplayRow(
|
||||
string Model,
|
||||
long ClaudeDoInputTokens,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Rendering;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Usage bar with three draggable stage markers: soft (throttle to 2 slots), hard (1 slot) and gate
|
||||
/// (queue paused). Positions are computed against the control's real width — no hardcoded track
|
||||
/// size — and the drag math lives in <see cref="UsageThresholdDrag"/> so it stays testable.
|
||||
/// Values are written back through TwoWay bindings while dragging; <see cref="CommitCommand"/>
|
||||
/// fires once on release, which is when the host persists them.
|
||||
/// A row without thresholds (plan-dependent scoped buckets) renders as a plain read-only bar.
|
||||
/// </summary>
|
||||
public sealed class UsageGaugeBar : Control, ICustomHitTest
|
||||
{
|
||||
/// <summary>How close the pointer has to be to grab a marker.</summary>
|
||||
private const double GrabRadiusPx = 12;
|
||||
|
||||
private const double TrackHeightPx = 10;
|
||||
private const double MarkerWidthPx = 2;
|
||||
|
||||
public static readonly StyledProperty<double> PercentProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, double>(nameof(Percent));
|
||||
|
||||
public static readonly StyledProperty<bool> IsWarnProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, bool>(nameof(IsWarn));
|
||||
|
||||
public static readonly StyledProperty<int?> SoftPctProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, int?>(
|
||||
nameof(SoftPct), defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public static readonly StyledProperty<int?> HardPctProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, int?>(
|
||||
nameof(HardPct), defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public static readonly StyledProperty<int?> GatePctProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, int?>(
|
||||
nameof(GatePct), defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public static readonly StyledProperty<IBrush?> TrackBrushProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(TrackBrush));
|
||||
|
||||
public static readonly StyledProperty<IBrush?> FillBrushProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(FillBrush));
|
||||
|
||||
public static readonly StyledProperty<IBrush?> WarnFillBrushProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(WarnFillBrush));
|
||||
|
||||
public static readonly StyledProperty<IBrush?> SoftMarkerBrushProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(SoftMarkerBrush));
|
||||
|
||||
public static readonly StyledProperty<IBrush?> HardMarkerBrushProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(HardMarkerBrush));
|
||||
|
||||
public static readonly StyledProperty<IBrush?> GateMarkerBrushProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(GateMarkerBrush));
|
||||
|
||||
public static readonly StyledProperty<ICommand?> CommitCommandProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, ICommand?>(nameof(CommitCommand));
|
||||
|
||||
static UsageGaugeBar()
|
||||
{
|
||||
AffectsRender<UsageGaugeBar>(
|
||||
PercentProperty, IsWarnProperty, SoftPctProperty, HardPctProperty, GatePctProperty,
|
||||
TrackBrushProperty, FillBrushProperty, WarnFillBrushProperty,
|
||||
SoftMarkerBrushProperty, HardMarkerBrushProperty, GateMarkerBrushProperty);
|
||||
}
|
||||
|
||||
private UsageThresholdDrag.Stage? _dragging;
|
||||
|
||||
public double Percent
|
||||
{
|
||||
get => GetValue(PercentProperty);
|
||||
set => SetValue(PercentProperty, value);
|
||||
}
|
||||
|
||||
public bool IsWarn
|
||||
{
|
||||
get => GetValue(IsWarnProperty);
|
||||
set => SetValue(IsWarnProperty, value);
|
||||
}
|
||||
|
||||
public int? SoftPct
|
||||
{
|
||||
get => GetValue(SoftPctProperty);
|
||||
set => SetValue(SoftPctProperty, value);
|
||||
}
|
||||
|
||||
public int? HardPct
|
||||
{
|
||||
get => GetValue(HardPctProperty);
|
||||
set => SetValue(HardPctProperty, value);
|
||||
}
|
||||
|
||||
public int? GatePct
|
||||
{
|
||||
get => GetValue(GatePctProperty);
|
||||
set => SetValue(GatePctProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? TrackBrush
|
||||
{
|
||||
get => GetValue(TrackBrushProperty);
|
||||
set => SetValue(TrackBrushProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? FillBrush
|
||||
{
|
||||
get => GetValue(FillBrushProperty);
|
||||
set => SetValue(FillBrushProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? WarnFillBrush
|
||||
{
|
||||
get => GetValue(WarnFillBrushProperty);
|
||||
set => SetValue(WarnFillBrushProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? SoftMarkerBrush
|
||||
{
|
||||
get => GetValue(SoftMarkerBrushProperty);
|
||||
set => SetValue(SoftMarkerBrushProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? HardMarkerBrush
|
||||
{
|
||||
get => GetValue(HardMarkerBrushProperty);
|
||||
set => SetValue(HardMarkerBrushProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? GateMarkerBrush
|
||||
{
|
||||
get => GetValue(GateMarkerBrushProperty);
|
||||
set => SetValue(GateMarkerBrushProperty, value);
|
||||
}
|
||||
|
||||
public ICommand? CommitCommand
|
||||
{
|
||||
get => GetValue(CommitCommandProperty);
|
||||
set => SetValue(CommitCommandProperty, value);
|
||||
}
|
||||
|
||||
private bool IsAdjustable => SoftPct is not null && HardPct is not null && GatePct is not null;
|
||||
|
||||
// Custom hit test (point is in local coordinates): the control draws itself, so the whole
|
||||
// rectangle takes the pointer — not just the pixels the track happens to cover.
|
||||
public bool HitTest(Point point) => new Rect(Bounds.Size).Contains(point);
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
var width = Bounds.Width;
|
||||
var height = Bounds.Height;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
|
||||
var top = Math.Max(0, (height - TrackHeightPx) / 2);
|
||||
var trackHeight = Math.Min(TrackHeightPx, height);
|
||||
var radius = trackHeight / 2;
|
||||
|
||||
// Transparent full-bounds fill keeps the grab area the whole control, not just the track.
|
||||
context.FillRectangle(Brushes.Transparent, new Rect(0, 0, width, height));
|
||||
|
||||
if (TrackBrush is { } track)
|
||||
context.DrawRectangle(track, null, new RoundedRect(new Rect(0, top, width, trackHeight), radius));
|
||||
|
||||
var fillWidth = width * Math.Clamp(Percent, 0, 100) / 100.0;
|
||||
var fill = IsWarn ? WarnFillBrush ?? FillBrush : FillBrush;
|
||||
if (fillWidth > 0 && fill is not null)
|
||||
context.DrawRectangle(fill, null, new RoundedRect(new Rect(0, top, fillWidth, trackHeight), radius));
|
||||
|
||||
DrawMarker(context, SoftPct, SoftMarkerBrush, width, height);
|
||||
DrawMarker(context, HardPct, HardMarkerBrush, width, height);
|
||||
DrawMarker(context, GatePct, GateMarkerBrush, width, height);
|
||||
}
|
||||
|
||||
private static void DrawMarker(DrawingContext context, int? percent, IBrush? brush, double width, double height)
|
||||
{
|
||||
if (percent is not { } value || brush is null) return;
|
||||
|
||||
var x = Math.Clamp(width * Math.Clamp(value, 0, 100) / 100.0 - MarkerWidthPx / 2, 0, Math.Max(0, width - MarkerWidthPx));
|
||||
context.FillRectangle(brush, new Rect(x, 0, MarkerWidthPx, height));
|
||||
}
|
||||
|
||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
if (!IsAdjustable) return;
|
||||
|
||||
var percent = PercentAt(e.GetPosition(this).X);
|
||||
_dragging = UsageThresholdDrag.Nearest(
|
||||
SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent());
|
||||
if (_dragging is null) return;
|
||||
|
||||
e.Pointer.Capture(this);
|
||||
ApplyDrag(_dragging.Value, percent);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
if (!IsAdjustable) return;
|
||||
|
||||
var percent = PercentAt(e.GetPosition(this).X);
|
||||
|
||||
if (_dragging is { } stage)
|
||||
{
|
||||
ApplyDrag(stage, percent);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var hover = UsageThresholdDrag.Nearest(
|
||||
SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent());
|
||||
Cursor = new Cursor(hover is null ? StandardCursorType.Arrow : StandardCursorType.SizeWestEast);
|
||||
}
|
||||
|
||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||
{
|
||||
base.OnPointerReleased(e);
|
||||
if (_dragging is null) return;
|
||||
|
||||
_dragging = null;
|
||||
e.Pointer.Capture(null);
|
||||
e.Handled = true;
|
||||
|
||||
if (CommitCommand is { } command && command.CanExecute(null))
|
||||
command.Execute(null);
|
||||
}
|
||||
|
||||
private void ApplyDrag(UsageThresholdDrag.Stage stage, double percent)
|
||||
{
|
||||
var (soft, hard, gate) = UsageThresholdDrag.Apply(
|
||||
SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, percent);
|
||||
|
||||
SoftPct = soft;
|
||||
HardPct = hard;
|
||||
GatePct = gate;
|
||||
}
|
||||
|
||||
private double PercentAt(double x) => Bounds.Width <= 0 ? 0 : Math.Clamp(x / Bounds.Width * 100.0, 0, 100);
|
||||
|
||||
private double GrabTolerancePercent() => Bounds.Width <= 0 ? 0 : GrabRadiusPx / Bounds.Width * 100.0;
|
||||
}
|
||||
@@ -390,7 +390,8 @@
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}">
|
||||
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}"
|
||||
IsVisible="{Binding ShowOnlineInbox}">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="14" Margin="0,8,0,0">
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ public partial class SettingsModalView : Window
|
||||
public SettingsModalView()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
|
||||
@@ -18,19 +18,6 @@
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="ProgressBar.usage-gauge">
|
||||
<Setter Property="Height" Value="10"/>
|
||||
<Setter Property="CornerRadius" Value="4"/>
|
||||
<Setter Property="Minimum" Value="0"/>
|
||||
<Setter Property="Maximum" Value="100"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="ProgressBar.usage-gauge.warn">
|
||||
<Setter Property="Foreground" Value="{DynamicResource StatusReviewBrush}"/>
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<ctl:ModalShell Title="{loc:Tr modals.usageMonitor.title}" CloseCommand="{Binding CloseCommand}">
|
||||
<DockPanel>
|
||||
|
||||
@@ -82,19 +69,60 @@
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:UsageGaugeRowViewModel">
|
||||
<Border Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="240">
|
||||
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="270">
|
||||
<StackPanel Spacing="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="eyebrow" Text="{Binding Label}"/>
|
||||
<TextBlock Classes="meta" Text="{Binding Percent, StringFormat={}{0:0}%}" HorizontalAlignment="Right"/>
|
||||
</StackPanel>
|
||||
<Grid Height="10">
|
||||
<ProgressBar Classes="usage-gauge" Classes.warn="{Binding IsWarnSeverity}" Value="{Binding Percent}"/>
|
||||
<Canvas IsHitTestVisible="False">
|
||||
<Rectangle Canvas.Left="{Binding ThresholdMarkerLeftPx}" Width="2" Height="10"
|
||||
Fill="{DynamicResource TextDimBrush}"
|
||||
IsVisible="{Binding ThresholdPercent, Converter={x:Static conv:ObjectConverters.IsNotNull}}"/>
|
||||
</Canvas>
|
||||
<ctl:UsageGaugeBar Height="16"
|
||||
Percent="{Binding Percent}"
|
||||
IsWarn="{Binding IsWarnSeverity}"
|
||||
SoftPct="{Binding SoftPct, Mode=TwoWay}"
|
||||
HardPct="{Binding HardPct, Mode=TwoWay}"
|
||||
GatePct="{Binding GatePct, Mode=TwoWay}"
|
||||
CommitCommand="{Binding CommitCommand}"
|
||||
TrackBrush="{DynamicResource LineBrush}"
|
||||
FillBrush="{DynamicResource AccentBrush}"
|
||||
WarnFillBrush="{DynamicResource StatusReviewBrush}"
|
||||
SoftMarkerBrush="{DynamicResource TextDimBrush}"
|
||||
HardMarkerBrush="{DynamicResource StatusReviewBrush}"
|
||||
GateMarkerBrush="{DynamicResource StatusErrorBrush}"
|
||||
ToolTip.Tip="{loc:Tr modals.usageMonitor.dragHint}"/>
|
||||
|
||||
<!-- Legend doubles as the numeric editor: swatch colours match the bar's markers,
|
||||
and each box commits on Enter / focus loss (handlers in the code-behind). -->
|
||||
<Grid ColumnDefinitions="10,*,62" RowDefinitions="Auto,Auto,Auto"
|
||||
IsVisible="{Binding IsAdjustable}" Margin="0,2,0,0">
|
||||
<Rectangle Grid.Row="0" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
|
||||
VerticalAlignment="Center" Fill="{DynamicResource TextDimBrush}"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
|
||||
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendSoft}"/>
|
||||
<NumericUpDown Grid.Row="0" Grid.Column="2" Tag="soft"
|
||||
Value="{Binding SoftPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
|
||||
Minimum="0" Maximum="100" Increment="5" FormatString="0"
|
||||
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
|
||||
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
|
||||
|
||||
<Rectangle Grid.Row="1" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
|
||||
VerticalAlignment="Center" Fill="{DynamicResource StatusReviewBrush}"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
|
||||
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendHard}"/>
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="2" Tag="hard"
|
||||
Value="{Binding HardPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
|
||||
Minimum="0" Maximum="100" Increment="5" FormatString="0"
|
||||
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
|
||||
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
|
||||
|
||||
<Rectangle Grid.Row="2" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
|
||||
VerticalAlignment="Center" Fill="{DynamicResource StatusErrorBrush}"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
|
||||
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendGate}"/>
|
||||
<NumericUpDown Grid.Row="2" Grid.Column="2" Tag="gate"
|
||||
Value="{Binding GatePct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
|
||||
Minimum="0" Maximum="100" Increment="5" FormatString="0"
|
||||
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
|
||||
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
|
||||
</Grid>
|
||||
<TextBlock Classes="meta" Text="{Binding ResetText}" IsVisible="{Binding ResetText, Converter={x:Static conv:StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
@@ -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();
|
||||
|
||||
/// <summary>
|
||||
/// Persists a stage typed into a gauge's legend box. `NumericUpDown` has no commit command, so
|
||||
/// the box's <c>Tag</c> names the stage and the row's matching command does the clamp + save.
|
||||
/// </summary>
|
||||
private void OnStageBoxCommit(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Control { Tag: string stage, DataContext: UsageGaugeRowViewModel row }) return;
|
||||
|
||||
var command = stage switch
|
||||
{
|
||||
"soft" => row.CommitSoftCommand,
|
||||
"hard" => row.CommitHardCommand,
|
||||
_ => row.CommitGateCommand,
|
||||
};
|
||||
|
||||
if (command.CanExecute(null)) command.Execute(null);
|
||||
}
|
||||
|
||||
private void OnStageBoxKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Enter) return;
|
||||
OnStageBoxCommit(sender, e);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -50,7 +50,12 @@ public record AppSettingsDto(
|
||||
List<ModelPresetDto>? ModelPresets = null,
|
||||
int UsageGateFiveHourPct = 80,
|
||||
int UsageGateSevenDayPct = 90,
|
||||
int MaxTurnsCeiling = 80);
|
||||
int MaxTurnsCeiling = 80,
|
||||
// Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings.
|
||||
int UsageThrottleFiveHourSoftPct = 50,
|
||||
int UsageThrottleFiveHourHardPct = 65,
|
||||
int UsageThrottleSevenDaySoftPct = 50,
|
||||
int UsageThrottleSevenDayHardPct = 65);
|
||||
|
||||
// Per-model run defaults (effort + turn budget) edited in Settings -> General.
|
||||
public record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
||||
@@ -137,7 +142,12 @@ public record UsageSnapshotDto(
|
||||
string? LastError,
|
||||
int ConfiguredSlots,
|
||||
int EffectiveSlots,
|
||||
string? ThrottleBucket);
|
||||
string? ThrottleBucket,
|
||||
// Throttle stages per bucket, so the usage monitor can draw (and drag) them on each gauge.
|
||||
int ThrottleFiveHourSoftPct,
|
||||
int ThrottleFiveHourHardPct,
|
||||
int ThrottleSevenDaySoftPct,
|
||||
int ThrottleSevenDayHardPct);
|
||||
|
||||
public record ModelUsageRowDto(
|
||||
DateOnly Date,
|
||||
@@ -446,7 +456,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
.Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList(),
|
||||
row.UsageGateFiveHourPct,
|
||||
row.UsageGateSevenDayPct,
|
||||
row.MaxTurnsCeiling);
|
||||
row.MaxTurnsCeiling,
|
||||
row.UsageThrottleFiveHourSoftPct,
|
||||
row.UsageThrottleFiveHourHardPct,
|
||||
row.UsageThrottleSevenDaySoftPct,
|
||||
row.UsageThrottleSevenDayHardPct);
|
||||
}
|
||||
|
||||
public async Task UpdateAppSettings(AppSettingsDto dto)
|
||||
@@ -477,6 +491,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
UsageGateFiveHourPct = dto.UsageGateFiveHourPct,
|
||||
UsageGateSevenDayPct = dto.UsageGateSevenDayPct,
|
||||
MaxTurnsCeiling = dto.MaxTurnsCeiling,
|
||||
UsageThrottleFiveHourSoftPct = dto.UsageThrottleFiveHourSoftPct,
|
||||
UsageThrottleFiveHourHardPct = dto.UsageThrottleFiveHourHardPct,
|
||||
UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct,
|
||||
UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -242,16 +242,18 @@ public sealed class QueueService : BackgroundService
|
||||
public async Task<(int Configured, int Effective)> GetSlotCountsAsync(CancellationToken ct)
|
||||
{
|
||||
int configured;
|
||||
int softPct, hardPct, gateFivePct, gateSevenPct;
|
||||
UsageThresholds fiveHour, sevenDay;
|
||||
try
|
||||
{
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var settings = await new AppSettingsRepository(context).GetAsync(ct);
|
||||
configured = Math.Max(1, settings.MaxParallelExecutions);
|
||||
softPct = settings.UsageThrottleSoftPct;
|
||||
hardPct = settings.UsageThrottleHardPct;
|
||||
gateFivePct = settings.UsageGateFiveHourPct;
|
||||
gateSevenPct = settings.UsageGateSevenDayPct;
|
||||
fiveHour = new UsageThresholds(
|
||||
settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct,
|
||||
settings.UsageGateFiveHourPct);
|
||||
sevenDay = new UsageThresholds(
|
||||
settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct,
|
||||
settings.UsageGateSevenDayPct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -267,8 +269,9 @@ public sealed class QueueService : BackgroundService
|
||||
}
|
||||
|
||||
var effective = UsageThrottle.EffectiveSlots(
|
||||
configured, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization,
|
||||
softPct, hardPct, gateFivePct, gateSevenPct);
|
||||
configured,
|
||||
snapshot.FiveHour?.Utilization, fiveHour,
|
||||
snapshot.SevenDay?.Utilization, sevenDay);
|
||||
|
||||
ReportThrottleTransition(configured, effective, snapshot);
|
||||
return (configured, effective);
|
||||
|
||||
@@ -28,9 +28,15 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
|
||||
|
||||
if (Directory.Exists(_projectsRoot))
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(_projectsRoot, "*.jsonl", SearchOption.AllDirectories))
|
||||
// A transcript last written before the window began cannot hold a record inside it, so
|
||||
// it is skipped unread — that is what keeps a 7-day range off the full history (hundreds
|
||||
// of MB). One day of slack absorbs local-vs-UTC skew between mtime and record stamps.
|
||||
var mtimeCutoff = start.ToDateTime(TimeOnly.MinValue).AddDays(-1);
|
||||
|
||||
foreach (var file in new DirectoryInfo(_projectsRoot).EnumerateFiles("*.jsonl", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (file.LastWriteTime < mtimeCutoff) continue;
|
||||
|
||||
foreach (var record in GetOrReadFile(file))
|
||||
{
|
||||
@@ -67,8 +73,8 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
|
||||
if (string.IsNullOrWhiteSpace(sessionId) || !Directory.Exists(_projectsRoot))
|
||||
return Task.FromResult<SessionUsageTotals?>(null);
|
||||
|
||||
var file = Directory
|
||||
.EnumerateFiles(_projectsRoot, $"{sessionId}.jsonl", SearchOption.AllDirectories)
|
||||
var file = new DirectoryInfo(_projectsRoot)
|
||||
.EnumerateFiles($"{sessionId}.jsonl", SearchOption.AllDirectories)
|
||||
.FirstOrDefault();
|
||||
if (file is null) return Task.FromResult<SessionUsageTotals?>(null);
|
||||
|
||||
@@ -89,17 +95,16 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
|
||||
new SessionUsageTotals(input, output, cacheRead, cacheCreation));
|
||||
}
|
||||
|
||||
private List<UsageMessageRecord> GetOrReadFile(string file)
|
||||
private List<UsageMessageRecord> GetOrReadFile(FileInfo info)
|
||||
{
|
||||
var info = new FileInfo(file);
|
||||
if (_cache.TryGetValue(file, out var cached) &&
|
||||
if (_cache.TryGetValue(info.FullName, out var cached) &&
|
||||
cached.Length == info.Length && cached.LastWriteUtc == info.LastWriteTimeUtc)
|
||||
{
|
||||
return cached.Records;
|
||||
}
|
||||
|
||||
var records = ReadFile(file);
|
||||
_cache[file] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records);
|
||||
var records = ReadFile(info.FullName);
|
||||
_cache[info.FullName] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records);
|
||||
return records;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,13 +49,18 @@ public sealed class UsageSnapshotBuilder
|
||||
.Select(l => new UsageLimitDto(l.Kind, l.Group, l.Percent, l.Severity, l.ResetsAt, l.ScopeModelDisplayName, l.IsActive))
|
||||
.ToList();
|
||||
|
||||
var fiveHourThresholds = new UsageThresholds(
|
||||
settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct, settings.UsageGateFiveHourPct);
|
||||
var sevenDayThresholds = new UsageThresholds(
|
||||
settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct, settings.UsageGateSevenDayPct);
|
||||
|
||||
var configuredSlots = Math.Max(1, settings.MaxParallelExecutions);
|
||||
var effectiveSlots = snapshot is null || lastError is not null
|
||||
? configuredSlots
|
||||
: UsageThrottle.EffectiveSlots(
|
||||
configuredSlots, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization,
|
||||
settings.UsageThrottleSoftPct, settings.UsageThrottleHardPct,
|
||||
settings.UsageGateFiveHourPct, settings.UsageGateSevenDayPct);
|
||||
configuredSlots,
|
||||
snapshot.FiveHour?.Utilization, fiveHourThresholds,
|
||||
snapshot.SevenDay?.Utilization, sevenDayThresholds);
|
||||
var throttleBucket = effectiveSlots < configuredSlots
|
||||
? DecisiveBucket(snapshot?.FiveHour?.Utilization, snapshot?.SevenDay?.Utilization)
|
||||
: null;
|
||||
@@ -75,7 +80,11 @@ public sealed class UsageSnapshotBuilder
|
||||
lastError,
|
||||
configuredSlots,
|
||||
effectiveSlots,
|
||||
throttleBucket);
|
||||
throttleBucket,
|
||||
fiveHourThresholds.SoftPct,
|
||||
fiveHourThresholds.HardPct,
|
||||
sevenDayThresholds.SoftPct,
|
||||
sevenDayThresholds.HardPct);
|
||||
}
|
||||
|
||||
private static string? DecisiveBucket(double? fiveHourPct, double? sevenDayPct)
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
namespace ClaudeDo.Worker.Usage;
|
||||
|
||||
/// <summary>
|
||||
/// Pure staged throttle sitting ahead of <see cref="UsageGate"/>'s hard pause: as the 5h/7d usage
|
||||
/// window fills up, the queue's effective parallelism steps down before it hits zero, instead of
|
||||
/// running at full tilt right up to the gate threshold. Whichever of the two buckets is more
|
||||
/// utilized decides the stage. A missing bucket (null) is treated as 0% for that bucket only —
|
||||
/// callers with no snapshot at all should skip this and use <paramref name="configuredSlots"/>
|
||||
/// directly (fail-open), same policy as <see cref="UsageGate"/>.
|
||||
/// The soft/hard/gate percentages of a single usage bucket. Soft caps parallelism at 2 slots, hard
|
||||
/// at 1, gate blocks the queue refill entirely. A threshold of 0 disables that stage.
|
||||
/// </summary>
|
||||
public readonly record struct UsageThresholds(int SoftPct, int HardPct, int GatePct);
|
||||
|
||||
/// <summary>
|
||||
/// Pure staged throttle sitting ahead of <see cref="UsageGate"/>'s hard pause: as a usage window
|
||||
/// fills up, the queue's effective parallelism steps down before it hits zero, instead of running
|
||||
/// at full tilt right up to the gate threshold. Each bucket carries its own thresholds (the 5h and
|
||||
/// 7d windows fill at very different rates) and the strictest bucket decides. A missing bucket
|
||||
/// (null) never throttles — callers with no snapshot at all should skip this and use
|
||||
/// <paramref name="configuredSlots"/> directly (fail-open), same policy as <see cref="UsageGate"/>.
|
||||
/// </summary>
|
||||
public static class UsageThrottle
|
||||
{
|
||||
public static int EffectiveSlots(
|
||||
int configuredSlots,
|
||||
double? fiveHourPct,
|
||||
UsageThresholds fiveHour,
|
||||
double? sevenDayPct,
|
||||
int softPct,
|
||||
int hardPct,
|
||||
int gateFiveHourPct,
|
||||
int gateSevenDayPct)
|
||||
UsageThresholds sevenDay)
|
||||
{
|
||||
var slots = Math.Max(1, configuredSlots);
|
||||
|
||||
if (gateFiveHourPct > 0 && fiveHourPct is { } five && five >= gateFiveHourPct)
|
||||
return 0;
|
||||
if (gateSevenDayPct > 0 && sevenDayPct is { } seven && seven >= gateSevenDayPct)
|
||||
return 0;
|
||||
return Math.Min(
|
||||
BucketSlots(slots, fiveHourPct, fiveHour),
|
||||
BucketSlots(slots, sevenDayPct, sevenDay));
|
||||
}
|
||||
|
||||
var worst = Math.Max(fiveHourPct ?? 0, sevenDayPct ?? 0);
|
||||
private static int BucketSlots(int slots, double? pct, UsageThresholds thresholds)
|
||||
{
|
||||
if (pct is not { } utilization) return slots;
|
||||
|
||||
if (hardPct > 0 && worst >= hardPct)
|
||||
return Math.Min(slots, 1);
|
||||
if (softPct > 0 && worst >= softPct)
|
||||
return Math.Min(slots, 2);
|
||||
if (thresholds.GatePct > 0 && utilization >= thresholds.GatePct) return 0;
|
||||
if (thresholds.HardPct > 0 && utilization >= thresholds.HardPct) return Math.Min(slots, 1);
|
||||
if (thresholds.SoftPct > 0 && utilization >= thresholds.SoftPct) return Math.Min(slots, 2);
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -33,7 +33,16 @@ public class UsageMonitorModalViewModelTests
|
||||
public int RefreshCalls;
|
||||
public Exception? RefreshThrows;
|
||||
|
||||
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult(Snapshot);
|
||||
/// <summary>When set, the snapshot fetch never completes — stands in for the slow first
|
||||
/// transcript scan on the worker side.</summary>
|
||||
public TaskCompletionSource<UsageSnapshotDto?>? SnapshotGate;
|
||||
public Exception? SnapshotThrows;
|
||||
|
||||
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
|
||||
{
|
||||
if (SnapshotThrows is not null) throw SnapshotThrows;
|
||||
return SnapshotGate?.Task ?? Task.FromResult(Snapshot);
|
||||
}
|
||||
|
||||
public override Task<UsageSnapshotDto?> RefreshUsageAsync()
|
||||
{
|
||||
@@ -53,8 +62,25 @@ public class UsageMonitorModalViewModelTests
|
||||
TaskUsageCalls++;
|
||||
return Task.FromResult(TaskRows);
|
||||
}
|
||||
|
||||
public AppSettingsDto? AppSettings;
|
||||
public AppSettingsDto? SavedSettings;
|
||||
|
||||
public override Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult(AppSettings);
|
||||
|
||||
public override Task UpdateAppSettingsAsync(AppSettingsDto dto)
|
||||
{
|
||||
SavedSettings = dto;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static AppSettingsDto AppSettings() =>
|
||||
new(DefaultClaudeInstructions: "", DefaultModel: "sonnet", DefaultMaxTurns: 30,
|
||||
DefaultPermissionMode: "auto", MaxParallelExecutions: 3, WorktreeStrategy: "sibling",
|
||||
CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 7,
|
||||
ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5);
|
||||
|
||||
private static UsageLimitDto Limit(
|
||||
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<UsageLimitDto>(),
|
||||
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<UsageSnapshotDto?>() };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
|
||||
vm.BeginLoad();
|
||||
|
||||
Assert.True(vm.IsBusy);
|
||||
Assert.False(vm.ModelsEmpty);
|
||||
Assert.False(vm.TasksEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginLoad_WorkerThrows_ReportsErrorInsteadOfCrashing()
|
||||
{
|
||||
var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
string? reported = null;
|
||||
vm.ErrorReported += m => reported = m;
|
||||
|
||||
vm.BeginLoad();
|
||||
|
||||
Assert.NotNull(reported);
|
||||
Assert.Contains("worker offline", reported);
|
||||
Assert.False(vm.IsBusy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_WorkerThrows_ReportsErrorAndClearsBusy()
|
||||
{
|
||||
var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
string? reported = null;
|
||||
vm.ErrorReported += m => reported = m;
|
||||
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.NotNull(reported);
|
||||
Assert.False(vm.IsBusy);
|
||||
}
|
||||
|
||||
// ── Manual refresh ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<UsageLimitRow>(), DateTime.UtcNow));
|
||||
|
||||
var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync();
|
||||
|
||||
Assert.Equal(45, dto.ThrottleFiveHourSoftPct);
|
||||
Assert.Equal(60, dto.ThrottleFiveHourHardPct);
|
||||
Assert.Equal(70, dto.ThrottleSevenDaySoftPct);
|
||||
Assert.Equal(85, dto.ThrottleSevenDayHardPct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Per_bucket_stages_apply_independently_to_effective_slots()
|
||||
{
|
||||
await SetThresholdsAsync(80, 90);
|
||||
// Both buckets sit at 60%: past the 5h soft stage (45) but below every 7d stage (70/85).
|
||||
await SetPerBucketThrottleAsync(maxParallel: 3, fiveSoft: 45, fiveHard: 90, sevenSoft: 70, sevenHard: 85);
|
||||
|
||||
var state = new UsageState();
|
||||
state.ReportSuccess(new UsageSnapshot(
|
||||
new UsageBucket(60, null), new UsageBucket(60, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
|
||||
|
||||
var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync();
|
||||
|
||||
Assert.Equal(2, dto.EffectiveSlots);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Throttled_slots_and_decisive_bucket_reported()
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user