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:
@@ -4,8 +4,8 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
|||||||
|
|
||||||
## Models
|
## 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.
|
- **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 semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model.
|
- 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).
|
- `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).
|
- `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.
|
- 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`
|
- `task_attachments`
|
||||||
|
|
||||||
Managed by EF Core migrations in `Migrations/` — **`ls Migrations/` is the authoritative history**;
|
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
|
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`).
|
`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
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired()
|
builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired()
|
||||||
.HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None);
|
.HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None);
|
||||||
builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id");
|
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.ScheduledFor).HasColumnName("scheduled_for");
|
||||||
builder.Property(t => t.Result).HasColumnName("result");
|
builder.Property(t => t.Result).HasColumnName("result");
|
||||||
builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");
|
builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");
|
||||||
@@ -115,6 +116,13 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
.HasForeignKey(t => t.BlockedByTaskId)
|
.HasForeignKey(t => t.BlockedByTaskId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.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)
|
builder.HasOne(t => t.List)
|
||||||
.WithMany(l => l.Tasks)
|
.WithMany(l => l.Tasks)
|
||||||
.HasForeignKey(t => t.ListId)
|
.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 => 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.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
|
||||||
builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");
|
builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");
|
||||||
|
builder.HasIndex(t => t.DependsOnTaskId).HasDatabaseName("idx_tasks_depends_on");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -389,6 +389,21 @@ public sealed class GitService
|
|||||||
.Count(s => s.Length > 0);
|
.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(
|
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
|
||||||
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
|
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")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("created_by");
|
.HasColumnName("created_by");
|
||||||
|
|
||||||
|
b.Property<string>("DependsOnTaskId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("depends_on_task_id");
|
||||||
|
|
||||||
b.Property<string>("Description")
|
b.Property<string>("Description")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("description");
|
.HasColumnName("description");
|
||||||
@@ -600,6 +604,9 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
b.HasIndex("BlockedByTaskId")
|
b.HasIndex("BlockedByTaskId")
|
||||||
.HasDatabaseName("idx_tasks_blocked_by");
|
.HasDatabaseName("idx_tasks_blocked_by");
|
||||||
|
|
||||||
|
b.HasIndex("DependsOnTaskId")
|
||||||
|
.HasDatabaseName("idx_tasks_depends_on");
|
||||||
|
|
||||||
b.HasIndex("ListId")
|
b.HasIndex("ListId")
|
||||||
.HasDatabaseName("idx_tasks_list_id");
|
.HasDatabaseName("idx_tasks_list_id");
|
||||||
|
|
||||||
@@ -825,6 +832,11 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasForeignKey("BlockedByTaskId")
|
.HasForeignKey("BlockedByTaskId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DependsOnTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||||
.WithMany("Tasks")
|
.WithMany("Tasks")
|
||||||
.HasForeignKey("ListId")
|
.HasForeignKey("ListId")
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ public sealed class TaskEntity
|
|||||||
public TaskStatus Status { get; set; } = TaskStatus.Idle;
|
public TaskStatus Status { get; set; } = TaskStatus.Idle;
|
||||||
public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None;
|
public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None;
|
||||||
public string? BlockedByTaskId { get; set; }
|
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 DateTime? ScheduledFor { get; set; }
|
||||||
public string? Result { get; set; }
|
public string? Result { get; set; }
|
||||||
public string? ReviewFeedback { get; set; }
|
public string? ReviewFeedback { get; set; }
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ subfolder within their area; the namespace stays the area namespace.
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
|
- **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.
|
- **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` 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).
|
- **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.
|
- **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).
|
- **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`.
|
- **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. |
|
| `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`. |
|
| `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. |
|
| `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. |
|
| `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ public sealed class BatchMcpTools
|
|||||||
{
|
{
|
||||||
var created = await _svc.AddTask(
|
var created = await _svc.AddTask(
|
||||||
listId, item.Title, item.Description, createdBy,
|
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));
|
results.Add(new BatchAddTaskResult(i, item.Title, true, created.Task, created.PossibleDuplicates, null));
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { throw; }
|
catch (OperationCanceledException) { throw; }
|
||||||
|
|||||||
+142
-25
@@ -45,7 +45,14 @@ public sealed record TaskDto(
|
|||||||
// A planning/improvement child reporting > 0 still goes straight to Done (see
|
// 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
|
// ClaudeDo.Worker/CLAUDE.md → Unified parent model) -- this is the only MCP-visible signal
|
||||||
// that it may have delivered nothing despite that Done status.
|
// 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
|
// 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.
|
// without re-sending Description/Result, which the caller just sent or already has.
|
||||||
@@ -56,7 +63,10 @@ public sealed record TaskRefDto(
|
|||||||
string Status,
|
string Status,
|
||||||
int SortOrder,
|
int SortOrder,
|
||||||
bool IsMyDay,
|
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
|
// tasks is populated when includeDescription=false (the default): lean references, no
|
||||||
// Description/Result. tasksFull is populated when includeDescription=true: full tasks incl.
|
// 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
|
// 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".
|
// means tiny" when it actually means "nothing to review".
|
||||||
public sealed record MergePreviewToolDto(
|
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(
|
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);
|
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds);
|
||||||
|
|
||||||
@@ -200,9 +217,59 @@ public sealed class ExternalMcpService
|
|||||||
query = query.Where(t => t.Status == statusFilter);
|
query = query.Where(t => t.Status == statusFilter);
|
||||||
|
|
||||||
var filtered = query.ToList();
|
var filtered = query.ToList();
|
||||||
|
var blocked = await ComputeBlockedInfoAsync(filtered, cancellationToken);
|
||||||
return includeDescription
|
return includeDescription
|
||||||
? new ListTasksResult(true, null, filtered.Select(ToDto).ToList())
|
? new ListTasksResult(true, null, filtered.Select(t => ToDto(t, blocked[t.Id].Blocked, blocked[t.Id].Reason)).ToList())
|
||||||
: new ListTasksResult(false, filtered.Select(ToRefDto).ToList(), null);
|
: 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(
|
[McpServerTool, Description(
|
||||||
@@ -216,7 +283,8 @@ public sealed class ExternalMcpService
|
|||||||
{
|
{
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
?? 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)
|
// 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)
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
?? 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(
|
[McpServerTool, Description(
|
||||||
@@ -243,6 +312,11 @@ public sealed class ExternalMcpService
|
|||||||
"for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " +
|
"for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " +
|
||||||
"list/global default (normally sonnet).")]
|
"list/global default (normally sonnet).")]
|
||||||
string? model = null,
|
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)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(listId))
|
if (string.IsNullOrWhiteSpace(listId))
|
||||||
@@ -271,6 +345,14 @@ public sealed class ExternalMcpService
|
|||||||
};
|
};
|
||||||
await _tasks.AddAsync(entity, cancellationToken);
|
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)
|
if (queueImmediately)
|
||||||
{
|
{
|
||||||
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
|
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
|
||||||
@@ -280,7 +362,8 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(entity.Id);
|
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
|
// 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(
|
[McpServerTool, Description(
|
||||||
"Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged." +
|
"Update an existing task's title, description, commit type, and/or dependsOn link. Pass null to leave a " +
|
||||||
McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
|
"field unchanged." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
|
||||||
public async Task<TaskRefDto> UpdateTask(
|
public async Task<TaskRefDto> UpdateTask(
|
||||||
string taskId,
|
string taskId,
|
||||||
string? title = null,
|
string? title = null,
|
||||||
string? description = null,
|
string? description = null,
|
||||||
string? commitType = 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)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
@@ -373,9 +460,17 @@ public sealed class ExternalMcpService
|
|||||||
if (commitType is not null) task.CommitType = commitType;
|
if (commitType is not null) task.CommitType = commitType;
|
||||||
await _tasks.UpdateAsync(task, cancellationToken);
|
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))!;
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
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(
|
[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 " +
|
"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 " +
|
"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 " +
|
"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 " +
|
"rather than reading a small changedFileCount as empty. staleFiles lists files this branch touches that the " +
|
||||||
"nor a handler commit range, or the list's working directory is missing from disk.")]
|
"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(
|
public async Task<MergePreviewToolDto> PreviewMerge(
|
||||||
string taskId,
|
string taskId,
|
||||||
[Description("Branch to preview against; defaults to the repo's current branch.")]
|
[Description("Branch to preview against; defaults to the repo's current branch.")]
|
||||||
string? targetBranch = null,
|
string? targetBranch = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var (preview, behind, _, isEmpty) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
|
var (preview, behind, _, isEmpty, staleFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
|
||||||
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty);
|
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty, staleFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
@@ -970,9 +1069,9 @@ public sealed class ExternalMcpService
|
|||||||
{
|
{
|
||||||
try
|
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(
|
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;
|
filesByTask[taskId] = changedFiles;
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
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
|
// (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
|
// HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
|
||||||
// range's own diff-stat instead of throwing "has no worktree".
|
// 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)
|
string taskId, string? targetBranch, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var ctx = _dbFactory.CreateDbContext();
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
@@ -1032,7 +1131,16 @@ public sealed class ExternalMcpService
|
|||||||
? ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct))
|
? ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct))
|
||||||
: Array.Empty<string>();
|
: 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)
|
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));
|
: ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct));
|
||||||
|
|
||||||
var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty<string>(), changedFiles.Count);
|
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.");
|
throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
||||||
@@ -1313,7 +1424,7 @@ public sealed class ExternalMcpService
|
|||||||
return files;
|
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.Id,
|
||||||
t.ListId,
|
t.ListId,
|
||||||
t.Title,
|
t.Title,
|
||||||
@@ -1326,16 +1437,22 @@ public sealed class ExternalMcpService
|
|||||||
t.FinishedAt,
|
t.FinishedAt,
|
||||||
t.IsMyDay,
|
t.IsMyDay,
|
||||||
t.SortOrder,
|
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.Id,
|
||||||
t.ListId,
|
t.ListId,
|
||||||
t.Title,
|
t.Title,
|
||||||
t.Status.ToString(),
|
t.Status.ToString(),
|
||||||
t.SortOrder,
|
t.SortOrder,
|
||||||
t.IsMyDay,
|
t.IsMyDay,
|
||||||
t.RoadblockCount);
|
t.RoadblockCount,
|
||||||
|
t.DependsOnTaskId,
|
||||||
|
blocked,
|
||||||
|
blockedReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static class DailyPrepFilter
|
internal static class DailyPrepFilter
|
||||||
|
|||||||
+55
-12
@@ -6,7 +6,9 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|||||||
|
|
||||||
namespace ClaudeDo.Worker.External;
|
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);
|
public sealed record WaitForTaskChangeResult(IReadOnlyList<TaskStatusChangeDto> Changed, bool TimedOut);
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
@@ -31,11 +33,14 @@ public sealed class TaskWaitMcpTools
|
|||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Blocks until at least one of the given tasks leaves Queued/Running -- use this instead of " +
|
"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 " +
|
"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 " +
|
"(an unknown id reports status \"NotFound\" and counts as changed). A Queued task the picker " +
|
||||||
"goes Running -> WaitingForChildren while its children are still working, so by default " +
|
"will not claim yet (a planning-chain predecessor, or a depends_on link whose target isn't " +
|
||||||
"waiting on a parent returns early; see treatWaitingForChildrenAsBusy. Requires the calling " +
|
"Done) also reports immediately as status \"Blocked\" with blockedReason set, instead of " +
|
||||||
"claude process to run with MCP_TOOL_TIMEOUT >= 930000 (ms) for a long wait to actually be " +
|
"silently waiting out the full timeout. Pitfall: a planning parent goes Running -> " +
|
||||||
"held open -- ClaudeDo's own launchers already set this.")]
|
"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(
|
public async Task<WaitForTaskChangeResult> WaitForTaskChange(
|
||||||
string[] taskIds,
|
string[] taskIds,
|
||||||
[Description(
|
[Description(
|
||||||
@@ -80,22 +85,60 @@ public sealed class TaskWaitMcpTools
|
|||||||
var rows = await ctx.Tasks
|
var rows = await ctx.Tasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(t => taskIds.Contains(t.Id))
|
.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);
|
.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>();
|
var result = new List<TaskStatusChangeDto>();
|
||||||
foreach (var id in taskIds)
|
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"));
|
result.Add(new TaskStatusChangeDto(id, "NotFound"));
|
||||||
continue;
|
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)
|
if (!busy)
|
||||||
result.Add(new TaskStatusChangeDto(id, status.ToString()));
|
result.Add(new TaskStatusChangeDto(id, row.Status.ToString()));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ public sealed class QueuePicker : IQueuePicker
|
|||||||
{
|
{
|
||||||
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
|
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
|
||||||
// Raw SQL because EF cannot express UPDATE...RETURNING.
|
// 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.
|
// 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);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
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.blocked_by_task_id IS NULL
|
||||||
AND t.is_manual = 0
|
AND t.is_manual = 0
|
||||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {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
|
ORDER BY t.sort_order ASC, t.created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ public interface ITaskStateService
|
|||||||
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
|
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
|
||||||
Task<TransitionResult> UnblockAsync(string taskId, 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.
|
// 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,
|
// 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).
|
// 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);
|
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)
|
public async Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var resultText = "[stale] " + reason;
|
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 ----
|
// ---- AppSettingsRepository: get-or-create resilience ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -216,6 +216,23 @@ public sealed class BatchMcpToolsTests : IDisposable
|
|||||||
Assert.Equal("the full description", found.TaskFull!.Description);
|
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]
|
[Fact]
|
||||||
public async Task BatchDeleteTasks_RunningTask_ReportedNotOk_OthersDeleted()
|
public async Task BatchDeleteTasks_RunningTask_ReportedNotOk_OthersDeleted()
|
||||||
{
|
{
|
||||||
|
|||||||
+248
-4
@@ -182,7 +182,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var queue = CreateQueue();
|
var queue = CreateQueue();
|
||||||
var sut = BuildSut(queue);
|
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);
|
Assert.Equal("new title", dto.Title);
|
||||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
@@ -203,7 +203,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
await _tasks.UpdateAsync(task, CancellationToken.None);
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
||||||
var sut = BuildSut(CreateQueue());
|
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(task.Id, dto.Id);
|
||||||
Assert.Equal(listId, dto.ListId);
|
Assert.Equal(listId, dto.ListId);
|
||||||
@@ -224,6 +224,91 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
Assert.Equal("the full description text", dto.Description);
|
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]
|
[Fact]
|
||||||
public async Task UpdateTask_OnRunning_Throws()
|
public async Task UpdateTask_OnRunning_Throws()
|
||||||
{
|
{
|
||||||
@@ -233,7 +318,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var sut = BuildSut(queue);
|
var sut = BuildSut(queue);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
sut.UpdateTask(task.Id, "x", null, null, CancellationToken.None));
|
sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -243,7 +328,51 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var sut = BuildSut(queue);
|
var sut = BuildSut(queue);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
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]
|
[Fact]
|
||||||
@@ -1609,6 +1738,92 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase);
|
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]
|
[Fact]
|
||||||
public async Task PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap()
|
public async Task PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap()
|
||||||
{
|
{
|
||||||
@@ -1756,6 +1971,35 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
() => sut.AddTask(listId, "t", model: "gpt4", cancellationToken: CancellationToken.None));
|
() => 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 ────────────────────────────────────────
|
// ── AddTask possible-duplicate check ────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -196,6 +196,64 @@ public sealed class TaskWaitMcpToolsTests : IDisposable
|
|||||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(2), $"took {sw.Elapsed}");
|
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]
|
[Fact]
|
||||||
public void MaxTimeoutSeconds_StaysComfortablyUnderMcpToolTimeout()
|
public void MaxTimeoutSeconds_StaysComfortablyUnderMcpToolTimeout()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
string? blockedBy = null,
|
string? blockedBy = null,
|
||||||
bool taskAgentTag = false,
|
bool taskAgentTag = false,
|
||||||
int? sortOrder = null,
|
int? sortOrder = null,
|
||||||
bool isManual = false)
|
bool isManual = false,
|
||||||
|
string? dependsOn = null)
|
||||||
{
|
{
|
||||||
var task = new TaskEntity
|
var task = new TaskEntity
|
||||||
{
|
{
|
||||||
@@ -60,6 +61,7 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
CreatedAt = createdAt ?? DateTime.UtcNow,
|
CreatedAt = createdAt ?? DateTime.UtcNow,
|
||||||
ScheduledFor = scheduledFor,
|
ScheduledFor = scheduledFor,
|
||||||
BlockedByTaskId = blockedBy,
|
BlockedByTaskId = blockedBy,
|
||||||
|
DependsOnTaskId = dependsOn,
|
||||||
CommitType = "feat",
|
CommitType = "feat",
|
||||||
IsManual = isManual,
|
IsManual = isManual,
|
||||||
};
|
};
|
||||||
@@ -110,6 +112,49 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
Assert.Null(second);
|
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]
|
[Fact]
|
||||||
public async Task ClaimNextAsync_Picks_TasksWithoutAgentTag()
|
public async Task ClaimNextAsync_Picks_TasksWithoutAgentTag()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -472,6 +472,100 @@ public sealed class TaskStateServiceTests : IDisposable
|
|||||||
Assert.True(_built.WakeCount() > wakesBefore);
|
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 ─────────────────────────────────────────
|
// ─── RecoverStaleRunningAsync ─────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user