refactor(data): retire legacy TaskStatus values and backfill existing rows
Slice 6 of the worker state and queue consolidation refactor. * Drop Manual, Planning, Planned, Draft, Waiting from the TaskStatus enum and from the EF value converter; only the lifecycle values remain (Idle, Queued, Running, Done, Failed, Cancelled). * Add migration RetireLegacyTaskStatus that rewrites existing rows: manual/draft -> idle, planning -> idle+planning_phase=active, planned -> idle+planning_phase=finalized, waiting -> queued+blocked_by derived from sort_order via a CTE with LAG(). * Reroute every call site that compared/set legacy values to the new three-field model (Status + PlanningPhase + BlockedByTaskId), including the planning repo helpers, MCP services, the planning chain coordinator, and the UI view-models. TaskRowViewModel now exposes PlanningPhase to drive the planning badge. * Refresh Worker/CLAUDE.md and Data/CLAUDE.md, the docs/plan.md status section, and the planning verification notes in docs/open.md.
This commit is contained in:
@@ -2,15 +2,60 @@
|
||||
|
||||
ASP.NET Core hosted service that executes tasks via Claude CLI in isolated environments.
|
||||
|
||||
## Folder Layout
|
||||
|
||||
```
|
||||
Worker/
|
||||
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
|
||||
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService
|
||||
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService
|
||||
Worktrees/ — WorktreeMaintenanceService
|
||||
Agents/ — AgentFileService, DefaultAgentSeeder
|
||||
Runner/ — TaskRunner + Claude CLI integration
|
||||
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService
|
||||
External/ — ExternalMcpService
|
||||
Hub/ — WorkerHub, HubBroadcaster
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
|
||||
- **QueueService** — `BackgroundService` with two execution slots:
|
||||
- Queue slot: FIFO sequential processing of "agent"-tagged queued tasks
|
||||
- Override slot: immediate execution via `RunNow(taskId)`
|
||||
- Wake signaling via `SemaphoreSlim`, backstop timer (30s default)
|
||||
- **StaleTaskRecovery** — startup-only service, flips orphaned "running" tasks to "failed"
|
||||
- **External/ExternalMcpService** — always-on MCP tools for general Claude sessions: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask` (with tags), `UpdateTask`, `UpdateTaskStatus`, `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`. Auth via optional `X-ClaudeDo-Key` header.
|
||||
- **TaskStateService** — 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`, schedule, and the `agent` tag; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`.
|
||||
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
||||
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
||||
- **External/ExternalMcpService** — always-on MCP tools for general Claude sessions: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask` (with tags), `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`. Auth via optional `X-ClaudeDo-Key` header.
|
||||
|
||||
## Status Model
|
||||
|
||||
`TaskEntity` carries three orthogonal fields. Lifecycle, planning hierarchy, and chain blocking are no longer conflated.
|
||||
|
||||
| Field | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `Status` | `Idle`, `Queued`, `Running`, `Done`, `Failed`, `Cancelled` | Lifecycle only. |
|
||||
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
|
||||
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. |
|
||||
|
||||
Allowed transitions (enforced by `TaskStateService`):
|
||||
|
||||
```
|
||||
Idle → Queued | Running (RunNow)
|
||||
Queued → Running | Cancelled | Idle
|
||||
Running → Done | Failed | Cancelled
|
||||
Done → Idle (re-run)
|
||||
Failed → Idle | Queued
|
||||
Cancelled → Idle | Queued
|
||||
```
|
||||
|
||||
## Planning Flow
|
||||
|
||||
`PlanningSessionManager.FinalizeAsync` is the single path:
|
||||
|
||||
1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized`.
|
||||
2. `PlanningChainCoordinator.SetupChainAsync` attaches the `agent` tag to every child, enqueues child[0], and `BlockOn`s child[i] → child[i-1].
|
||||
3. The first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.
|
||||
|
||||
`TaskRepository.FinalizePlanningAsync` no longer exists. The `Mark*Async` repository helpers are `internal` — only `TaskStateService` calls them.
|
||||
|
||||
## Task Execution Pipeline
|
||||
|
||||
@@ -28,7 +73,7 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir
|
||||
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever permission mode the app settings specify). Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree).
|
||||
- **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
|
||||
- **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser.
|
||||
- **TaskResetService** — discards a failed task's worktree and resets the task row to Manual; preserves run history.
|
||||
- **TaskResetService** — discards a failed task's worktree and resets the task row to Idle; preserves run history.
|
||||
- **WorktreeManager** — creates worktrees at `claudedo/{taskId[:8]}` branches, commits changes with semantic messages, updates DB with head commit and diff stats
|
||||
- **CommitMessageBuilder** — formats `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`
|
||||
- **AgentFileService** — manages `~/.todo-app/agents/*.md` agent definition files; exposes list/refresh via SignalR
|
||||
|
||||
@@ -117,7 +117,7 @@ public sealed class ExternalMcpService
|
||||
ListId = listId,
|
||||
Title = title,
|
||||
Description = description,
|
||||
Status = TaskStatus.Manual,
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = list.DefaultCommitType,
|
||||
CreatedBy = createdBy,
|
||||
@@ -167,7 +167,7 @@ public sealed class ExternalMcpService
|
||||
return ToDto(reload);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Update a task's status. Only 'Manual' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")]
|
||||
[McpServerTool, Description("Update a task's status. Only 'Idle' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")]
|
||||
public async Task<TaskDto> UpdateTaskStatus(
|
||||
string taskId,
|
||||
string status,
|
||||
@@ -181,7 +181,7 @@ public sealed class ExternalMcpService
|
||||
|
||||
switch (target)
|
||||
{
|
||||
case TaskStatus.Manual:
|
||||
case TaskStatus.Idle:
|
||||
await _tasks.ResetToManualAsync(taskId, cancellationToken);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
break;
|
||||
|
||||
@@ -39,16 +39,10 @@ public sealed class PlanningChainCoordinator
|
||||
if (children.Count == 0)
|
||||
throw new InvalidOperationException("Parent has no subtasks.");
|
||||
|
||||
// Eligibility: new layout uses Status=Idle. Tolerate legacy Manual/Planned/Draft
|
||||
// values during this slice — they will be migrated away in slice 6.
|
||||
var bad = children.FirstOrDefault(c =>
|
||||
c.Status != TaskStatus.Idle &&
|
||||
c.Status != TaskStatus.Manual &&
|
||||
c.Status != TaskStatus.Planned &&
|
||||
c.Status != TaskStatus.Draft);
|
||||
var bad = children.FirstOrDefault(c => c.Status != TaskStatus.Idle);
|
||||
if (bad is not null)
|
||||
throw new InvalidOperationException(
|
||||
$"Child {bad.Id} is in status {bad.Status}; expected Idle (or legacy Manual/Planned/Draft).");
|
||||
$"Child {bad.Id} is in status {bad.Status}; expected Idle.");
|
||||
|
||||
// Worker queue picker requires the "agent" tag — attach it so children are pickable.
|
||||
var agentTag = await ctx.Tags.FirstOrDefaultAsync(t => t.Name == "agent", ct);
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class PlanningMcpService
|
||||
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(child.Id, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
return new CreatedChildDto(child.Id, "Draft");
|
||||
return new CreatedChildDto(child.Id, child.Status.ToString());
|
||||
}
|
||||
|
||||
[McpServerTool, Description("List all child tasks under the current planning session's parent task.")]
|
||||
@@ -68,9 +68,9 @@ public sealed class PlanningMcpService
|
||||
}
|
||||
|
||||
private static readonly TaskStatus[] EditableStatuses =
|
||||
{ TaskStatus.Draft, TaskStatus.Idle, TaskStatus.Manual, TaskStatus.Queued };
|
||||
{ TaskStatus.Idle, TaskStatus.Queued };
|
||||
|
||||
[McpServerTool, Description("Update a child task in the active planning session. Can change title, description, tags (replaces the full set), commit type, and status. Status must be one of: Draft, Idle, Manual, Queued.")]
|
||||
[McpServerTool, Description("Update a child task in the active planning session. Can change title, description, tags (replaces the full set), commit type, and status. Status must be one of: Idle, Queued.")]
|
||||
public async Task<ChildTaskDto> UpdateChildTask(
|
||||
string taskId,
|
||||
string? title,
|
||||
@@ -97,7 +97,7 @@ public sealed class PlanningMcpService
|
||||
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
||||
throw new InvalidOperationException($"Unknown status '{status}'.");
|
||||
if (!EditableStatuses.Contains(parsed))
|
||||
throw new InvalidOperationException($"Status '{parsed}' cannot be set via MCP. Allowed: Draft, Idle, Manual, Queued.");
|
||||
throw new InvalidOperationException($"Status '{parsed}' cannot be set via MCP. Allowed: Idle, Queued.");
|
||||
newStatus = parsed;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,8 +81,9 @@ public sealed class PlanningSessionManager
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.ParentTaskId is not null)
|
||||
throw new InvalidOperationException("Cannot start a planning session on a child task.");
|
||||
if (task.Status != TaskStatus.Manual)
|
||||
throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
|
||||
if (task.Status != TaskStatus.Idle || task.PlanningPhase != PlanningPhase.None)
|
||||
throw new InvalidOperationException(
|
||||
$"Task is in status {task.Status}/{task.PlanningPhase}; only Idle+None can start planning.");
|
||||
|
||||
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||
@@ -232,7 +233,7 @@ public sealed class PlanningSessionManager
|
||||
var (tasks, _, settings, ctx) = CreateRepos();
|
||||
await using var __ = ctx;
|
||||
var children = await tasks.GetChildrenAsync(taskId, ct);
|
||||
return children.Count(c => c.Status == TaskStatus.Draft);
|
||||
return children.Count(c => c.Status == TaskStatus.Idle);
|
||||
}
|
||||
|
||||
public async Task DiscardAsync(string taskId, CancellationToken ct)
|
||||
@@ -261,8 +262,9 @@ public sealed class PlanningSessionManager
|
||||
|
||||
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status != TaskStatus.Planning)
|
||||
throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
|
||||
if (task.PlanningPhase != PlanningPhase.Active)
|
||||
throw new InvalidOperationException(
|
||||
$"Task planning phase is {task.PlanningPhase}; resume requires Active planning.");
|
||||
if (string.IsNullOrEmpty(task.PlanningSessionId))
|
||||
throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class PlanningTokenAuthMiddleware
|
||||
|
||||
var token = auth.Substring("Bearer ".Length).Trim();
|
||||
var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted);
|
||||
if (parent is null || parent.Status != ClaudeDo.Data.Models.TaskStatus.Planning)
|
||||
if (parent is null || parent.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.Active)
|
||||
{
|
||||
ctx.Response.StatusCode = 401;
|
||||
await ctx.Response.WriteAsync("Invalid or expired planning token");
|
||||
|
||||
@@ -144,10 +144,10 @@ public sealed class TaskStateService : ITaskStateService
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == parentId &&
|
||||
(t.Status == TaskStatus.Manual || t.Status == TaskStatus.Idle))
|
||||
.Where(t => t.Id == parentId
|
||||
&& t.Status == TaskStatus.Idle
|
||||
&& t.PlanningPhase == PlanningPhase.None)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Planning)
|
||||
.SetProperty(t => t.PlanningPhase, PlanningPhase.Active), ct);
|
||||
|
||||
if (affected == 0)
|
||||
@@ -198,13 +198,6 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not found.");
|
||||
|
||||
// Bridge to legacy chain layout: a Waiting predecessor-blocked sibling becomes Queued
|
||||
// when its predecessor finishes. New layout (post-Slice 4) stores siblings as
|
||||
// Status=Queued + BlockedByTaskId set, so this is a no-op for them.
|
||||
await ctx.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status == TaskStatus.Waiting)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Queued), ct);
|
||||
|
||||
_waker.Wake();
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
|
||||
Reference in New Issue
Block a user