6.2 KiB
Planning: Draft → Planned → Queue gate
Date: 2026-05-29 Status: Approved (design)
Problem
When a planning parent is finalized, PlanningChainCoordinator.SetupChainAsync immediately
enqueues the entire child chain (child[0] runs, successors wait blocked on their predecessor).
There is no review step: a user cannot hold finalized subtasks in a "ready but not running"
state, and the "DRAFT" label in the UI is only a derived side effect
(TaskRowViewModel.IsDraft => IsChild && Status == Idle) with no gate behind it — a draft
child already satisfies CanSendToQueue and can be queued directly.
We want an explicit lifecycle for planning children:
- Draft — child of a plan still being built (parent
PlanningPhase == Active). Not queueable. - Planned — child of a finalized plan (parent
PlanningPhase == Finalized), stillIdle. Queueable.
Finalizing a plan promotes its children Draft → Planned without queuing anything. The user then explicitly sends the plan to the queue, which builds the sequential chain (today's behavior, just user-triggered). The gate is enforced in both the UI and the server so no path (UI, MCP, external agents) can queue or run a Draft child.
Decisions
- Q1 — Finalize semantics: Finalize auto-marks children Planned (not Draft); nothing is queued until the user explicitly sends to queue. Draft exists only while the plan is unfinalized.
- Q2 — Queue granularity: A single parent-level "Send plan to queue" action queues all
Planned children as a sequential chain (reuses
SetupChainAsync). No per-child queueing. - Q3 — Enforcement: UI and server. The gate is a server invariant in
TaskStateService, so MCP / external agents are bound by it too. - Data model — Approach 1 (derive, no schema change): Draft/Planned is a pure function of the
parent's
PlanningPhase. No new column, no migration, no parent/child drift.
Core invariant
No schema change. A child task's stage is derived from its parent's PlanningPhase:
Parent PlanningPhase |
Child (Status = Idle) |
Queueable? |
|---|---|---|
Active (plan being built) |
DRAFT | no |
Finalized |
PLANNED | yes |
Server invariant: a child task (ParentTaskId != null) may transition Idle → Queued or
Idle → Running only if its parent's PlanningPhase == Finalized. Standalone (non-child)
tasks are unaffected.
A failed/cancelled child returning to Idle while its parent is still Finalized is therefore
"Planned" again and re-queueable — desired.
Components
Worker / server
-
TaskStateServicetransition guard — the single enforcement point. When a child task is about to enterQueuedorRunning, look up the parent'sPlanningPhase; if it is notFinalized, return a failedTransitionResult(no exception — consistent with the existing no-throw transition pattern). This covers:- UI single-task enqueue (
SetTaskStatus → Queued) RunNow(StartRunningAsync,Idle → Running)- the queue picker's
Queued → Runningclaim (defense in depth; a Draft child can't reachQueuedin the first place) - MCP
UpdateTaskStatus(Queued)/RunTaskNow
- UI single-task enqueue (
-
Finalize stops auto-queuing — audit every
FinalizeAsync(taskId, queueAgentTasks, ct)call site and passqueueAgentTasks: false. Known callers to update: the UI finalize command and the planning-MCP finalize tool. After this,FinalizeAsynconly flips the parent toFinalized(children become Planned);SetupChainAsyncis no longer invoked from finalize. -
New queue action — add
WorkerHub.QueuePlan(parentTaskId)→PlanningChainCoordinator.SetupChainAsync(parentTaskId). Guarded so it only runs when the parent isFinalized; otherwise returns a failure the UI surfaces. This is the user-triggered replacement for the auto-chain.
UI
-
TaskRowViewModel- Add
ParentFinalized(bool), set byTasksIslandViewModel. IsDraft => IsChild && Status == Idle && !ParentFinalizedIsPlanned => IsChild && Status == Idle && ParentFinalizedCanSendToQueuegains&& (!IsChild || ParentFinalized)- Child badge renders
DRAFT/PLANNED(drive offIsDraft/IsPlanned). - Raise
PropertyChangedfor the new derived members from the relevantOn*Changedhooks (OnStatusChanged,OnParentTaskIdChanged, and a newOnParentFinalizedChanged).
- Add
-
TasksIslandViewModel— when building/refreshing rows, resolve each child's parentPlanningPhasefrom the loaded task set and setParentFinalized. If the parent is not in the loaded set, default tofalse(Draft — the safe, non-queueable default). -
DetailsIslandViewModelCanEnqueuefor a selected child additionally requires the parent to beFinalized.- Add a parent-level "Send plan to queue" command, enabled when the selected task is a
Finalizedplanning parent with at least one Planned (Idle) child and nothing already queued/running; callsQueuePlanAsync(parentId).
-
IWorkerClient/WorkerClient— addQueuePlanAsync(string parentId). Update the test fakes (UI + Worker test projects) to implement the new member.
Testing
- Worker (
TaskStateService): child enqueue/run rejected when parentActive; allowed when parentFinalized. Standalone task enqueue still allowed. Picker skips/ignores draft children. - Worker (finalize):
FinalizeAsync(..., queueAgentTasks: false)flips parent toFinalizedand queues nothing; children remainIdle. - Worker (
QueuePlan): on aFinalizedparent, builds the sequential chain (child[0] unblocked + queued, successors blocked on predecessor); on a non-Finalizedparent, fails. - UI VM (
TaskRowViewModel): Draft vs Planned derivation andCanSendToQueuegating across parent phases; badge text. - UI VM (
DetailsIslandViewModel):CanEnqueuegating for children; "Send plan to queue" enablement.
Out of scope
- Per-child manual promotion while a plan is still being built (Draft → Planned without finalizing). Promotion happens only via finalize.
- Per-child independent queueing (Q2 = parent-level chain only).
- Any database schema / migration change.