diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 357f20d1..5a3e627d 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -4,8 +4,8 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio ## Models -- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId. - - Status / PlanningPhase / BlockedByTaskId semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model. +- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), DependsOnTaskId (FK to a user/MCP-declared predecessor, distinct from BlockedByTaskId), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId. + - Status / PlanningPhase / BlockedByTaskId / DependsOnTaskId semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model. - `HandlerBaseCommit`/`HandlerHeadCommit` = the review range for a **worktree-less "list handler" host task** ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair whenever `Worktree` is null → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). - `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). - Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill via the `RetireLegacyTaskStatus` migration. @@ -96,8 +96,10 @@ Tables (one per line so parallel migrations don't collide on the same line): - `task_attachments` Managed by EF Core migrations in `Migrations/` — **`ls Migrations/` is the authoritative history**; -don't maintain a changelog here. `tasks` holds `status`, `planning_phase` (default `none`), and -`blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). +don't maintain a changelog here. `tasks` holds `status`, `planning_phase` (default `none`), +`blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`), and `depends_on_task_id` (same FK +shape, but a separate column — see Worker/CLAUDE.md → Status Model for why it isn't unified with +`blocked_by_task_id`). ## Conventions diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index de8aa8a3..dba832a8 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -74,6 +74,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired() .HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None); builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id"); + builder.Property(t => t.DependsOnTaskId).HasColumnName("depends_on_task_id"); builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for"); builder.Property(t => t.Result).HasColumnName("result"); builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback"); @@ -119,6 +120,13 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration .HasForeignKey(t => t.BlockedByTaskId) .OnDelete(DeleteBehavior.SetNull); + // DependsOn: user/MCP-declared predecessor. SetNull on delete so the dependent becomes + // pickable rather than blocked forever on a task that no longer exists. + builder.HasOne() + .WithMany() + .HasForeignKey(t => t.DependsOnTaskId) + .OnDelete(DeleteBehavior.SetNull); + builder.HasOne(t => t.List) .WithMany(l => l.Tasks) .HasForeignKey(t => t.ListId) @@ -133,5 +141,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort"); builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id"); builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by"); + builder.HasIndex(t => t.DependsOnTaskId).HasDatabaseName("idx_tasks_depends_on"); } } diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index 678dbc0b..d82c073f 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -478,6 +478,21 @@ public sealed class GitService .Count(s => s.Length > 0); } + /// Files that differ between two exact refs (2-dot, no merge-base resolution) -- used to see + /// what a target branch itself picked up since a task's fork point, as opposed to + /// 's 3-dot count of a branch's own changes. + public async Task> GetChangedFileNamesAsync( + string repoDir, string fromRef, string toRef, CancellationToken ct = default) + { + var (exitCode, stdout, _) = await RunGitAsync(repoDir, + ["diff", "--name-only", $"{fromRef}..{toRef}"], ct); + if (exitCode != 0) return Array.Empty(); + return stdout + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(s => s.Length > 0) + .ToList(); + } + private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync( string workDir, IEnumerable args, CancellationToken ct, string? stdinData = null, bool trimOutput = true) { diff --git a/src/ClaudeDo.Data/Migrations/20260810115110_AddTaskDependency.Designer.cs b/src/ClaudeDo.Data/Migrations/20260810115110_AddTaskDependency.Designer.cs new file mode 100644 index 00000000..2c3b2808 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260810115110_AddTaskDependency.Designer.cs @@ -0,0 +1,901 @@ +// +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("20260810115110_AddTaskDependency")] + partial class AddTaskDependency + { + /// + 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("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("DependsOnTaskId") + .HasColumnType("TEXT") + .HasColumnName("depends_on_task_id"); + + 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("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("DependsOnTaskId") + .HasDatabaseName("idx_tasks_depends_on"); + + 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.TaskEntity", null) + .WithMany() + .HasForeignKey("DependsOnTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260810115110_AddTaskDependency.cs b/src/ClaudeDo.Data/Migrations/20260810115110_AddTaskDependency.cs new file mode 100644 index 00000000..7717acd8 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260810115110_AddTaskDependency.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddTaskDependency : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "depends_on_task_id", + table: "tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "idx_tasks_depends_on", + table: "tasks", + column: "depends_on_task_id"); + + migrationBuilder.AddForeignKey( + name: "FK_tasks_tasks_depends_on_task_id", + table: "tasks", + column: "depends_on_task_id", + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_tasks_tasks_depends_on_task_id", + table: "tasks"); + + migrationBuilder.DropIndex( + name: "idx_tasks_depends_on", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "depends_on_task_id", + table: "tasks"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index a5559555..8a9f4888 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -473,6 +473,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("created_by"); + b.Property("DependsOnTaskId") + .HasColumnType("TEXT") + .HasColumnName("depends_on_task_id"); + b.Property("Description") .HasColumnType("TEXT") .HasColumnName("description"); @@ -622,6 +626,9 @@ namespace ClaudeDo.Data.Migrations b.HasIndex("BlockedByTaskId") .HasDatabaseName("idx_tasks_blocked_by"); + b.HasIndex("DependsOnTaskId") + .HasDatabaseName("idx_tasks_depends_on"); + b.HasIndex("ListId") .HasDatabaseName("idx_tasks_list_id"); @@ -859,6 +866,11 @@ namespace ClaudeDo.Data.Migrations .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") diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index 76c9efca..5f4f1078 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -28,6 +28,13 @@ public sealed class TaskEntity public TaskStatus Status { get; set; } = TaskStatus.Idle; public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None; public string? BlockedByTaskId { get; set; } + + // A user/MCP-declared predecessor, distinct from BlockedByTaskId (the planning chain's own + // internal link): the picker also skips a Queued task while this is set and the referenced + // task's Status isn't Done. Unlike the chain, a Failed/Cancelled dependency does NOT cascade + // or auto-resolve -- the dependent just stays blocked and reports why (see TaskStateService. + // SetDependsOnAsync and QueuePicker). + public string? DependsOnTaskId { get; set; } public DateTime? ScheduledFor { get; set; } public string? Result { get; set; } public string? ReviewFeedback { get; set; } diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index e52acd66..ebb15cdf 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -45,8 +45,8 @@ subfolder within their area; the namespace stays the area namespace. ## Architecture - **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821` -- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions. -- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). +- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, `DependsOnTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions. `SetDependsOnAsync` rejects a self-reference, an unknown dependency id, or a link that would create a cycle (walks the proposed predecessor's own `DependsOnTaskId` chain). +- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and (`DependsOnTaskId IS NULL` OR the dependency's `Status = 'done'`); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). - **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle. - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. @@ -61,7 +61,8 @@ not conflated. |---|---|---| | `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work done, waiting on children. | | `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. | -| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. | +| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. Internal to `PlanningChainCoordinator` — resolves (or cascades) on ANY terminal state of the predecessor. | +| `DependsOnTaskId` | nullable FK | User/MCP-declared predecessor (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker only skips a queued row while the dependency's `Status` isn't `Done` -- a Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked (see `QueuePicker`, `TaskStateService.SetDependsOnAsync`). `get_task`/`list_tasks`/`batch_get_tasks` surface this as `blocked`/`blockedReason`; `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. | | `IsManual` | bool | Reminder only the user can do. `EnqueueAsync`/`StartRunningAsync` refuse it, the picker skips it, `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. | | `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. | diff --git a/src/ClaudeDo.Worker/External/BatchMcpTools.cs b/src/ClaudeDo.Worker/External/BatchMcpTools.cs index 46cd41a9..74310829 100644 --- a/src/ClaudeDo.Worker/External/BatchMcpTools.cs +++ b/src/ClaudeDo.Worker/External/BatchMcpTools.cs @@ -217,7 +217,7 @@ public sealed class BatchMcpTools { var created = await _svc.AddTask( listId, item.Title, item.Description, createdBy, - queueImmediately, item.Model, cancellationToken); + queueImmediately, item.Model, cancellationToken: cancellationToken); results.Add(new BatchAddTaskResult(i, item.Title, true, created.Task, created.PossibleDuplicates, null)); } catch (OperationCanceledException) { throw; } diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 92bdbe81..34428ca5 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -51,7 +51,14 @@ public sealed record TaskDto( // field. Lets a caller triage without pulling get_task_log's raw NDJSON. string? FailureReason = null, int? FailureTurnsUsed = null, - int? FailureMaxTurns = null); + int? FailureMaxTurns = null, + // A user/MCP-declared predecessor (set via add_task/update_task), distinct from the + // planning chain's own internal BlockedByTaskId link. + string? DependsOnTaskId = null, + // True only while Status is Queued and the picker will not claim this task yet -- either a + // planning-chain predecessor or DependsOnTaskId hasn't reached Done. See BlockedReason. + bool Blocked = false, + string? BlockedReason = null); // Lean counterpart to TaskDto for writing/status-changing tools: echoes back what changed // without re-sending Description/Result, which the caller just sent or already has. @@ -65,7 +72,10 @@ public sealed record TaskRefDto( int RoadblockCount = 0, string? FailureReason = null, int? FailureTurnsUsed = null, - int? FailureMaxTurns = null); + int? FailureMaxTurns = null, + string? DependsOnTaskId = null, + bool Blocked = false, + string? BlockedReason = null); // tasks is populated when includeDescription=false (the default): lean references, no // Description/Result. tasksFull is populated when includeDescription=true: full tasks incl. @@ -119,11 +129,18 @@ public sealed record ResolveConflictHunkResultDto( // of its output in VerifyOutputTail. public sealed record MergePreviewToolDto( string Status, IReadOnlyList ConflictFiles, int ChangedFileCount, int Behind, bool IsEmpty = false, - int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null); + int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null, + // Files this branch touches that the target branch ALSO touched since the branch's fork + // point -- an honest staleness signal `behind` alone can't give (a branch can be far behind + // yet touch nothing the target changed, or close behind yet collide on the one file that + // matters). Empty for a worktree-less handler task, which commits straight onto the list's + // working dir and has no fork point to compare against. + IReadOnlyList? StaleFiles = null); public sealed record MergePreviewSetEntryDto( string TaskId, string Status, IReadOnlyList ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false, - int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null); + int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null, + IReadOnlyList? StaleFiles = null); public sealed record FileOverlapDto(string File, IReadOnlyList TaskIds); @@ -234,9 +251,59 @@ public sealed class ExternalMcpService query = query.Where(t => t.Status == statusFilter); var filtered = query.ToList(); + var blocked = await ComputeBlockedInfoAsync(filtered, cancellationToken); return includeDescription - ? new ListTasksResult(true, null, filtered.Select(ToDto).ToList()) - : new ListTasksResult(false, filtered.Select(ToRefDto).ToList(), null); + ? new ListTasksResult(true, null, filtered.Select(t => ToDto(t, blocked[t.Id].Blocked, blocked[t.Id].Reason)).ToList()) + : new ListTasksResult(false, filtered.Select(t => ToRefDto(t, blocked[t.Id].Blocked, blocked[t.Id].Reason)).ToList(), null); + } + + // Batch-resolves, for each task, whether the picker is currently skipping it (Queued with + // either a planning-chain BlockedByTaskId or an unmet DependsOnTaskId) and why. Only Queued + // tasks can be blocked -- once a task has run, or hasn't been queued yet, blocking is moot. + private async Task> ComputeBlockedInfoAsync( + IReadOnlyList tasks, CancellationToken ct) + { + var dependencyIds = tasks + .Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId is null && t.DependsOnTaskId is not null) + .Select(t => t.DependsOnTaskId!) + .Distinct() + .ToList(); + + var dependencyStatuses = new Dictionary(); + if (dependencyIds.Count > 0) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + dependencyStatuses = await ctx.Tasks.AsNoTracking() + .Where(t => dependencyIds.Contains(t.Id)) + .Select(t => new { t.Id, t.Status }) + .ToDictionaryAsync(t => t.Id, t => t.Status, ct); + } + + var result = new Dictionary(); + foreach (var t in tasks) + { + if (t.Status != TaskStatus.Queued) + { + result[t.Id] = (false, null); + } + else if (t.BlockedByTaskId is not null) + { + result[t.Id] = (true, $"Blocked by planning-chain predecessor {t.BlockedByTaskId}."); + } + else if (t.DependsOnTaskId is not null) + { + var known = dependencyStatuses.TryGetValue(t.DependsOnTaskId, out var depStatus); + result[t.Id] = known && depStatus == TaskStatus.Done + ? (false, null) + : (true, $"Blocked: depends on task {t.DependsOnTaskId} (status: " + + (known ? depStatus.ToString() : "not found") + ")."); + } + else + { + result[t.Id] = (false, null); + } + } + return result; } [McpServerTool, Description( @@ -251,7 +318,8 @@ public sealed class ExternalMcpService { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); - return ToDto(task); + var blocked = await ComputeBlockedInfoAsync([task], cancellationToken); + return ToDto(task, blocked[task.Id].Blocked, blocked[task.Id].Reason); } // Lean counterpart to GetTask, used internally by BatchGetTasks' default (includeDescription=false) @@ -260,7 +328,8 @@ public sealed class ExternalMcpService { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); - return ToRefDto(task); + var blocked = await ComputeBlockedInfoAsync([task], cancellationToken); + return ToRefDto(task, blocked[task.Id].Blocked, blocked[task.Id].Reason); } [McpServerTool, Description( @@ -278,6 +347,11 @@ public sealed class ExternalMcpService "for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " + "list/global default (normally sonnet).")] string? model = null, + [Description("Id of a task that must reach Done (i.e. be merged) before the picker will claim this one, " + + "even once queued. Rejected if it doesn't exist, is this task's own id, or would create a " + + "dependency cycle. If that predecessor instead ends up Failed or Cancelled, this task simply " + + "stays blocked rather than starving silently -- check get_task/list_tasks' blocked field.")] + string? dependsOnTaskId = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(listId)) @@ -306,6 +380,14 @@ public sealed class ExternalMcpService }; await _tasks.AddAsync(entity, cancellationToken); + if (dependsOnTaskId is not null) + { + var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOnTaskId, cancellationToken); + if (!dependsResult.Ok) + throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId."); + entity.DependsOnTaskId = dependsOnTaskId; + } + if (queueImmediately) { var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken); @@ -315,7 +397,8 @@ public sealed class ExternalMcpService } await _broadcaster.TaskUpdated(entity.Id); - return new AddTaskResult(ToRefDto(entity), possibleDuplicates); + var blocked = await ComputeBlockedInfoAsync([entity], cancellationToken); + return new AddTaskResult(ToRefDto(entity, blocked[entity.Id].Blocked, blocked[entity.Id].Reason), possibleDuplicates); } // Non-terminal: a task still open enough that a new, similarly-titled task might be a duplicate @@ -389,13 +472,17 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged." + - McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)] + "Update an existing task's title, description, commit type, and/or dependsOn link. Pass null to leave a " + + "field unchanged." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)] public async Task UpdateTask( string taskId, string? title = null, string? description = null, string? commitType = null, + [Description("Id of a task that must reach Done before the picker will claim this one. Pass an empty " + + "string to clear an existing link; null leaves it unchanged. Rejected if it doesn't exist, " + + "is this task's own id, or would create a dependency cycle.")] + string? dependsOnTaskId = null, CancellationToken cancellationToken = default) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -408,9 +495,17 @@ public sealed class ExternalMcpService if (commitType is not null) task.CommitType = commitType; await _tasks.UpdateAsync(task, cancellationToken); + if (dependsOnTaskId is not null) + { + var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOnTaskId.NullIfBlank(), cancellationToken); + if (!dependsResult.Ok) + throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId."); + } + var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; await _broadcaster.TaskUpdated(taskId); - return ToRefDto(reload); + var blocked = await ComputeBlockedInfoAsync([reload], cancellationToken); + return ToRefDto(reload, blocked[reload.Id].Blocked, blocked[reload.Id].Reason); } [McpServerTool, Description( @@ -1079,17 +1174,21 @@ public sealed class ExternalMcpService "verifyExitCode 0 means it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the " + "tail of its output in verifyOutputTail; verifyExitCode stays null when no verify command is configured. " + "isEmpty=true means the task's review range contributed nothing; check that flag rather than reading a " + - "small changedFileCount as empty. Throws if the task has neither an active worktree nor a handler commit " + - "range, or the list's working directory is missing from disk.")] + "small changedFileCount as empty. staleFiles lists files this branch touches that the target branch ALSO " + + "changed since this branch's fork point — a more honest staleness signal than `behind` alone, since a " + + "branch can be far behind yet touch nothing the target changed, or barely behind yet collide on the one " + + "file that matters (always empty for a worktree-less handler task, which has no fork point). Throws if the " + + "task has neither an active worktree nor a handler commit range, or the list's working directory is missing " + + "from disk.")] public async Task PreviewMerge( string taskId, [Description("Branch to preview against; defaults to the repo's current branch.")] string? targetBranch = null, CancellationToken cancellationToken = default) { - var (preview, behind, _, isEmpty) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken); + var (preview, behind, _, isEmpty, staleFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken); return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty, - preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail); + preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles); } [McpServerTool, Description( @@ -1124,10 +1223,10 @@ public sealed class ExternalMcpService { try { - var (preview, behind, changedFiles, isEmpty) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken); + var (preview, behind, changedFiles, isEmpty, staleFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken); entries.Add(new MergePreviewSetEntryDto( taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty, - preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail)); + preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles)); filesByTask[taskId] = changedFiles; } catch (InvalidOperationException ex) @@ -1184,7 +1283,7 @@ public sealed class ExternalMcpService // (its commits already sit on the list's working dir) — falls back to the fixed // HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that // range's own diff-stat instead of throwing "has no worktree". - private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList ChangedFiles, bool IsEmpty)> PreviewMergeCoreAsync( + private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList ChangedFiles, bool IsEmpty, IReadOnlyList StaleFiles)> PreviewMergeCoreAsync( string taskId, string? targetBranch, bool runVerify, CancellationToken ct) { using var ctx = _dbFactory.CreateDbContext(); @@ -1216,7 +1315,16 @@ public sealed class ExternalMcpService ? ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct: ct)) : Array.Empty(); - return (preview, behind, changedFiles, changedFiles.Count == 0); + // What the target itself picked up since this branch's fork point, so `behind` + // (a commit count) doesn't have to stand in for "does this collide" -- a branch can + // be far behind but touch nothing the target changed, or close behind and collide on + // the one file that matters. + var targetChangedSinceFork = await _git.GetChangedFileNamesAsync(list.WorkingDir, wt.BaseCommit, target, ct); + var staleFiles = changedFiles + .Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles); } if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead) @@ -1230,7 +1338,10 @@ public sealed class ExternalMcpService : ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct: ct)); var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty(), changedFiles.Count); - return (preview, 0, changedFiles, isEmpty); + // No fork point to diff against: a handler task commits straight onto the list's + // working dir instead of a branch, so there is nothing else that could have "changed + // in the target since the fork". + return (preview, 0, changedFiles, isEmpty, Array.Empty()); } throw new InvalidOperationException($"Task {taskId} has no worktree."); @@ -1497,7 +1608,7 @@ public sealed class ExternalMcpService return files; } - private static TaskDto ToDto(TaskEntity t) => new( + private static TaskDto ToDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new( t.Id, t.ListId, t.Title, @@ -1513,9 +1624,12 @@ public sealed class ExternalMcpService t.RoadblockCount, FailureReasonOf(t), t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null, - t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null); + t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null, + t.DependsOnTaskId, + blocked, + blockedReason); - private static TaskRefDto ToRefDto(TaskEntity t) => new( + private static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new( t.Id, t.ListId, t.Title, @@ -1525,7 +1639,10 @@ public sealed class ExternalMcpService t.RoadblockCount, FailureReasonOf(t), t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null, - t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null); + t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null, + t.DependsOnTaskId, + blocked, + blockedReason); // "unknown" covers a Failed task that predates this field (never got a classified reason // stamped) — a defined value rather than null so callers don't have to special-case it. diff --git a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs index a75eb2a4..6d3da27b 100644 --- a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs +++ b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs @@ -7,7 +7,9 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.External; -public sealed record TaskStatusChangeDto(string TaskId, string Status); +// BlockedReason is set only when Status is "Blocked" -- a Queued task the picker will not +// claim yet, either because of a planning-chain predecessor or an unmet depends-on link. +public sealed record TaskStatusChangeDto(string TaskId, string Status, string? BlockedReason = null); public sealed record WaitForTaskChangeResult(IReadOnlyList Changed, bool TimedOut); [McpServerToolType] @@ -42,9 +44,12 @@ public sealed class TaskWaitMcpTools [McpServerTool, Description( "Blocks until at least one of the given tasks leaves Queued/Running -- use this instead of " + "polling get_task in a loop. Returns immediately if a task is already outside Queued/Running " + - "(an unknown id reports status \"NotFound\" and counts as changed). Pitfall: a planning parent " + - "goes Running -> WaitingForChildren while its children are still working, so by default " + - "waiting on a parent returns early; see treatWaitingForChildrenAsBusy. Sends MCP progress " + + "(an unknown id reports status \"NotFound\" and counts as changed). A Queued task the picker " + + "will not claim yet (a planning-chain predecessor, or a depends_on link whose target isn't " + + "Done) also reports immediately as status \"Blocked\" with blockedReason set, instead of " + + "silently waiting out the full timeout. Pitfall: a planning parent goes Running -> " + + "WaitingForChildren while its children are still working, so by default waiting on a parent " + + "returns early; see treatWaitingForChildrenAsBusy. Sends MCP progress " + "pings every 30s while waiting so a long wait survives the calling client's own idle-silence " + "abort (Claude Code defaults to killing an MCP call after ~300s of silence) -- this is not " + "guaranteed by every possible MCP client.")] @@ -101,22 +106,60 @@ public sealed class TaskWaitMcpTools var rows = await ctx.Tasks .AsNoTracking() .Where(t => taskIds.Contains(t.Id)) - .Select(t => new { t.Id, t.Status }) + .Select(t => new { t.Id, t.Status, t.BlockedByTaskId, t.DependsOnTaskId }) .ToListAsync(ct); - var byId = rows.ToDictionary(r => r.Id, r => r.Status); + var byId = rows.ToDictionary(r => r.Id, r => r); + + // A Queued task with an unmet depends_on link never becomes "changed" by itself -- the + // picker will not touch it. Batch-resolve those dependencies' statuses once instead of a + // query per candidate. + var dependencyIds = rows + .Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null && r.DependsOnTaskId is not null) + .Select(r => r.DependsOnTaskId!) + .Distinct() + .ToList(); + var dependencyStatuses = dependencyIds.Count == 0 + ? new Dictionary() + : await ctx.Tasks.AsNoTracking() + .Where(t => dependencyIds.Contains(t.Id)) + .Select(t => new { t.Id, t.Status }) + .ToDictionaryAsync(t => t.Id, t => t.Status, ct); + var result = new List(); foreach (var id in taskIds) { - if (!byId.TryGetValue(id, out var status)) + if (!byId.TryGetValue(id, out var row)) { result.Add(new TaskStatusChangeDto(id, "NotFound")); continue; } - var busy = status == TaskStatus.Queued || status == TaskStatus.Running - || (treatWaitingForChildrenAsBusy && status == TaskStatus.WaitingForChildren); + + if (row.Status == TaskStatus.Queued) + { + if (row.BlockedByTaskId is not null) + { + result.Add(new TaskStatusChangeDto(id, "Blocked", + $"Blocked by planning-chain predecessor {row.BlockedByTaskId}.")); + continue; + } + if (row.DependsOnTaskId is not null) + { + var known = dependencyStatuses.TryGetValue(row.DependsOnTaskId, out var depStatus); + if (!known || depStatus != TaskStatus.Done) + { + result.Add(new TaskStatusChangeDto(id, "Blocked", + $"Blocked: depends on task {row.DependsOnTaskId} (status: " + + (known ? depStatus.ToString() : "not found") + ").")); + continue; + } + } + } + + var busy = row.Status == TaskStatus.Queued || row.Status == TaskStatus.Running + || (treatWaitingForChildrenAsBusy && row.Status == TaskStatus.WaitingForChildren); if (!busy) - result.Add(new TaskStatusChangeDto(id, status.ToString())); + result.Add(new TaskStatusChangeDto(id, row.Status.ToString())); } return result; } diff --git a/src/ClaudeDo.Worker/Queue/QueuePicker.cs b/src/ClaudeDo.Worker/Queue/QueuePicker.cs index 72be8c90..db758427 100644 --- a/src/ClaudeDo.Worker/Queue/QueuePicker.cs +++ b/src/ClaudeDo.Worker/Queue/QueuePicker.cs @@ -36,7 +36,10 @@ public sealed class QueuePicker : IQueuePicker { // 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). + // Eligible task must be Queued, unblocked (chain and depends-on), not manual, and due + // (or unscheduled). depends_on_task_id only gates on the dependency's Status='done' -- + // unlike blocked_by_task_id there is no cascade on the dependency failing, so a task + // with a Failed dependency simply stays here, skipped, until someone intervenes. // 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} @@ -46,6 +49,11 @@ public sealed class QueuePicker : IQueuePicker AND t.blocked_by_task_id IS NULL AND t.is_manual = 0 AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0}) + AND (t.depends_on_task_id IS NULL + OR EXISTS ( + SELECT 1 FROM tasks d + WHERE d.id = t.depends_on_task_id AND d.status = 'done' + )) ORDER BY t.sort_order ASC, t.created_at ASC LIMIT 1 ) @@ -72,8 +80,28 @@ public sealed class QueuePicker : IQueuePicker .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ToListAsync(ct); + // The same depends-on gate ClaimTopEligibleAsync applies in SQL: a declared dependency must + // be Done. Resolved as one extra query over just the referenced ids (rather than repeating + // the correlated subquery) so both claim paths agree -- without it, opting a list into + // scope serialization would silently stop enforcing dependencies. + var dependencyIds = candidates + .Where(t => t.DependsOnTaskId != null) + .Select(t => t.DependsOnTaskId!) + .Distinct() + .ToList(); + var doneDependencyIds = dependencyIds.Count == 0 + ? new HashSet(StringComparer.Ordinal) + : (await ctx.Tasks.AsNoTracking() + .Where(d => dependencyIds.Contains(d.Id) && d.Status == TaskStatus.Done) + .Select(d => d.Id) + .ToListAsync(ct)) + .ToHashSet(StringComparer.Ordinal); + foreach (var candidate in candidates) { + if (candidate.DependsOnTaskId is { } dependsOn && !doneDependencyIds.Contains(dependsOn)) + continue; + if (serializingListIds.Contains(candidate.ListId) && await ScopeOverlap.FindBlockingSiblingAsync(ctx, candidate, ct) is not null) continue; diff --git a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs index ffcc047d..9e257ef1 100644 --- a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs +++ b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs @@ -27,6 +27,10 @@ public interface ITaskStateService Task BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct); Task UnblockAsync(string taskId, CancellationToken ct); + // dependsOnTaskId null clears the dependency. Rejects self-reference, an unknown dependency + // id, and a link that would create a cycle -- see TaskStateService for the walk. + Task SetDependsOnAsync(string taskId, string? dependsOnTaskId, CancellationToken ct); + // Surfaces a WaitingForChildren parent for review once all its children are terminal. // Best-effort (swallows and logs failures) — safe to call after any child mutation, // e.g. deleting the last non-terminal child (no terminal transition fires for a delete). diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index 4653400e..24429b55 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -432,6 +432,50 @@ public sealed class TaskStateService : ITaskStateService return new TransitionResult(true, null); } + public async Task SetDependsOnAsync(string taskId, string? dependsOnTaskId, CancellationToken ct) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + + if (dependsOnTaskId is not null) + { + if (dependsOnTaskId == taskId) + return new TransitionResult(false, "A task cannot depend on itself."); + + if (!await ctx.Tasks.AsNoTracking().AnyAsync(t => t.Id == dependsOnTaskId, ct)) + return new TransitionResult(false, $"Dependency task {dependsOnTaskId} not found."); + + // Walk the proposed predecessor's own chain of dependencies; if it leads back to + // taskId, linking here would create a cycle that starves both tasks forever (the + // picker never claims either). `visited` also stops us looping forever on + // pre-existing bad data unrelated to this write. + var current = dependsOnTaskId; + var visited = new HashSet(); + while (current is not null) + { + if (current == taskId) + return new TransitionResult(false, "Setting this dependency would create a cycle."); + if (!visited.Add(current)) + break; + current = await ctx.Tasks.AsNoTracking() + .Where(t => t.Id == current) + .Select(t => t.DependsOnTaskId) + .FirstOrDefaultAsync(ct); + } + } + + var affected = await ctx.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.DependsOnTaskId, dependsOnTaskId), ct); + + if (affected == 0) + return new TransitionResult(false, "Task not found."); + + // Clearing a dependency may free up a Queued task the picker was skipping. + if (dependsOnTaskId is null) _waker.Wake(); + await _broadcaster.TaskUpdated(taskId); + return new TransitionResult(true, null); + } + public async Task RecoverStaleRunningAsync(string reason, CancellationToken ct) { var resultText = "[stale] " + reason; diff --git a/tests/ClaudeDo.Data.Tests/ForeignKeyTests.cs b/tests/ClaudeDo.Data.Tests/ForeignKeyTests.cs index 0dd3eb78..b2c51577 100644 --- a/tests/ClaudeDo.Data.Tests/ForeignKeyTests.cs +++ b/tests/ClaudeDo.Data.Tests/ForeignKeyTests.cs @@ -69,6 +69,35 @@ public sealed class ForeignKeyTests : IDisposable } } + [Fact] + public async Task DependsOnTaskId_is_nulled_when_predecessor_deleted_on_fresh_context() + { + var listId = Guid.NewGuid().ToString(); + var predecessorId = Guid.NewGuid().ToString(); + var dependentId = Guid.NewGuid().ToString(); + + await using (var ctx = Open()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = predecessorId, ListId = listId, Title = "Predecessor", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = dependentId, ListId = listId, Title = "Dependent", Status = TaskStatus.Idle, DependsOnTaskId = predecessorId, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + + await using (var ctx = Open()) + { + var predecessor = await ctx.Tasks.FindAsync(predecessorId); + ctx.Tasks.Remove(predecessor!); + await ctx.SaveChangesAsync(); + } + + await using (var ctx = Open()) + { + var dependent = await ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == dependentId); + Assert.Null(dependent.DependsOnTaskId); + } + } + // ---- AppSettingsRepository: get-or-create resilience ---- [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs index b6228b39..6ae26e6b 100644 --- a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -366,6 +366,23 @@ public sealed class BatchMcpToolsTests : IDisposable Assert.True(json.Length < 25_000, $"response was {json.Length} chars, expected < 25000"); } + [Fact] + public async Task BatchGetTasks_QueuedWithUnmetDependsOn_ReportsBlocked() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle); + var task = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued); + task.DependsOnTaskId = predecessor.Id; + await _tasks.UpdateAsync(task); + var sut = BuildSut(); + + var results = await sut.BatchGetTasks(new[] { task.Id }, cancellationToken: CancellationToken.None); + + var found = results.Single(r => r.Id == task.Id); + Assert.True(found.Task!.Blocked); + Assert.Contains(predecessor.Id, found.Task!.BlockedReason); + } + [Fact] public async Task BatchDeleteTasks_RunningTask_ReportedNotOk_OthersDeleted() { diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index e2699304..fdc5b717 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -185,7 +185,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var queue = CreateQueue(); var sut = BuildSut(queue); - var dto = await sut.UpdateTask(task.Id, "new title", null, null, CancellationToken.None); + var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None); Assert.Equal("new title", dto.Title); var loaded = await _tasks.GetByIdAsync(task.Id); @@ -206,7 +206,7 @@ public sealed class ExternalMcpServiceTests : IDisposable await _tasks.UpdateAsync(task, CancellationToken.None); var sut = BuildSut(CreateQueue()); - var dto = await sut.UpdateTask(task.Id, "new title", null, null, CancellationToken.None); + var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None); Assert.Equal(task.Id, dto.Id); Assert.Equal(listId, dto.ListId); @@ -289,6 +289,91 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.Equal("error", dto.FailureReason); } + [Fact] + public async Task GetTask_QueuedWithUnmetDependsOn_ReportsBlockedTrueAndReason() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); + task.DependsOnTaskId = predecessor.Id; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.True(dto.Blocked); + Assert.Contains(predecessor.Id, dto.BlockedReason); + } + + [Fact] + public async Task GetTask_QueuedWithDoneDependsOn_ReportsBlockedFalse() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Done); + var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); + task.DependsOnTaskId = predecessor.Id; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.False(dto.Blocked); + Assert.Null(dto.BlockedReason); + } + + [Fact] + public async Task GetTask_IdleWithDependsOnTaskId_ReportsBlockedFalse_NotYetQueued() + { + // Blocking is only meaningful once the picker would actually be gating the task -- + // an Idle task hasn't been queued at all, so DependsOnTaskId doesn't apply yet. + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); + task.DependsOnTaskId = predecessor.Id; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.False(dto.Blocked); + } + + [Fact] + public async Task ListTasks_ReportsBlockedPerTask() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle); + var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued); + blocked.DependsOnTaskId = predecessor.Id; + await _tasks.UpdateAsync(blocked, CancellationToken.None); + var unblocked = await SeedTaskAsync(listId, "unblocked", TaskStatus.Queued); + var sut = BuildSut(CreateQueue()); + + var result = await sut.ListTasks(listId, cancellationToken: CancellationToken.None); + + var blockedRef = result.Tasks!.Single(t => t.Id == blocked.Id); + var unblockedRef = result.Tasks!.Single(t => t.Id == unblocked.Id); + Assert.True(blockedRef.Blocked); + Assert.Contains(predecessor.Id, blockedRef.BlockedReason); + Assert.False(unblockedRef.Blocked); + } + + [Fact] + public async Task ListTasks_IncludeDescriptionTrue_ReportsBlockedOnFullDto() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle); + var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued); + blocked.DependsOnTaskId = predecessor.Id; + await _tasks.UpdateAsync(blocked, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None); + + var dto = result.TasksFull!.Single(t => t.Id == blocked.Id); + Assert.True(dto.Blocked); + } + [Fact] public async Task UpdateTask_OnRunning_Throws() { @@ -298,7 +383,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var sut = BuildSut(queue); await Assert.ThrowsAsync(() => - sut.UpdateTask(task.Id, "x", null, null, CancellationToken.None)); + sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None)); } [Fact] @@ -308,7 +393,51 @@ public sealed class ExternalMcpServiceTests : IDisposable var sut = BuildSut(queue); await Assert.ThrowsAsync(() => - sut.UpdateTask("does-not-exist", "x", null, null, CancellationToken.None)); + sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task UpdateTask_WithDependsOnTaskId_SetsLinkAndReportsBlocked() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None); + + Assert.Equal(predecessor.Id, dto.DependsOnTaskId); + Assert.True(dto.Blocked); + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId); + } + + [Fact] + public async Task UpdateTask_WithEmptyStringDependsOnTaskId_ClearsExistingLink() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); + var sut = BuildSut(CreateQueue()); + await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None); + + var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: "", cancellationToken: CancellationToken.None); + + Assert.Null(dto.DependsOnTaskId); + Assert.False(dto.Blocked); + } + + [Fact] + public async Task UpdateTask_WithCyclicDependsOnTaskId_Throws() + { + var listId = await SeedListAsync(); + var a = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var b = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var sut = BuildSut(CreateQueue()); + await sut.UpdateTask(b.Id, dependsOnTaskId: a.Id, cancellationToken: CancellationToken.None); + + await Assert.ThrowsAsync(() => + sut.UpdateTask(a.Id, dependsOnTaskId: b.Id, cancellationToken: CancellationToken.None)); } [Fact] @@ -1911,6 +2040,92 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None)); } + [Fact] + public async Task PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); + await CreateActiveWorktreeAsync(repo, task.Id, "shared.txt", "from branch\n"); + + // The target branch itself moved on and touched the same file after the fork point. + File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "from main\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "shared.txt"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt"); + + var sut = BuildSut(CreateQueue()); + var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None); + + Assert.Equal(1, result.Behind); + Assert.NotNull(result.StaleFiles); + Assert.Contains("shared.txt", result.StaleFiles!); + } + + [Fact] + public async Task PreviewMerge_TargetBehindButUnrelatedFile_ReportsNoStaleFiles() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); + await CreateActiveWorktreeAsync(repo, task.Id, "branch-only.txt", "from branch\n"); + + // Target moved on (behind > 0) but touched an entirely different file -- a stale + // branch that still collides with nothing. + File.WriteAllText(Path.Combine(repo.RepoDir, "unrelated.txt"), "from main\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "unrelated.txt"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else"); + + var sut = BuildSut(CreateQueue()); + var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None); + + Assert.Equal(1, result.Behind); + Assert.NotNull(result.StaleFiles); + Assert.Empty(result.StaleFiles!); + } + + [Fact] + public async Task PreviewMerge_WorktreeLessHandlerTask_ReportsNoStaleFiles() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); + _repos.Add(repo); + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); + + File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); + var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); + + using (var ctx = _db.CreateContext()) + { + var t = await ctx.Tasks.FindAsync(task.Id); + t!.HandlerBaseCommit = repo.BaseCommit; + t.HandlerHeadCommit = headCommit; + await ctx.SaveChangesAsync(); + } + + var sut = BuildSut(CreateQueue()); + var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None); + + Assert.NotNull(result.StaleFiles); + Assert.Empty(result.StaleFiles!); + } + [Fact] public async Task PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap() { @@ -2152,6 +2367,35 @@ public sealed class ExternalMcpServiceTests : IDisposable () => sut.AddTask(listId, "t", model: "gpt4", cancellationToken: CancellationToken.None)); } + [Fact] + public async Task AddTask_WithDependsOnTaskId_PersistsLinkAndReportsBlocked() + { + var listId = await SeedListAsync(); + var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var sut = NewService(); + + var dto = await sut.AddTask( + listId, "t", dependsOnTaskId: predecessor.Id, queueImmediately: true, cancellationToken: CancellationToken.None); + + Assert.Equal(predecessor.Id, dto.Task.DependsOnTaskId); + Assert.True(dto.Task.Blocked); + Assert.Contains(predecessor.Id, dto.Task.BlockedReason); + var loaded = await _tasks.GetByIdAsync(dto.Task.Id); + Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId); + } + + [Fact] + public async Task AddTask_WithSelfReferencingDependsOnTaskId_Throws() + { + // Can't reference its own not-yet-known id, so this exercises the not-found path -- + // an unknown dependsOnTaskId is rejected the same way a self-reference would be. + var listId = await SeedListAsync(); + var sut = NewService(); + + await Assert.ThrowsAsync( + () => sut.AddTask(listId, "t", dependsOnTaskId: "does-not-exist", cancellationToken: CancellationToken.None)); + } + // ── AddTask possible-duplicate check ──────────────────────────────────────── [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/External/TaskWaitMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/TaskWaitMcpToolsTests.cs index c630a894..4d6f6dd7 100644 --- a/tests/ClaudeDo.Worker.Tests/External/TaskWaitMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/TaskWaitMcpToolsTests.cs @@ -197,6 +197,64 @@ public sealed class TaskWaitMcpToolsTests : IDisposable Assert.True(sw.Elapsed < TimeSpan.FromSeconds(2), $"took {sw.Elapsed}"); } + [Fact] + public async Task WaitForTaskChange_QueuedWithBlockedByTaskId_ReportsBlockedImmediately() + { + var predecessor = await SeedTaskAsync(TaskStatus.Queued); + var task = await SeedTaskAsync(TaskStatus.Queued); + task.BlockedByTaskId = predecessor.Id; + await _tasks.UpdateAsync(task); + var sut = BuildSut(); + var sw = Stopwatch.StartNew(); + + var result = await sut.WaitForTaskChange([task.Id], timeoutSeconds: 30, cancellationToken: CancellationToken.None); + + sw.Stop(); + Assert.False(result.TimedOut); + var change = Assert.Single(result.Changed); + Assert.Equal("Blocked", change.Status); + Assert.Contains(predecessor.Id, change.BlockedReason); + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(2), $"took {sw.Elapsed}"); + } + + [Fact] + public async Task WaitForTaskChange_QueuedWithUnmetDependsOn_ReportsBlockedImmediately_InsteadOfTimingOut() + { + var dependency = await SeedTaskAsync(TaskStatus.Idle); + var task = await SeedTaskAsync(TaskStatus.Queued); + task.DependsOnTaskId = dependency.Id; + await _tasks.UpdateAsync(task); + var sut = BuildSut(); + var sw = Stopwatch.StartNew(); + + var result = await sut.WaitForTaskChange([task.Id], timeoutSeconds: 30, cancellationToken: CancellationToken.None); + + sw.Stop(); + Assert.False(result.TimedOut); + var change = Assert.Single(result.Changed); + Assert.Equal("Blocked", change.Status); + Assert.Contains(dependency.Id, change.BlockedReason); + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(2), $"took {sw.Elapsed}"); + } + + [Fact] + public async Task WaitForTaskChange_QueuedWithDoneDependsOn_IsNotBlocked_StillWaitsAsBusy() + { + var dependency = await SeedTaskAsync(TaskStatus.Done); + var task = await SeedTaskAsync(TaskStatus.Queued); + task.DependsOnTaskId = dependency.Id; + await _tasks.UpdateAsync(task); + var sut = BuildSut(); + var sw = Stopwatch.StartNew(); + + var result = await sut.WaitForTaskChange([task.Id], timeoutSeconds: 1, cancellationToken: CancellationToken.None); + + sw.Stop(); + Assert.True(result.TimedOut); + Assert.Empty(result.Changed); + Assert.True(sw.Elapsed >= TimeSpan.FromMilliseconds(900), $"took {sw.Elapsed}"); + } + [Fact] public void MaxTimeoutSeconds_StaysComfortablyUnderMcpToolTimeout() { diff --git a/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs b/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs index 9670354c..03f6128f 100644 --- a/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs @@ -50,7 +50,8 @@ public sealed class QueuePickerTests : IDisposable bool taskAgentTag = false, int? sortOrder = null, bool isManual = false, - string? scopeGlobs = null) + string? scopeGlobs = null, + string? dependsOn = null) { var task = new TaskEntity { @@ -61,6 +62,7 @@ public sealed class QueuePickerTests : IDisposable CreatedAt = createdAt ?? DateTime.UtcNow, ScheduledFor = scheduledFor, BlockedByTaskId = blockedBy, + DependsOnTaskId = dependsOn, CommitType = "feat", IsManual = isManual, ScopeGlobs = scopeGlobs, @@ -131,6 +133,49 @@ public sealed class QueuePickerTests : IDisposable Assert.Null(second); } + [Fact] + public async Task ClaimNextAsync_Skips_TasksWithUnmetDependsOn() + { + var listId = await CreateListAsync(); + var dependency = await SeedAsync(listId, status: TaskStatus.Idle); + await SeedAsync(listId, dependsOn: dependency.Id); + + // The dependency is only Idle (not Done), so both stay unclaimed: the dependent is + // gated on depends_on_task_id, and the dependency itself was never queued. + Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None)); + } + + [Fact] + public async Task ClaimNextAsync_Claims_TaskWhoseDependsOnIsDone() + { + var listId = await CreateListAsync(); + var dependency = await SeedAsync(listId, status: TaskStatus.Done); + var dependent = await SeedAsync(listId, dependsOn: dependency.Id); + + var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); + + Assert.NotNull(picked); + Assert.Equal(dependent.Id, picked!.Id); + } + + [Fact] + public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnFailed_ButKeepsClaimingOthers() + { + var listId = await CreateListAsync(); + var dependency = await SeedAsync(listId, status: TaskStatus.Failed); + await SeedAsync(listId, dependsOn: dependency.Id); + var independent = await SeedAsync(listId, createdAt: DateTime.UtcNow.AddMinutes(1)); + + // A Failed dependency does not cascade-cancel or auto-unblock the dependent (unlike the + // planning chain's BlockedByTaskId) -- it just stays Queued and skipped forever. + var first = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); + Assert.NotNull(first); + Assert.Equal(independent.Id, first!.Id); + + var second = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); + Assert.Null(second); + } + [Fact] public async Task ClaimNextAsync_Picks_TasksWithoutAgentTag() { diff --git a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs index 629b666c..1df64ae4 100644 --- a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs @@ -472,6 +472,100 @@ public sealed class TaskStateServiceTests : IDisposable Assert.True(_built.WakeCount() > wakesBefore); } + // ─── SetDependsOnAsync ──────────────────────────────────────────────── + + [Fact] + public async Task SetDependsOnAsync_SetsDependsOnTaskId() + { + var predecessor = await SeedTaskAsync(TaskStatus.Idle); + var task = await SeedTaskAsync(TaskStatus.Queued); + + var result = await _sut.SetDependsOnAsync(task, predecessor, default); + + Assert.True(result.Ok); + var t = await GetTaskAsync(task); + Assert.Equal(predecessor, t.DependsOnTaskId); + } + + [Fact] + public async Task SetDependsOnAsync_Null_ClearsDependsOnTaskId_AndWakesQueue() + { + var predecessor = await SeedTaskAsync(TaskStatus.Idle); + var task = await SeedTaskAsync(TaskStatus.Queued); + await _sut.SetDependsOnAsync(task, predecessor, default); + var wakesBefore = _built.WakeCount(); + + var result = await _sut.SetDependsOnAsync(task, null, default); + + Assert.True(result.Ok); + var t = await GetTaskAsync(task); + Assert.Null(t.DependsOnTaskId); + Assert.True(_built.WakeCount() > wakesBefore); + } + + [Fact] + public async Task SetDependsOnAsync_SelfReference_Rejected() + { + var task = await SeedTaskAsync(TaskStatus.Queued); + + var result = await _sut.SetDependsOnAsync(task, task, default); + + Assert.False(result.Ok); + var t = await GetTaskAsync(task); + Assert.Null(t.DependsOnTaskId); + } + + [Fact] + public async Task SetDependsOnAsync_UnknownDependency_Rejected() + { + var task = await SeedTaskAsync(TaskStatus.Queued); + + var result = await _sut.SetDependsOnAsync(task, "does-not-exist", default); + + Assert.False(result.Ok); + } + + [Fact] + public async Task SetDependsOnAsync_NotFound_Rejected() + { + var predecessor = await SeedTaskAsync(TaskStatus.Idle); + + var result = await _sut.SetDependsOnAsync("does-not-exist", predecessor, default); + + Assert.False(result.Ok); + } + + [Fact] + public async Task SetDependsOnAsync_DirectCycle_Rejected() + { + var a = await SeedTaskAsync(TaskStatus.Queued); + var b = await SeedTaskAsync(TaskStatus.Queued); + var setup = await _sut.SetDependsOnAsync(b, a, default); + Assert.True(setup.Ok); + + // a -> b would close the loop a -> b -> a. + var result = await _sut.SetDependsOnAsync(a, b, default); + + Assert.False(result.Ok); + var t = await GetTaskAsync(a); + Assert.Null(t.DependsOnTaskId); + } + + [Fact] + public async Task SetDependsOnAsync_TransitiveCycle_Rejected() + { + var a = await SeedTaskAsync(TaskStatus.Queued); + var b = await SeedTaskAsync(TaskStatus.Queued); + var c = await SeedTaskAsync(TaskStatus.Queued); + Assert.True((await _sut.SetDependsOnAsync(b, a, default)).Ok); + Assert.True((await _sut.SetDependsOnAsync(c, b, default)).Ok); + + // a -> c would close the loop a -> c -> b -> a. + var result = await _sut.SetDependsOnAsync(a, c, default); + + Assert.False(result.Ok); + } + // ─── RecoverStaleRunningAsync ───────────────────────────────────────── [Fact]