119 lines
6.2 KiB
Markdown
119 lines
6.2 KiB
Markdown
# 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.
|