Files
fusion/packages/engine/src/executor/workflow-merge-boundary.ts
gsxdsm 5ba0b0cffc FN-9168: Record terminal merge-boundary parks in run audit
Add failure-isolated audit visibility for merge-boundary proof failures without changing terminal park behavior.

- Emit a redacted event from retry-boundary and graph-terminal park paths.
- Bound audit sink latency and isolate absent, throwing, rejecting, or hung sinks.
- Add reason-code coverage, catalogue documentation, and a patch changeset.

Files changed:
 .changeset/fn-9168-merge-boundary-audit.md         |  7 ++
 AGENTS.md                                          |  1 +
 docs/run-audit.md                                  |  1 +
 .../src/__tests__/executor-graph-boundary.test.ts  | 33 ++++++---
 .../__tests__/merge-boundary-unproven-park.test.ts | 78 +++++++++++++++++++-
 .../executor/emit-merge-boundary-unproven-audit.ts | 82 ++++++++++++++++++++++
 .../engine/src/executor/handle-graph-failure.ts    | 23 +++++-
 .../executor/route-graph-merge-failure-to-retry.ts | 36 +++++++++-
 .../engine/src/executor/workflow-merge-boundary.ts | 38 ++++++++--
 .../engine/src/run-audit/run-audit-catalogue.ts    |  5 +-
 packages/engine/src/util/run-audit.ts              | 10 +++
 11 files changed, 293 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-9168

Fusion-Task-Lineage: cdeb5c1f-4b22-4531-878e-b18955e6ea5a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-19 19:34:53 -07:00

158 lines
7.4 KiB
TypeScript

