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>
6.7 KiB
category, module, date, problem_type, component, severity, applies_when, tags, related_components
| category | module | date | problem_type | component | severity | applies_when | tags | related_components | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| architecture-patterns | @fusion/core, @fusion/engine | 2026-07-26 | architecture_pattern | workflow-graph | high |
|
|
|
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:
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.- 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
todocard 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 apassedplan-reviewentry toworkflowStepResults. A held unreviewed card is the gate working; that path belongs topre-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. - The trace no longer starts at
startfor a card past intake. Assertions onvisitedNodeIdsshould 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).