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>
92 lines
4.2 KiB
TypeScript
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 };
|
|
}
|