From 84219a9f88c465b9fafdc8f405f772e2657f4428 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 27 Aug 2026 16:43:14 +0200 Subject: [PATCH] refactor: Datei-Scope-Serialisierung entfernen ScopeGlobs auf Tasks und SerializeOnFileOverlap auf der Listen-Config waren ungenutzt: der Scope wurde nie befuellt, also hat der Queue-Picker nie serialisiert. ScopeOverlap, das Picker-Gate, die DTO-Felder, die UI-Option und die Spalten fallen weg (Migration DropFileScopeSerialization). --- src/ClaudeDo.Data/CLAUDE.md | 4 +- .../ListConfigEntityConfiguration.cs | 2 - .../Configuration/TaskEntityConfiguration.cs | 1 - ...318_DropFileScopeSerialization.Designer.cs | 974 ++++++++++++++++++ ...260827125318_DropFileScopeSerialization.cs | 39 + .../ClaudeDoDbContextModelSnapshot.cs | 10 - src/ClaudeDo.Data/Models/ListConfigEntity.cs | 5 - src/ClaudeDo.Data/Models/TaskEntity.cs | 6 - .../Repositories/ListRepository.cs | 1 - src/ClaudeDo.Data/Wire.cs | 11 +- src/ClaudeDo.Localization/locales/de.json | 2 - src/ClaudeDo.Localization/locales/en.json | 2 - .../Agent/AgentConfigEditorViewModel.cs | 11 +- .../Modals/ListSettingsModalViewModel.cs | 5 - src/ClaudeDo.Worker/CLAUDE.md | 9 +- .../External/ConfigMcpTools.cs | 10 +- .../External/QueueStateMcpTools.cs | 35 +- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 16 +- src/ClaudeDo.Worker/Queue/QueuePicker.cs | 73 -- src/ClaudeDo.Worker/Queue/ScopeOverlap.cs | 89 -- .../External/ConfigMcpToolsTests.cs | 8 +- .../External/QueueStateMcpToolsTests.cs | 44 - .../Hub/ListConfigHubTests.cs | 66 +- .../Queue/QueuePickerTests.cs | 103 -- .../Tickets/ListConfigTicketProjectTests.cs | 7 +- 25 files changed, 1051 insertions(+), 482 deletions(-) create mode 100644 src/ClaudeDo.Data/Migrations/20260827125318_DropFileScopeSerialization.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260827125318_DropFileScopeSerialization.cs delete mode 100644 src/ClaudeDo.Worker/Queue/ScopeOverlap.cs diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 6fdac5e2..994d5f6a 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -12,8 +12,8 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio - `Number` (INTEGER NOT NULL, unique index `idx_tasks_number`) — a global, monotonically increasing display alias for tasks. **Never reused**: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated by `TaskNumberAllocator.AddWithNumberAsync` on every insert via a persistent counter (`app_settings.next_task_number`). **Why not `MAX(number) + 1`:** deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses an `UPDATE…RETURNING` statement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). The one-time backfill for pre-existing rows lived in the `AddTaskNumbers` migration, squashed away 2026-08-26. - Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired. The backfill migration was squashed away (2026-08-26) — a database still holding those values can't be upgraded, see Schema below. - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`) -- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand, PermissionMode, TicketProjectId (all nullable) + SerializeOnFileOverlap (bool). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). `PermissionMode` is null = inherit the global default; resolution order task → list → global lives in `EffectiveRunConfigResolver`. `TicketProjectId` (`ticket_project_id`) links the list to a project in the external ticket system; null = not linked → `ClaudeDo.Worker/CLAUDE.md` Tickets section. - - ⚠️ `ListRepository.SetConfigAsync` copies the entity **verbatim**, so every writer must carry the fields it doesn't own (`SessionSkills`, `SerializeOnFileOverlap`, `PermissionMode`, `TicketProjectId`) or they silently reset. `UpdateListConfigDto.SerializeOnFileOverlap` and `TicketProjectId` are tri-state (`null` = keep stored) for exactly that reason — only the list-settings modal sends an explicit value. This bit `PermissionMode` for real: its update-branch copy was missing from `SetConfigAsync` until `f0a3a186`, so a list's permission-mode override only ever stuck on the list's *first* save, never on a later one. +- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand, PermissionMode, TicketProjectId (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). `PermissionMode` is null = inherit the global default; resolution order task → list → global lives in `EffectiveRunConfigResolver`. `TicketProjectId` (`ticket_project_id`) links the list to a project in the external ticket system; null = not linked → `ClaudeDo.Worker/CLAUDE.md` Tickets section. + - ⚠️ `ListRepository.SetConfigAsync` copies the entity **verbatim**, so every writer must carry the fields it doesn't own (`SessionSkills`, `PermissionMode`, `TicketProjectId`) or they silently reset. `UpdateListConfigDto.TicketProjectId` is tri-state (`null` = keep stored) for exactly that reason — only the list-settings modal sends an explicit value. This bit `PermissionMode` for real: its update-branch copy was missing from `SetConfigAsync` until `f0a3a186`, so a list's permission-mode override only ever stuck on the list's *first* save, never on a later one. - **WorktreeEntity** — TaskId (PK, 1:1), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable — SHA of the merge commit this branch produced; the only thing making `revert_merge` possible without searching `git log`), State (`Active|Merged|Discarded|Kept`) - **TaskRunEntity** — per-run record: session_id, turns, result, structured output, exit code, log path, nullable `Model` (what the run actually executed with), and `TokensIn`/`TokensOut`/`CacheReadTokens`/`CacheWriteTokens`. ⚠️ Token fields come from the **session transcript**, not the stream-json event, as a per-run delta → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). - **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, column `days_of_week`), TimeOfDay, Enabled, LastRunAt, PromptOverride, Kind (`PrimeActionKind`, column `action_kind`, default `ping`), CreatedAt. Recurs on selected weekdays; no date range. `PromptOverride`'s role depends on `Kind`: ignored for `Ping`, appended to the daily-prep prompt for `FillMyDay`, and the entire prompt for `Custom`. diff --git a/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs index 79230a2b..94db8af4 100644 --- a/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs @@ -19,8 +19,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration c.SessionSkills).HasColumnName("session_skills"); builder.Property(c => c.VerifyCommand).HasColumnName("verify_command"); builder.Property(c => c.PermissionMode).HasColumnName("permission_mode"); - builder.Property(c => c.SerializeOnFileOverlap).HasColumnName("serialize_on_file_overlap") - .IsRequired().HasDefaultValue(false); builder.Property(c => c.TicketProjectId).HasColumnName("ticket_project_id"); } } diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index 4791e44c..4d1cf410 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -100,7 +100,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.Property(t => t.Notes).HasColumnName("notes"); builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.Property(t => t.SessionSkills).HasColumnName("session_skills"); - builder.Property(t => t.ScopeGlobs).HasColumnName("scope_globs"); builder.Property(t => t.HandlerBaseCommit).HasColumnName("handler_base_commit"); builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit"); builder.Property(t => t.InteractiveSessionId).HasColumnName("interactive_session_id"); diff --git a/src/ClaudeDo.Data/Migrations/20260827125318_DropFileScopeSerialization.Designer.cs b/src/ClaudeDo.Data/Migrations/20260827125318_DropFileScopeSerialization.Designer.cs new file mode 100644 index 00000000..863f82a5 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260827125318_DropFileScopeSerialization.Designer.cs @@ -0,0 +1,974 @@ +// +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("20260827125318_DropFileScopeSerialization")] + partial class DropFileScopeSerialization + { + /// + 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("AutoContinueOnUsageLimit") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("auto_continue_on_usage_limit"); + + 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("NextTaskNumber") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("next_task_number"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("UsageThrottleFiveHourHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_five_hour_hard_pct"); + + b.Property("UsageThrottleFiveHourSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_five_hour_soft_pct"); + + b.Property("UsageThrottleSevenDayHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_seven_day_hard_pct"); + + b.Property("UsageThrottleSevenDaySoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_seven_day_soft_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + AutoContinueOnUsageLimit = false, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 40, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, + NextTaskNumber = 1, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + UsageThrottleFiveHourHardPct = 65, + UsageThrottleFiveHourSoftPct = 50, + UsageThrottleSevenDayHardPct = 65, + UsageThrottleSevenDaySoftPct = 50, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("TicketProjectId") + .HasColumnType("INTEGER") + .HasColumnName("ticket_project_id"); + + 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("FindingsTracked") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("findings_tracked"); + + 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("Kind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("ping") + .HasColumnName("action_kind"); + + 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("DependsOnTaskId") + .HasColumnType("TEXT") + .HasColumnName("depends_on_task_id"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FailureMaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("failure_max_turns"); + + b.Property("FailureReason") + .HasColumnType("TEXT") + .HasColumnName("failure_reason"); + + b.Property("FailureTurnsUsed") + .HasColumnType("INTEGER") + .HasColumnName("failure_turns_used"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("Number") + .HasColumnType("INTEGER") + .HasColumnName("number"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + 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("TicketRef") + .HasColumnType("TEXT") + .HasColumnName("ticket_ref"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("UsageLimitAutoContinuedAt") + .HasColumnType("TEXT") + .HasColumnName("usage_limit_auto_continued_at"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("DependsOnTaskId") + .HasDatabaseName("idx_tasks_depends_on"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("Number") + .IsUnique() + .HasDatabaseName("idx_tasks_number"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CacheReadTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_read_tokens"); + + b.Property("CacheWriteTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_write_tokens"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("Errors") + .HasColumnType("TEXT") + .HasColumnName("errors"); + + 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("ResultSubtype") + .HasColumnType("TEXT") + .HasColumnName("result_subtype"); + + 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("TerminalReason") + .HasColumnType("TEXT") + .HasColumnName("terminal_reason"); + + 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.TaskEntity", null) + .WithMany() + .HasForeignKey("DependsOnTaskId") + .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/20260827125318_DropFileScopeSerialization.cs b/src/ClaudeDo.Data/Migrations/20260827125318_DropFileScopeSerialization.cs new file mode 100644 index 00000000..16e49bac --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260827125318_DropFileScopeSerialization.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class DropFileScopeSerialization : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "scope_globs", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "serialize_on_file_overlap", + table: "list_config"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "scope_globs", + table: "tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "serialize_on_file_overlap", + table: "list_config", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 079b35b6..30d5ef34 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -243,12 +243,6 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("permission_mode"); - b.Property("SerializeOnFileOverlap") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("serialize_on_file_overlap"); - b.Property("SessionSkills") .HasColumnType("TEXT") .HasColumnName("session_skills"); @@ -626,10 +620,6 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("scheduled_for"); - b.Property("ScopeGlobs") - .HasColumnType("TEXT") - .HasColumnName("scope_globs"); - b.Property("SessionSkills") .HasColumnType("TEXT") .HasColumnName("session_skills"); diff --git a/src/ClaudeDo.Data/Models/ListConfigEntity.cs b/src/ClaudeDo.Data/Models/ListConfigEntity.cs index 08bd8c49..9e65dbe4 100644 --- a/src/ClaudeDo.Data/Models/ListConfigEntity.cs +++ b/src/ClaudeDo.Data/Models/ListConfigEntity.cs @@ -12,11 +12,6 @@ public sealed class ListConfigEntity // Null = inherit AppSettingsEntity.DefaultPermissionMode. One of PermissionModeRegistry.Modes. public string? PermissionMode { get; set; } - // Off by default: tasks in this list run in parallel exactly as before. When on, the queue - // picker holds back a queued task whose declared ScopeGlobs overlaps a running or - // awaiting-merge sibling in the same list instead of claiming it. - public bool SerializeOnFileOverlap { get; set; } - // Id des Projekts im externen Ticketsystem, aus dem diese Liste Tickets importiert. // Null = nicht verknüpft. ⚠️ SetConfigAsync kopiert verbatim — jeder Writer muss dieses // Feld mitführen, sonst setzt es sich still zurück. diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index 38f1c399..22ee442d 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -75,12 +75,6 @@ public sealed class TaskEntity public int SortOrder { get; set; } public string? SessionSkills { get; set; } - // Newline-separated declared file paths/globs this task expects to touch. User-supplied only - // (never inferred). Only consulted by the queue picker when the list's - // ListConfigEntity.SerializeOnFileOverlap is on, and only for this task's own overlap check -- - // an unset value means "no basis to hold this task back", not "touches nothing". - public string? ScopeGlobs { get; set; } - // Verknüpftes Ticket im externen Ticketsystem, Format ":" (aktuell immer // "bandel:"). Gesetzt vom Ticket-Import, sonst null. Der Provider-Präfix existiert, // damit ein zweites Ticketsystem später keine Datenmigration braucht. diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs index 334908d5..48a77b25 100644 --- a/src/ClaudeDo.Data/Repositories/ListRepository.cs +++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs @@ -88,7 +88,6 @@ public sealed class ListRepository existing.MaxTurns = config.MaxTurns; existing.SessionSkills = config.SessionSkills; existing.VerifyCommand = config.VerifyCommand; - existing.SerializeOnFileOverlap = config.SerializeOnFileOverlap; existing.PermissionMode = config.PermissionMode; existing.TicketProjectId = config.TicketProjectId; } diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index f87e0451..460d51b0 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -94,15 +94,14 @@ public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false); -// SerializeOnFileOverlap and TicketProjectId are tri-state on purpose: null = leave the stored -// value alone. A caller that doesn't own the field (anything but the list-settings modal) must not -// be able to clear it by omission — SetConfigAsync copies the entity verbatim. TicketProjectId's -// own scale: null = keep stored, <= 0 = clear the link, > 0 = set it. -public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, bool? SerializeOnFileOverlap = null, string? PermissionMode = null, int? TicketProjectId = null); +// TicketProjectId is tri-state on purpose: a caller that doesn't own the field (anything but the +// list-settings modal) must not be able to clear it by omission — SetConfigAsync copies the entity +// verbatim. Its scale: null = keep stored, <= 0 = clear the link, > 0 = set it. +public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? PermissionMode = null); -public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, bool SerializeOnFileOverlap = false, string? PermissionMode = null, int? TicketProjectId = null); +public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null); public record SeedResultDto(int Copied, int Skipped); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 875f8c17..f9221c04 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -391,8 +391,6 @@ "manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.", "findingsTracked": "Findings-Ordner .claudedo einchecken", "findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.", - "serializeOnFileOverlap": "Tasks mit gleichen Dateien serialisieren", - "serializeOnFileOverlapHint": "Aus: Tasks dieser Liste laufen parallel. An: ein wartender Task, dessen deklarierter Datei-Scope einen laufenden oder review-wartenden Nachbarn überlappt, wartet statt zu starten.", "sectionAgent": "AGENT", "resetAgentSettings": "Agent-Einstellungen zurücksetzen", "sectionVerify": "VERIFIKATION", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index c2b567a3..a76e6fd6 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -391,8 +391,6 @@ "manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.", "findingsTracked": "Commit the .claudedo findings folder", "findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.", - "serializeOnFileOverlap": "Serialize tasks that touch the same files", - "serializeOnFileOverlapHint": "Off: tasks in this list run in parallel. On: a queued task whose declared file scope overlaps a running or awaiting-review sibling waits instead of starting.", "sectionAgent": "AGENT", "resetAgentSettings": "Reset agent settings", "sectionVerify": "VERIFICATION", diff --git a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs index f2333acc..8511e379 100644 --- a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs @@ -208,12 +208,11 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa catch { } } - // verifyCommand, serializeOnFileOverlap and ticketProjectId are List-only fields owned by - // ListSettingsModalViewModel (not this editor, which is also reused for Task scope); the caller - // passes them through so the single UpdateListConfig call carries the full desired row instead - // of clobbering it. + // verifyCommand and ticketProjectId are List-only fields owned by ListSettingsModalViewModel + // (not this editor, which is also reused for Task scope); the caller passes them through so the + // single UpdateListConfig call carries the full desired row instead of clobbering it. public async System.Threading.Tasks.Task SaveAsync( - string? verifyCommand = null, bool? serializeOnFileOverlap = null, int? ticketProjectId = null) + string? verifyCommand = null, int? ticketProjectId = null) { if (TargetId is null) return; var model = string.IsNullOrWhiteSpace(Model) ? null : Model; @@ -226,7 +225,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa if (_scope == AgentConfigScope.Task) await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission)); else - await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, serializeOnFileOverlap, permission, ticketProjectId)); + await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId)); } private List? SelectedSessionSkillNames() diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs index a122879f..2d480c3c 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs @@ -41,9 +41,6 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase // Optional post-merge verification command (build/test), run in WorkingDir after a merge // lands; a non-zero exit keeps the task out of Done instead of silently reporting merged. [ObservableProperty] private string _verifyCommand = ""; - // When on, the queue picker holds back a queued task whose declared scope globs overlap a - // running/awaiting-merge sibling in this list (ListConfigEntity.SerializeOnFileOverlap). - [ObservableProperty] private bool _serializeOnFileOverlap; // Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab). [ObservableProperty] private bool _ticketsAvailable; [ObservableProperty] private TicketProjectOption? _selectedTicketProject; @@ -83,7 +80,6 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase await Agent.LoadForListAsync(listId, ct); var cfg = await _worker.GetListConfigAsync(listId); VerifyCommand = cfg?.VerifyCommand ?? ""; - SerializeOnFileOverlap = cfg?.SerializeOnFileOverlap ?? false; var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled ? await _worker.GetTicketSettingsAsync() @@ -124,7 +120,6 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase await Agent.SaveAsync( string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand, - SerializeOnFileOverlap, ticketProjectId); CloseAction?.Invoke(); diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index fd759e70..bec5b1be 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -261,11 +261,10 @@ non-obvious, behaviour-changing. A fixed bug is git history, not a finding. Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`, `max_turns`, `session_skills`, `permission_mode`; tasks override each individually (resolution lives in `EffectiveRunConfigResolver`, so `TaskRunner` and -`get_effective_run_config` can't drift). `verify_command` and `serialize_on_file_overlap` are -list-only — no task-level override. `verify_command` is written via `set_list_config`'s -`verifyCommand` parameter (`ConfigMcpTools`) and the UI's list config editor; -`serialize_on_file_overlap`, `permission_mode`, and `ticket_project_id` are UI-only -(`set_list_config` preserves but does not expose them). +`get_effective_run_config` can't drift). `verify_command` is list-only — no task-level override — +and is written via `set_list_config`'s `verifyCommand` parameter (`ConfigMcpTools`) and the UI's +list config editor; `permission_mode` and `ticket_project_id` are UI-only (`set_list_config` +preserves but does not expose them). ## Tickets (Bandel ticket system) diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index 6c7a117b..78108c13 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -105,12 +105,11 @@ public sealed class ConfigMcpTools // Fields this tool doesn't expose but that live on the same row. They must survive every // write here — ListRepository.SetConfigAsync copies the entity verbatim, so anything left - // at its default would silently reset (a SerializeOnFileOverlap reset only shows up as - // tasks no longer serializing, long after this write; a TicketProjectId reset silently - // kills the list<->ticket-project link — the tool has no ticket parameter on purpose, - // that link is UI-only). + // at its default would silently reset (a TicketProjectId reset silently kills the + // list<->ticket-project link — the tool has no ticket parameter on purpose, that link is + // UI-only). var hasUnrelatedSettings = existing is not null - && (existing.SessionSkills is not null || existing.SerializeOnFileOverlap + && (existing.SessionSkills is not null || existing.PermissionMode is not null || existing.TicketProjectId is not null); ListConfigDto? config; @@ -130,7 +129,6 @@ public sealed class ConfigMcpTools { ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap, MaxTurns = mt, VerifyCommand = vc, SessionSkills = existing?.SessionSkills, - SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false, PermissionMode = existing?.PermissionMode, TicketProjectId = existing?.TicketProjectId, }, cancellationToken); diff --git a/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs b/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs index 57c1cad8..785672ac 100644 --- a/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs +++ b/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs @@ -9,14 +9,11 @@ namespace ClaudeDo.Worker.External; public sealed record QueueSlotDto(string Slot, string TaskId, int? Number, DateTime StartedAt); -public sealed record QueueWaitReasonDto(string TaskId, int Number, string Reason, string BlockedByTaskId, int? BlockedByNumber); - public sealed record GetQueueStateResult( int ConfiguredSlots, int EffectiveSlots, IReadOnlyList ActiveSlots, IReadOnlyList WaitingTaskIds, - IReadOnlyList ScopeBlockedTasks, IReadOnlyList WaitingTaskNumbers); [McpServerToolType] @@ -37,11 +34,7 @@ public sealed class QueueStateMcpTools "by the usage throttle (lower when the 5h/7d usage window fills up), so comparing the two " + "shows whether throttling is currently active. Each active slot is \"queue\" (a normal " + "queue slot) or \"override\" (the single run_task_now/continue_task slot). waitingTaskIds " + - "lists queued, unblocked, non-manual, due tasks in the order the queue would pick them next -- " + - "including any held back purely by file-scope overlap, which is why a waiting task can " + - "outlast a free slot. scopeBlockedTasks explains those: the list opted into " + - "serializeOnFileOverlap and this task's declared scope overlaps blockedByTaskId, a running " + - "or awaiting-merge sibling in the same list.")] + "lists queued, unblocked, non-manual, due tasks in the order the queue would pick them next.")] public async Task GetQueueState(CancellationToken cancellationToken = default) { var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken); @@ -68,32 +61,8 @@ public sealed class QueueStateMcpTools .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ToListAsync(cancellationToken); - var serializingListIds = (await ctx.ListConfigs - .Where(c => c.SerializeOnFileOverlap) - .Select(c => c.ListId) - .ToListAsync(cancellationToken)) - .ToHashSet(StringComparer.Ordinal); - - var scopeBlocked = new List(); - if (serializingListIds.Count > 0) - { - foreach (var t in waiting) - { - if (!serializingListIds.Contains(t.ListId)) continue; - var blockerId = await ScopeOverlap.FindBlockingSiblingAsync(ctx, t, cancellationToken); - if (blockerId is not null) - { - var blockerNumber = await ctx.Tasks - .Where(b => b.Id == blockerId) - .Select(b => (int?)b.Number) - .FirstOrDefaultAsync(cancellationToken); - scopeBlocked.Add(new QueueWaitReasonDto(t.Id, t.Number, "scope_overlap", blockerId, blockerNumber)); - } - } - } - return new GetQueueStateResult( - configured, effective, activeSlots, waiting.Select(t => t.Id).ToList(), scopeBlocked, + configured, effective, activeSlots, waiting.Select(t => t.Id).ToList(), waiting.Select(t => t.Number).ToList()); } } diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index cc55c429..bd7f3282 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -560,15 +560,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var verifyCommand = dto.VerifyCommand.NullIfBlank(); var permissionMode = NormalizePermissionMode(dto.PermissionMode); - // A null SerializeOnFileOverlap means "leave it as stored" — only the list-settings modal - // owns that field, so every other caller must not drop it (neither by deleting the row nor - // by overwriting it: SetConfigAsync copies the entity verbatim). + // Tri-State: null = gespeicherten Wert behalten, 0 = Verknüpfung löschen. Ohne das würde + // jeder fremde Writer (set_list_config MCP-Tool, Agent-Settings) die Ticket-Verknüpfung + // still kappen — SetConfigAsync kopiert verbatim. var existing = await repo.GetConfigAsync(dto.ListId); - var serializeOnFileOverlap = dto.SerializeOnFileOverlap ?? existing?.SerializeOnFileOverlap ?? false; - - // Gleiche Tri-State-Regel wie oben: null = gespeicherten Wert behalten, 0 = Verknüpfung - // löschen. Ohne das würde jeder fremde Writer (set_list_config MCP-Tool, Agent-Settings) - // die Ticket-Verknüpfung still kappen — SetConfigAsync kopiert verbatim. var ticketProjectId = dto.TicketProjectId switch { null => existing?.TicketProjectId, @@ -576,7 +571,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var id => id, }; - if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && !serializeOnFileOverlap && ticketProjectId is null) + if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && ticketProjectId is null) { await repo.DeleteConfigAsync(dto.ListId); } @@ -591,7 +586,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub MaxTurns = dto.MaxTurns, SessionSkills = sessionSkills, VerifyCommand = verifyCommand, - SerializeOnFileOverlap = serializeOnFileOverlap, PermissionMode = permissionMode, TicketProjectId = ticketProjectId, }); @@ -617,7 +611,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var repo = new ListRepository(ctx); var config = await repo.GetConfigAsync(listId); if (config is null) return null; - return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.SerializeOnFileOverlap, config.PermissionMode, config.TicketProjectId); + return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.PermissionMode, config.TicketProjectId); } public async Task SetTaskStatus(string taskId, string status) diff --git a/src/ClaudeDo.Worker/Queue/QueuePicker.cs b/src/ClaudeDo.Worker/Queue/QueuePicker.cs index db758427..63fcff4c 100644 --- a/src/ClaudeDo.Worker/Queue/QueuePicker.cs +++ b/src/ClaudeDo.Worker/Queue/QueuePicker.cs @@ -18,22 +18,6 @@ public sealed class QueuePicker : IQueuePicker var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff"); var startedAtStr = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fffffff"); - var serializingListIds = await ctx.ListConfigs - .Where(c => c.SerializeOnFileOverlap) - .Select(c => c.ListId) - .ToListAsync(ct); - - // Fast path: no list has opted into file-scope serialization, so behavior and cost stay - // identical to before that option existed -- single atomic UPDATE...RETURNING. - if (serializingListIds.Count == 0) - return await ClaimTopEligibleAsync(ctx, nowStr, startedAtStr, ct); - - return await ClaimRespectingScopeAsync(ctx, now, startedAtStr, serializingListIds.ToHashSet(StringComparer.Ordinal), ct); - } - - private static async Task ClaimTopEligibleAsync( - ClaudeDoDbContext ctx, string nowStr, string startedAtStr, CancellationToken ct) - { // Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races. // Raw SQL because EF cannot express UPDATE...RETURNING. // Eligible task must be Queued, unblocked (chain and depends-on), not manual, and due @@ -62,61 +46,4 @@ public sealed class QueuePicker : IQueuePicker return rows.FirstOrDefault(); } - - // At least one list wants overlapping-scope tasks serialized: walk eligible candidates in the - // usual order and skip any whose declared scope overlaps a running or awaiting-merge sibling - // in the same list. A candidate with no declared scope, or belonging to a list not in - // serializingListIds, is claimed exactly as before -- there is no attempt to predict a scope - // that was never declared and no finished sibling to infer it from. - private async Task ClaimRespectingScopeAsync( - ClaudeDoDbContext ctx, DateTime now, string startedAtStr, HashSet serializingListIds, CancellationToken ct) - { - var candidates = await ctx.Tasks - .AsNoTracking() - .Where(t => t.Status == TaskStatus.Queued - && t.BlockedByTaskId == null - && !t.IsManual - && (t.ScheduledFor == null || t.ScheduledFor <= now)) - .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) - .ToListAsync(ct); - - // The same depends-on gate ClaimTopEligibleAsync applies in SQL: a declared dependency must - // be Done. Resolved as one extra query over just the referenced ids (rather than repeating - // the correlated subquery) so both claim paths agree -- without it, opting a list into - // scope serialization would silently stop enforcing dependencies. - var dependencyIds = candidates - .Where(t => t.DependsOnTaskId != null) - .Select(t => t.DependsOnTaskId!) - .Distinct() - .ToList(); - var doneDependencyIds = dependencyIds.Count == 0 - ? new HashSet(StringComparer.Ordinal) - : (await ctx.Tasks.AsNoTracking() - .Where(d => dependencyIds.Contains(d.Id) && d.Status == TaskStatus.Done) - .Select(d => d.Id) - .ToListAsync(ct)) - .ToHashSet(StringComparer.Ordinal); - - foreach (var candidate in candidates) - { - if (candidate.DependsOnTaskId is { } dependsOn && !doneDependencyIds.Contains(dependsOn)) - continue; - - if (serializingListIds.Contains(candidate.ListId) - && await ScopeOverlap.FindBlockingSiblingAsync(ctx, candidate, ct) is not null) - continue; - - var rows = await ctx.Tasks.FromSqlRaw(""" - UPDATE tasks SET status = 'running', started_at = {1} - WHERE id = {0} AND status = 'queued' - RETURNING * - """, candidate.Id, startedAtStr).ToListAsync(ct); - - var claimed = rows.FirstOrDefault(); - if (claimed is not null) return claimed; - // Lost the race for this row to a concurrent picker -- try the next candidate. - } - - return null; - } } diff --git a/src/ClaudeDo.Worker/Queue/ScopeOverlap.cs b/src/ClaudeDo.Worker/Queue/ScopeOverlap.cs deleted file mode 100644 index e8a888aa..00000000 --- a/src/ClaudeDo.Worker/Queue/ScopeOverlap.cs +++ /dev/null @@ -1,89 +0,0 @@ -using ClaudeDo.Data; -using ClaudeDo.Data.Models; -using Microsoft.EntityFrameworkCore; -using TaskStatus = ClaudeDo.Data.Models.TaskStatus; - -namespace ClaudeDo.Worker.Queue; - -// Conservative overlap check between two sets of declared/observed file scopes. A scope entry may -// be an exact path or a glob (e.g. "src/Foo/*.cs"); comparing the literal prefix before the first -// wildcard is enough to catch real collisions without a full glob-matching engine, and it never -// under-reports -- worst case it serializes two tasks that would not actually have collided. -public static class ScopeOverlap -{ - public static IReadOnlyList ParseScopeGlobs(string? scopeGlobs) - => string.IsNullOrWhiteSpace(scopeGlobs) - ? [] - : scopeGlobs.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - public static IReadOnlyList ParseDiffStatFiles(string? diffStat) - { - if (string.IsNullOrWhiteSpace(diffStat)) return []; - var files = new List(); - foreach (var line in diffStat.Split('\n')) - { - var idx = line.IndexOf('|'); - if (idx > 0) files.Add(line[..idx].Trim()); - } - return files; - } - - public static bool Overlaps(IEnumerable a, IEnumerable b) - { - var bPrefixes = b.Select(LiteralPrefix).Where(p => p.Length > 0).ToList(); - if (bPrefixes.Count == 0) return false; - - foreach (var pa in a.Select(LiteralPrefix)) - { - if (pa.Length == 0) continue; - foreach (var pb in bPrefixes) - { - if (PrefixesOverlap(pa, pb)) return true; - } - } - return false; - } - - private static bool PrefixesOverlap(string a, string b) - => a.Length <= b.Length - ? b.StartsWith(a, StringComparison.OrdinalIgnoreCase) - : a.StartsWith(b, StringComparison.OrdinalIgnoreCase); - - private static string LiteralPrefix(string pattern) - { - var normalized = pattern.Trim().Replace('\\', '/').TrimStart('/'); - var idx = normalized.IndexOfAny(['*', '?']); - return idx < 0 ? normalized : normalized[..idx]; - } - - // Finds the first currently-running or awaiting-merge sibling in the candidate's list whose - // scope overlaps the candidate's declared ScopeGlobs. Returns null (no basis to hold it back) - // when the candidate declares no scope at all -- this never predicts scope for a task that - // hasn't run and has none declared. - public static async Task FindBlockingSiblingAsync(ClaudeDoDbContext ctx, TaskEntity candidate, CancellationToken ct) - { - var scope = ParseScopeGlobs(candidate.ScopeGlobs); - if (scope.Count == 0) return null; - - var siblings = await ctx.Tasks - .AsNoTracking() - .Include(t => t.Worktree) - .Where(t => t.ListId == candidate.ListId - && t.Id != candidate.Id - && (t.Status == TaskStatus.Running - || (t.Status == TaskStatus.WaitingForReview && t.Worktree != null && t.Worktree.State == WorktreeState.Active))) - .ToListAsync(ct); - - foreach (var sibling in siblings) - { - var siblingScope = sibling.Status == TaskStatus.Running - ? ParseScopeGlobs(sibling.ScopeGlobs) - : ParseDiffStatFiles(sibling.Worktree?.DiffStat); - - if (siblingScope.Count > 0 && Overlaps(scope, siblingScope)) - return sibling.Id; - } - - return null; - } -} diff --git a/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs index b252ecba..be151d57 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs @@ -312,7 +312,7 @@ public sealed class ConfigMcpToolsTests : IDisposable Model = "sonnet", SessionSkills = "[\"superpowers\"]", VerifyCommand = "dotnet build", - SerializeOnFileOverlap = true, + PermissionMode = "acceptEdits", }); await _sut.SetListConfig(listId, model: "opus", cancellationToken: CancellationToken.None); @@ -324,7 +324,7 @@ public sealed class ConfigMcpToolsTests : IDisposable Assert.Equal("dotnet build", cfg.VerifyCommand); // SetConfigAsync copies the entity verbatim, so a field this tool doesn't expose would // reset to its default unless it is carried over explicitly. - Assert.True(cfg.SerializeOnFileOverlap); + Assert.Equal("acceptEdits", cfg.PermissionMode); } [Fact] @@ -335,7 +335,7 @@ public sealed class ConfigMcpToolsTests : IDisposable { ListId = listId, Model = "sonnet", - SerializeOnFileOverlap = true, + PermissionMode = "acceptEdits", }); var result = await _sut.SetListConfig( @@ -349,7 +349,7 @@ public sealed class ConfigMcpToolsTests : IDisposable var cfg = await _lists.GetConfigAsync(listId); Assert.NotNull(cfg); Assert.Null(cfg!.Model); - Assert.True(cfg.SerializeOnFileOverlap); + Assert.Equal("acceptEdits", cfg.PermissionMode); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs index 9e11bf44..10a30d93 100644 --- a/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs @@ -176,48 +176,4 @@ public sealed class QueueStateMcpToolsTests : IDisposable Assert.Equal(new[] { first.Id, second.Id }, result.WaitingTaskIds); } - - [Fact] - public async Task GetQueueState_ScopeBlockedTasks_Empty_WhenNoListSerializes() - { - var listId = await SeedListAsync(); - var (_, sut) = CreateSut(); - - var running = await SeedTaskAsync(listId, TaskStatus.Running); - running.ScopeGlobs = "src/Foo.cs"; - await _ctx.SaveChangesAsync(); - var queued = await SeedTaskAsync(listId, TaskStatus.Queued); - queued.ScopeGlobs = "src/Foo.cs"; - await _ctx.SaveChangesAsync(); - - var result = await sut.GetQueueState(CancellationToken.None); - - Assert.Contains(queued.Id, result.WaitingTaskIds); - Assert.Empty(result.ScopeBlockedTasks); - } - - [Fact] - public async Task GetQueueState_ScopeBlockedTasks_ReportsReasonAndBlocker_WhenListSerializes() - { - var listId = await SeedListAsync(); - await _listRepo.SetConfigAsync(new ListConfigEntity { ListId = listId, SerializeOnFileOverlap = true }); - - var (_, sut) = CreateSut(); - - var running = await SeedTaskAsync(listId, TaskStatus.Running); - running.ScopeGlobs = "src/Foo.cs"; - await _ctx.SaveChangesAsync(); - - var queued = await SeedTaskAsync(listId, TaskStatus.Queued); - queued.ScopeGlobs = "src/Foo.cs"; - await _ctx.SaveChangesAsync(); - - var result = await sut.GetQueueState(CancellationToken.None); - - Assert.Contains(queued.Id, result.WaitingTaskIds); - var reason = Assert.Single(result.ScopeBlockedTasks); - Assert.Equal(queued.Id, reason.TaskId); - Assert.Equal(running.Id, reason.BlockedByTaskId); - Assert.Equal("scope_overlap", reason.Reason); - } } diff --git a/tests/ClaudeDo.Worker.Tests/Hub/ListConfigHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/ListConfigHubTests.cs index 373ed443..02f0d5fb 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/ListConfigHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/ListConfigHubTests.cs @@ -8,8 +8,8 @@ using Xunit; namespace ClaudeDo.Worker.Tests.Hub; /// UpdateListConfig's "all fields blank -> delete the row" branch used to delete unconditionally, -/// silently dropping SerializeOnFileOverlap. The DTO field is tri-state: the list-settings modal -/// sends an explicit true/false, every other caller sends null and must not clear the stored flag. +/// silently dropping fields the caller doesn't own. It may only delete when nothing else is stored; +/// the preserve-the-stored-value side is covered by ListConfigTicketProjectTests. public sealed class ListConfigHubTests : IDisposable { private readonly DbFixture _db = new(); @@ -42,13 +42,10 @@ public sealed class ListConfigHubTests : IDisposable return listId; } - private async Task SeedConfigAsync(string listId, string? model = null, bool serializeOnFileOverlap = false) + private async Task SeedConfigAsync(string listId, string? model = null) { await using var ctx = _db.CreateContext(); - await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity - { - ListId = listId, Model = model, SerializeOnFileOverlap = serializeOnFileOverlap, - }); + await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, Model = model }); } private async Task GetConfigAsync(string listId) @@ -68,59 +65,4 @@ public sealed class ListConfigHubTests : IDisposable Assert.Null(await GetConfigAsync(listId)); } - - [Fact] - public async Task UpdateListConfig_AllBlank_WithSerializeOnFileOverlap_KeepsFlag_RowSurvives() - { - var hub = CreateHub(); - var listId = await SeedListAsync(); - await SeedConfigAsync(listId, model: "opus", serializeOnFileOverlap: true); - - await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null)); - - var config = await GetConfigAsync(listId); - Assert.NotNull(config); - Assert.True(config!.SerializeOnFileOverlap); - Assert.Null(config.Model); - } - - [Fact] - public async Task UpdateListConfig_WithModel_UpsertsNormally_PreservesSerializeOnFileOverlap() - { - var hub = CreateHub(); - var listId = await SeedListAsync(); - await SeedConfigAsync(listId, serializeOnFileOverlap: true); - - await hub.UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null)); - - var config = await GetConfigAsync(listId); - Assert.NotNull(config); - Assert.Equal("opus", config!.Model); - Assert.True(config.SerializeOnFileOverlap); - } - - [Fact] - public async Task UpdateListConfig_ExplicitFalse_ClearsFlagAndDeletesOtherwiseEmptyRow() - { - var hub = CreateHub(); - var listId = await SeedListAsync(); - await SeedConfigAsync(listId, serializeOnFileOverlap: true); - - await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, SerializeOnFileOverlap: false)); - - Assert.Null(await GetConfigAsync(listId)); - } - - [Fact] - public async Task UpdateListConfig_ExplicitTrue_SetsFlagOnOtherwiseEmptyRow() - { - var hub = CreateHub(); - var listId = await SeedListAsync(); - - await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, SerializeOnFileOverlap: true)); - - var config = await GetConfigAsync(listId); - Assert.NotNull(config); - Assert.True(config!.SerializeOnFileOverlap); - } } diff --git a/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs b/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs index 03f6128f..3789f54d 100644 --- a/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs @@ -50,7 +50,6 @@ public sealed class QueuePickerTests : IDisposable bool taskAgentTag = false, int? sortOrder = null, bool isManual = false, - string? scopeGlobs = null, string? dependsOn = null) { var task = new TaskEntity @@ -65,7 +64,6 @@ public sealed class QueuePickerTests : IDisposable DependsOnTaskId = dependsOn, CommitType = "feat", IsManual = isManual, - ScopeGlobs = scopeGlobs, }; await _tasks.AddAsync(task); if (sortOrder is not null) @@ -76,25 +74,6 @@ public sealed class QueuePickerTests : IDisposable return task; } - private async Task SetSerializeOnFileOverlapAsync(string listId, bool value) - => await _lists.SetConfigAsync(new ListConfigEntity { ListId = listId, SerializeOnFileOverlap = value }); - - private async Task SeedWorktreeAsync(string taskId, WorktreeState state, string? diffStat) - { - _ctx.Worktrees.Add(new WorktreeEntity - { - TaskId = taskId, - Path = $"C:\\fake\\{taskId}", - BranchName = $"claudedo/{taskId[..8]}", - BaseCommit = "base", - HeadCommit = "head", - DiffStat = diffStat, - State = state, - CreatedAt = DateTime.UtcNow, - }); - await _ctx.SaveChangesAsync(); - } - [Fact] public async Task ClaimNextAsync_Skips_ManualTasks() { @@ -260,86 +239,4 @@ public sealed class QueuePickerTests : IDisposable var nonNull = results.Where(r => r is not null).ToList(); Assert.Single(nonNull); } - - [Fact] - public async Task ClaimNextAsync_SerializeOff_ClaimsOverlappingQueuedTask_EvenWithRunningOverlap() - { - // Default (option off): behavior is unaffected by ScopeGlobs, even when it overlaps a - // running sibling's declared scope. - var listId = await CreateListAsync(); - await SeedAsync(listId, status: TaskStatus.Running, scopeGlobs: "src/Foo.cs"); - var queued = await SeedAsync(listId, scopeGlobs: "src/Foo.cs"); - - var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); - - Assert.NotNull(picked); - Assert.Equal(queued.Id, picked!.Id); - } - - [Fact] - public async Task ClaimNextAsync_SerializeOn_SkipsQueuedTask_OverlappingRunningSibling() - { - var listId = await CreateListAsync(); - await SetSerializeOnFileOverlapAsync(listId, true); - - await SeedAsync(listId, status: TaskStatus.Running, scopeGlobs: "src/Foo.cs"); - var overlapping = await SeedAsync(listId, scopeGlobs: "src/Foo.cs", sortOrder: 0, createdAt: DateTime.UtcNow.AddMinutes(-5)); - var clear = await SeedAsync(listId, scopeGlobs: "src/Bar.cs", sortOrder: 1, createdAt: DateTime.UtcNow); - - var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); - - Assert.NotNull(picked); - Assert.Equal(clear.Id, picked!.Id); - - var stillQueued = await _tasks.GetByIdAsync(overlapping.Id); - Assert.Equal(TaskStatus.Queued, stillQueued!.Status); - } - - [Fact] - public async Task ClaimNextAsync_SerializeOn_NoDeclaredScope_StillClaimed() - { - var listId = await CreateListAsync(); - await SetSerializeOnFileOverlapAsync(listId, true); - - await SeedAsync(listId, status: TaskStatus.Running, scopeGlobs: "src/Foo.cs"); - var queued = await SeedAsync(listId); // no ScopeGlobs declared -- no basis to hold it back - - var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); - - Assert.NotNull(picked); - Assert.Equal(queued.Id, picked!.Id); - } - - [Fact] - public async Task ClaimNextAsync_SerializeOn_SkipsQueuedTask_OverlappingUnmergedFinishedSiblingDiff() - { - var listId = await CreateListAsync(); - await SetSerializeOnFileOverlapAsync(listId, true); - - var finished = await SeedAsync(listId, status: TaskStatus.WaitingForReview); - await SeedWorktreeAsync(finished.Id, WorktreeState.Active, "src/Foo.cs | 3 ++-"); - await SeedAsync(listId, scopeGlobs: "src/Foo.cs"); - - var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); - - Assert.Null(picked); - } - - [Fact] - public async Task ClaimNextAsync_SerializeOn_IgnoresMergedSiblingDiff() - { - // A merged sibling's changes are already on the base branch -- not a live conflict risk, - // so it must not hold back an overlapping queued task. - var listId = await CreateListAsync(); - await SetSerializeOnFileOverlapAsync(listId, true); - - var merged = await SeedAsync(listId, status: TaskStatus.Done); - await SeedWorktreeAsync(merged.Id, WorktreeState.Merged, "src/Foo.cs | 3 ++-"); - var queued = await SeedAsync(listId, scopeGlobs: "src/Foo.cs"); - - var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); - - Assert.NotNull(picked); - Assert.Equal(queued.Id, picked!.Id); - } } diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/ListConfigTicketProjectTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/ListConfigTicketProjectTests.cs index 819c99a7..44803218 100644 --- a/tests/ClaudeDo.Worker.Tests/Tickets/ListConfigTicketProjectTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Tickets/ListConfigTicketProjectTests.cs @@ -9,10 +9,9 @@ using Xunit; namespace ClaudeDo.Worker.Tests.Tickets; -/// TicketProjectId shares the exact SetConfigAsync-copies-verbatim trap that SerializeOnFileOverlap -/// already hit once: any writer that doesn't carry the field forward silently clears the -/// list<->ticket-project link. UpdateListConfigDto's TicketProjectId is tri-state for the same -/// reason SerializeOnFileOverlap is: null = keep stored, <=0 = clear, >0 = set. The MCP +/// TicketProjectId sits in the SetConfigAsync-copies-verbatim trap: any writer that doesn't carry +/// the field forward silently clears the list<->ticket-project link. UpdateListConfigDto's +/// TicketProjectId is therefore tri-state: null = keep stored, <=0 = clear, >0 = set. The MCP /// set_list_config tool doesn't expose the field at all (UI-only), so it must preserve whatever is /// already stored. public sealed class ListConfigTicketProjectTests : IDisposable