Files
fusion/docs/solutions/architecture-patterns/workflow-node-column-placement-and-graph-entry-contract.md
gsxdsm 19dffe36f6 fix(engine): make the planning->plan-review handoff atomic so planned cards stop stranding in Todo
Triage announced specification completion before its finally block marked the
plan work item terminal, so the Plan Review seeder saw its own still-running
predecessor as an "active continuation", bailed, and the discarded result
silently stranded the card until FN-8592 self-healing re-seeded it ~10 minutes
later (529 occurrences in 18 days).

- seedStrandedPlanReviewContinuation gains retirePredecessorId: idle check
  excludes the named predecessor, then retires it and installs the successor in
  ONE transaction under the task lock; a bailed seed mutates nothing.
- triage threads planningWorkItemId through PlanningHandoffReport; the runtime
  reaction passes it as retirePredecessorId.
- reactToSpecificationComplete consumes the seed result: typed quiet parks
  (incl. new "no-pre-release-plan-review"), bounded retries with a fresh
  task/IR snapshot per attempt (mid-retry pause/needs-replan honored), loud
  warning naming self-healing on exhaustion.
- Tests: PG both-orderings/no-mutation-on-bail/cross-task cases, direct engine
  seeder handoff cases, reaction retry/park/pause/replan cases.
- docs/solutions: new planning-handoff-race writeup; graph-entry-contract doc
  reclassifies the FN-8592 sweep as backstop-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 21:24:53 -07:00

127 lines
6.7 KiB
Markdown

---
category: architecture-patterns
module: "@fusion/core, @fusion/engine"
date: 2026-07-26
problem_type: architecture_pattern
component: workflow-graph
severity: high
applies_when:
- "Moving a built-in workflow node into a different column"
- "Reasoning about where a graph run starts when there is no durable continuation"
- "Debugging a card that walks backward out of In progress mid-run, or strands in a pre-WIP column"
- "Writing a scheduler/release test whose fixture puts a card in the hold column"
tags:
- workflow-ir
- column-placement
- plan-in-place
- plan-review
- graph-entry
- hold-release
- abort-on-exit
related_components:
- workflow_graph
- scheduler
- triage
---
# The graph entry contract, and why it decides where planning can live
A workflow node's `column` is a lifecycle contract, not a display choice: it decides **who drives the
node**, **whether the card holds a WIP slot**, and **whether anything can move the card onward**.
## The entry contract
**A graph run with no durable continuation resumes at the card's own column — never at `start`.**
(`resolveColumnResumeNode`, `workflow-graph-executor.ts`.)
`ir.columns` is ordered, and that order is the lifecycle order. A run enters at the first node, in
forward pipeline order from `start`, whose column is not *behind* the card's current column. Rework
back-edges and failure edges are excluded, so the entry point is always the main path and never a
remediation node that merely happens to sit in that column.
| Card is in | Resumes at | Why it matters |
|---|---|---|
| `triage` | `start` | nothing is behind it |
| `todo` | `plan` | it still needs a spec |
| `in-progress` | `parse` | **never re-plans**, never moves backward out of WIP |
| `in-review` | `browser-verification` | first *review* node — gates are not skipped |
Before this contract, every continuation-less run — self-healing's graph re-entry, a fresh dispatch,
an operator drag into a processing column — replayed from the first column and dragged the card
backward through columns it had already left, firing `abort-on-exit` on its live session. That
backward drag is the single reason planning nodes used to be pinned to the implementation column.
## Plan-in-place (now the default for every coding workflow)
The whole specification phase — `plan`, `plan-review`, `plan-replan` — runs in the planning lane
(`todo`), so a card under specification never holds an implementation slot. The chain, with the check
each link performs:
```
triage specifies the card (writes PROMPT.md) → finalize moves it to `todo`
→ onSpecifyComplete seeds a plan-review continuation
... only if planReviewNode.column === task.column (seedPreReleasePlanReviewContinuation)
→ drainWorkflowContinuations claims runnable `waitReason:"planning"` items
→ executor.execute() resumes the graph AT plan-review
→ on success the next node is in `in-progress`; the boundary SUSPENDS with a
`waitReason:"capacity"` continuation instead of moving
→ isUnplannedForExecution sees that continuation and lets the scheduler release
→ executor resumes at `parse`
```
**`todo`, not `triage`.** The planning lane spans both columns, but `triage` is an intake column with
no releaser — a card parked there waits for a human. `todo` carries `hold` + the capacity sweep, and
it is where triage's finalize actually leaves the card. Every plan-in-place check keys on the card's
own column, so the node must be where the card rests.
## The release gate is narrow on purpose
`isUnplannedForExecution` applies its pre-release plan-review gate only when **both**:
1. `planReviewNode.column === task.column` — the plan-in-place shape. Keying on merely "not a WIP
column" gates every held card against an upstream node their graph never routes through, so they
can never produce the continuation the gate waits for.
2. The plan-review group is **enabled for that task**. A task with the gate toggled off has no gate to
enforce, and holding it deadlocks — nothing will ever record the evidence.
## Consequences to know
- **A `todo` card is releasable only once Plan Review passed** (or the graph suspended at the capacity
boundary). Scheduler/release **test fixtures must model a card that cleared the gate** — add a
`passed` `plan-review` entry to `workflowStepResults`. A held unreviewed card is the gate working;
that path belongs to `pre-release-plan-review.test.ts`.
- **Pre-existing cards** sitting in Todo with a real spec and no continuation are re-seeded
automatically by FN-8592's stranded-hold-continuation sweep. Since 2026-08-13 that sweep is a pure
backstop: the primary handoff retires its own planning work item atomically with the successor
install (`retirePredecessorId`), so routine post-planning stranding — which once made the sweep
the de facto handoff at a ~10-minute delay per card — is a bug, not expected behavior. See
[planning-handoff-race-silently-strands-plan-review](../logic-errors/planning-handoff-race-silently-strands-plan-review.md).
- **The trace no longer starts at `start`** for a card past intake. Assertions on `visitedNodeIds`
should expect the card's column entry point.
- **Coding (Ideas) no longer has a private planning shape.** Its planning-node re-home is deleted —
the default graph it clones is already plan-in-place. It now differs only in intake column and its
reduced node set.
## Removing a column is a lifecycle-vocabulary refactor, not a workflow edit
Merging Todo into Planning (one column carrying `intake` + `hold`) was implemented and reverted. The
IR change is ten lines and the merge gate stays green — which is exactly the trap. The engine names
`todo` **literally** in ~207 production sites, of which:
- **82 are guards** (`column === "todo"` / `column !== "todo"`) that would silently stop matching. A
guard that never fires does not fail a test; it disables a recovery path in production.
- **43 are writes** (`moveTask(id, "todo", ...)`) targeting a column the workflow no longer declares.
- **59 more live in the dashboard.**
Converting them means resolving the hold column by trait at each call site (`resolveReboundTarget`,
`columnsWithFlag(ir, "hold")`), and many of those sites have no IR in scope — so it needs plumbing,
not find-and-replace. Do it as its own change, with `reconcileUndeclaredTaskColumns` (already
shipped) handling the rows left behind in the removed column.
## Don't couple UI badges to columns
The dashboard's optional-gate badge was lane-gated on `triage`/`todo`, which silently hid the Plan
Review badge whenever the node ran elsewhere. A gate's **running state** is the signal; the card's
column is not a second opinion on it. Only gate a badge on a column when the gate genuinely moves the
card there (Code Review / Browser Verification → `in-review`).