Rebased onto post-#2515 `main`. **This PR is the fix for a P0 stall**,
not just a conversion.
## Stall 1 — approved plans were never released
`recoverApprovedTask` opened with a bare `task.column !== "triage"`.
#2515 merged Todo into Planning on the default lineage, so every default
card now sits in `todo` and **this guard rejected all of them**. An
approved plan whose finalize was interrupted was never released, and
nothing else owns that card. Callers: `triage.ts:1296` (stuck-kill
recovery) and `in-process-runtime.ts:1460`.
`triage` stayed a legal id, so nothing threw — the guard just stopped
matching.
I verified the fix **mechanism** rather than assuming it.
`resolveLifecycleColumns` on the IR #2515 actually shipped returns:
```
{ intake: "todo", hold: "todo", wip: "in-progress", review: "in-review", complete: "done", archived: "archived" }
```
so the converted guard admits default cards. The new regression test
asserts the **return value**, because on a merged lineage the card is
already where the release would send it — "no move issued" is what
*both* the broken and the fixed code do, so only the outcome
discriminates.
**Mutation-verified:** restoring the literal `!== "triage"` fails 2 of 5
tests.
## A defect of my own, found while auditing — same shape as the P0
`clearStaleSpecifyingStatuses` is a board-wide startup sweep with no
single task to resolve lanes against, and I had resolved **both** its
queries from the default workflow. Post-#2515 that workflow's `intake`
and `hold` are the **same** column, so both queries collapsed onto
`todo` and **nothing ever swept `triage`**. A legacy or Coding (Ideas)
card holding a stale `planning` status would then occupy a planning
admission slot permanently — exactly the failure the 2026-07-04 note
above that function warns about.
Now queries the **union** of the legacy planner ids and the resolved
lanes, deduped by task id. Querying extra columns is free here: the
sweep only reads, and every row is filtered on `status === "planning"`
before anything is written.
Caught by `triage.test.ts`, **not by my own tests** — worth recording,
since it is the same collapse the P0 is about.
## Rebase note
The discovery conflict was resolved **in favour of `main`**. Main's
version is strictly better than mine: it resolves lanes with the
**async** `resolveTaskLifecycleColumns` (so it is not subject to the
sync-resolver limitation below), keeps the two admission branches
disjoint for a merged column, and bounds concurrency. My sync version
was dropped.
## Measured
| file | comparisons before | after |
|---|---:|---:|
| `packages/engine/src/triage.ts` | **11** | **0** |
## Known red, NOT from this PR
8 tests in `triage.test.ts` fail on **clean `origin/main`** — confirmed
by swapping main's `triage.ts` into this tree and re-running (same 8).
They are reporting the upgrade stall, not stale expectations: a card
*sitting* in `triage` is admitted by nothing after #2515 (`expected
"specifyTask" to be called 4 times, but got 0 times`), and #2515 shipped
no data migration re-homing those rows. Left untouched here — the fix is
a data migration, not a conversion. Reported to the coordinator
separately.
## Verification
- merge gate green (414 + 10 + 71), tsc clean, lint clean
- mutation-verified as above
## Separate finding — affects every worker
`resolveTaskWorkflowIrSync` **cannot resolve a task's selection in
production.** `getTaskWorkflowSelectionImpl` is `return undefined`
unconditionally and `getTaskWorkflowSelectionAsyncImpl` is *"always
PostgreSQL path"*, so the sync resolver **always** returns the DEFAULT
workflow IR. `moves.ts` already hit this and fixed it by going async.
Consequence for `resolvePlannerLanes` here: correct for default-lineage
cards (the default IR is exactly what comes back — which is why Stall 1
is genuinely fixed) and **inert for custom workflows**. Not papered
over; the async path is main's discovery code, and converting the
remaining event-listener sites needs the handler-reordering problem
solved first.
No changeset: `@fusion/engine` is private.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
## P0 audit table — every `triage` site in my assigned files
(a) does it still fire for a default-workflow card after #2515? (b) if
not, what silently stops happening? (c) fix.
| site | (a) still fires? | (b) what silently stops | (c) disposition |
|---|---|---|---|
| `triage.ts:613` wake handler | **yes** | — OR-shaped (`todo \|\|
triage`), still matches | converted anyway |
| `triage.ts:651` evacuation guard | **yes** | — OR-shaped, still
matches | converted anyway |
| `triage.ts:741` stale-planning sweep | **yes** | — OR-shaped, still
matches | converted anyway |
| `triage.ts:1088` `recoverApprovedTask` | **NO** | **STALL 1** —
approved plan never released; nothing else owns the card | **fixed +
regression test + mutation-verified** |
| `triage.ts:1396` advanced-recovery discovery | **NO** | that recovery
never matches a default card | fixed by the same conversion |
| `clearStaleSpecifyingStatuses` (mine) | **NO** | **my own defect** —
both queries collapsed onto `todo`, `triage` never swept; stale
`planning` holds an admission slot forever | **fixed** (union of legacy
+ resolved lanes) |
| `replan-target.ts:177` / `:185` | **NO** | **STALL 2** — see #2552 |
fixed in #2552 |
| `spec-staleness.ts:95` | **NO** | narrow: a Planning card with null
status and `currentStep > 0` now skips staleness where it previously did
not | **recorded, not fixed** — see below |
| discovery (`isAtIntakeColumn`) | **NO** | **STALL 3** — a card
*sitting* in `triage` is admitted by nothing | **reported, not fixed** —
needs a data migration |
**Why `spec-staleness.ts:95` is not fixed here.** The guard already
returns `false` for `status === "planning"` and `needs-replan`, so an
*actively* planning card is still covered by status. The `column ===
"triage"` arm only added coverage for a planner-lane card with **no**
status — and post-merge that case is genuinely ambiguous, because `todo`
is now both the planning lane and the hold lane, so a card with progress
there may legitimately be a released card that *should* skip. Guessing
either way is a behaviour change without evidence, so I recorded it
rather than picking one.