consolidate/u7: plugins to zero + 8 executor rebound guards + resume lanes (supersedes #2607, #2635, #2640) (#2644)

Consolidation branch for U7, per the new one-branch working mode.
**Supersedes #2607, #2635, #2640** — the three of my PRs that were stuck
on review threads. My other seven (#2602, #2605, #2606, #2611, #2621,
#2628, #2633) are green with **zero unresolved threads** and are
deliberately left alone for the merge sweep.

## What is in here, file by file

| file | change | guards before → after |
|---|---|---|
| `plugins/…/glasses/src/agent-actions.ts` | gates, destinations and
degraded-resolution refusal all resolve from the task's own workflow | 2
→ 0 |
| `plugins/…/glasses/src/quick-capture.ts` | accepted capture columns
come from the board; default no longer names the deleted column | 1 → 0
|
| `plugins/…/glasses/src/settings.ts` | quick-capture default was
`triage`, the column #2515 removed | (assignment, uncounted) |
| `plugins/…/dependency-graph/src/GraphTaskNode.tsx` | redundant column
condition deleted | 1 → 0 |
| `packages/engine/src/executor.ts` | 8 rebound guards compare the
resolved column; 4 resume-eligibility literals share one resolver | 151
→ 143 (+4 off-bar) |
| `packages/engine/src/__tests__/` | 4 new suites, 26 cases | — |

`plugins/` reaches **zero** column guards with this branch.

## The three threads it closes

**#2607 — five findings, all mine, all the same rule.** I kept
*qualifying* a legacy-id fallback instead of removing it:

| attempt | rule | hole review found |
|---|---|---|
| 1 | fall back to `todo` when the role is missing | moved cards to
phantom columns |
| 2 | …only if the workflow **declares** `todo` | aliased **review**
lane named `todo` |
| 3 | …and only if no other role is assigned to it | **traitless**
parking column named `todo` |

The qualifications were the mistake. Once `resolveLanes` returns a lane
set the workflow *has* a column vocabulary, so "no column carries the
hold trait" is a complete answer — refuse. `destination()` is two lines
now, with no aliasing surface left to qualify.

Plus a sixth, which is a genuinely different state: **degraded
resolution is indistinguishable from the default board.**
`resolveWorkflowIrForTask` is total by design — a missing definition
silently returns the *default* coding IR — so a card on a custom board
whose definition could not be read resolved to `todo`/`in-progress`.
`undefined` lanes cannot express that (it means "no workflow at all",
where the legacy ids *are* the answer). The actions now refuse with 409.
#2618 would replace this check with resolver provenance; it is not
merged, so this does not depend on it.

**#2635 — "seven rebound sites remain untested."** Fair; my "same shape"
note was an assertion, not coverage. Seven of the eight need a live
graph run to reach, so the *shape* is pinned instead: a static check
that no guard in front of a rebound move compares against a column
literal, with a vacuity case (the same detection run against the
original shape) and a match-count floor (≥8), because a guard reporting
success on zero matches is worse than no guard.

**#2640 — duplicate workflow resolution.** Framed as I/O; it is also a
correctness bug. Eligibility and re-entry are two halves of one decision
and resolved the workflow separately, so a workflow edit landing between
them has the halves reading *different boards*. Now one caller-owned
memo per decision — caller-owned because a process-lifetime cache would
have to guess when a mid-flight workflow edit invalidates it.

## Behavioural findings, not tidying

- **The last-resort recovery for completed-but-stranded work did not
exist off the default lineage.** `promotedFromPlannerColumn` was false
on a renamed board, so finished work resting in planning was never
promoted; the code fell through to a review handoff that role adjacency
rejects, and the card stayed stuck with its work complete.
- **Rebound guards could not see the column their own move targeted.**
U5b converted the move target; the eight `column !== "todo"` checks in
front of it were left literal, so on a renamed board the engine moved a
card into the column it was already in — and `moveTaskInternal` runs
reset-on-entry on every real move, so at the `preserveProgress: false`
site it reset step progress a second time.
- **The FN-1404 `task:move` audit row was lying**, recording `to:
"todo"` while the move target was resolved. A run-audit trail that
disagrees with the move it describes is worse than none. Not a
comparison, so no census counts it.
- **A task interrupted by an engine pause never resumed on a renamed
board** (off-bar, `in-review`/`in-progress` literals): four comparisons
decided one question and had to agree; two of them disagreed on a
renamed board, so re-entry silently never fired.

## Revert proofs, isolated per site

| reverted | result |
|---|---|
| `destination()` back to attempt 3 | 3 of 38 fail |
| degraded-resolution refusals removed | 2 of 42 fail |
| capture set back to the legacy five | 2 of 3 fail (renamed-board
suite) |
| forward exclusions → literals | 1 of 14 fails |
| missing-wip refusal removed | 2 of 14 fail |
| `promotedFromPlannerColumn` → literals | 3 of 7 fail |
| promotion target → `"in-progress"` | 3 of 7 fail |
| one rebound guard → `!== "todo"` | 1 of 3 fails (static shape) |
| resume lanes → legacy trio | 1 of 5 fails |

Every conversion is paired with a negative — a forward move, a
not-a-planner-lane card, a default-lineage card, an unresolvable
workflow — so neither "always fire" nor "never fire" can pass for
"resolve the role".

## Commit discipline

Twelve commits, each one thing: the code move (`resolvePlannerLanes` out
of `triage.ts`) is separate from every behavior change, and each review
fix is its own commit with its own revert proof.

## Verification

- `pnpm test:gate` **71/71**
- 162/162 across the glasses plugin's 19 files; 26/26 across the four
new engine suites
- engine + glasses typecheck clean; `pnpm lint` clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Engine recovery and retries now work correctly with renamed or
customized workflow columns.
  * Tasks in manual-intake columns are no longer automatically planned.
* Agent actions and quick capture now respect each board’s declared
columns and lifecycle stages.
* Awaiting-approval tasks are recognized regardless of their current
column.
* Command Center SDLC funnel stages now accurately reflect customized
workflows.

* **Documentation**
* Added guidance for safely changing workflow-column logic and
interpreting lifecycle-column checks.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 00:52:55 -07:00
committed by GitHub
parent 76e92f33c4
commit dca20496f4
27 changed files with 2551 additions and 65 deletions

View File

@@ -0,0 +1,91 @@
---
category: logic-errors
module: "@fusion/engine, @fusion/core, @fusion/dashboard"
date: 2026-07-31
problem_type: logic_error
component: workflow-lifecycle-columns
severity: high
applies_when:
- "Replacing a `task.column === \"<legacy id>\"` comparison with a trait-resolved lifecycle role"
- "Converting a guard in triage discovery, hold release, self-healing, or a dashboard affordance"
- "Reviewing a lifecycle-column conversion PR"
- "A card started being auto-planned, auto-advanced, or auto-shown after a conversion that 'only renamed things'"
---
# Converting a column literal can WIDEN the gate it was guarding
## The two failure directions
A lifecycle-column conversion replaces "is this card in the column named X" with "is this card in the
column carrying role R". Both directions of error are common, and only one of them is obvious.
**Direction 1 — narrower / phantom (the obvious one).** The gate resolves the role but a *destination*
or an *exemption* beside it stays literal. The guard admits a card on a renamed board and the move
then targets a column that board does not declare. Symptoms: `TransitionRejectionError`, a card in a
column nothing renders, or a recovery that reports failure after a partial move. This shape happened
four times in one plugin file and twice in `executor.ts` during Phase C.
**Direction 2 — WIDER (the one that gets shipped).** The literal was holding a gate *shut* for a
workflow whose column simply did not match it. Resolving the role makes the predicate start matching,
so behaviour the literal excluded by accident now happens. Nothing errors. Nothing is rejected. The
engine just does more than it used to.
## The incident
`builtin:coding-ideas` exists so an operator can park a capture without the engine planning it
(FN-7596). That rule was enforced *accidentally*: triage discovery's admission predicate compared
against `"triage"`, and an `ideas` card matched no branch.
Converting that predicate to resolve intake **by trait** made `ideas` the resolved intake column for
that workflow — so discovery began specifying parked ideas. Measured: `poll()` called `specifyTask`
on the parked card. The conversion was correct in vocabulary and wider in effect.
The fix is not to revert the conversion. It is to make the *real* rule explicit: the intake trait
carries `autoTriage: false`, and a manual intake is never auto-admitted. The signal was in the IR the
whole time; the literal had been standing in for it.
## Why the guarding test did not catch it
`triage.test.ts` had a case named "excludes a parked ideas-column task from the poll's
specify-dispatch set". It passed before the conversion, passed after the rule broke, and passes today.
Its store has **no workflow readers**. Lifecycle resolution therefore falls back to `triage`/`todo`,
an `ideas` card matches neither branch, and the card is excluded — for a reason that has nothing to do
with manual intake. The fixture could not reach the code path the test was named after.
The tell was in its own comment, which still described the mechanism the conversion had removed
("`eligibleTriageTasks`, which only matches `column === "triage"`"). **A comment describing a
mechanism that no longer exists is evidence the test is no longer testing it.**
## What to do when converting a column literal
1. **Ask what the literal EXCLUDED**, not just what it matched. List the workflows whose columns did
not match it, and decide deliberately whether each should now be included. That question is what
distinguishes a rename from a behaviour change.
2. **Convert the destinations and the exemptions in the same commit.** A role-resolved rule with a
name-matched carve-out inverts the carve-out.
3. **Make the fixture resolve a workflow.** A store without `getTaskWorkflowSelection` /
`getWorkflowDefinition` cannot reach any trait-resolved branch, so a test built on one proves
nothing about the conversion. Drive the real builtin IR (`BUILTIN_CODING_IDEAS_WORKFLOW_IR`) or a
renamed fixture, and assert on both a renamed and a merged shape.
4. **Pair every conversion with a negative.** "Never fires" and "always fires" must both fail. For an
admission gate that means: the manual intake is refused, an AUTO intake still admits, and an
unresolvable workflow behaves as before.
5. **Re-read the comment above the test you are relying on.** If it names a mechanism the diff
removed, that test is now decoration.
## Where the guards are
`node scripts/lifecycle-column-census.mjs` reports the remaining comparisons, classified into column
guards (the backlog), agent-role comparisons, entity-status comparisons, and reviewed
`DELIBERATE-LITERAL` sites. Only the first class is convertible; converting a role comparison
reintroduces this bug's cousin, because the planner *lane* is named `triage` and keeps that name.
## References
- `packages/engine/src/__tests__/manual-intake-admission.test.ts` — the invariant, with the surface
enumeration and the measured pre-fix behaviour.
- `packages/engine/src/triage.ts` — `isAtIntakeColumn`, and why the hold branch is deliberately not
gated (a card in hold was RELEASED there).
- `docs/testing.md` → "Lifecycle-column census (report-only)" — the four classes and why they are
never netted into one number.