# 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.
