feat(worker): dependsOn via MCP + honest staleness signal in merge preview

Adds a user/MCP-declared task dependency (DependsOnTaskId) distinct from the
planning chain's internal BlockedByTaskId: add_task/update_task can set it,
the queue picker skips a Queued task until the dependency reaches Done, a
Failed/Cancelled dependency leaves the dependent blocked instead of starving
silently, and setting a link rejects self-reference/unknown-id/cycles.
get_task/list_tasks/batch_get_tasks now report blocked/blockedReason, and
wait_for_task_change reports "Blocked" immediately instead of running out its
timeout on a task the picker will never claim.

preview_merge/preview_merge_set gain staleFiles: files a branch touches that
the target branch also changed since the branch's fork point, a more honest
staleness signal than `behind` alone.
This commit is contained in:
mika kuns
2026-08-10 14:18:55 +02:00
parent 6a2a19cc9e
commit 6c8ec245b3
20 changed files with 1750 additions and 51 deletions
+6 -4
View File
@@ -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
@@ -74,6 +74,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
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");
@@ -115,6 +116,13 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
.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<TaskEntity>()
.WithMany()
.HasForeignKey(t => t.DependsOnTaskId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(t => t.List)
.WithMany(l => l.Tasks)
.HasForeignKey(t => t.ListId)
@@ -129,5 +137,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
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");
}
}
+15
View File
@@ -389,6 +389,21 @@ public sealed class GitService
.Count(s => s.Length > 0);
}
/// <summary>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
/// <see cref="CountChangedFilesAsync"/>'s 3-dot count of a branch's own changes.</summary>
public async Task<IReadOnlyList<string>> 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<string>();
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<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
{
@@ -0,0 +1,901 @@
// <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("20260810115110_AddTaskDependency")]
partial class AddTaskDependency
{
/// <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<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<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,
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<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>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
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<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<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<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
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>("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<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<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<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<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,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddTaskDependency : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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);
}
/// <inheritdoc />
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");
}
}
}
@@ -467,6 +467,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
@@ -600,6 +604,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");
@@ -825,6 +832,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")
+7
View File
@@ -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; }
+4 -3
View File
@@ -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. |
+1 -1
View File
@@ -102,7 +102,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; }
+142 -25
View File
@@ -45,7 +45,14 @@ public sealed record TaskDto(
// A planning/improvement child reporting > 0 still goes straight to Done (see
// ClaudeDo.Worker/CLAUDE.md → Unified parent model) -- this is the only MCP-visible signal
// that it may have delivered nothing despite that Done status.
int RoadblockCount = 0);
int RoadblockCount = 0,
// 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.
@@ -56,7 +63,10 @@ public sealed record TaskRefDto(
string Status,
int SortOrder,
bool IsMyDay,
int RoadblockCount = 0);
int RoadblockCount = 0,
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.
@@ -93,10 +103,17 @@ public sealed record MergeContinuationResultDto(
// a merge that is merely small, so an empty branch can't be misread as "changedFileCount: 0
// means tiny" when it actually means "nothing to review".
public sealed record MergePreviewToolDto(
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, bool IsEmpty = false);
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, bool IsEmpty = false,
// 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<string>? StaleFiles = null);
public sealed record MergePreviewSetEntryDto(
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false);
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false,
IReadOnlyList<string>? StaleFiles = null);
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds);
@@ -200,9 +217,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<Dictionary<string, (bool Blocked, string? Reason)>> ComputeBlockedInfoAsync(
IReadOnlyList<TaskEntity> 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<string, TaskStatus>();
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<string, (bool Blocked, string? Reason)>();
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(
@@ -216,7 +283,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)
@@ -225,7 +293,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(
@@ -243,6 +312,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))
@@ -271,6 +345,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);
@@ -280,7 +362,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
@@ -354,13 +437,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<TaskRefDto> 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)
@@ -373,9 +460,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(
@@ -934,16 +1029,20 @@ public sealed class ExternalMcpService
"when the preview is clean. IMPORTANT: a clean preview says nothing about whether the result compiles or " +
"passes tests — git can merge two changes cleanly (one file deleting a symbol another still references) and " +
"still break the build. 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.")]
"rather than reading a 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<MergePreviewToolDto> 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, cancellationToken);
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty);
var (preview, behind, _, isEmpty, staleFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty, staleFiles);
}
[McpServerTool, Description(
@@ -970,9 +1069,9 @@ public sealed class ExternalMcpService
{
try
{
var (preview, behind, changedFiles, isEmpty) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
var (preview, behind, changedFiles, isEmpty, staleFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
entries.Add(new MergePreviewSetEntryDto(
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty));
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty, staleFiles));
filesByTask[taskId] = changedFiles;
}
catch (InvalidOperationException ex)
@@ -1000,7 +1099,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<string> ChangedFiles, bool IsEmpty)> PreviewMergeCoreAsync(
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles)> PreviewMergeCoreAsync(
string taskId, string? targetBranch, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
@@ -1032,7 +1131,16 @@ public sealed class ExternalMcpService
? ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct))
: Array.Empty<string>();
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)
@@ -1046,7 +1154,10 @@ public sealed class ExternalMcpService
: ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct));
var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty<string>(), 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<string>());
}
throw new InvalidOperationException($"Task {taskId} has no worktree.");
@@ -1313,7 +1424,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,
@@ -1326,16 +1437,22 @@ public sealed class ExternalMcpService
t.FinishedAt,
t.IsMyDay,
t.SortOrder,
t.RoadblockCount);
t.RoadblockCount,
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,
t.Status.ToString(),
t.SortOrder,
t.IsMyDay,
t.RoadblockCount);
t.RoadblockCount,
t.DependsOnTaskId,
blocked,
blockedReason);
}
internal static class DailyPrepFilter
+55 -12
View File
@@ -6,7 +6,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<TaskStatusChangeDto> Changed, bool TimedOut);
[McpServerToolType]
@@ -31,11 +33,14 @@ 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. Requires the calling " +
"claude process to run with MCP_TOOL_TIMEOUT >= 930000 (ms) for a long wait to actually be " +
"held open -- ClaudeDo's own launchers already set this.")]
"(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. Requires the calling claude process to run " +
"with MCP_TOOL_TIMEOUT >= 930000 (ms) for a long wait to actually be held open -- ClaudeDo's " +
"own launchers already set this.")]
public async Task<WaitForTaskChangeResult> WaitForTaskChange(
string[] taskIds,
[Description(
@@ -80,22 +85,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<string, TaskStatus>()
: 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<TaskStatusChangeDto>();
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;
}
+9 -1
View File
@@ -15,7 +15,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.
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
@@ -29,6 +32,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
)
@@ -25,6 +25,10 @@ public interface ITaskStateService
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
Task<TransitionResult> 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<TransitionResult> 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).
@@ -427,6 +427,50 @@ public sealed class TaskStateService : ITaskStateService
return new TransitionResult(true, null);
}
public async Task<TransitionResult> 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<string>();
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<int> RecoverStaleRunningAsync(string reason, CancellationToken ct)
{
var resultText = "[stale] " + reason;
@@ -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]
@@ -216,6 +216,23 @@ public sealed class BatchMcpToolsTests : IDisposable
Assert.Equal("the full description", found.TaskFull!.Description);
}
[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()
{
@@ -182,7 +182,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);
@@ -203,7 +203,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);
@@ -224,6 +224,91 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Equal("the full description text", dto.Description);
}
[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()
{
@@ -233,7 +318,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateTask(task.Id, "x", null, null, CancellationToken.None));
sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None));
}
[Fact]
@@ -243,7 +328,51 @@ public sealed class ExternalMcpServiceTests : IDisposable
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
sut.UpdateTask(a.Id, dependsOnTaskId: b.Id, cancellationToken: CancellationToken.None));
}
[Fact]
@@ -1609,6 +1738,92 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[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()
{
@@ -1756,6 +1971,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<InvalidOperationException>(
() => sut.AddTask(listId, "t", dependsOnTaskId: "does-not-exist", cancellationToken: CancellationToken.None));
}
// ── AddTask possible-duplicate check ────────────────────────────────────────
[Fact]
@@ -196,6 +196,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()
{
@@ -49,7 +49,8 @@ public sealed class QueuePickerTests : IDisposable
string? blockedBy = null,
bool taskAgentTag = false,
int? sortOrder = null,
bool isManual = false)
bool isManual = false,
string? dependsOn = null)
{
var task = new TaskEntity
{
@@ -60,6 +61,7 @@ public sealed class QueuePickerTests : IDisposable
CreatedAt = createdAt ?? DateTime.UtcNow,
ScheduledFor = scheduledFor,
BlockedByTaskId = blockedBy,
DependsOnTaskId = dependsOn,
CommitType = "feat",
IsManual = isManual,
};
@@ -110,6 +112,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()
{
@@ -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]