Files
ClaudeDo/docs/superpowers/specs/2026-05-29-planning-draft-planned-gate-design.md
2026-05-29 14:25:52 +02:00

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), 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

  1. 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).
  2. 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).

  3. 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).
  4. 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.