Merge branch 'claudedo/5c80a0aba5d9468a96cf809fc7a6e7a1'

This commit is contained in:
mika kuns
2026-08-10 15:15:26 +02:00
20 changed files with 1768 additions and 49 deletions
+4 -3
View File
@@ -45,8 +45,8 @@ subfolder within their area; the namespace stays the area namespace.
## Architecture
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions.
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, `DependsOnTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions. `SetDependsOnAsync` rejects a self-reference, an unknown dependency id, or a link that would create a cycle (walks the proposed predecessor's own `DependsOnTaskId` chain).
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and (`DependsOnTaskId IS NULL` OR the dependency's `Status = 'done'`); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
- **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
@@ -61,7 +61,8 @@ not conflated.
|---|---|---|
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work done, waiting on children. |
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. |
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. Internal to `PlanningChainCoordinator` — resolves (or cascades) on ANY terminal state of the predecessor. |
| `DependsOnTaskId` | nullable FK | User/MCP-declared predecessor (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker only skips a queued row while the dependency's `Status` isn't `Done` -- a Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked (see `QueuePicker`, `TaskStateService.SetDependsOnAsync`). `get_task`/`list_tasks`/`batch_get_tasks` surface this as `blocked`/`blockedReason`; `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. |
| `IsManual` | bool | Reminder only the user can do. `EnqueueAsync`/`StartRunningAsync` refuse it, the picker skips it, `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
| `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. |
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+29 -1
View File
@@ -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;