From 08ac8bf7b1bc13fc2afa91aee313427166d2a985 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 5 Aug 2026 15:40:02 +0200 Subject: [PATCH] feat(worker): clamp max-turns to a configurable ceiling Runaway sessions were the single biggest cost driver: model_presets was never persisted (stayed code-only), default_max_turns shipped at 100, and ResolveMaxTurns had no upper bound, so a task/list override could run hundreds of turns unchecked. - TaskRunner.ResolveMaxTurns now clamps the resolved value to AppSettings.MaxTurnsCeiling (new column, default 80) and logs a warning with task id / requested / effective value when it clamps. - default_max_turns default lowered from 100 to 40 (entity, EF config, and the seeded row via the new AddMaxTurnsCeiling migration). - AppSettingsRepository.GetAsync backfills model_presets with the shipping defaults on first read instead of leaving the column null. - Settings > General's per-model preset table and the task/list agent editor now show a hint when a set max-turns value exceeds the ceiling. --- docs/open.md | 15 + src/ClaudeDo.Data/CLAUDE.md | 4 +- .../AppSettingsEntityConfiguration.cs | 5 +- ...60805132052_AddMaxTurnsCeiling.Designer.cs | 843 ++++++++++++++++++ .../20260805132052_AddMaxTurnsCeiling.cs | 63 ++ .../ClaudeDoDbContextModelSnapshot.cs | 11 +- src/ClaudeDo.Data/Models/AppSettingsEntity.cs | 6 +- .../Repositories/AppSettingsRepository.cs | 51 +- src/ClaudeDo.Localization/locales/de.json | 1 + src/ClaudeDo.Localization/locales/en.json | 1 + src/ClaudeDo.Ui/Services/WorkerClient.cs | 3 +- .../Agent/AgentConfigEditorViewModel.cs | 6 + .../Settings/GeneralSettingsTabViewModel.cs | 22 +- .../Modals/SettingsModalViewModel.cs | 6 +- .../Views/Controls/AgentConfigEditor.axaml | 3 + .../Views/Modals/SettingsModalView.axaml | 21 +- src/ClaudeDo.Worker/CLAUDE.md | 2 +- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 7 +- src/ClaudeDo.Worker/Runner/TaskRunner.cs | 15 +- .../AppSettingsRepositoryTests.cs | 38 +- .../Runner/MaxTurnsResolutionTests.cs | 14 +- .../Runner/ModelResolutionWireTests.cs | 6 +- 22 files changed, 1093 insertions(+), 50 deletions(-) create mode 100644 src/ClaudeDo.Data/Migrations/20260805132052_AddMaxTurnsCeiling.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260805132052_AddMaxTurnsCeiling.cs diff --git a/docs/open.md b/docs/open.md index b6095c58..28cf41dd 100644 --- a/docs/open.md +++ b/docs/open.md @@ -108,6 +108,21 @@ Offene Entscheidungen dazu: undokumentiert und kann sich ändern; bei Ausfall/Formatänderung ist das Gate wirkungslos (fail-open by design — kein Blocker, aber der Schutz fällt dann aus, ohne dass es auffällt). +## Offene Verifikation (2026-08-05, Max-Turns-Ceiling) + +Build + unit tests grün (`ResolveMaxTurns`-Klemmung, Repository-Backfill von `model_presets`, +Migration `AddMaxTurnsCeiling` gegen eine Scratch-DB angewendet), aber **nicht visuell +verifiziert**: + +- Agent-Settings-Editor (Task **und** Liste): Max-Turns-Feld auf einen Wert über der Ceiling + (Default 80) setzen, Hinweistext unter dem `NumericUpDown` erscheint ("Runs are capped at + {N} turns…"). +- Settings → Allgemein → Vorgaben pro Modell: eine Zeile über 80 setzen, derselbe Hinweistext + erscheint unter der Zeile. +- `MaxTurnsCeiling` selbst hat noch **keinen** eigenen Editor in der Settings-UI — der Wert wird + beim Laden übernommen und beim Speichern nur unverändert zurückgeschrieben (kein Clobber), + aber nicht editierbar. Falls gewünscht, ein eigenes Feld ergänzen. + --- ## Bewusst verworfen (nicht erneut vorschlagen) diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 994280d3..491c5e7e 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` — `AppSettingsRepository.GetAsync` backfills shipping defaults into this column on the first read after it's null, so it's never null once a run has started), `MaxTurnsCeiling` (int, default 80, column `max_turns_ceiling` — hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run starts; `AppSettingsRepository.UpdateAsync` clamps it to a minimum of 1), 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). `DefaultMaxTurns` default was lowered from 100 to 40 (entity default + `AddMaxTurnsCeiling` migration backfill on the seeded row). - **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 `AddMaxTurnsCeiling` added `app_settings.max_turns_ceiling` (default 80) and lowered `default_max_turns`'s default/seeded value from 100 to 40. `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..8678937e 100644 --- a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs @@ -18,10 +18,13 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration s.DefaultModel) .HasColumnName("default_model").IsRequired().HasDefaultValue("sonnet"); builder.Property(s => s.DefaultMaxTurns) - .HasColumnName("default_max_turns").IsRequired().HasDefaultValue(30); + .HasColumnName("default_max_turns").IsRequired().HasDefaultValue(40); builder.Property(s => s.DefaultPermissionMode) .HasColumnName("default_permission_mode").IsRequired().HasDefaultValue("bypassPermissions"); + builder.Property(s => s.MaxTurnsCeiling) + .HasColumnName("max_turns_ceiling").IsRequired().HasDefaultValue(80); + builder.Property(s => s.MaxParallelExecutions) .HasColumnName("max_parallel_executions").IsRequired().HasDefaultValue(1); diff --git a/src/ClaudeDo.Data/Migrations/20260805132052_AddMaxTurnsCeiling.Designer.cs b/src/ClaudeDo.Data/Migrations/20260805132052_AddMaxTurnsCeiling.Designer.cs new file mode 100644 index 00000000..ee1739cd --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260805132052_AddMaxTurnsCeiling.Designer.cs @@ -0,0 +1,843 @@ +// +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("20260805132052_AddMaxTurnsCeiling")] + partial class AddMaxTurnsCeiling + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(40) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 40, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + 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/20260805132052_AddMaxTurnsCeiling.cs b/src/ClaudeDo.Data/Migrations/20260805132052_AddMaxTurnsCeiling.cs new file mode 100644 index 00000000..917b9ab8 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260805132052_AddMaxTurnsCeiling.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddMaxTurnsCeiling : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "default_max_turns", + table: "app_settings", + type: "INTEGER", + nullable: false, + defaultValue: 40, + oldClrType: typeof(int), + oldType: "INTEGER", + oldDefaultValue: 30); + + migrationBuilder.AddColumn( + name: "max_turns_ceiling", + table: "app_settings", + type: "INTEGER", + nullable: false, + defaultValue: 80); + + migrationBuilder.UpdateData( + table: "app_settings", + keyColumn: "id", + keyValue: 1, + columns: new[] { "default_max_turns", "max_turns_ceiling" }, + values: new object[] { 40, 80 }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "max_turns_ceiling", + table: "app_settings"); + + migrationBuilder.AlterColumn( + name: "default_max_turns", + table: "app_settings", + type: "INTEGER", + nullable: false, + defaultValue: 30, + oldClrType: typeof(int), + oldType: "INTEGER", + oldDefaultValue: 40); + + migrationBuilder.UpdateData( + table: "app_settings", + keyColumn: "id", + keyValue: 1, + column: "default_max_turns", + value: 100); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 331b471f..d51a9b9e 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -43,7 +43,7 @@ namespace ClaudeDo.Data.Migrations b.Property("DefaultMaxTurns") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") - .HasDefaultValue(30) + .HasDefaultValue(40) .HasColumnName("default_max_turns"); b.Property("DefaultModel") @@ -66,6 +66,12 @@ namespace ClaudeDo.Data.Migrations .HasDefaultValue(1) .HasColumnName("max_parallel_executions"); + b.Property("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + b.Property("ModelPresets") .HasColumnType("TEXT") .HasColumnName("model_presets"); @@ -129,10 +135,11 @@ namespace ClaudeDo.Data.Migrations Id = 1, DailyPrepMaxTasks = 5, DefaultClaudeInstructions = "", - DefaultMaxTurns = 100, + DefaultMaxTurns = 40, DefaultModel = "sonnet", DefaultPermissionMode = "auto", MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, StandupWeekday = 3, UsageGateFiveHourPct = 80, UsageGateSevenDayPct = 90, diff --git a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs index f0b8d833..aef12832 100644 --- a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs +++ b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs @@ -8,9 +8,13 @@ public sealed class AppSettingsEntity public string DefaultClaudeInstructions { get; set; } = string.Empty; public string DefaultModel { get; set; } = "sonnet"; - public int DefaultMaxTurns { get; set; } = 100; + public int DefaultMaxTurns { get; set; } = 40; public string DefaultPermissionMode { get; set; } = "auto"; + // Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run + // starts. Guards against runaway sessions regardless of what a task/list override requests. + public int MaxTurnsCeiling { get; set; } = 80; + public int MaxParallelExecutions { get; set; } = 1; public string WorktreeStrategy { get; set; } = "sibling"; diff --git a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs index e42bf61f..088d57b1 100644 --- a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs +++ b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs @@ -14,25 +14,45 @@ public sealed class AppSettingsRepository { var row = await _context.AppSettings.AsNoTracking() .FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct); - if (row is not null) return row; + if (row is null) + { + row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId, ModelPresets = ModelPresets.SerializeDefaults() }; + _context.AppSettings.Add(row); + try + { + await _context.SaveChangesAsync(ct); + _context.Entry(row).State = EntityState.Detached; + } + catch (DbUpdateException) + { + // Concurrent process already inserted the singleton — discard our attempt and re-read. + _context.Entry(row).State = EntityState.Detached; + row = await _context.AppSettings.AsNoTracking() + .FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct); + } + return row; + } + + // First read after upgrading from a null model_presets column: persist the shipping + // defaults so the Settings UI shows real, editable rows instead of a code-only fallback. + if (row.ModelPresets is null) + row.ModelPresets = await BackfillModelPresetsAsync(ct); - row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }; - _context.AppSettings.Add(row); - try - { - await _context.SaveChangesAsync(ct); - _context.Entry(row).State = EntityState.Detached; - } - catch (DbUpdateException) - { - // Concurrent process already inserted the singleton — discard our attempt and re-read. - _context.Entry(row).State = EntityState.Detached; - row = await _context.AppSettings.AsNoTracking() - .FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct); - } return row; } + private async Task BackfillModelPresetsAsync(CancellationToken ct) + { + var defaults = ModelPresets.SerializeDefaults(); + var tracked = await GetOrCreateTrackedRowAsync(ct); + if (tracked.ModelPresets is null) + { + tracked.ModelPresets = defaults; + await _context.SaveChangesAsync(ct); + } + return defaults; + } + private async Task GetOrCreateTrackedRowAsync(CancellationToken ct) { var row = await _context.AppSettings @@ -52,6 +72,7 @@ public sealed class AppSettingsRepository row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty; row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel; row.DefaultMaxTurns = updated.DefaultMaxTurns; + row.MaxTurnsCeiling = updated.MaxTurnsCeiling < 1 ? 1 : updated.MaxTurnsCeiling; row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode) ? "auto" : updated.DefaultPermissionMode; row.MaxParallelExecutions = updated.MaxParallelExecutions < 1 ? 1 : updated.MaxParallelExecutions; diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 7a8bfdec..ea12fdb1 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -103,6 +103,7 @@ "overrideBadge": "überschrieben", "resetToInherited": "Auf geerbt zurücksetzen" }, + "turnsCeilingHint": "Läufe sind auf {0} Durchläufe gedeckelt — dieser Wert wird geklemmt.", "agentEditor": { "model": "Modell", "maxTurns": "Max. Durchläufe", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 3df5d58c..5b060c41 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -103,6 +103,7 @@ "overrideBadge": "override", "resetToInherited": "Reset to inherited" }, + "turnsCeilingHint": "Runs are capped at {0} turns — this value will be clamped.", "agentEditor": { "model": "Model", "maxTurns": "Max turns", diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 9258cb6f..41424636 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -625,7 +625,8 @@ public sealed record AppSettingsDto( List? SessionSkills = null, List? ModelPresets = null, int UsageGateFiveHourPct = 80, - int UsageGateSevenDayPct = 90); + int UsageGateSevenDayPct = 90, + int MaxTurnsCeiling = 80); // Per-model run defaults (effort + turn budget) edited in Settings → General. public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns); diff --git a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs index 39fccbe4..ff309e56 100644 --- a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs @@ -43,6 +43,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa [ObservableProperty] private string _modelInheritedHint = ""; [ObservableProperty] private string _turnsBadge = ""; [ObservableProperty] private string _turnsInheritedHint = ""; + [ObservableProperty] private string _turnsCeilingHint = ""; [ObservableProperty] private string _agentBadge = ""; [ObservableProperty] private string _effectiveSystemPromptHint = ""; @@ -50,6 +51,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa // The global max-turns default is per-model (Settings -> General), so it moves with whichever // model actually ends up in effect here. private IReadOnlyList _presets = ModelPresets.Defaults; + private int _maxTurnsCeiling = 80; private string EffectiveModel => Model ?? _listModel ?? _globalModel; private int GlobalMaxTurns => ModelPresets.For(_presets, EffectiveModel).MaxTurns; private string? _listModel; // Task scope only @@ -145,6 +147,9 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa : InheritanceResolver.ResolveList(own, GlobalMaxTurns.ToString()); TurnsInheritedHint = value; TurnsBadge = BadgeFor(source, MaxTurns is not null); + TurnsCeilingHint = MaxTurns is decimal t && (int)t > _maxTurnsCeiling + ? Loc.T("settings.turnsCeilingHint", _maxTurnsCeiling) + : ""; } private void RecomputeAgentBadge() @@ -301,6 +306,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa _presets = app?.ModelPresets is { Count: > 0 } rows ? rows.Select(r => new ModelPreset(r.Model, r.Effort, r.MaxTurns)).ToList() : ModelPresets.Defaults; + _maxTurnsCeiling = app?.MaxTurnsCeiling ?? 80; } private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath) diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs index 4f3ab8fa..5b02d1f6 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using ClaudeDo.Data.Models; using ClaudeDo.Localization; +using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Agent; using CommunityToolkit.Mvvm.ComponentModel; @@ -15,7 +16,11 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase [ObservableProperty] private string _defaultClaudeInstructions = ""; [ObservableProperty] private string _defaultModel = ModelRegistry.DefaultAlias; - [ObservableProperty] private int _defaultMaxTurns = 100; + [ObservableProperty] private int _defaultMaxTurns = 40; + // Hard ceiling every resolved max-turns value is clamped to before a run starts. Not directly + // editable here yet — loaded and echoed back on save so it round-trips, and drives the clamp + // hints on the preset rows below and on AgentConfigEditorViewModel. + [ObservableProperty] private int _maxTurnsCeiling = 80; [ObservableProperty] private string _defaultPermissionMode = PermissionModeRegistry.DefaultMode; [ObservableProperty] private int _maxParallelExecutions = 1; // Percentage of the 5h/7d Claude usage window at which the autonomous queue pauses. 0 = gate off. @@ -56,14 +61,14 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase /// model. Supplies the global defaults; list- and task-level max-turns overrides still win. public ObservableCollection ModelPresets { get; } = new(); - public void LoadModelPresets(IReadOnlyCollection? presets) + public void LoadModelPresets(IReadOnlyCollection? presets, int ceiling) { ModelPresets.Clear(); var source = presets is { Count: > 0 } ? presets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns)).ToList() : Data.Models.ModelPresets.Defaults.ToList(); foreach (var p in Data.Models.ModelPresets.Parse(Data.Models.ModelPresets.Serialize(source))) - ModelPresets.Add(new ModelPresetRowViewModel(p)); + ModelPresets.Add(new ModelPresetRowViewModel(p, ceiling)); } public List ModelPresetDtos() @@ -130,11 +135,20 @@ public sealed partial class ModelPresetRowViewModel : ViewModelBase [ObservableProperty] private string _effort; // decimal so it binds straight to a NumericUpDown, like the other numeric settings. [ObservableProperty] private decimal _maxTurns; + [ObservableProperty] private int _ceiling; - public ModelPresetRowViewModel(ModelPreset preset) + public ModelPresetRowViewModel(ModelPreset preset, int ceiling) { Model = preset.Model; _effort = preset.Effort; _maxTurns = preset.MaxTurns; + _ceiling = ceiling; } + + public string ClampHint => MaxTurns > Ceiling + ? Loc.T("settings.turnsCeilingHint", Ceiling) + : ""; + + partial void OnMaxTurnsChanged(decimal value) => OnPropertyChanged(nameof(ClampHint)); + partial void OnCeilingChanged(int value) => OnPropertyChanged(nameof(ClampHint)); } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs index f6340bc1..18bb830b 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -59,6 +59,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase General.DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? ""; General.DefaultModel = dto.DefaultModel ?? "sonnet"; General.DefaultMaxTurns = dto.DefaultMaxTurns; + General.MaxTurnsCeiling = dto.MaxTurnsCeiling; General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto"; General.MaxParallelExecutions = dto.MaxParallelExecutions; General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct; @@ -80,7 +81,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase await OnlineInbox.LoadAsync(); await SessionSkills.LoadAsync(); await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills); - General.LoadModelPresets(dto?.ModelPresets); + General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling); } finally { IsBusy = false; } } @@ -113,7 +114,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase General.SelectedSessionSkillNames(), General.ModelPresetDtos(), General.UsageGateFiveHourPct, - General.UsageGateSevenDayPct); + General.UsageGateSevenDayPct, + General.MaxTurnsCeiling); await _worker.UpdateAppSettingsAsync(dto); await Prime.SaveAsync(); await OnlineInbox.SaveAsync(); diff --git a/src/ClaudeDo.Ui/Views/Controls/AgentConfigEditor.axaml b/src/ClaudeDo.Ui/Views/Controls/AgentConfigEditor.axaml index 576ddbc2..bed96c5a 100644 --- a/src/ClaudeDo.Ui/Views/Controls/AgentConfigEditor.axaml +++ b/src/ClaudeDo.Ui/Views/Controls/AgentConfigEditor.axaml @@ -34,6 +34,9 @@ PlaceholderText="{Binding TurnsInheritedHint}" Minimum="1" Maximum="200" Increment="1" FormatString="0" HorizontalAlignment="Stretch"/> + diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml index 08a2e9fe..3ef3763e 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -129,14 +129,19 @@ - - - - - + + + + + + + + diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 29169cf9..0e1a9e70 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -161,7 +161,7 @@ A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks) 1. Load task + list metadata from DB; resolve config from `list_config` + task-level overrides (model, system_prompt, agent_path) 2. Create worktree (if `WorkingDir` set) or sandbox directory 3. Mark task "running", broadcast `TaskStarted` -4. Resolve the effective model (task → list → `AppSettings.DefaultModel`), then take its `ModelPresets` row via `ModelPresets.For(global.ModelPresets, model, global.DefaultMaxTurns)`: the model string is resolved through `ModelRegistry.TryNormalizeAlias` first, so a full CLI model id (e.g. `claude-sonnet-4-6`, not just the bare `sonnet`/`opus`/`haiku`/`fable` aliases) still hits its alias's preset row; only a truly unrecognized model falls back to a synthesized row using `AppSettings.DefaultMaxTurns` (never a hardcoded number, and never throws — an unknown model must not block a run). The preset supplies `--effort` and the **global** max-turns default (task/list `MaxTurns` overrides still win). Build CLI args via `ClaudeArgsBuilder`; inject attachment absolute paths via `TaskPromptComposer.Compose` (appends a read-only "## Reference files" section); invoke `ClaudeProcess` with task prompt +4. Resolve the effective model (task → list → `AppSettings.DefaultModel`), then take its `ModelPresets` row via `ModelPresets.For(global.ModelPresets, model, global.DefaultMaxTurns)`: the model string is resolved through `ModelRegistry.TryNormalizeAlias` first, so a full CLI model id (e.g. `claude-sonnet-4-6`, not just the bare `sonnet`/`opus`/`haiku`/`fable` aliases) still hits its alias's preset row; only a truly unrecognized model falls back to a synthesized row using `AppSettings.DefaultMaxTurns` (never a hardcoded number, and never throws — an unknown model must not block a run). The preset supplies `--effort` and the **global** max-turns default (task/list `MaxTurns` overrides still win). The task/list/global-resolved value is then hard-clamped to `AppSettings.MaxTurnsCeiling` (default 80) via `TaskRunner.ResolveMaxTurns` — a task or list override above the ceiling still starts, just capped, and a Warn is logged with the task id, requested, and effective value. Build CLI args via `ClaudeArgsBuilder`; inject attachment absolute paths via `TaskPromptComposer.Compose` (appends a read-only "## Reference files" section); invoke `ClaudeProcess` with task prompt 5. Stream NDJSON output through `StreamAnalyzer`; lines forwarded to log file and SignalR (`TaskMessage`) 6. On success: auto-commit changes (worktree only), store run record, mark "done" 7. On failure: retry once if session ID available (`--resume`), then mark "failed" diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 59ede361..2b74b03b 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -45,7 +45,8 @@ public record AppSettingsDto( List? SessionSkills = null, List? ModelPresets = null, int UsageGateFiveHourPct = 80, - int UsageGateSevenDayPct = 90); + int UsageGateSevenDayPct = 90, + int MaxTurnsCeiling = 80); // Per-model run defaults (effort + turn budget) edited in Settings -> General. public record ModelPresetDto(string Model, string Effort, int MaxTurns); @@ -382,7 +383,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub Data.Models.ModelPresets.Parse(row.ModelPresets) .Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList(), row.UsageGateFiveHourPct, - row.UsageGateSevenDayPct); + row.UsageGateSevenDayPct, + row.MaxTurnsCeiling); } public async Task UpdateAppSettings(AppSettingsDto dto) @@ -412,6 +414,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub : Data.Models.ModelPresets.SerializeDefaults(), UsageGateFiveHourPct = dto.UsageGateFiveHourPct, UsageGateSevenDayPct = dto.UsageGateSevenDayPct, + MaxTurnsCeiling = dto.MaxTurnsCeiling, }); } diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 820b2457..55cc68d4 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -522,12 +522,21 @@ public sealed class TaskRunner var model = task.Model ?? listConfig?.Model ?? global.DefaultModel; var preset = Data.Models.ModelPresets.For(global.ModelPresets, model, global.DefaultMaxTurns); + var requestedMaxTurns = task.MaxTurns ?? listConfig?.MaxTurns ?? preset.MaxTurns; + var maxTurns = ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, preset.MaxTurns, global.MaxTurnsCeiling); + if (maxTurns < requestedMaxTurns) + { + _logger.LogWarning( + "Task {TaskId}: max turns clamped to ceiling (requested={Requested}, effective={Effective}, ceiling={Ceiling})", + task.Id, requestedMaxTurns, maxTurns, global.MaxTurnsCeiling); + } + return new ClaudeRunConfig( Model: model, SystemPrompt: string.IsNullOrWhiteSpace(instructions) ? null : instructions, AgentPath: task.AgentPath ?? listConfig?.AgentPath, ResumeSessionId: resumeSessionId, - MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, preset.MaxTurns), + MaxTurns: maxTurns, PermissionMode: global.DefaultPermissionMode, SkillNames: skillNames, Effort: preset.Effort); @@ -586,8 +595,8 @@ public sealed class TaskRunner return names; } - internal static int? ResolveMaxTurns(int? taskTurns, int? listTurns, int globalDefault) - => taskTurns ?? listTurns ?? globalDefault; + internal static int? ResolveMaxTurns(int? taskTurns, int? listTurns, int globalDefault, int ceiling) + => Math.Min(taskTurns ?? listTurns ?? globalDefault, ceiling); public static string MergeInstructions(params string?[] parts) { diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/AppSettingsRepositoryTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/AppSettingsRepositoryTests.cs index 7f80a8a2..6c7ce66e 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/AppSettingsRepositoryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/AppSettingsRepositoryTests.cs @@ -20,13 +20,49 @@ public class AppSettingsRepositoryTests : IDisposable Assert.Equal(AppSettingsEntity.SingletonId, row.Id); Assert.Equal("sonnet", row.DefaultModel); - Assert.Equal(100, row.DefaultMaxTurns); + Assert.Equal(40, row.DefaultMaxTurns); + Assert.Equal(80, row.MaxTurnsCeiling); Assert.Equal("auto", row.DefaultPermissionMode); Assert.Equal("sibling", row.WorktreeStrategy); Assert.Null(row.CentralWorktreeRoot); Assert.False(row.WorktreeAutoCleanupEnabled); } + [Fact] + public async Task UpdateAsync_Persists_MaxTurnsCeiling() + { + using (var ctx = _db.CreateContext()) + { + var repo = new AppSettingsRepository(ctx); + await repo.UpdateAsync(new AppSettingsEntity { MaxTurnsCeiling = 60 }); + } + + using var readCtx = _db.CreateContext(); + var row = await new AppSettingsRepository(readCtx).GetAsync(); + Assert.Equal(60, row.MaxTurnsCeiling); + } + + [Fact] + public async Task GetAsync_Backfills_Null_ModelPresets_With_Defaults() + { + using (var ctx = _db.CreateContext()) + { + var repo = new AppSettingsRepository(ctx); + // Force a row to exist with model_presets left null (the pre-upgrade state). + await repo.UpdateAsync(new AppSettingsEntity()); + } + + using var readCtx = _db.CreateContext(); + var row = await new AppSettingsRepository(readCtx).GetAsync(); + + Assert.NotNull(row.ModelPresets); + Assert.Equal(ModelPresets.SerializeDefaults(), row.ModelPresets); + + using var rereadCtx = _db.CreateContext(); + var reread = await new AppSettingsRepository(rereadCtx).GetAsync(); + Assert.Equal(ModelPresets.SerializeDefaults(), reread.ModelPresets); + } + [Fact] public async Task UpdateAsync_Persists_And_RoundTrips() { diff --git a/tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs index 20c8ccba..f022b125 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs @@ -7,13 +7,21 @@ public class MaxTurnsResolutionTests { [Fact] public void Task_override_wins() - => Assert.Equal(5, TaskRunner.ResolveMaxTurns(taskTurns: 5, listTurns: 20, globalDefault: 100)); + => Assert.Equal(5, TaskRunner.ResolveMaxTurns(taskTurns: 5, listTurns: 20, globalDefault: 100, ceiling: 200)); [Fact] public void List_override_used_when_no_task_override() - => Assert.Equal(20, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: 20, globalDefault: 100)); + => Assert.Equal(20, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: 20, globalDefault: 100, ceiling: 200)); [Fact] public void Falls_back_to_global_default() - => Assert.Equal(100, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: null, globalDefault: 100)); + => Assert.Equal(100, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: null, globalDefault: 100, ceiling: 200)); + + [Fact] + public void Below_ceiling_is_unchanged() + => Assert.Equal(60, TaskRunner.ResolveMaxTurns(taskTurns: 60, listTurns: null, globalDefault: 40, ceiling: 80)); + + [Fact] + public void Above_ceiling_is_clamped() + => Assert.Equal(80, TaskRunner.ResolveMaxTurns(taskTurns: 200, listTurns: null, globalDefault: 40, ceiling: 80)); } diff --git a/tests/ClaudeDo.Worker.Tests/Runner/ModelResolutionWireTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/ModelResolutionWireTests.cs index 50f55b52..5972197d 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/ModelResolutionWireTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/ModelResolutionWireTests.cs @@ -87,9 +87,7 @@ public sealed class ModelResolutionWireTests : IDisposable using (var ctx = _db.CreateContext()) { var settingsRepo = new AppSettingsRepository(ctx); - var settings = await settingsRepo.GetAsync(); - settings.DefaultMaxTurns = 100; - await ctx.SaveChangesAsync(); + await settingsRepo.UpdateAsync(new AppSettingsEntity { DefaultMaxTurns = 50 }); } Exception? thrown = null; @@ -106,6 +104,6 @@ public sealed class ModelResolutionWireTests : IDisposable Assert.Null(thrown); var args = getArgs().ToList(); Assert.Contains("--max-turns", args); - Assert.Equal("100", args[args.IndexOf("--max-turns") + 1]); + Assert.Equal("50", args[args.IndexOf("--max-turns") + 1]); } }