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).
This commit is contained in:
mika kuns
2026-08-27 16:43:14 +02:00
parent 4e55f9161c
commit 84219a9f88
25 changed files with 1051 additions and 482 deletions
+2 -2
View File
@@ -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`.
@@ -19,8 +19,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
builder.Property(c => 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");
}
}
@@ -100,7 +100,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
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");
@@ -0,0 +1,974 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260827125318_DropFileScopeSerialization")]
partial class DropFileScopeSerialization
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<bool>("AutoContinueOnUsageLimit")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("auto_continue_on_usage_limit");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<int>("NextTaskNumber")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("next_task_number");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
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<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<int?>("TicketProjectId")
.HasColumnType("INTEGER")
.HasColumnName("ticket_project_id");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<string>("Kind")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("ping")
.HasColumnName("action_kind");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<int?>("FailureMaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("failure_max_turns");
b.Property<string>("FailureReason")
.HasColumnType("TEXT")
.HasColumnName("failure_reason");
b.Property<int?>("FailureTurnsUsed")
.HasColumnType("INTEGER")
.HasColumnName("failure_turns_used");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<int>("Number")
.HasColumnType("INTEGER")
.HasColumnName("number");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("TicketRef")
.HasColumnType("TEXT")
.HasColumnName("ticket_ref");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.Property<DateTime?>("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<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<string>("Errors")
.HasColumnType("TEXT")
.HasColumnName("errors");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<string>("ResultSubtype")
.HasColumnType("TEXT")
.HasColumnName("result_subtype");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("TerminalReason")
.HasColumnType("TEXT")
.HasColumnName("terminal_reason");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.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
}
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class DropFileScopeSerialization : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "scope_globs",
table: "tasks");
migrationBuilder.DropColumn(
name: "serialize_on_file_overlap",
table: "list_config");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "scope_globs",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "serialize_on_file_overlap",
table: "list_config",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
}
}
@@ -243,12 +243,6 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<bool>("SerializeOnFileOverlap")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("serialize_on_file_overlap");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
@@ -626,10 +620,6 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("ScopeGlobs")
.HasColumnType("TEXT")
.HasColumnName("scope_globs");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
@@ -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.
-6
View File
@@ -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 "<provider>:<id>" (aktuell immer
// "bandel:<ticketId>"). Gesetzt vom Ticket-Import, sonst null. Der Provider-Präfix existiert,
// damit ein zweites Ticketsystem später keine Datenmigration braucht.
@@ -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;
}
+5 -6
View File
@@ -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<string>? 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<string>? 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<string>? SessionSkills = null, string? PermissionMode = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? 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<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null);
public record SeedResultDto(int Copied, int Skipped);
@@ -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",
@@ -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",
@@ -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<string>? SelectedSessionSkillNames()
@@ -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();
+4 -5
View File
@@ -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)
+4 -6
View File
@@ -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);
+2 -33
View File
@@ -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<QueueSlotDto> ActiveSlots,
IReadOnlyList<string> WaitingTaskIds,
IReadOnlyList<QueueWaitReasonDto> ScopeBlockedTasks,
IReadOnlyList<int> 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<GetQueueStateResult> 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<QueueWaitReasonDto>();
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());
}
}
+5 -11
View File
@@ -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<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
-73
View File
@@ -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<TaskEntity?> 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<TaskEntity?> ClaimRespectingScopeAsync(
ClaudeDoDbContext ctx, DateTime now, string startedAtStr, HashSet<string> 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<string>(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;
}
}
-89
View File
@@ -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<string> ParseScopeGlobs(string? scopeGlobs)
=> string.IsNullOrWhiteSpace(scopeGlobs)
? []
: scopeGlobs.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
public static IReadOnlyList<string> ParseDiffStatFiles(string? diffStat)
{
if (string.IsNullOrWhiteSpace(diffStat)) return [];
var files = new List<string>();
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<string> a, IEnumerable<string> 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<string?> 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;
}
}
@@ -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]
@@ -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);
}
}
@@ -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<ListConfigEntity?> 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);
}
}
@@ -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);
}
}
@@ -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&lt;-&gt;ticket-project link. UpdateListConfigDto's
/// TicketProjectId is therefore tri-state: null = keep stored, &lt;=0 = clear, &gt;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