diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 994280d3..1003e966 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -13,7 +13,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio - **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes` - **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → table `week_reports`, unique index on (start_date, end_date) - **TaskAttachmentEntity** — Id, TaskId (FK to tasks, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → table `task_attachments` -- **AppSettingsEntity** also carries `ReportExcludedPaths` (string?, JSON array of excluded path prefixes, column `report_excluded_paths`), `StandupWeekday` (int DayOfWeek, default Wednesday, column `standup_weekday`), and `DailyPrepMaxTasks` (int, default 5, column `daily_prep_max_tasks` — hard cap on how many open tasks the daily-prep / "Prime Claude" feature may place in MyDay) , `ModelPresets` (string?, JSON array of `ModelPreset` rows, column `model_presets`), and `UsageGateFiveHourPct` / `UsageGateSevenDayPct` (int, defaults 80/90, columns `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` — percentage of the 5h/7d Claude usage window at which the autonomous queue pauses; `0` = gate off; `AppSettingsRepository.UpdateAsync` clamps both to 0..100) +- **AppSettingsEntity** also carries `ReportExcludedPaths` (string?, JSON array of excluded path prefixes, column `report_excluded_paths`), `StandupWeekday` (int DayOfWeek, default Wednesday, column `standup_weekday`), and `DailyPrepMaxTasks` (int, default 5, column `daily_prep_max_tasks` — hard cap on how many open tasks the daily-prep / "Prime Claude" feature may place in MyDay) , `ModelPresets` (string?, JSON array of `ModelPreset` rows, column `model_presets`), `UsageGateFiveHourPct` / `UsageGateSevenDayPct` (int, defaults 80/90, columns `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` — percentage of the 5h/7d Claude usage window at which the autonomous queue pauses; `0` = gate off; `AppSettingsRepository.UpdateAsync` clamps both to 0..100), and `UsageThrottleSoftPct` / `UsageThrottleHardPct` (int, defaults 50/65, columns `usage_throttle_soft_pct` / `usage_throttle_hard_pct` — intermediate staging thresholds below the hard gate above, at which the queue's effective parallelism steps down to 2 then 1 slot; see `Worker/CLAUDE.md` → `UsageThrottle`; `0` = that stage off; also clamped 0..100) - **ModelPresets** / **ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`): one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1–200) and never throw — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns = 30)` always returns a usable row: `model` is resolved through `ModelRegistry.TryNormalizeAlias` first, so a full CLI model id (e.g. `claude-sonnet-4-6`) still hits its alias's preset row instead of missing every lookup and falling through; only a model that normalizes to nothing recognized falls back to a synthesized row (`EffortRegistry.DefaultLevel` + `fallbackMaxTurns` — callers pass `AppSettings.DefaultMaxTurns` here so that setting has a real effect instead of a hardcoded number). Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25. - **ModelRegistry.TryNormalizeAlias** — non-throwing counterpart to `NormalizeAlias` for the run path: exact alias match, then substring match against a full model id, else `null`. Never throws, unlike `NormalizeAlias` (which stays the strict, throwing validator for `add_task`/planning model input). - **EffortRegistry** — the `--effort` levels (`low|medium|high|xhigh|max`) + `NormalizeLevel` (blank → null = don't pass the flag) @@ -45,7 +45,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q ## Schema -Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. Migration `AddWorktreeMergeCommit` added the nullable `worktrees.merge_commit` column. Migration `AddVerifyCommand` added the nullable `list_config.verify_command` column. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store). +Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. Migration `AddWorktreeMergeCommit` added the nullable `worktrees.merge_commit` column. Migration `AddVerifyCommand` added the nullable `list_config.verify_command` column. Migration `AddUsageThrottleThresholds` added `app_settings.usage_throttle_soft_pct`/`usage_throttle_hard_pct` (defaults 50/65). `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store). ## Conventions diff --git a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs index 9eae923e..5df561d8 100644 --- a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs @@ -52,6 +52,11 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration s.UsageGateSevenDayPct) .HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90); + builder.Property(s => s.UsageThrottleSoftPct) + .HasColumnName("usage_throttle_soft_pct").IsRequired().HasDefaultValue(50); + builder.Property(s => s.UsageThrottleHardPct) + .HasColumnName("usage_throttle_hard_pct").IsRequired().HasDefaultValue(65); + builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); } } diff --git a/src/ClaudeDo.Data/Migrations/20260805133429_AddUsageThrottleThresholds.Designer.cs b/src/ClaudeDo.Data/Migrations/20260805133429_AddUsageThrottleThresholds.Designer.cs new file mode 100644 index 00000000..b0eeea84 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260805133429_AddUsageThrottleThresholds.Designer.cs @@ -0,0 +1,850 @@ +// +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("20260805133429_AddUsageThrottleThresholds")] + partial class AddUsageThrottleThresholds + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("UsageThrottleHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_hard_pct"); + + b.Property("UsageThrottleSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_soft_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 100, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + UsageThrottleHardPct = 65, + UsageThrottleSoftPct = 50, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260805133429_AddUsageThrottleThresholds.cs b/src/ClaudeDo.Data/Migrations/20260805133429_AddUsageThrottleThresholds.cs new file mode 100644 index 00000000..96b819b5 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260805133429_AddUsageThrottleThresholds.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddUsageThrottleThresholds : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "usage_throttle_hard_pct", + table: "app_settings", + type: "INTEGER", + nullable: false, + defaultValue: 65); + + migrationBuilder.AddColumn( + name: "usage_throttle_soft_pct", + table: "app_settings", + type: "INTEGER", + nullable: false, + defaultValue: 50); + + migrationBuilder.UpdateData( + table: "app_settings", + keyColumn: "id", + keyValue: 1, + columns: new[] { "usage_throttle_hard_pct", "usage_throttle_soft_pct" }, + values: new object[] { 65, 50 }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "usage_throttle_hard_pct", + table: "app_settings"); + + migrationBuilder.DropColumn( + name: "usage_throttle_soft_pct", + table: "app_settings"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 331b471f..5fa45d0d 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -100,6 +100,18 @@ namespace ClaudeDo.Data.Migrations .HasDefaultValue(90) .HasColumnName("usage_gate_seven_day_pct"); + b.Property("UsageThrottleHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_hard_pct"); + + b.Property("UsageThrottleSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_soft_pct"); + b.Property("WorktreeAutoCleanupDays") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") @@ -136,6 +148,8 @@ namespace ClaudeDo.Data.Migrations StandupWeekday = 3, UsageGateFiveHourPct = 80, UsageGateSevenDayPct = 90, + UsageThrottleHardPct = 65, + UsageThrottleSoftPct = 50, WorktreeAutoCleanupDays = 7, WorktreeAutoCleanupEnabled = false, WorktreeStrategy = "sibling" diff --git a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs index f0b8d833..664e08f8 100644 --- a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs +++ b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs @@ -38,4 +38,9 @@ public sealed class AppSettingsEntity // Percentage of the 5h/7d Claude usage window at which the autonomous queue pauses. 0 = gate off. 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; } diff --git a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs index e42bf61f..f931a429 100644 --- a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs +++ b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs @@ -67,6 +67,8 @@ 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); await _context.SaveChangesAsync(ct); } diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 7a8bfdec..dd594ac6 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -430,6 +430,7 @@ "staleFormat": "Werte veraltet (Stand {0})", "staleGateHint": "Das Gate greift in diesem Zustand nicht.", "gateBlockedFormat": "Queue pausiert — {0}", + "throttleFormat": "Queue gedrosselt: {0}/{1} Slots ({2})", "resetIn": "Reset in {0}", "gaugeSession": "Session (5 Std.)", "gaugeWeeklyAll": "Woche (alle Modelle)", @@ -615,7 +616,8 @@ "durationMinutes": "{0} m", "blockedReason": "Blockiert: {0}", "stale": "veraltet (Stand {0})", - "lastError": "Letzter Fehler: {0}" + "lastError": "Letzter Fehler: {0}", + "throttled": "Gedrosselt: {0}/{1} Slots ({2})" } } } diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 3df5d58c..738194c6 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -430,6 +430,7 @@ "staleFormat": "Values stale (as of {0})", "staleGateHint": "The gate does not apply while values are stale.", "gateBlockedFormat": "Queue paused — {0}", + "throttleFormat": "Queue throttled: {0}/{1} slots ({2})", "resetIn": "Reset in {0}", "gaugeSession": "Session (5h)", "gaugeWeeklyAll": "Week (all models)", @@ -615,7 +616,8 @@ "durationMinutes": "{0} m", "blockedReason": "Blocked: {0}", "stale": "stale (as of {0})", - "lastError": "Last error: {0}" + "lastError": "Last error: {0}", + "throttled": "Throttled: {0}/{1} slots ({2})" } } } diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index adc0bc72..6a20e622 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -39,7 +39,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle - **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards) — the ROADBLOCK card also has a reply field (`RoadblockReplyDraft`/`SendRoadblockReplyCommand`, gated by `CanReplyToRoadblock` on `LatestRunSessionId`) that resumes the session via the same `ContinueTaskAsync` transport as `ContinueCommand` but with the user's own text instead of the fixed re-run prompt; failures raise `ErrorReported`, wired by the shell into `FlashFooterError`, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file. - **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping it jumps to that Mission Control pane); list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`). - **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`. -- **UsagePillViewModel** — one shared instance backs the `UsagePill` control hosted in both the footer and the Mission Control header; loads via `GetUsageSnapshotAsync` and updates live off `IWorkerClient.UsageUpdatedEvent`; derives display text, tooltip, and dot state (normal/warn/stale/blocked, mutually exclusive priority blocked > stale > warn > normal) from the shared `UsageSnapshotDto`. +- **UsagePillViewModel** — one shared instance backs the `UsagePill` control hosted in both the footer and the Mission Control header; loads via `GetUsageSnapshotAsync` and updates live off `IWorkerClient.UsageUpdatedEvent`; derives display text, tooltip, and dot state (normal/warn/stale/blocked, mutually exclusive priority blocked > stale > warn > normal) from the shared `UsageSnapshotDto`. `IsThrottled` (effective slots below configured, and not gate-blocked) adds a tooltip line naming the effective/configured slot count and the decisive bucket (`ThrottleBucket` on the DTO — `"five_hour"`/`"seven_day"`). - **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows). General hosts the per-model preset table (`ModelPresets` → `ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field, `ListSettingsModalViewModel` (name, working dir, commit type, "manual list" flag, `VerifyCommand` (optional post-merge verify gate, own field/section — not part of `AgentConfigEditorViewModel`), delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync(verifyCommand)`, since both fields land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `MergeHelperSelectionModalViewModel` ("Let Claude handle it": checkbox picker over one list's non-terminal, non-manual tasks, pre-ticks the actionable ones; list-scoped only — `Configure(listId, listName)`, no global scope. Opened from the list row's context menu, which is hidden when the list has no working dir; on confirm `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell → `MissionControlViewModel.OpenMergeHelperConPtySessionAsync`, which first calls `IWorkerClient.CreateMergeHelperTaskAsync` to create one new ClaudeDo task per run in that list — `Idle`/`IsManual=true` (never queued), title/description localized (`missionControl.mergeHelperTaskTitle`/`mergeHelperTaskDescriptionHeader`), `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD — then opens a **task-based** ConPTY tile for it (deduped by `TaskId` like `OpenConPtySessionAsync`, not `CreateAdHoc`) running the five-phase handler prompt. The handler still merges the tasks it handles itself; the host task never gets a worktree of its own, so "Submit for review" stamps `HandlerHeadCommit` instead of committing a worktree, and the detail pane's `MergeSectionViewModel` falls back to `HandlerBaseCommit`/`HandlerHeadCommit` over the list's working dir for its diff — see `TaskEntity` in `ClaudeDo.Data/CLAUDE.md`), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`), `UsageMonitorModalViewModel` (opened from the usage pill; renders one gauge per row in `UsageSnapshotDto.Limits` — **dynamic**, since the fixed `seven_day_opus`/`seven_day_sonnet`-style buckets the raw Anthropic API can return are plan-dependent and come back `null` on plans that don't have them, so a fixed gauge layout would break; also shows model usage (`GetModelUsageAsync`, ClaudeDo-vs-Other split per model) and top-task usage (`GetTaskUsageAsync`) tables over a 7d/30d preset or custom date range). - **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders. - **Conflicts** — `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`). diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 9258cb6f..f026f5fc 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -714,7 +714,10 @@ public sealed record UsageSnapshotDto( string? GateReason, DateTime? FetchedAtUtc, bool IsStale, - string? LastError); + string? LastError, + int ConfiguredSlots, + int EffectiveSlots, + string? ThrottleBucket); public sealed record ModelUsageRowDto( DateOnly Date, diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs index 0aa03d77..1a11f8f1 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs @@ -28,6 +28,8 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase [NotifyPropertyChangedFor(nameof(StaleStampText))] [NotifyPropertyChangedFor(nameof(StaleBandText))] [NotifyPropertyChangedFor(nameof(GateBandText))] + [NotifyPropertyChangedFor(nameof(IsThrottled))] + [NotifyPropertyChangedFor(nameof(ThrottleBandText))] private UsageSnapshotDto? _snapshot; [ObservableProperty] private DateTime? _startDate; @@ -60,6 +62,18 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase public string StaleBandText => Loc.T("modals.usageMonitor.staleFormat", StaleStampText); public string GateBandText => GateReason is null ? "" : Loc.T("modals.usageMonitor.gateBlockedFormat", GateReason); + public bool IsThrottled => Snapshot is { } s && !IsGateBlocked && s.EffectiveSlots < s.ConfiguredSlots; + public string ThrottleBandText => Snapshot is not { } s || !IsThrottled + ? "" + : Loc.T("modals.usageMonitor.throttleFormat", s.EffectiveSlots, s.ConfiguredSlots, BucketLabel(s.ThrottleBucket)); + + private static string BucketLabel(string? bucket) => bucket switch + { + "five_hour" => Loc.T("usage.pill.fiveHourLabel"), + "seven_day" => Loc.T("usage.pill.sevenDayLabel"), + _ => "", + }; + [RelayCommand] private void Close() { diff --git a/src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs b/src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs index 6f963636..7e1d800d 100644 --- a/src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs @@ -40,6 +40,7 @@ public sealed partial class UsagePillViewModel : ViewModelBase OnPropertyChanged(nameof(IsWarn)); OnPropertyChanged(nameof(IsBlocked)); OnPropertyChanged(nameof(IsStale)); + OnPropertyChanged(nameof(IsThrottled)); OnPropertyChanged(nameof(Tooltip)); OnPropertyChanged(nameof(ShowNormalDot)); OnPropertyChanged(nameof(ShowWarnDot)); @@ -52,6 +53,8 @@ public sealed partial class UsagePillViewModel : ViewModelBase public bool IsStale => Snapshot?.IsStale == true; + public bool IsThrottled => Snapshot is { } s && !IsBlocked && s.EffectiveSlots < s.ConfiguredSlots; + public bool IsWarn => Snapshot is { } s && ((s.FiveHourPercent is { } five && five >= s.FiveHourThresholdPct - 10) || (s.SevenDayPercent is { } seven && seven >= s.SevenDayThresholdPct - 10)); @@ -95,6 +98,8 @@ public sealed partial class UsagePillViewModel : ViewModelBase lines.Add(Loc.T("usage.pill.resetIn", Loc.T("usage.pill.sevenDayLabel"), FormatRemaining(sevenReset))); if (s.IsGateBlocked && !string.IsNullOrEmpty(s.GateReason)) lines.Add(Loc.T("usage.pill.blockedReason", s.GateReason)); + else if (s.EffectiveSlots < s.ConfiguredSlots) + lines.Add(Loc.T("usage.pill.throttled", s.EffectiveSlots, s.ConfiguredSlots, BucketLabel(s.ThrottleBucket))); if (s.IsStale) { var stamp = s.FetchedAtUtc?.ToLocalTime().ToString("HH:mm") ?? "?"; @@ -106,6 +111,13 @@ public sealed partial class UsagePillViewModel : ViewModelBase return lines.Count > 0 ? string.Join(Environment.NewLine, lines) : Loc.T("usage.pill.empty"); } + private static string BucketLabel(string? bucket) => bucket switch + { + "five_hour" => Loc.T("usage.pill.fiveHourLabel"), + "seven_day" => Loc.T("usage.pill.sevenDayLabel"), + _ => "", + }; + private static string FormatRemaining(DateTimeOffset resetsAt) { var remaining = resetsAt - DateTimeOffset.UtcNow; diff --git a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml index 4d45ed63..cd8b52d6 100644 --- a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml @@ -52,6 +52,12 @@ BorderThickness="1" CornerRadius="6" Padding="12,8"> + + + diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 29169cf9..e8eff9c6 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -21,7 +21,7 @@ Worker/ Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/ Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster) Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (OIDC discovery + refresh-token flow), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider) - Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message, broadcasts `HubBroadcaster.UsageUpdated` after every tick via `UsageSnapshotBuilder`), UsageSnapshotBuilder (builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings` thresholds — the one place `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` share the stale/threshold/gate logic), TranscriptUsageReader (aggregates Claude Code transcript token usage from `~/.claude/projects/**/*.jsonl` by date/model/scope (ClaudeDo vs Other), deduped by requestId, with a per-file length+mtime cache), UsageGate (reads `UsageState` + `AppSettings.UsageGateFiveHourPct`/`UsageGateSevenDayPct`, returns a `UsageGateDecision(IsBlocked, Reason)`; `Utilization` from `UsageBucket` is already a 0–100 percent, compared directly against the threshold with `>=`; threshold `0` = that bucket never gates; fail-open — no snapshot yet, a failed last poll, or a settings-read error all resolve to not-blocked); interfaces in Usage/Interfaces/ (IUsageClient, ITranscriptUsageReader, IUsageGate) + Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message, broadcasts `HubBroadcaster.UsageUpdated` after every tick via `UsageSnapshotBuilder`), UsageSnapshotBuilder (builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings` thresholds — the one place `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` share the stale/threshold/gate logic), TranscriptUsageReader (aggregates Claude Code transcript token usage from `~/.claude/projects/**/*.jsonl` by date/model/scope (ClaudeDo vs Other), deduped by requestId, with a per-file length+mtime cache), UsageGate (reads `UsageState` + `AppSettings.UsageGateFiveHourPct`/`UsageGateSevenDayPct`, returns a `UsageGateDecision(IsBlocked, Reason)`; `Utilization` from `UsageBucket` is already a 0–100 percent, compared directly against the threshold with `>=`; threshold `0` = that bucket never gates; fail-open — no snapshot yet, a failed last poll, or a settings-read error all resolve to not-blocked), UsageThrottle (pure static `EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct, gateFiveHourPct, gateSevenDayPct)` — stages parallelism down ahead of the hard gate: whichever bucket is more utilized decides the stage, `0` for a threshold disables that stage, `>= softPct` caps at 2 slots, `>= hardPct` caps at 1, `>= either gate threshold` returns 0 — same 0 as `UsageGate`'s hard block, kept in sync since both read the same gate thresholds); interfaces in Usage/Interfaces/ (IUsageClient, ITranscriptUsageReader, IUsageGate) ``` Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace. @@ -30,7 +30,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an ` - **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821` - **TaskStateService** — only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions. -- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`. On each loop tick, after computing `maxParallel`, it also asks `IUsageGate.EvaluateAsync` — if blocked, the slot-fill loop is skipped entirely for that tick (already-running slots are untouched; `RunNow`/`ContinueTask`/interactive/planning/daily-prep all bypass the queue and are unaffected). A blocked↔free transition is logged/broadcast (`WorkerLog`, Warn on block / Info on resume) exactly once per change, not on every tick; the 30 s backstop timer re-evaluates the gate on its own even with no wake signal, so the queue self-recovers once usage drops back under the threshold. +- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`. On each loop tick, `GetEffectiveMaxParallelAsync` reads `AppSettings.MaxParallelExecutions` and steps it down via `UsageThrottle.EffectiveSlots` against the current `UsageState` snapshot (a missing/failed snapshot fails open to the configured value — never throttles on a broken poll); a stage change (not every tick) logs once via the standard logger. Separately, it also asks `IUsageGate.EvaluateAsync` — if blocked, the slot-fill loop is skipped entirely for that tick (already-running slots are untouched in either case; `RunNow`/`ContinueTask`/interactive/planning/daily-prep all bypass the queue and are unaffected). A blocked↔free transition is logged/broadcast (`WorkerLog`, Warn on block / Info on resume) exactly once per change, not on every tick; the 30 s backstop timer re-evaluates both the throttle and the gate on its own even with no wake signal, so the queue self-recovers once usage drops back under the threshold. - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. - **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." **No external tool returns bare `Task` or a nullable payload directly** — an MCP client can't tell an empty/omitted response apart from a dropped one. Write tools return a small confirmation record (`{ ok/deleted/removed/reset/started: true, , ... }`, e.g. `DeleteListResult`, `RunTaskNowResult`, `ResetFailedTaskResult`, `RemoveAttachmentResult`; `SetListConfigResult`/`SetTaskConfigResult` additionally echo the resulting config so the caller can see which fields were set vs. cleared to null); read tools that may have nothing to return use an explicit `Found`/`Available` flag alongside the nullable payload (`TaskConfigResult`, pre-existing `BatchGetTaskResult`, `TaskLogResult`) instead of returning null outright. Organized by concern: @@ -79,6 +79,18 @@ recovery is just the queue's 30s backstop timer re-evaluating the gate on its ow drops back under the threshold. See `Usage/` in the folder layout above for the component breakdown. +Ahead of that hard gate, `UsageThrottle` steps the queue's effective parallelism down in two +stages (thresholds `usage_throttle_soft_pct`/`usage_throttle_hard_pct`, defaults 50/65): +whichever of 5h/7d is more utilized decides the stage — below soft = full configured +`max_parallel_executions`, at/above soft = capped to 2 slots, at/above hard = capped to 1, +at/above either gate threshold = 0 (the pre-existing hard pause, unchanged). Only *new* slot +fills are affected; a run already occupying a slot when the stage tightens keeps running to +completion. Same fail-open policy as the gate — no snapshot yet means no throttling, full +configured parallelism. The effective stage (configured vs. effective slots, decisive bucket) +rides along on `UsageSnapshotDto`/`GetUsageSnapshot` for UI display (`UsagePillViewModel` +tooltip, `UsageMonitorModalViewModel`'s throttle band) — it does not change what the gate +itself gates on. + ## Status Model `TaskEntity` carries three orthogonal fields. Lifecycle, planning hierarchy, and chain blocking are no longer conflated. diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 59ede361..d73c9f43 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -128,7 +128,10 @@ public record UsageSnapshotDto( string? GateReason, DateTime? FetchedAtUtc, bool IsStale, - string? LastError); + string? LastError, + int ConfiguredSlots, + int EffectiveSlots, + string? ThrottleBucket); public record ModelUsageRowDto( DateOnly Date, diff --git a/src/ClaudeDo.Worker/Queue/QueueService.cs b/src/ClaudeDo.Worker/Queue/QueueService.cs index ed63bc8e..64ba509f 100644 --- a/src/ClaudeDo.Worker/Queue/QueueService.cs +++ b/src/ClaudeDo.Worker/Queue/QueueService.cs @@ -30,6 +30,7 @@ public sealed class QueueService : BackgroundService private readonly object _lock = new(); private readonly Dictionary _queueSlots = new(); private bool _usageGateBlocked; + private int? _lastEffectiveSlots; public QueueService( IDbContextFactory dbFactory, @@ -124,7 +125,7 @@ public sealed class QueueService : BackgroundService await Task.WhenAny(wakeTask, timerTask); - var maxParallel = await GetMaxParallelAsync(stoppingToken); + var maxParallel = await GetEffectiveMaxParallelAsync(stoppingToken); var gateDecision = await _usageGate.EvaluateAsync(stoppingToken); await ReportUsageGateTransitionAsync(gateDecision); @@ -198,19 +199,64 @@ public sealed class QueueService : BackgroundService } } - private async Task GetMaxParallelAsync(CancellationToken ct) + /// + /// Configured parallelism, stepped down by ahead of the hard usage + /// gate. A missing snapshot (poll hasn't landed / endpoint unreachable) fails open to the + /// configured value — a broken usage poll must never stall the queue. + /// + private async Task GetEffectiveMaxParallelAsync(CancellationToken ct) { + int configured; + int softPct, hardPct, gateFivePct, gateSevenPct; try { using var context = _dbFactory.CreateDbContext(); var settings = await new AppSettingsRepository(context).GetAsync(ct); - return Math.Max(1, settings.MaxParallelExecutions); + configured = Math.Max(1, settings.MaxParallelExecutions); + softPct = settings.UsageThrottleSoftPct; + hardPct = settings.UsageThrottleHardPct; + gateFivePct = settings.UsageGateFiveHourPct; + gateSevenPct = settings.UsageGateSevenDayPct; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to read max parallel executions; defaulting to 1"); return 1; } + + var snapshot = _usageState.Snapshot; + if (snapshot is null || _usageState.LastError is not null) + { + _lastEffectiveSlots = configured; + return configured; + } + + var effective = UsageThrottle.EffectiveSlots( + configured, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization, + softPct, hardPct, gateFivePct, gateSevenPct); + + ReportThrottleTransition(configured, effective, snapshot); + return effective; + } + + private void ReportThrottleTransition(int configured, int effective, UsageSnapshot snapshot) + { + if (_lastEffectiveSlots == effective) return; + var previous = _lastEffectiveSlots; + _lastEffectiveSlots = effective; + + if (previous is null && effective == configured) return; // baseline, nothing to report + + if (effective < configured) + { + _logger.LogInformation( + "QueueService: usage throttle stepped to {Effective}/{Configured} slots (5h={FiveHour}%, 7d={SevenDay}%)", + effective, configured, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization); + } + else + { + _logger.LogInformation("QueueService: usage throttle cleared, back to {Configured} slots", configured); + } } private async Task RunInSlotAsync(string taskId, CancellationToken ct) diff --git a/src/ClaudeDo.Worker/Usage/UsageSnapshotBuilder.cs b/src/ClaudeDo.Worker/Usage/UsageSnapshotBuilder.cs index efd6535a..10694b33 100644 --- a/src/ClaudeDo.Worker/Usage/UsageSnapshotBuilder.cs +++ b/src/ClaudeDo.Worker/Usage/UsageSnapshotBuilder.cs @@ -46,6 +46,17 @@ public sealed class UsageSnapshotBuilder .Select(l => new UsageLimitDto(l.Kind, l.Group, l.Percent, l.Severity, l.ResetsAt, l.ScopeModelDisplayName, l.IsActive)) .ToList(); + var configuredSlots = Math.Max(1, settings.MaxParallelExecutions); + var effectiveSlots = snapshot is null || lastError is not null + ? configuredSlots + : UsageThrottle.EffectiveSlots( + configuredSlots, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization, + settings.UsageThrottleSoftPct, settings.UsageThrottleHardPct, + settings.UsageGateFiveHourPct, settings.UsageGateSevenDayPct); + var throttleBucket = effectiveSlots < configuredSlots + ? DecisiveBucket(snapshot?.FiveHour?.Utilization, snapshot?.SevenDay?.Utilization) + : null; + return new UsageSnapshotDto( snapshot?.FiveHour?.Utilization, snapshot?.FiveHour?.ResetsAt, @@ -58,6 +69,15 @@ public sealed class UsageSnapshotBuilder decision.Reason, snapshot?.FetchedAtUtc, isStale, - lastError); + lastError, + configuredSlots, + effectiveSlots, + throttleBucket); + } + + private static string? DecisiveBucket(double? fiveHourPct, double? sevenDayPct) + { + if (fiveHourPct is null && sevenDayPct is null) return null; + return (fiveHourPct ?? 0) >= (sevenDayPct ?? 0) ? "five_hour" : "seven_day"; } } diff --git a/src/ClaudeDo.Worker/Usage/UsageThrottle.cs b/src/ClaudeDo.Worker/Usage/UsageThrottle.cs new file mode 100644 index 00000000..da8d349b --- /dev/null +++ b/src/ClaudeDo.Worker/Usage/UsageThrottle.cs @@ -0,0 +1,38 @@ +namespace ClaudeDo.Worker.Usage; + +/// +/// Pure staged throttle sitting ahead of 's hard pause: as the 5h/7d usage +/// window fills up, the queue's effective parallelism steps down before it hits zero, instead of +/// running at full tilt right up to the gate threshold. Whichever of the two buckets is more +/// utilized decides the stage. A missing bucket (null) is treated as 0% for that bucket only — +/// callers with no snapshot at all should skip this and use +/// directly (fail-open), same policy as . +/// +public static class UsageThrottle +{ + public static int EffectiveSlots( + int configuredSlots, + double? fiveHourPct, + double? sevenDayPct, + int softPct, + int hardPct, + int gateFiveHourPct, + int gateSevenDayPct) + { + var slots = Math.Max(1, configuredSlots); + + if (gateFiveHourPct > 0 && fiveHourPct is { } five && five >= gateFiveHourPct) + return 0; + if (gateSevenDayPct > 0 && sevenDayPct is { } seven && seven >= gateSevenDayPct) + return 0; + + var worst = Math.Max(fiveHourPct ?? 0, sevenDayPct ?? 0); + + if (hardPct > 0 && worst >= hardPct) + return Math.Min(slots, 1); + if (softPct > 0 && worst >= softPct) + return Math.Min(slots, 2); + + return slots; + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs index 8dbadab8..f986d49e 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs @@ -57,12 +57,16 @@ public class UsageMonitorModalViewModelTests string? gateReason = null, bool isStale = false, string? lastError = null, - DateTime? fetchedAtUtc = null) + DateTime? fetchedAtUtc = null, + int configuredSlots = 1, + int effectiveSlots = 1, + string? throttleBucket = null) => new( null, null, null, null, limits ?? Array.Empty(), fiveHourThresholdPct, sevenDayThresholdPct, - isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError); + isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError, + configuredSlots, effectiveSlots, throttleBucket); // ── Gauge label derivation ────────────────────────────────────────────── diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/UsagePillViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/UsagePillViewModelTests.cs index e5bffe5e..8e098ebb 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/UsagePillViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/UsagePillViewModelTests.cs @@ -39,11 +39,15 @@ public class UsagePillViewModelTests string? gateReason = null, DateTime? fetchedAtUtc = null, bool isStale = false, - string? lastError = null) + string? lastError = null, + int configuredSlots = 1, + int effectiveSlots = 1, + string? throttleBucket = null) => new( fiveHourPercent, fiveHourResetsAt, sevenDayPercent, sevenDayResetsAt, Array.Empty(), fiveHourThresholdPct, sevenDayThresholdPct, - isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError); + isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError, + configuredSlots, effectiveSlots, throttleBucket); // ── Text formatting ───────────────────────────────────────────────────── diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs index 5430a721..0fff73b0 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs @@ -50,7 +50,8 @@ public sealed class QueueServiceTests : IDisposable private (QueueService service, FakeClaudeProcess fakeProcess) CreateService( Func, Func, CancellationToken, Task>? handler = null, - FakeUsageGate? usageGate = null) + FakeUsageGate? usageGate = null, + UsageState? usageState = null) { var fake = new FakeClaudeProcess(handler); _hubContext = new CapturingHubContext(); @@ -67,10 +68,24 @@ public sealed class QueueServiceTests : IDisposable var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, built.RunCancels); _usageGate = usageGate ?? new FakeUsageGate(); var service = new QueueService(dbFactory, runner, _cfg, NullLogger.Instance, _waker, picker, overrideSlot, state, built.RunCancels, - _usageGate, new UsageState(), broadcaster); + _usageGate, usageState ?? new UsageState(), broadcaster); return (service, fake); } + private async Task SetAppSettingsAsync( + int maxParallel, int softPct = 50, int hardPct = 65, int gateFive = 80, int gateSeven = 90) + { + using var ctx = _db.CreateContext(); + var repo = new AppSettingsRepository(ctx); + var settings = await repo.GetAsync(); + settings.MaxParallelExecutions = maxParallel; + settings.UsageThrottleSoftPct = softPct; + settings.UsageThrottleHardPct = hardPct; + settings.UsageGateFiveHourPct = gateFive; + settings.UsageGateSevenDayPct = gateSeven; + await repo.UpdateAsync(settings); + } + private async Task SeedListAsync() { var listId = Guid.NewGuid().ToString(); @@ -452,4 +467,154 @@ public sealed class QueueServiceTests : IDisposable .Count(c => c.Method == "WorkerLog" && (WorkerLogLevel)c.Args[1]! == WorkerLogLevel.Warn); Assert.Equal(1, warnCalls); } + + // Polls until `read()` reaches `expected` (or times out), then waits a further grace period + // to make sure the count doesn't keep climbing past it — needed because slot fills happen + // concurrently and a fixed sleep is either flaky (too short) or slow (too long). + private static async Task AssertStableCountAsync(Func read, int expected) + { + var deadline = DateTime.UtcNow.AddSeconds(5); + while (read() < expected && DateTime.UtcNow < deadline) + await Task.Delay(20); + + await Task.Delay(250); + Assert.Equal(expected, read()); + } + + [Fact] + public async Task Throttle_StepsDownEffectiveSlots_BelowConfiguredMax() + { + var listId = await SeedListAsync(); + await SeedQueuedTask(listId); + await SeedQueuedTask(listId); + await SeedQueuedTask(listId); + + await SetAppSettingsAsync(maxParallel: 3); + + var usageState = new UsageState(); + usageState.ReportSuccess(new UsageSnapshot( + new UsageBucket(60, null), new UsageBucket(0, null), Array.Empty(), DateTime.UtcNow)); + + var startedCount = 0; + var block = new TaskCompletionSource(); + var (service, _) = CreateService(async (_, _, _, _, _) => + { + Interlocked.Increment(ref startedCount); + await block.Task; + return new RunResult { ExitCode = 0, ResultMarkdown = "ok" }; + }, usageState: usageState); + + using var cts = new CancellationTokenSource(); + await service.StartAsync(cts.Token); + _waker.Wake(); + + // 60% is between the soft (50) and hard (65) thresholds — capped at 2 slots even + // though 3 are configured and 3 tasks are queued. + await AssertStableCountAsync(() => Volatile.Read(ref startedCount), 2); + + block.SetResult(); + cts.Cancel(); + } + + [Fact] + public async Task Throttle_AtHardThreshold_CapsToOneSlot() + { + var listId = await SeedListAsync(); + await SeedQueuedTask(listId); + await SeedQueuedTask(listId); + + await SetAppSettingsAsync(maxParallel: 3); + + var usageState = new UsageState(); + usageState.ReportSuccess(new UsageSnapshot( + new UsageBucket(70, null), new UsageBucket(0, null), Array.Empty(), DateTime.UtcNow)); + + var startedCount = 0; + var block = new TaskCompletionSource(); + var (service, _) = CreateService(async (_, _, _, _, _) => + { + Interlocked.Increment(ref startedCount); + await block.Task; + return new RunResult { ExitCode = 0, ResultMarkdown = "ok" }; + }, usageState: usageState); + + using var cts = new CancellationTokenSource(); + await service.StartAsync(cts.Token); + _waker.Wake(); + + await AssertStableCountAsync(() => Volatile.Read(ref startedCount), 1); + + block.SetResult(); + cts.Cancel(); + } + + [Fact] + public async Task NoUsageSnapshot_FallsBackToFullConfiguredParallelism() + { + var listId = await SeedListAsync(); + await SeedQueuedTask(listId); + await SeedQueuedTask(listId); + await SeedQueuedTask(listId); + + await SetAppSettingsAsync(maxParallel: 3); + + // No snapshot has landed yet (fresh UsageState) — throttle must fail open. + var startedCount = 0; + var block = new TaskCompletionSource(); + var (service, _) = CreateService(async (_, _, _, _, _) => + { + Interlocked.Increment(ref startedCount); + await block.Task; + return new RunResult { ExitCode = 0, ResultMarkdown = "ok" }; + }, usageState: new UsageState()); + + using var cts = new CancellationTokenSource(); + await service.StartAsync(cts.Token); + _waker.Wake(); + + await AssertStableCountAsync(() => Volatile.Read(ref startedCount), 3); + + block.SetResult(); + cts.Cancel(); + } + + [Fact] + public async Task Throttle_Engaging_Does_Not_Cancel_AlreadyRunning_Slot() + { + var listId = await SeedListAsync(); + await SeedQueuedTask(listId); + + await SetAppSettingsAsync(maxParallel: 3); + var usageState = new UsageState(); + + var running = new TaskCompletionSource(); + var cancelled = false; + var (service, _) = CreateService(async (_, _, _, _, ct) => + { + running.SetResult(); + try + { + await Task.Delay(Timeout.Infinite, ct); + } + catch (OperationCanceledException) + { + cancelled = true; + throw; + } + return new RunResult { ExitCode = 0, ResultMarkdown = "ok" }; + }, usageState: usageState); + + using var cts = new CancellationTokenSource(); + await service.StartAsync(cts.Token); + _waker.Wake(); + await running.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Throttle engages hard after the slot is already running — several backstop ticks pass. + usageState.ReportSuccess(new UsageSnapshot( + new UsageBucket(70, null), new UsageBucket(0, null), Array.Empty(), DateTime.UtcNow)); + await Task.Delay(200); + + Assert.False(cancelled); + cts.Cancel(); + } } diff --git a/tests/ClaudeDo.Worker.Tests/Usage/UsageSnapshotBuilderTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/UsageSnapshotBuilderTests.cs index 9b6aaf1e..476d6e30 100644 --- a/tests/ClaudeDo.Worker.Tests/Usage/UsageSnapshotBuilderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Usage/UsageSnapshotBuilderTests.cs @@ -142,4 +142,86 @@ public sealed class UsageSnapshotBuilderTests : IDisposable Assert.True(dto.IsStale); Assert.Equal("boom", dto.LastError); } + + private async Task SetThrottleAsync(int maxParallel, int softPct, int hardPct) + { + using var ctx = _db.CreateContext(); + var repo = new AppSettingsRepository(ctx); + var settings = await repo.GetAsync(); + settings.MaxParallelExecutions = maxParallel; + settings.UsageThrottleSoftPct = softPct; + settings.UsageThrottleHardPct = hardPct; + await repo.UpdateAsync(settings); + } + + [Fact] + public async Task Throttled_slots_and_decisive_bucket_reported() + { + await SetThresholdsAsync(80, 90); + await SetThrottleAsync(maxParallel: 3, softPct: 50, hardPct: 65); + + var state = new UsageState(); + state.ReportSuccess(new UsageSnapshot( + new UsageBucket(70, null), new UsageBucket(20, null), Array.Empty(), DateTime.UtcNow)); + + var builder = CreateBuilder(state, new UsageGateDecision(false, null)); + + var dto = await builder.BuildAsync(); + + Assert.Equal(3, dto.ConfiguredSlots); + Assert.Equal(1, dto.EffectiveSlots); + Assert.Equal("five_hour", dto.ThrottleBucket); + } + + [Fact] + public async Task SevenDay_decisive_bucket_reported_when_higher() + { + await SetThresholdsAsync(80, 90); + await SetThrottleAsync(maxParallel: 3, softPct: 50, hardPct: 65); + + var state = new UsageState(); + state.ReportSuccess(new UsageSnapshot( + new UsageBucket(10, null), new UsageBucket(70, null), Array.Empty(), DateTime.UtcNow)); + + var builder = CreateBuilder(state, new UsageGateDecision(false, null)); + + var dto = await builder.BuildAsync(); + + Assert.Equal("seven_day", dto.ThrottleBucket); + } + + [Fact] + public async Task Not_throttled_reports_null_bucket_and_equal_slots() + { + await SetThresholdsAsync(80, 90); + await SetThrottleAsync(maxParallel: 3, softPct: 50, hardPct: 65); + + var state = new UsageState(); + state.ReportSuccess(new UsageSnapshot( + new UsageBucket(10, null), new UsageBucket(20, null), Array.Empty(), DateTime.UtcNow)); + + var builder = CreateBuilder(state, new UsageGateDecision(false, null)); + + var dto = await builder.BuildAsync(); + + Assert.Equal(3, dto.ConfiguredSlots); + Assert.Equal(3, dto.EffectiveSlots); + Assert.Null(dto.ThrottleBucket); + } + + [Fact] + public async Task No_snapshot_falls_back_to_configured_slots_without_throttling() + { + await SetThresholdsAsync(80, 90); + await SetThrottleAsync(maxParallel: 3, softPct: 50, hardPct: 65); + + var state = new UsageState(); + var builder = CreateBuilder(state, new UsageGateDecision(false, null)); + + var dto = await builder.BuildAsync(); + + Assert.Equal(3, dto.ConfiguredSlots); + Assert.Equal(3, dto.EffectiveSlots); + Assert.Null(dto.ThrottleBucket); + } } diff --git a/tests/ClaudeDo.Worker.Tests/Usage/UsageThrottleTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/UsageThrottleTests.cs new file mode 100644 index 00000000..fb82db1c --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Usage/UsageThrottleTests.cs @@ -0,0 +1,111 @@ +using ClaudeDo.Worker.Usage; + +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 int Effective(double? five, double? seven, int configured = 3) => + UsageThrottle.EffectiveSlots(configured, five, seven, Soft, Hard, GateFive, GateSeven); + + [Fact] + public void BelowSoftThreshold_ReturnsFullConfiguredSlots() + { + Assert.Equal(3, Effective(30, 40)); + } + + [Fact] + public void AtSoftThreshold_CapsAtTwo() + { + Assert.Equal(2, Effective(50, 10)); + } + + [Fact] + public void BetweenSoftAndHard_CapsAtTwo() + { + Assert.Equal(2, Effective(60, 0)); + } + + [Fact] + public void AtHardThreshold_CapsAtOne() + { + Assert.Equal(1, Effective(65, 0)); + } + + [Fact] + public void BetweenHardAndGate_CapsAtOne() + { + Assert.Equal(1, Effective(70, 0)); + } + + [Fact] + public void AtGateThreshold_ReturnsZero() + { + Assert.Equal(0, Effective(80, 0)); + } + + [Fact] + public void AboveGateThreshold_ReturnsZero() + { + Assert.Equal(0, Effective(95, 0)); + } + + [Fact] + public void FiveHourAloneDecisive_SevenDayLow() + { + Assert.Equal(1, Effective(70, 5)); + } + + [Fact] + public void SevenDayAloneDecisive_FiveHourLow() + { + Assert.Equal(1, Effective(5, 70)); + } + + [Fact] + public void SevenDayAloneAtGate_ReturnsZero() + { + Assert.Equal(0, Effective(10, 90)); + } + + [Fact] + public void FiveHourAloneAtGate_ReturnsZero() + { + Assert.Equal(0, Effective(80, 10)); + } + + [Fact] + public void ConfiguredSlotsSmallerThanCap_NeverIncreased() + { + // Soft tier caps at 2, but configured is only 1 — throttle never raises parallelism. + Assert.Equal(1, Effective(55, 0, configured: 1)); + } + + [Fact] + public void NoUtilizationAtAll_ReturnsFullConfiguredSlots() + { + Assert.Equal(3, Effective(null, null)); + } + + [Fact] + public void ZeroSoftAndHardThresholds_NeverThrottleBelowGate() + { + Assert.Equal(3, UsageThrottle.EffectiveSlots(3, 79, 89, softPct: 0, hardPct: 0, gateFiveHourPct: GateFive, gateSevenDayPct: GateSeven)); + } + + [Fact] + public void ZeroGateThresholds_NeverHardBlock() + { + Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 99, 99, softPct: Soft, hardPct: Hard, gateFiveHourPct: 0, gateSevenDayPct: 0)); + } + + [Fact] + public void ConfiguredSlotsBelowOne_ClampedToOneBeforeThrottling() + { + Assert.Equal(1, Effective(10, 10, configured: 0)); + } +}