diff --git a/docs/superpowers/specs/2026-05-29-planning-draft-planned-gate-design.md b/docs/superpowers/specs/2026-05-29-planning-draft-planned-gate-design.md new file mode 100644 index 0000000..a6840b5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-planning-draft-planned-gate-design.md @@ -0,0 +1,118 @@ +# 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.