/**
* FNXC:CodeOrganization 2026-08-03-20:55:
* ensureWorkflowMergeBoundaryTask peeled from TaskExecutor (U4).
* Establish durable merge-column handoff + graph-native checklist projection before merge.
*/
import type { TaskDetail, TaskStore } from "@fusion/core";
import type { EngineRunContext } from "../util/run-audit.js";
import { resolveCompleteColumnFor } from "./lifecycle-columns.js";
export type WorkflowMergeBoundaryProof = {
hasForeachStepExecute: boolean;
complete: boolean;
hasRelevantNodeResult: boolean;
allResultsTerminal: boolean;
hasLiveStepImplementationProof: boolean;
nonTerminalResult?: { workflowStepId?: string; status?: string } | null;
missingInstanceIds: string[];
};
export type MergeBoundaryUnprovenReasonCode =
| "no-node-result"
| "non-terminal-node-result"
| "missing-foreach-instances";
export type WorkflowMergeBoundaryResult = {
task: TaskDetail;
blocked?: {
reason: string;
code: MergeBoundaryUnprovenReasonCode;
missingInstanceCount: number;
};
};
export type WorkflowMergeBoundaryDeps = {
store: TaskStore;
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
resolveMergeBoundaryColumn: (taskId: string, nodeId: string) => Promise<string>;
evaluateWorkflowMergeBoundary: (
live: TaskDetail,
runId: string,
) => Promise<WorkflowMergeBoundaryProof>;
shouldCompleteChecklistAtWorkflowMerge: (
live: TaskDetail,
mergeProof: WorkflowMergeBoundaryProof,
) => boolean;
};
export async function ensureWorkflowMergeBoundaryTask(
deps: WorkflowMergeBoundaryDeps,
task: TaskDetail,
metadata: { reason: string; nodeId: string; workflowId: string; runId: string },
): Promise<WorkflowMergeBoundaryResult> {
let live = await deps.store.getTask(task.id);
if (!live) return { task };
/*
FNXC:WorkflowMerge 2026-07-19-04:10 (U5a / R1 / KTD-7):
The merge NODE's OWN column drives the pre-merge handoff — not a hardcoded
"in-review". builtin:coding places its merge-class nodes (merge-gate /
merge-attempt / …) in `in-review`, so the default pipeline lands in `in-review`
exactly as before (KTD-7 parity oracle). A user-authored workflow (the 6-column
benchmark) places the merge node in `Merging`, so the card lands there because
the IR says so — deleting the hardcoded-"in-review" +
handoff-invariant-violation-allowlist assumption. Resolution failures fall back
to `in-review` so a bad/unresolvable IR never strands the merge boundary.
*/
const targetColumn = await deps.resolveMergeBoundaryColumn(task.id, metadata.nodeId);
/*
FNXC:WorkflowMerge 2026-07-26-22:59:
A prior review handoff can move a graph-native workflow into its merge column before this boundary projects successful node results onto the legacy checklist. Preserve the no-move behavior, but do not return until the projection has run.
*/
const alreadyAtMergeColumn = live.column === targetColumn;
if (live.column === await resolveCompleteColumnFor(deps.store, live.id)) return { task: live };
if (live.paused || live.userPaused) return { task: live };
/*
FNXC:WorkflowMerge 2026-06-29-10:15:
User-authored workflows may legitimately route execution directly to a merge node without an explicit review node. Reaching that node is the workflow-owned merge boundary, so the engine must establish the durable in-review/merge lifecycle handoff before requesting merge instead of assuming a prior node already moved the card.
FNXC:WorkflowMerge 2026-06-29-15:28:
Compound Engineering and similar graph-native workflows execute skill nodes instead of legacy parsed task steps. The graph records those nodes as `workflowStepResults.source = "node"`; at the merge boundary, project a successful graph-native run onto the legacy checklist so `task has incomplete steps` cannot block a workflow that already completed its authoritative nodes.
*/
/*
FNXC:WorkflowMerge 2026-08-20-00:50:
FN-9157 permits merge admission from complete terminal live foreach coverage
when Review Level 0 has deliberately disabled optional node-recording groups.
The proof still excludes zero expected instances and any pending step; checklist
projection continues to depend only on proof.complete.
*/
const mergeProof = await deps.evaluateWorkflowMergeBoundary(live, metadata.runId);
if (mergeProof.hasForeachStepExecute && !mergeProof.complete) {
const blocked = !mergeProof.hasRelevantNodeResult
? { reason: "no pre-merge node result recorded", code: "no-node-result" as const }
: !mergeProof.allResultsTerminal
? {
reason: `non-terminal pre-merge node result ${mergeProof.nonTerminalResult?.workflowStepId ?? "unknown"} (${mergeProof.nonTerminalResult?.status ?? "unknown"})`,
code: "non-terminal-node-result" as const,
}
: {
reason: `foreach step instances incomplete at merge boundary: missing ${mergeProof.missingInstanceIds.join(", ")}`,
code: "missing-foreach-instances" as const,
};
/*
FNXC:RunAudit 2026-08-20-02:00:
Boundary reason prose can contain foreach instance IDs and node-result status text. Run-audit
metadata must remain ids/counts/outcomes-only, so terminal parks receive this closed code and
missing-instance count rather than this human-readable reason.
*/
await deps.store.logEntry(live.id, `Workflow merge boundary blocked: ${blocked.reason}`, undefined, deps.getRunContextFor(live.id));
return {
task: live,
blocked: { ...blocked, missingInstanceCount: mergeProof.missingInstanceIds.length },
};
}
if (deps.shouldCompleteChecklistAtWorkflowMerge(live, mergeProof)) {
const completedSteps = live.steps.map((step) =>
step.status === "done" || step.status === "skipped"
? step
: { ...step, status: "done" as const },
);
const updated = await deps.store.updateTask(
live.id,
{
steps: completedSteps,
currentStep: Math.max(0, completedSteps.length - 1),
} as Partial<TaskDetail>,
deps.getRunContextFor(live.id),
);
live = (updated as TaskDetail | undefined) ?? { ...live, steps: completedSteps, currentStep: Math.max(0, completedSteps.length - 1) };
await deps.store.logEntry(
live.id,
"Workflow merge boundary completed graph-native task checklist before requesting merge",
undefined,
deps.getRunContextFor(live.id),
);
}
if (alreadyAtMergeColumn) return { task: live };
const moveOptions = {
preserveProgress: true,
moveSource: "engine" as const,
workflowMoveSource: "workflow-graph",
workflowMoveMetadata: metadata,
};
const storeWithMove = deps.store as typeof deps.store & {
moveTask?: (id: string, column: string, options?: unknown) => Promise<TaskDetail | undefined>;
};
if (typeof storeWithMove.moveTask === "function") {
const moved = await storeWithMove.moveTask(live.id, targetColumn, moveOptions);
await deps.store.logEntry(live.id, `Workflow merge boundary moved task to ${targetColumn} before requesting merge`, undefined, deps.getRunContextFor(live.id));
return { task: moved ?? { ...live, column: targetColumn } };
}
await deps.store.updateTask(live.id, { column: targetColumn } as Partial<TaskDetail>, deps.getRunContextFor(live.id));
await deps.store.logEntry(live.id, `Workflow merge boundary moved task to ${targetColumn} before requesting merge`, undefined, deps.getRunContextFor(live.id));
return { task: { ...live, column: targetColumn } };
}