diff --git a/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs index 9a9d3051..fcb26df0 100644 --- a/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs @@ -18,5 +18,7 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration c.MaxTurns).HasColumnName("max_turns"); builder.Property(c => c.SessionSkills).HasColumnName("session_skills"); builder.Property(c => c.VerifyCommand).HasColumnName("verify_command"); + builder.Property(c => c.SerializeOnFileOverlap).HasColumnName("serialize_on_file_overlap") + .IsRequired().HasDefaultValue(false); } } diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index a42e2f7b..a83feed7 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -93,6 +93,7 @@ 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/20260810113643_AddScopeOverlapFields.Designer.cs b/src/ClaudeDo.Data/Migrations/20260810113643_AddScopeOverlapFields.Designer.cs new file mode 100644 index 00000000..d2f26cf1 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260810113643_AddScopeOverlapFields.Designer.cs @@ -0,0 +1,899 @@ +// +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("20260810113643_AddScopeOverlapFields")] + partial class AddScopeOverlapFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(40) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("UsageThrottleFiveHourHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_five_hour_hard_pct"); + + b.Property("UsageThrottleFiveHourSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_five_hour_soft_pct"); + + b.Property("UsageThrottleSevenDayHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_seven_day_hard_pct"); + + b.Property("UsageThrottleSevenDaySoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_seven_day_soft_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 40, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + UsageThrottleFiveHourHardPct = 65, + UsageThrottleFiveHourSoftPct = 50, + UsageThrottleSevenDayHardPct = 65, + UsageThrottleSevenDaySoftPct = 50, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SerializeOnFileOverlap") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("serialize_on_file_overlap"); + + 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("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("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("ScopeGlobs") + .HasColumnType("TEXT") + .HasColumnName("scope_globs"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CacheReadTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_read_tokens"); + + b.Property("CacheWriteTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_write_tokens"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260810113643_AddScopeOverlapFields.cs b/src/ClaudeDo.Data/Migrations/20260810113643_AddScopeOverlapFields.cs new file mode 100644 index 00000000..4301257b --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260810113643_AddScopeOverlapFields.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddScopeOverlapFields : Migration + { + /// + protected override void Up(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "scope_globs", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "serialize_on_file_overlap", + table: "list_config"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 73802154..8e1c8ee5 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -225,6 +225,12 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("model"); + b.Property("SerializeOnFileOverlap") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("serialize_on_file_overlap"); + b.Property("SessionSkills") .HasColumnType("TEXT") .HasColumnName("session_skills"); @@ -567,6 +573,10 @@ 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 bee09fce..026877d9 100644 --- a/src/ClaudeDo.Data/Models/ListConfigEntity.cs +++ b/src/ClaudeDo.Data/Models/ListConfigEntity.cs @@ -10,6 +10,11 @@ public sealed class ListConfigEntity public string? SessionSkills { get; set; } public string? VerifyCommand { 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; } + // Navigation property public ListEntity List { get; set; } = null!; } diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index 97febdfe..3a98de53 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -51,6 +51,12 @@ 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; } + // Review range for a worktree-less task hosting an interactive "list handler" run // (Mission Control's "Let Claude handle it"): the handler commits its own changes // straight to the list's working dir, so there is no per-task worktree to diff. These diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs index 6875cefe..f925128b 100644 --- a/src/ClaudeDo.Data/Repositories/ListRepository.cs +++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs @@ -79,6 +79,7 @@ public sealed class ListRepository existing.MaxTurns = config.MaxTurns; existing.SessionSkills = config.SessionSkills; existing.VerifyCommand = config.VerifyCommand; + existing.SerializeOnFileOverlap = config.SerializeOnFileOverlap; } await _context.SaveChangesAsync(ct); } diff --git a/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs b/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs index d1d65ca4..e5d92231 100644 --- a/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs +++ b/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs @@ -9,11 +9,14 @@ namespace ClaudeDo.Worker.External; public sealed record QueueSlotDto(string Slot, string TaskId, DateTime StartedAt); +public sealed record QueueWaitReasonDto(string TaskId, string Reason, string BlockedByTaskId); + public sealed record GetQueueStateResult( int ConfiguredSlots, int EffectiveSlots, IReadOnlyList ActiveSlots, - IReadOnlyList WaitingTaskIds); + IReadOnlyList WaitingTaskIds, + IReadOnlyList ScopeBlockedTasks); [McpServerToolType] public sealed class QueueStateMcpTools @@ -33,7 +36,11 @@ 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.")] + "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.")] public async Task GetQueueState(CancellationToken cancellationToken = default) { var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken); @@ -44,15 +51,32 @@ public sealed class QueueStateMcpTools await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); var now = DateTime.UtcNow; - var waitingTaskIds = await ctx.Tasks + var waiting = await ctx.Tasks .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) - .Select(t => t.Id) .ToListAsync(cancellationToken); - return new GetQueueStateResult(configured, effective, activeSlots, waitingTaskIds); + 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) + scopeBlocked.Add(new QueueWaitReasonDto(t.Id, "scope_overlap", blockerId)); + } + } + + return new GetQueueStateResult(configured, effective, activeSlots, waiting.Select(t => t.Id).ToList(), scopeBlocked); } } diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 7add000e..c4d844ad 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -682,6 +682,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub } else { + // Preserve SerializeOnFileOverlap: it has no UI/hub affordance yet (set via + // set_list_config or directly against ListConfigEntity), so a save from this path + // must not silently clear it. + var existing = await repo.GetConfigAsync(dto.ListId); await repo.SetConfigAsync(new ListConfigEntity { ListId = dto.ListId, @@ -691,6 +695,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub MaxTurns = dto.MaxTurns, SessionSkills = sessionSkills, VerifyCommand = verifyCommand, + SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false, }); } diff --git a/src/ClaudeDo.Worker/Queue/QueuePicker.cs b/src/ClaudeDo.Worker/Queue/QueuePicker.cs index 1f127f8a..72be8c90 100644 --- a/src/ClaudeDo.Worker/Queue/QueuePicker.cs +++ b/src/ClaudeDo.Worker/Queue/QueuePicker.cs @@ -1,6 +1,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Queue; @@ -13,14 +14,30 @@ public sealed class QueuePicker : IQueuePicker public async Task ClaimNextAsync(DateTime now, 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, not manual, and due (or unscheduled). - // EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison. await using var ctx = await _dbFactory.CreateDbContextAsync(ct); 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, not manual, and due (or unscheduled). + // EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison. var rows = await ctx.Tasks.FromSqlRaw(""" UPDATE tasks SET status = 'running', started_at = {1} WHERE id = ( @@ -37,4 +54,41 @@ 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); + + foreach (var candidate in candidates) + { + 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 new file mode 100644 index 00000000..e8a888aa --- /dev/null +++ b/src/ClaudeDo.Worker/Queue/ScopeOverlap.cs @@ -0,0 +1,89 @@ +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/QueueStateMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs index 69ef045f..8d99d0d2 100644 --- a/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs @@ -172,4 +172,48 @@ 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/Queue/QueuePickerTests.cs b/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs index f220537f..9670354c 100644 --- a/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs @@ -49,7 +49,8 @@ public sealed class QueuePickerTests : IDisposable string? blockedBy = null, bool taskAgentTag = false, int? sortOrder = null, - bool isManual = false) + bool isManual = false, + string? scopeGlobs = null) { var task = new TaskEntity { @@ -62,6 +63,7 @@ public sealed class QueuePickerTests : IDisposable BlockedByTaskId = blockedBy, CommitType = "feat", IsManual = isManual, + ScopeGlobs = scopeGlobs, }; await _tasks.AddAsync(task); if (sortOrder is not null) @@ -72,6 +74,25 @@ 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() { @@ -194,4 +215,86 @@ 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); + } }