# 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`), still `Idle`. 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 1. **`TaskStateService` transition guard** — the single enforcement point. When a child task is about to enter `Queued` or `Running`, look up the parent's `PlanningPhase`; if it is not `Finalized`, return a failed `TransitionResult` (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 → Running` claim (defense in depth; a Draft child can't reach `Queued` in the first place) - MCP `UpdateTaskStatus(Queued)` / `RunTaskNow` 2. **Finalize stops auto-queuing** — audit every `FinalizeAsync(taskId, queueAgentTasks, ct)` call site and pass `queueAgentTasks: false`. Known callers to update: the UI finalize command and the planning-MCP finalize tool. After this, `FinalizeAsync` only flips the parent to `Finalized` (children become Planned); `SetupChainAsync` is no longer invoked from finalize. 3. **New queue action** — add `WorkerHub.QueuePlan(parentTaskId)` → `PlanningChainCoordinator.SetupChainAsync(parentTaskId)`. Guarded so it only runs when the parent is `Finalized`; otherwise returns a failure the UI surfaces. This is the user-triggered replacement for the auto-chain. ### UI 4. **`TaskRowViewModel`** - Add `ParentFinalized` (`bool`), set by `TasksIslandViewModel`. - `IsDraft => IsChild && Status == Idle && !ParentFinalized` - `IsPlanned => IsChild && Status == Idle && ParentFinalized` - `CanSendToQueue` gains `&& (!IsChild || ParentFinalized)` - Child badge renders `DRAFT` / `PLANNED` (drive off `IsDraft` / `IsPlanned`). - Raise `PropertyChanged` for the new derived members from the relevant `On*Changed` hooks (`OnStatusChanged`, `OnParentTaskIdChanged`, and a new `OnParentFinalizedChanged`). 5. **`TasksIslandViewModel`** — when building/refreshing rows, resolve each child's parent `PlanningPhase` from the loaded task set and set `ParentFinalized`. If the parent is not in the loaded set, default to `false` (Draft — the safe, non-queueable default). 6. **`DetailsIslandViewModel`** - `CanEnqueue` for a selected child additionally requires the parent to be `Finalized`. - Add a parent-level **"Send plan to queue"** command, enabled when the selected task is a `Finalized` planning parent with at least one Planned (`Idle`) child and nothing already queued/running; calls `QueuePlanAsync(parentId)`. 7. **`IWorkerClient` / `WorkerClient`** — add `QueuePlanAsync(string parentId)`. Update the test fakes (UI + Worker test projects) to implement the new member. ## Testing - **Worker (`TaskStateService`):** child enqueue/run rejected when parent `Active`; allowed when parent `Finalized`. Standalone task enqueue still allowed. Picker skips/ignores draft children. - **Worker (finalize):** `FinalizeAsync(..., queueAgentTasks: false)` flips parent to `Finalized` and queues nothing; children remain `Idle`. - **Worker (`QueuePlan`):** on a `Finalized` parent, builds the sequential chain (child[0] unblocked + queued, successors blocked on predecessor); on a non-`Finalized` parent, fails. - **UI VM (`TaskRowViewModel`):** Draft vs Planned derivation and `CanSendToQueue` gating across parent phases; badge text. - **UI VM (`DetailsIslandViewModel`):** `CanEnqueue` gating 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.