Four agent actions still keyed on literals, with three census-invisible `moveTask` destinations between them. `agent-actions.ts` already had `laneContext`/`destination` from an earlier partial conversion — these were simply never migrated. ## Census | file | main | here | | --- | ---: | ---: | | `plugins/fusion-plugin-even-realities-glasses/src/agent-actions.ts` | 4 | **0** | Plus 3 hardcoded `moveTask` destinations the census cannot see (`requestReview` → `in-review`, `returnToAgent` → `todo`, `retryTask` → `todo`). ## The real finding: this plugin could never resolve a review lane `resolveLifecycleColumns` keys its `review` role on the **`mergeOrchestration` trait alone**. A board whose review column carries only `merge-blocker` and/or `human-review` — the common custom shape, since `merge` is opt-in — resolves **no review lane at all**. So every review-gated action here (`requestReview`, `acceptReview`, `returnToAgent`, `retryTask`) compared against `undefined` and **refused every card**, and `requestReview` had nowhere to move one. This is not a regression from converting them; it is why they *could not* be converted with `lanes.review` as-is. Converting the four guards without noticing would have shipped four actions that fail closed on exactly the boards this program exists to support — a conversion that looks complete, passes its suite, and makes the plugin useless on a custom board. **Widened in `laneContext`, not in the shared resolver.** `resolveLifecycleColumns` is consumed well beyond this plugin, and its `review` role deliberately means "the merge-orchestration column" for the merge queue. The gap is already recorded in `notification-renamed-lifecycle-columns.test.ts` and in #2807 — reconciling the two definitions is a core-level decision, not one to take from a plugin. `mergeBlocker` is preferred over `humanReview` because a card cannot leave a merge-blocking column until the gate clears, which is the closer analogue of the legacy `in-review`. ## The suite caught an over-reach of mine My first version put a blanket `if (degraded) conflict(...)` at the top of `retryTask`, which broke a pinned invariant the test names outright: **"a degraded workflow does not block retries that move nothing."** The status-only retry just clears fields; refusing it because the workflow could not be read breaks a recovery that needs no lane at all. Degraded now blocks only the branches that actually **move**. Same reasoning applied to `acceptReview`, which also moves nothing. The existing `startWork` convention — conflict on degraded — is right precisely *because* it moves. ## Ordering `returnToAgent` and `retryTask` now resolve their destination **before** the field clear. Both cleared first, so a rejected move left the assignee and status — or the worktree, branch and base refs — nulled with the card exactly where it was. That is the fifth instance of this half-applied shape in the audit, and it is rule 3 in the class doc. ## Revert results (measured, each independently) | conversion | reverted → | | --- | --- | | `requestReview` destination | 1 failed — moves to the literal `in-review`, which this workflow does not declare | | `returnToAgent` destination | 1 failed — moves to the literal `todo`, same | Plus a non-vacuous companion: a renamed card *not* in the wip lane must still be refused by `requestReview`, so a gate admitting everything would not pass. ## Verification - Plugin suite — **186/186** - `pnpm test:gate` — 161 + 487 + 13 + 71, green - `tsc` on the plugin — clean - `pnpm lint`, `check:changesets`, census `--strict` — all clean (run explicitly)
7.9 KiB
category, module, date, problem_type, component, severity, applies_when, tags
| category | module | date | problem_type | component | severity | applies_when | tags | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| architecture-patterns | workflow-resolved-columns | 2026-07-30 | systemic_gap | engine | high |
|
|
A guard and its moveTask are two halves of one conversion, and the census only counts one
The shape
if (task.column !== "in-review") { … return; } // the census counts THIS
…
await this.store.moveTask(taskId, "in-progress"); // and cannot see THIS
The census is an AST scan for comparisons against a lifecycle id. A moveTask destination is a
call argument, so no entry in the backlog ever points at one. Converting the guard alone is worse
than leaving both: the handler starts admitting work on a renamed board and then tries to move the
card into a lane that board may not declare.
Hit twice in one week, both times only because the guard next to it was being converted:
auto-recovery-handlers/branch-worktree.ts— the counted guard wastask.column === "in-progress"; the invisible half wasmoveTask(task.id, "todo", …), requeuing into a lane that may not exist (PR #2797).pr-comment-handler.ts— the counted guard dropped a GitHub "changes requested" review; the invisible half wasmoveTask(taskId, "in-progress")(PR #2807).
The measurement
Across packages/core, packages/engine, packages/dashboard, packages/cli and plugins,
excluding __tests__/*.test.* and comment lines:
| count | |
|---|---|
hardcoded moveTask destinations in production |
51 |
…passing recoveryRehome: true — deliberate, see below |
22 |
| …plain, i.e. rejected on a board that does not declare the target | 29 |
The plain 29 at the time of measurement, by file. 13 have since been converted on this branch and in PRs #2797/#2807 — the parenthesised entries are done, and the count stands at 16 remaining:
6 packages/engine/src/self-healing.ts (all 6 sit in query-gated sweeps — see below)
3 plugins/fusion-plugin-even-realities-glasses/src/agent-actions.ts
2 packages/cli/src/extension.ts
2 packages/engine/src/replan-target.ts (both are COMMENT lines, not call sites)
1 packages/dashboard/app/utils/appLifecycle.ts
1 packages/engine/src/project-engine.ts
---- converted ----
3 packages/engine/src/executor.ts (done)
2 packages/engine/src/project-engine.ts (done)
2 packages/engine/src/recovery/foreign-only-contamination.ts (done)
2 packages/cli/src/commands/task-lifecycle.ts (done)
1 packages/core/src/duplicate-intake.ts (done)
1 packages/core/src/duplicate-guard.ts (done)
1 packages/engine/src/auto-recovery-handlers/contamination.ts (done)
1 packages/engine/src/restart-recovery-coordinator.ts (done)
1 packages/engine/src/pr-comment-handler.ts (done, #2807)
self-healing.ts's 6 are deliberately last. Every one sits inside a sweep whose task list comes from
a hardcoded listTasks({ column: … }) filter, so on a renamed board the sweep returns no rows and the
moveTask below it is never reached. Converting those destinations changes nothing observable until the
query layer is fixed — see the sibling doc on self-healing. Converting them first would look like
progress and deliver none.
Three shared resolvers now cover the converted sites, in workflow-lifecycle-traits.ts beside
resolveTaskLifecycleColumns: resolveReboundTargetForTask, resolveArchiveTargetForTask,
resolveWipTargetForTask. Use them rather than re-deriving a destination per call site.
Re-measure with:
grep -rn 'moveTask(' packages/*/src packages/dashboard/app packages/cli/src plugins \
--include=*.ts --include=*.tsx | grep -v __tests__ | grep -v '\.test\.'
then split on whether recoveryRehome: true appears in the option object.
The 22 are NOT defects — do not "fix" them
moves.ts deliberately exempts them:
const recoveryToLegacy =
options?.recoveryRehome === true && (COLUMNS as readonly string[]).includes(toColumn);
if (!workflowHasColumn(workflowIr, toColumn) && !recoveryToLegacy) { throw … }
The comment there records why (#1411): a custom-workflow card stranded in an undeclared column must still be rescuable to a legacy safe-landing column, or it can never be recovered at all. A sweep that "converts" these re-homes removes the rescue path.
Why this got sharper recently
The workflowHasColumn(workflowIr, toColumn) rejection used to sit inside a block gated on
isWorkflowColumnsCompatibilityFlagEnabled, which reads a raw settings key nothing in production
writes — so the check did not execute and the legacy VALID_TRANSITIONS table decided instead. U12
hoisted it out of that dead branch, and it is now live and unconditional whenever the workflow resolves.
Proven on a real store by packages/core/src/__tests__/live-move-path-undeclared-target.test.ts:
moveTask(card in "todo" -> "triage") now REJECTS: /Unknown column for this workflow/
That changed the failure mode of all 29. Before, a hardcoded destination silently landed the card in an undeclared column — invisible to every trait-driven sweep until reconciliation re-homed it. Now it throws. Whether that surfaces or disappears depends entirely on whether the caller catches, which is per-site and is not measured here — do not read "29" as "29 crashes".
The one remaining non-blocked site, and why it is not converted
packages/dashboard/app/utils/appLifecycle.ts:245 — the CLI-session cancel action, deps.moveTask(session.id, "todo"). Its dependency type pins the literal in the signature itself:
moveTask: (id: string, column: "todo") => Promise<unknown>;
Converting it needs the resolved hold lane threaded from the caller (App.tsx#handleCliAction), and there is no resolved-columns map in that scope — the app-side convention is an optional columnFlagsById passed down, as deriveStatsFromTasks does, and App does not have one here.
Adding the parameter without wiring it would be an unwired parameter, which is precisely the anti-pattern the caller audit (#2803) removed five of. So this is left for the dashboard-app owner, who can introduce the map at the same time. One site, recorded rather than half-done.
What to do
- Convert the pair or neither. When a census entry sits in a function that also performs a
moveTask, the destination is in scope for the same change. Resolve it —resolveReboundTarget(ir)for a rebound, the appropriatecolumnsWithFlag(ir, …)lane otherwise — and keep the legacy id as the fallback. - Guard the move. Even a resolved destination can be rejected (a deleted task, a guard, capacity).
Catch at the move, record an audit row naming the actual failure, and do not let a recovery handler
die on it — see
branch-worktree.ts, where the rejection is classified fromTransitionRejectionError.rejection.coderather than by message match. - Do not clear state before the move. If the move can be rejected, anything cleared beforehand is
lost with no requeue.
branch-worktree.tsclearedbranch/baseCommitShafirst and destroyed the only pointers back to the work on a rejected move.
Related
docs/solutions/architecture-patterns/self-healing-sweeps-are-blind-on-a-renamed-board.md— the other census-invisible class, where a hardcodedlistTasks({ column })filter means the guard never runs at all.docs/solutions/test-failures/optional-flags-seam-hides-unconverted-column-guards.md— the census counts syntax; its "literal COLLECTION" section is the third invisible class (array/Setmembership).packages/core/src/__tests__/live-move-path-undeclared-target.test.ts— the live proof that the rejection now fires.