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:
Mika Kuns
2026-04-27 15:28:55 +02:00
parent ff7c239959
commit dc3fc443b4
37 changed files with 306 additions and 229 deletions

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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.");

View File

@@ -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");