Files
fusion/packages/core/src/node-override-guard.ts
gsxdsm 4e8c621e9c FN-7641: fix cards stranded after out-of-band/workspace merges by allowing proven-merge rehome
Fixes a state-machine bug family where cards got stranded after out-of-band or workspace merges landed: store.moveTask now allows a proven-merge recoveryRehome to cross legacy columns (e.g. todo→done), and nodeId='end' finalize no longer silently no-ops — it finalizes on durable merge proof or returns an explicit error, consistently across the dashboard route, the CLI task-update tool, and store.updateTask.

- packages/core/src/store.ts: allow proven-merge recoveryRehome moves across legacy columns (e.g. todo→done) instead of rejecting them
- packages/core/src/node-override-guard.ts: nodeId='end' finalize now checks for durable merge proof and returns an explicit error instead of silently no-op'ing
- packages/dashboard/src/routes/register-task-workflow-routes.ts: dashboard workflow route surfaces the new explicit finalize error/behavior
- packages/cli/src/extension.ts: CLI task-update tool surfaces the same explicit finalize error/behavior
- docs/task-management.md: documented the updated finalize/rehome behavior
- Added regression tests across core (node-override-guard, store-movement, task-node-override), dashboard (register-task-workflow-routes.nodeid-finalize), engine (merger-merge-lifecycle), and CLI (extension) covering the stranded-card invariant
- Added changeset for @runfusion/fusion (patch)

Files changed:
 .changeset/fn-7641-stranded-cards-after-merge.md   |  7 ++
 docs/task-management.md                            |  2 +
 packages/cli/src/__tests__/extension.test.ts       | 59 ++++++++++++++
 packages/cli/src/extension.ts                      | 10 +++
 .../core/src/__tests__/node-override-guard.test.ts | 93 +++++++++++++++++++++
 packages/core/src/__tests__/store-movement.test.ts | 94 ++++++++++++++++++++++
 .../core/src/__tests__/task-node-override.test.ts  | 73 +++++++++++++++++
 packages/core/src/node-override-guard.ts           | 69 +++++++++++++++-
 packages/core/src/store.ts                         | 69 +++++++++++++++-
 ...er-task-workflow-routes.nodeid-finalize.test.ts | 90 +++++++++++++++++++++
 .../src/routes/register-task-workflow-routes.ts    | 10 +++
 .../src/__tests__/merger-merge-lifecycle.test.ts   | 58 +++++++++++++
 12 files changed, 631 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7641

Fusion-Task-Lineage: 48ea7851-ee68-48f1-92f9-302d0da5acff

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-07 22:06:20 -07:00

92 lines
4.2 KiB
TypeScript

export type NodeOverrideBlockReason = "task-in-progress" | "terminal-without-merge-proof";
export interface NodeOverrideValidationResult {
allowed: boolean;
reason?: NodeOverrideBlockReason;
message?: string;
/**
* FNXC:StateMachine 2026-07-07-12:00:
* True when `newNodeId` resolves to the task workflow's terminal `end` node,
* the task is not already `done`, AND durable merge proof already exists
* (`mergeDetails.mergeConfirmed === true`). Callers MUST route this case
* through a finalize-to-done move (e.g. `store.moveTask(id, 'done', {
* recoveryRehome: true, preserveProgress: true })`) instead of writing
* `nodeId` as a bare field — a bare field write is exactly the Signature-2
* silent no-op this flag exists to prevent (FN-7641 / NEXT-322 / NEXT-375 /
* NEXT-340: a human/agent merges the branch tip directly into `main`, then
* `nodeId='end'` is set and the card silently stays in `in-review` forever).
*/
requiresFinalize?: boolean;
}
export interface NodeOverrideTaskInput {
column: string;
nodeId?: string;
id: string;
mergeDetails?: { mergeConfirmed?: boolean } | null;
}
export interface NodeOverrideValidationOptions {
/**
* Resolve whether `nodeId` is the task workflow's terminal `end` node.
* Callers with access to the task's resolved workflow IR (e.g.
* `TaskStore`) should pass a real resolver keyed off `node.kind === "end"`.
* Callers without cheap IR access (dashboard route, CLI tool) may omit this
* — the default fallback below still catches the literal `nodeId === "end"`
* id used by every built-in workflow's terminal node, which covers the
* exact reported symptom and the common case.
*/
isTerminalNodeId?: (nodeId: string) => boolean;
}
const defaultIsTerminalNodeId = (nodeId: string): boolean => nodeId === "end";
export function validateNodeOverrideChange(
task: NodeOverrideTaskInput,
newNodeId: string | null | undefined,
options?: NodeOverrideValidationOptions,
): NodeOverrideValidationResult {
if (newNodeId === undefined) {
return { allowed: true };
}
if (task.column === "in-progress") {
return {
allowed: false,
reason: "task-in-progress",
message: `Cannot change node override for ${task.id} while it is in progress. The task is currently executing and routing cannot be changed mid-flight. Wait for the task to complete, or pause/stop it first before changing the node assignment.`,
};
}
/*
FNXC:StateMachine 2026-07-07-12:00:
Signature 2 (FN-7641 / NEXT-322 / NEXT-375 / NEXT-340): setting nodeId='end' after work
merged out-of-band (bypassing the merge node) must never silently no-op. Before this fix
the field was written verbatim and the card stayed wherever it was (e.g. in-review with
all steps done) with no error and no advancement. Resolve the intent explicitly instead:
a terminal `end` override with durable merge proof finalizes the card (requiresFinalize);
a terminal `end` override with NO merge proof is rejected with an actionable error so the
caller knows to confirm the merge first. Non-terminal nodeId overrides and clearing the
override (newNodeId === null) are untouched — this only gates the terminal-node case.
*/
const isTerminal =
newNodeId !== null &&
(options?.isTerminalNodeId ? options.isTerminalNodeId(newNodeId) : defaultIsTerminalNodeId(newNodeId));
if (isTerminal && task.column !== "done") {
const mergeConfirmed = task.mergeDetails?.mergeConfirmed === true;
if (mergeConfirmed) {
return { allowed: true, requiresFinalize: true };
}
return {
allowed: false,
reason: "terminal-without-merge-proof",
message:
`Cannot set node override to '${newNodeId}' for ${task.id}: setting nodeId='end' does not finalize a card by itself. ` +
`This task has no durable merge proof (mergeDetails.mergeConfirmed is not true), so the workflow finalize path was not applied and the card was left unchanged rather than silently no-op. ` +
`If the work already merged out-of-band, confirm the merge (record mergeDetails.mergeConfirmed=true via the merge-confirm/reconcile path) and retry, or move the task to done through the normal review/merge flow instead of overriding nodeId directly.`,
};
}
return { allowed: true };
}