fix(cli): the node-override guard never fired on a renamed board, so mid-flight changes were allowed (#3019)

## The node-override guard never fired on a renamed board

`fn_task_update` called the guard with no options:

```ts
const validation = validateNodeOverrideChange(task, normalizedNodeId ?? null);
```

so `wipColumns` fell back to its documented default of
`{"in-progress"}`. On a board whose WIP lane is named anything else,
`wipColumns.has(task.column)` is false, the mid-flight check passes, and
**an operator can change the node override on a running task** —
precisely what that guard exists to refuse, in its own words:

> "Is this task executing right now?" — keyed on the literal, a renamed
board let an operator change the node override MID-FLIGHT on a running
task, which is exactly what this guard exists to refuse.

That note is attached to the `wipColumns` option added for this purpose.
The CLI simply never passed it.

## Two assumptions in the guard's own docs that did not hold

```
Both callers supply them. An omitted set keeps the legacy id, which is what a caller
without cheap IR access (a CLI tool, a route with only a task row) still gets.
```

1. **"Both callers"** — this is a *third* one, and it was in
`check-lane-wiring`'s known-unwired baseline the whole time.
2. **"a CLI tool … without cheap IR access"** — this handler is async
and has already awaited `store.getTask`, so one more resolve costs
exactly what `resolveTaskLifecycleColumns` already costs elsewhere **in
this same file** (the linked-lineage label at ~1239). The assumption was
reasonable in general and wrong here.

Passed present-but-conditionally-valued rather than as a conditional
argument: an omitted set still keeps the documented legacy default, and
only that shape is visible to `lane-wiring-census`, which matches an
object-literal argument and cannot see a ternary.

## Coverage — stated rather than implied

**There is no new unit test.** The regression guard is the ratchet
itself, and it is a real revert-proof: with the wiring removed,

```
[check-lane-wiring] call sites not passing a resolved lane argument INCREASED:
  packages/cli/src/extension.ts: 1 unwired now, baseline allows 0
```

Verified by actually reverting it, not by assuming. Baseline re-recorded
19 → 18 in the same commit, so the allowance cannot be regrown into.

A behavioural test would need a custom workflow definition persisted
*and* selected inside the integration harness to get a card resting in a
renamed WIP lane. That is worth doing and I would take it as follow-up
harness work — but it is not part of this fix, and I would rather name
the gap than let "85 passed" imply coverage I did not write.

## Verification (measured)

- **85 passed** across `extension.test.ts`,
`extension-experiment-finalize.test.ts`,
`task-list-board-columns.test.ts`
- `tsc --noEmit`, `eslint` — clean
- `check-lane-wiring` (18, none added), `lifecycle-column-census
--strict`, `check-inert-flag-seams`, `check-fnxc-future-dates`,
`check:changesets` — green

Changeset included (`patch`): `packages/cli` is the published
`@runfusion/fusion` and this changes guard behaviour operators rely on.
This commit is contained in:
gsxdsm
2026-07-31 00:52:50 -07:00
committed by GitHub
parent 5bdb8a1102
commit 6f936f2de7
3 changed files with 31 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix the node-override guard not blocking mid-flight changes on boards with a renamed WIP lane.
category: fix
dev: `fn_task_update` called `validateNodeOverrideChange` without options, so `wipColumns` fell back to the literal `{"in-progress"}` and the running-task check never fired on a renamed board. It now resolves the task's own WIP and COMPLETE lanes via `resolveTaskLifecycleColumns`.

View File

@@ -1819,7 +1819,30 @@ export default function kbExtension(pi: ExtensionAPI) {
store.updateTask all exhibit identical behavior.
*/
const normalizedNodeId = normalizeNullableStringInput(params.nodeId);
const validation = validateNodeOverrideChange(task, normalizedNodeId ?? null);
/*
FNXC:WorkflowResolvedColumns 2026-07-31-12:20:
Supply this task's resolved WIP and COMPLETE lanes — without them the guard does not fire.
`validateNodeOverrideChange` defaults `wipColumns` to `{"in-progress"}`, so on a board whose
WIP lane is named anything else `wipColumns.has(task.column)` is false and the mid-flight
check passes. An operator could then change the node override on a RUNNING task, which is
precisely what that guard exists to refuse (see its own note in node-override-guard.ts).
The guard's options doc says "Both callers supply them" and assumes a CLI tool has no cheap
IR access. Neither held here: this is a third caller, and it is an async handler that has
already awaited `store.getTask`, so one more resolve is the same cost `resolveTaskLifecycleColumns`
is already paid for elsewhere in this file (the linked-lineage label at ~1239).
Passed as present-but-conditionally-valued rather than a conditional argument: an omitted
set keeps the documented legacy default, and only this shape is visible to
scripts/lib/lane-wiring-census.mjs, which matches an object-literal argument and cannot see a
ternary. This site was a known-unwired entry in that gate's baseline.
*/
const nodeOverrideLifecycle = await resolveTaskLifecycleColumns(store, task.id);
const validation = validateNodeOverrideChange(task, normalizedNodeId ?? null, {
wipColumns: nodeOverrideLifecycle?.wip ? new Set([nodeOverrideLifecycle.wip]) : undefined,
completeColumns: nodeOverrideLifecycle?.complete ? new Set([nodeOverrideLifecycle.complete]) : undefined,
});
if (!validation.allowed) {
return {
content: [{ type: "text", text: validation.message ?? "Node override change blocked" }],

View File

@@ -15,7 +15,6 @@
"packages/dashboard/app/hooks/useBlockerFanout.ts": 1,
"packages/cli/src/commands/dashboard-tui/app.tsx": 1,
"packages/cli/src/commands/dashboard-tui/bucket-mapping.ts": 1,
"packages/cli/src/extension.ts": 1,
"plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx": 1,
"plugins/fusion-plugin-even-realities-glasses/src/routes/board-routes.ts": 1
}