Merge branch 'claudedo/5c80a0aba5d9468a96cf809fc7a6e7a1'
This commit is contained in:
@@ -45,8 +45,8 @@ subfolder within their area; the namespace stays the area namespace.
|
||||
## Architecture
|
||||
|
||||
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
|
||||
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions.
|
||||
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, `DependsOnTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions. `SetDependsOnAsync` rejects a self-reference, an unknown dependency id, or a link that would create a cycle (walks the proposed predecessor's own `DependsOnTaskId` chain).
|
||||
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and (`DependsOnTaskId IS NULL` OR the dependency's `Status = 'done'`); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
|
||||
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
||||
- **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
||||
@@ -61,7 +61,8 @@ not conflated.
|
||||
|---|---|---|
|
||||
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work done, waiting on children. |
|
||||
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
|
||||
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. |
|
||||
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. Internal to `PlanningChainCoordinator` — resolves (or cascades) on ANY terminal state of the predecessor. |
|
||||
| `DependsOnTaskId` | nullable FK | User/MCP-declared predecessor (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker only skips a queued row while the dependency's `Status` isn't `Done` -- a Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked (see `QueuePicker`, `TaskStateService.SetDependsOnAsync`). `get_task`/`list_tasks`/`batch_get_tasks` surface this as `blocked`/`blockedReason`; `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. |
|
||||
| `IsManual` | bool | Reminder only the user can do. `EnqueueAsync`/`StartRunningAsync` refuse it, the picker skips it, `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
|
||||
| `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. |
|
||||
|
||||
|
||||
+1
-1
@@ -217,7 +217,7 @@ public sealed class BatchMcpTools
|
||||
{
|
||||
var created = await _svc.AddTask(
|
||||
listId, item.Title, item.Description, createdBy,
|
||||
queueImmediately, item.Model, cancellationToken);
|
||||
queueImmediately, item.Model, cancellationToken: cancellationToken);
|
||||
results.Add(new BatchAddTaskResult(i, item.Title, true, created.Task, created.PossibleDuplicates, null));
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
|
||||
+142
-25
@@ -51,7 +51,14 @@ public sealed record TaskDto(
|
||||
// field. Lets a caller triage without pulling get_task_log's raw NDJSON.
|
||||
string? FailureReason = null,
|
||||
int? FailureTurnsUsed = null,
|
||||
int? FailureMaxTurns = null);
|
||||
int? FailureMaxTurns = null,
|
||||
// A user/MCP-declared predecessor (set via add_task/update_task), distinct from the
|
||||
// planning chain's own internal BlockedByTaskId link.
|
||||
string? DependsOnTaskId = null,
|
||||
// True only while Status is Queued and the picker will not claim this task yet -- either a
|
||||
// planning-chain predecessor or DependsOnTaskId hasn't reached Done. See BlockedReason.
|
||||
bool Blocked = false,
|
||||
string? BlockedReason = null);
|
||||
|
||||
// Lean counterpart to TaskDto for writing/status-changing tools: echoes back what changed
|
||||
// without re-sending Description/Result, which the caller just sent or already has.
|
||||
@@ -65,7 +72,10 @@ public sealed record TaskRefDto(
|
||||
int RoadblockCount = 0,
|
||||
string? FailureReason = null,
|
||||
int? FailureTurnsUsed = null,
|
||||
int? FailureMaxTurns = null);
|
||||
int? FailureMaxTurns = null,
|
||||
string? DependsOnTaskId = null,
|
||||
bool Blocked = false,
|
||||
string? BlockedReason = null);
|
||||
|
||||
// tasks is populated when includeDescription=false (the default): lean references, no
|
||||
// Description/Result. tasksFull is populated when includeDescription=true: full tasks incl.
|
||||
@@ -119,11 +129,18 @@ public sealed record ResolveConflictHunkResultDto(
|
||||
// of its output in VerifyOutputTail.
|
||||
public sealed record MergePreviewToolDto(
|
||||
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, bool IsEmpty = false,
|
||||
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null);
|
||||
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
|
||||
// Files this branch touches that the target branch ALSO touched since the branch's fork
|
||||
// point -- an honest staleness signal `behind` alone can't give (a branch can be far behind
|
||||
// yet touch nothing the target changed, or close behind yet collide on the one file that
|
||||
// matters). Empty for a worktree-less handler task, which commits straight onto the list's
|
||||
// working dir and has no fork point to compare against.
|
||||
IReadOnlyList<string>? StaleFiles = null);
|
||||
|
||||
public sealed record MergePreviewSetEntryDto(
|
||||
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false,
|
||||
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null);
|
||||
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
|
||||
IReadOnlyList<string>? StaleFiles = null);
|
||||
|
||||
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds);
|
||||
|
||||
@@ -234,9 +251,59 @@ public sealed class ExternalMcpService
|
||||
query = query.Where(t => t.Status == statusFilter);
|
||||
|
||||
var filtered = query.ToList();
|
||||
var blocked = await ComputeBlockedInfoAsync(filtered, cancellationToken);
|
||||
return includeDescription
|
||||
? new ListTasksResult(true, null, filtered.Select(ToDto).ToList())
|
||||
: new ListTasksResult(false, filtered.Select(ToRefDto).ToList(), null);
|
||||
? new ListTasksResult(true, null, filtered.Select(t => ToDto(t, blocked[t.Id].Blocked, blocked[t.Id].Reason)).ToList())
|
||||
: new ListTasksResult(false, filtered.Select(t => ToRefDto(t, blocked[t.Id].Blocked, blocked[t.Id].Reason)).ToList(), null);
|
||||
}
|
||||
|
||||
// Batch-resolves, for each task, whether the picker is currently skipping it (Queued with
|
||||
// either a planning-chain BlockedByTaskId or an unmet DependsOnTaskId) and why. Only Queued
|
||||
// tasks can be blocked -- once a task has run, or hasn't been queued yet, blocking is moot.
|
||||
private async Task<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(
|
||||
@@ -251,7 +318,8 @@ public sealed class ExternalMcpService
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
return ToDto(task);
|
||||
var blocked = await ComputeBlockedInfoAsync([task], cancellationToken);
|
||||
return ToDto(task, blocked[task.Id].Blocked, blocked[task.Id].Reason);
|
||||
}
|
||||
|
||||
// Lean counterpart to GetTask, used internally by BatchGetTasks' default (includeDescription=false)
|
||||
@@ -260,7 +328,8 @@ public sealed class ExternalMcpService
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
return ToRefDto(task);
|
||||
var blocked = await ComputeBlockedInfoAsync([task], cancellationToken);
|
||||
return ToRefDto(task, blocked[task.Id].Blocked, blocked[task.Id].Reason);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
@@ -278,6 +347,11 @@ public sealed class ExternalMcpService
|
||||
"for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " +
|
||||
"list/global default (normally sonnet).")]
|
||||
string? model = null,
|
||||
[Description("Id of a task that must reach Done (i.e. be merged) before the picker will claim this one, " +
|
||||
"even once queued. Rejected if it doesn't exist, is this task's own id, or would create a " +
|
||||
"dependency cycle. If that predecessor instead ends up Failed or Cancelled, this task simply " +
|
||||
"stays blocked rather than starving silently -- check get_task/list_tasks' blocked field.")]
|
||||
string? dependsOnTaskId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(listId))
|
||||
@@ -306,6 +380,14 @@ public sealed class ExternalMcpService
|
||||
};
|
||||
await _tasks.AddAsync(entity, cancellationToken);
|
||||
|
||||
if (dependsOnTaskId is not null)
|
||||
{
|
||||
var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOnTaskId, cancellationToken);
|
||||
if (!dependsResult.Ok)
|
||||
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
|
||||
entity.DependsOnTaskId = dependsOnTaskId;
|
||||
}
|
||||
|
||||
if (queueImmediately)
|
||||
{
|
||||
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
|
||||
@@ -315,7 +397,8 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
|
||||
await _broadcaster.TaskUpdated(entity.Id);
|
||||
return new AddTaskResult(ToRefDto(entity), possibleDuplicates);
|
||||
var blocked = await ComputeBlockedInfoAsync([entity], cancellationToken);
|
||||
return new AddTaskResult(ToRefDto(entity, blocked[entity.Id].Blocked, blocked[entity.Id].Reason), possibleDuplicates);
|
||||
}
|
||||
|
||||
// Non-terminal: a task still open enough that a new, similarly-titled task might be a duplicate
|
||||
@@ -389,13 +472,17 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged." +
|
||||
McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
|
||||
"Update an existing task's title, description, commit type, and/or dependsOn link. Pass null to leave a " +
|
||||
"field unchanged." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
|
||||
public async Task<TaskRefDto> UpdateTask(
|
||||
string taskId,
|
||||
string? title = null,
|
||||
string? description = null,
|
||||
string? commitType = null,
|
||||
[Description("Id of a task that must reach Done before the picker will claim this one. Pass an empty " +
|
||||
"string to clear an existing link; null leaves it unchanged. Rejected if it doesn't exist, " +
|
||||
"is this task's own id, or would create a dependency cycle.")]
|
||||
string? dependsOnTaskId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
@@ -408,9 +495,17 @@ public sealed class ExternalMcpService
|
||||
if (commitType is not null) task.CommitType = commitType;
|
||||
await _tasks.UpdateAsync(task, cancellationToken);
|
||||
|
||||
if (dependsOnTaskId is not null)
|
||||
{
|
||||
var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOnTaskId.NullIfBlank(), cancellationToken);
|
||||
if (!dependsResult.Ok)
|
||||
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
|
||||
}
|
||||
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return ToRefDto(reload);
|
||||
var blocked = await ComputeBlockedInfoAsync([reload], cancellationToken);
|
||||
return ToRefDto(reload, blocked[reload.Id].Blocked, blocked[reload.Id].Reason);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
@@ -1079,17 +1174,21 @@ public sealed class ExternalMcpService
|
||||
"verifyExitCode 0 means it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the " +
|
||||
"tail of its output in verifyOutputTail; verifyExitCode stays null when no verify command is configured. " +
|
||||
"isEmpty=true means the task's review range contributed nothing; check that flag rather than reading a " +
|
||||
"small changedFileCount as empty. Throws if the task has neither an active worktree nor a handler commit " +
|
||||
"range, or the list's working directory is missing from disk.")]
|
||||
"small changedFileCount as empty. staleFiles lists files this branch touches that the target branch ALSO " +
|
||||
"changed since this branch's fork point — a more honest staleness signal than `behind` alone, since a " +
|
||||
"branch can be far behind yet touch nothing the target changed, or barely behind yet collide on the one " +
|
||||
"file that matters (always empty for a worktree-less handler task, which has no fork point). Throws if the " +
|
||||
"task has neither an active worktree nor a handler commit range, or the list's working directory is missing " +
|
||||
"from disk.")]
|
||||
public async Task<MergePreviewToolDto> PreviewMerge(
|
||||
string taskId,
|
||||
[Description("Branch to preview against; defaults to the repo's current branch.")]
|
||||
string? targetBranch = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (preview, behind, _, isEmpty) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken);
|
||||
var (preview, behind, _, isEmpty, staleFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken);
|
||||
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
|
||||
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail);
|
||||
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
@@ -1124,10 +1223,10 @@ public sealed class ExternalMcpService
|
||||
{
|
||||
try
|
||||
{
|
||||
var (preview, behind, changedFiles, isEmpty) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken);
|
||||
var (preview, behind, changedFiles, isEmpty, staleFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken);
|
||||
entries.Add(new MergePreviewSetEntryDto(
|
||||
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
|
||||
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail));
|
||||
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles));
|
||||
filesByTask[taskId] = changedFiles;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
@@ -1184,7 +1283,7 @@ public sealed class ExternalMcpService
|
||||
// (its commits already sit on the list's working dir) — falls back to the fixed
|
||||
// HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
|
||||
// range's own diff-stat instead of throwing "has no worktree".
|
||||
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<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, bool runVerify, CancellationToken ct)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
@@ -1216,7 +1315,16 @@ public sealed class ExternalMcpService
|
||||
? ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct: ct))
|
||||
: Array.Empty<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)
|
||||
@@ -1230,7 +1338,10 @@ public sealed class ExternalMcpService
|
||||
: ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct: ct));
|
||||
|
||||
var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty<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.");
|
||||
@@ -1497,7 +1608,7 @@ public sealed class ExternalMcpService
|
||||
return files;
|
||||
}
|
||||
|
||||
private static TaskDto ToDto(TaskEntity t) => new(
|
||||
private static TaskDto ToDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
|
||||
t.Id,
|
||||
t.ListId,
|
||||
t.Title,
|
||||
@@ -1513,9 +1624,12 @@ public sealed class ExternalMcpService
|
||||
t.RoadblockCount,
|
||||
FailureReasonOf(t),
|
||||
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
|
||||
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null);
|
||||
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
|
||||
t.DependsOnTaskId,
|
||||
blocked,
|
||||
blockedReason);
|
||||
|
||||
private static TaskRefDto ToRefDto(TaskEntity t) => new(
|
||||
private static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
|
||||
t.Id,
|
||||
t.ListId,
|
||||
t.Title,
|
||||
@@ -1525,7 +1639,10 @@ public sealed class ExternalMcpService
|
||||
t.RoadblockCount,
|
||||
FailureReasonOf(t),
|
||||
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
|
||||
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null);
|
||||
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
|
||||
t.DependsOnTaskId,
|
||||
blocked,
|
||||
blockedReason);
|
||||
|
||||
// "unknown" covers a Failed task that predates this field (never got a classified reason
|
||||
// stamped) — a defined value rather than null so callers don't have to special-case it.
|
||||
|
||||
+53
-10
@@ -7,7 +7,9 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record TaskStatusChangeDto(string TaskId, string Status);
|
||||
// BlockedReason is set only when Status is "Blocked" -- a Queued task the picker will not
|
||||
// claim yet, either because of a planning-chain predecessor or an unmet depends-on link.
|
||||
public sealed record TaskStatusChangeDto(string TaskId, string Status, string? BlockedReason = null);
|
||||
public sealed record WaitForTaskChangeResult(IReadOnlyList<TaskStatusChangeDto> Changed, bool TimedOut);
|
||||
|
||||
[McpServerToolType]
|
||||
@@ -42,9 +44,12 @@ public sealed class TaskWaitMcpTools
|
||||
[McpServerTool, Description(
|
||||
"Blocks until at least one of the given tasks leaves Queued/Running -- use this instead of " +
|
||||
"polling get_task in a loop. Returns immediately if a task is already outside Queued/Running " +
|
||||
"(an unknown id reports status \"NotFound\" and counts as changed). Pitfall: a planning parent " +
|
||||
"goes Running -> WaitingForChildren while its children are still working, so by default " +
|
||||
"waiting on a parent returns early; see treatWaitingForChildrenAsBusy. Sends MCP progress " +
|
||||
"(an unknown id reports status \"NotFound\" and counts as changed). A Queued task the picker " +
|
||||
"will not claim yet (a planning-chain predecessor, or a depends_on link whose target isn't " +
|
||||
"Done) also reports immediately as status \"Blocked\" with blockedReason set, instead of " +
|
||||
"silently waiting out the full timeout. Pitfall: a planning parent goes Running -> " +
|
||||
"WaitingForChildren while its children are still working, so by default waiting on a parent " +
|
||||
"returns early; see treatWaitingForChildrenAsBusy. Sends MCP progress " +
|
||||
"pings every 30s while waiting so a long wait survives the calling client's own idle-silence " +
|
||||
"abort (Claude Code defaults to killing an MCP call after ~300s of silence) -- this is not " +
|
||||
"guaranteed by every possible MCP client.")]
|
||||
@@ -101,22 +106,60 @@ public sealed class TaskWaitMcpTools
|
||||
var rows = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Where(t => taskIds.Contains(t.Id))
|
||||
.Select(t => new { t.Id, t.Status })
|
||||
.Select(t => new { t.Id, t.Status, t.BlockedByTaskId, t.DependsOnTaskId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var byId = rows.ToDictionary(r => r.Id, r => r.Status);
|
||||
var byId = rows.ToDictionary(r => r.Id, r => r);
|
||||
|
||||
// A Queued task with an unmet depends_on link never becomes "changed" by itself -- the
|
||||
// picker will not touch it. Batch-resolve those dependencies' statuses once instead of a
|
||||
// query per candidate.
|
||||
var dependencyIds = rows
|
||||
.Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null && r.DependsOnTaskId is not null)
|
||||
.Select(r => r.DependsOnTaskId!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var dependencyStatuses = dependencyIds.Count == 0
|
||||
? new Dictionary<string, TaskStatus>()
|
||||
: await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => dependencyIds.Contains(t.Id))
|
||||
.Select(t => new { t.Id, t.Status })
|
||||
.ToDictionaryAsync(t => t.Id, t => t.Status, ct);
|
||||
|
||||
var result = new List<TaskStatusChangeDto>();
|
||||
foreach (var id in taskIds)
|
||||
{
|
||||
if (!byId.TryGetValue(id, out var status))
|
||||
if (!byId.TryGetValue(id, out var row))
|
||||
{
|
||||
result.Add(new TaskStatusChangeDto(id, "NotFound"));
|
||||
continue;
|
||||
}
|
||||
var busy = status == TaskStatus.Queued || status == TaskStatus.Running
|
||||
|| (treatWaitingForChildrenAsBusy && status == TaskStatus.WaitingForChildren);
|
||||
|
||||
if (row.Status == TaskStatus.Queued)
|
||||
{
|
||||
if (row.BlockedByTaskId is not null)
|
||||
{
|
||||
result.Add(new TaskStatusChangeDto(id, "Blocked",
|
||||
$"Blocked by planning-chain predecessor {row.BlockedByTaskId}."));
|
||||
continue;
|
||||
}
|
||||
if (row.DependsOnTaskId is not null)
|
||||
{
|
||||
var known = dependencyStatuses.TryGetValue(row.DependsOnTaskId, out var depStatus);
|
||||
if (!known || depStatus != TaskStatus.Done)
|
||||
{
|
||||
result.Add(new TaskStatusChangeDto(id, "Blocked",
|
||||
$"Blocked: depends on task {row.DependsOnTaskId} (status: " +
|
||||
(known ? depStatus.ToString() : "not found") + ")."));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var busy = row.Status == TaskStatus.Queued || row.Status == TaskStatus.Running
|
||||
|| (treatWaitingForChildrenAsBusy && row.Status == TaskStatus.WaitingForChildren);
|
||||
if (!busy)
|
||||
result.Add(new TaskStatusChangeDto(id, status.ToString()));
|
||||
result.Add(new TaskStatusChangeDto(id, row.Status.ToString()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@ public sealed class QueuePicker : IQueuePicker
|
||||
{
|
||||
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
|
||||
// Raw SQL because EF cannot express UPDATE...RETURNING.
|
||||
// Eligible task must be Queued, unblocked, not manual, and due (or unscheduled).
|
||||
// Eligible task must be Queued, unblocked (chain and depends-on), not manual, and due
|
||||
// (or unscheduled). depends_on_task_id only gates on the dependency's Status='done' --
|
||||
// unlike blocked_by_task_id there is no cascade on the dependency failing, so a task
|
||||
// with a Failed dependency simply stays here, skipped, until someone intervenes.
|
||||
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
|
||||
var rows = await ctx.Tasks.FromSqlRaw("""
|
||||
UPDATE tasks SET status = 'running', started_at = {1}
|
||||
@@ -46,6 +49,11 @@ public sealed class QueuePicker : IQueuePicker
|
||||
AND t.blocked_by_task_id IS NULL
|
||||
AND t.is_manual = 0
|
||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
|
||||
AND (t.depends_on_task_id IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM tasks d
|
||||
WHERE d.id = t.depends_on_task_id AND d.status = 'done'
|
||||
))
|
||||
ORDER BY t.sort_order ASC, t.created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
@@ -72,8 +80,28 @@ public sealed class QueuePicker : IQueuePicker
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// The same depends-on gate ClaimTopEligibleAsync applies in SQL: a declared dependency must
|
||||
// be Done. Resolved as one extra query over just the referenced ids (rather than repeating
|
||||
// the correlated subquery) so both claim paths agree -- without it, opting a list into
|
||||
// scope serialization would silently stop enforcing dependencies.
|
||||
var dependencyIds = candidates
|
||||
.Where(t => t.DependsOnTaskId != null)
|
||||
.Select(t => t.DependsOnTaskId!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var doneDependencyIds = dependencyIds.Count == 0
|
||||
? new HashSet<string>(StringComparer.Ordinal)
|
||||
: (await ctx.Tasks.AsNoTracking()
|
||||
.Where(d => dependencyIds.Contains(d.Id) && d.Status == TaskStatus.Done)
|
||||
.Select(d => d.Id)
|
||||
.ToListAsync(ct))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (candidate.DependsOnTaskId is { } dependsOn && !doneDependencyIds.Contains(dependsOn))
|
||||
continue;
|
||||
|
||||
if (serializingListIds.Contains(candidate.ListId)
|
||||
&& await ScopeOverlap.FindBlockingSiblingAsync(ctx, candidate, ct) is not null)
|
||||
continue;
|
||||
|
||||
@@ -27,6 +27,10 @@ public interface ITaskStateService
|
||||
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
|
||||
Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
|
||||
|
||||
// dependsOnTaskId null clears the dependency. Rejects self-reference, an unknown dependency
|
||||
// id, and a link that would create a cycle -- see TaskStateService for the walk.
|
||||
Task<TransitionResult> SetDependsOnAsync(string taskId, string? dependsOnTaskId, CancellationToken ct);
|
||||
|
||||
// Surfaces a WaitingForChildren parent for review once all its children are terminal.
|
||||
// Best-effort (swallows and logs failures) — safe to call after any child mutation,
|
||||
// e.g. deleting the last non-terminal child (no terminal transition fires for a delete).
|
||||
|
||||
@@ -432,6 +432,50 @@ public sealed class TaskStateService : ITaskStateService
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> SetDependsOnAsync(string taskId, string? dependsOnTaskId, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
if (dependsOnTaskId is not null)
|
||||
{
|
||||
if (dependsOnTaskId == taskId)
|
||||
return new TransitionResult(false, "A task cannot depend on itself.");
|
||||
|
||||
if (!await ctx.Tasks.AsNoTracking().AnyAsync(t => t.Id == dependsOnTaskId, ct))
|
||||
return new TransitionResult(false, $"Dependency task {dependsOnTaskId} not found.");
|
||||
|
||||
// Walk the proposed predecessor's own chain of dependencies; if it leads back to
|
||||
// taskId, linking here would create a cycle that starves both tasks forever (the
|
||||
// picker never claims either). `visited` also stops us looping forever on
|
||||
// pre-existing bad data unrelated to this write.
|
||||
var current = dependsOnTaskId;
|
||||
var visited = new HashSet<string>();
|
||||
while (current is not null)
|
||||
{
|
||||
if (current == taskId)
|
||||
return new TransitionResult(false, "Setting this dependency would create a cycle.");
|
||||
if (!visited.Add(current))
|
||||
break;
|
||||
current = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Id == current)
|
||||
.Select(t => t.DependsOnTaskId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.DependsOnTaskId, dependsOnTaskId), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not found.");
|
||||
|
||||
// Clearing a dependency may free up a Queued task the picker was skipping.
|
||||
if (dependsOnTaskId is null) _waker.Wake();
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
var resultText = "[stale] " + reason;
|
||||
|
||||
Reference in New Issue
Block a user