Files
fusion/packages/engine/src/executor/create-authoritative-workflow-primitives.ts
Fusion Agent 7563fcfe1b FN-037: configure AI-authored task output language
Configure a persisted output-language setting and apply it across AI-authored task text and workflow summaries.

- Add project/global output-language settings with defaults, validation, UI controls, translations, and documentation.
- Propagate the selected language through planning, execution, review, summaries, and task creation.
- Add focused coverage for language resolution and update affected dashboard, engine, and localization tests.

Files changed:
 .changeset/fn-037-task-output-language.md          |   7 ++
 docs/settings-reference.md                         |   4 +
 docs/task-management.md                            |   4 +
 .../core/src/__tests__/ai-output-language.test.ts  |  21 +++++
 packages/core/src/ai/ai-output-language.ts         |  42 +++++++++
 packages/core/src/ai/ai-summarize.ts               |  16 +++-
 packages/core/src/config/settings-schema.ts        |   2 +
 packages/core/src/index.gate.ts                    |   2 +
 packages/core/src/index.ts                         |   2 +
 packages/core/src/task-store/settings-ops.ts       |   7 ++
 packages/core/src/task-store/task-creation.ts      |   4 +
 packages/core/src/types/settings/settings-scope.ts |   2 +
 .../app/__tests__/settings-sections.test.tsx       |  21 +++--
 .../__tests__/SettingsModal.models-auth.test.tsx   |  10 +-
 .../app/components/settings/section-keys.ts        |   2 +-
 .../sections/ProjectModelsSection.search.ts        |  13 ++-
 .../settings/sections/ProjectModelsSection.tsx     |  28 ++++--
 .../settings-default-descriptions.test.tsx         |   4 +-
 .../src/__tests__/github-tracking.test.ts          |   4 +-
 .../__tests__/planning-prompt-resolution.test.ts   |  29 ++++--
 .../src/__tests__/routes-planning.test.ts          |  32 ++++++-
 packages/dashboard/src/ai-session-store.ts         |  15 ++-
 packages/dashboard/src/github-tracking.ts          |   3 +
 packages/dashboard/src/planning.ts                 |  75 +++++++++++++--
 .../routes/register-ai-text-assistant-routes.ts    |  11 ++-
 .../src/routes/register-planning-subtask-routes.ts |   6 +-
 .../src/routes/register-task-workflow-routes.ts    |   9 +-
 packages/dashboard/src/shared/settings-sections.ts |   2 +-
 .../__tests__/ce-workflow-step-executor.test.ts    |  45 ++++++++-
 .../__tests__/executor-review-artifacts.test.ts    |  65 +++++++++++++
 packages/engine/src/__tests__/triage.test.ts       |   7 +-
 .../src/__tests__/workflow-task-runtime.test.ts    |  23 +++++
 .../create-authoritative-workflow-primitives.ts    |  14 ++-
 .../create-authoritative-workflow-seams.ts         |  17 +++-
 .../engine/src/executor/execute-workflow-graph.ts  |  19 +++-
 .../engine/src/executor/execute-workflow-step.ts   |  13 ++-
 packages/engine/src/executor/execution-prompt.ts   |  10 +-
 .../engine/src/executor/handoff-task-to-review.ts  |  10 +-
 .../engine/src/executor/run-graph-custom-node.ts   |   6 +-
 .../src/executor/task-executor-graph-facades.ts    |   8 +-
 packages/engine/src/triage.ts                      |  35 ++-----
 .../src/workflows/workflow-completion-summary.ts   | 101 ++++++++++++++++++---
 .../engine/src/workflows/workflow-task-runtime.ts  |  12 +++
 packages/i18n/locales/en/app.json                  |   7 +-
 packages/i18n/locales/es/app.json                  |   7 +-
 packages/i18n/locales/fr/app.json                  |   7 +-
 packages/i18n/locales/ko/app.json                  |   7 +-
 packages/i18n/locales/pt-BR/app.json               |  14 ++-
 packages/i18n/locales/zh-CN/app.json               |   7 +-
 packages/i18n/locales/zh-TW/app.json               |   7 +-
 packages/i18n/src/resources.d.ts                   |  13 ++-
 51 files changed, 680 insertions(+), 151 deletions(-)

Fusion-Task-Id: FN-037

Fusion-Task-Lineage: 968a707e-7ffe-43ba-b983-d679b031f7a5

Co-authored-by: Fusion <noreply@runfusion.ai>
2026-08-20 03:57:23 +00:00

515 lines
26 KiB
TypeScript

/**
* FNXC:CodeOrganization 2026-08-03-14:15:
* createAuthoritativeWorkflowPrimitivesFromExecutor peeled from TaskExecutor (U4).
*
* FNXC:WorkflowExecution 2026-06-23-11:49 / 2026-06-23-22:31:
* prepareWorktree must not re-acquire; only trust live rows for the same task id.
*
* FNXC:WorkflowExecutionOwnership 2026-07-29-16:20:
* runCodingSession is the live implementation owner and announces NodeCompleted exits.
*/
import type { ResolvedTaskOutputLanguage, Settings, TaskDetail, TaskStore } from "@fusion/core";
import { emitWorkflowLifecycleEvent, resolveTaskLifecycleColumns, resolveTaskOutputLanguage } from "@fusion/core";
import type { ImplementationExit } from "./implementation-exit.js";
import type {
AuditPrimitiveInput,
PreparedWorktree,
WorkflowPrimitiveContext,
WorkflowRuntimePrimitives,
} from "../execution/runtime-primitives.js";
import { WorkflowPlanningService } from "../workflows/workflow-planning-service.js";
import { MERGE_BOUNDARY_UNPROVEN_VALUE } from "../workflows/workflow-merge-nodes.js";
import {
FOREACH_ACTIVE_CONTEXT_KEY,
SEAM_GOVERNING_NODE_CONTEXT_KEY,
SEAM_SKILL_NAME_CONTEXT_KEY,
SPLIT_ACTIVE_CONTEXT_KEY,
type ForeachActiveContext,
} from "../workflows/workflow-node-handlers.js";
import { graphActiveContextKey } from "./task-predicates.js";
import { hasNonTerminalWorkflowSteps } from "./workflow-step-satisfaction.js";
import { makeAncestryBlastRadiusGuard, resetStepToBaseline } from "../execution/step-runner.js";
import { finalizeProvenAutoMergeTask } from "../merge/auto-merge-finalization.js";
import { createRunAuditor, type EngineRunContext } from "../util/run-audit.js";
import { executorLog } from "../logger.js";
import { resolveExternalExecutionCheckoutRoute } from "../execution/external-execution-checkout.js";
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirror TaskExecutor method surface without re-typing the class
type AnyFn = (...args: any[]) => any;
export type CreateAuthoritativeWorkflowPrimitivesDeps = {
store: TaskStore;
rootDir: string;
graphSeamGoverningNodeId: Map<string, string>;
graphStepActiveContext: Map<string, unknown>;
pausedAborted: Set<string>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- merge requester accepts optional signal bag
mergeRequester?: ((taskId: string, opts?: any) => Promise<any>) | null;
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
buildParseStepsDeps: AnyFn;
createAuthoritativeWorkflowSeams: AnyFn;
ensureWorkflowMergeBoundaryTask: AnyFn;
getWorkflowMergeImplementationProofFailure: AnyFn;
handoffTaskToReview: AnyFn;
markPausedAborted: AnyFn;
persistTokenUsage: AnyFn;
runImplementationPhase: AnyFn;
runProjectedGraphTaskStep: AnyFn;
};
export function createAuthoritativeWorkflowPrimitivesFromExecutor(
deps: CreateAuthoritativeWorkflowPrimitivesDeps,
settings: Settings,
outputLanguage?: ResolvedTaskOutputLanguage,
): WorkflowRuntimePrimitives {
const logAudit = async (taskId: string | undefined, input: AuditPrimitiveInput): Promise<void> => {
if (!taskId) return;
try {
await deps.store.logEntry(taskId, input.message, input.metadata ? JSON.stringify(input.metadata) : undefined);
} catch {
// Audit is diagnostic-only and must not affect workflow execution.
}
};
const planningService = new WorkflowPlanningService();
return {
prepareWorktree: async (_ctx, task) => {
const live = await deps.store.getTask(task.id).catch(() => null);
const liveTask = live?.id === task.id ? live : null;
const routedTask = liveTask ?? task;
const externalRoute = await resolveExternalExecutionCheckoutRoute(routedTask);
if (externalRoute.configured && !externalRoute.valid) {
return {
outcome: "failure",
value: `external-execution-checkout-invalid: ${externalRoute.reason ?? "unknown error"}`,
};
}
/*
FNXC:WorkflowExecution 2026-06-23-11:49:
The workflow execute node must not perform a second worktree acquisition ahead of the authoritative executor. Passing the repo root as a prepared worktree makes the inner execute() reject a valid fresh-worktree task as repo-root reuse; pass only an existing task worktree and let execute() acquire when none exists.
FNXC:WorkflowExecution 2026-06-23-22:31:
Upgrade safety requires the graph primitive to tolerate older or minimal stores that return null or a mismatched row during startup/cutover. Only trust the live row when it is for the requested task; otherwise fall back to the runner snapshot.
FNXC:ExternalExecutionCheckout 2026-08-09-23:53:
Operator-routed external checkouts supply the prepared path/branch when configured.
*/
const prepared: PreparedWorktree = {
worktreePath: externalRoute.configured
? externalRoute.checkoutPath ?? ""
: liveTask?.worktree || task.worktree || "",
branchName: externalRoute.configured
? externalRoute.branch
: liveTask?.branch || task.branch,
};
return { outcome: "success", value: "worktree-ready", data: prepared };
},
readArtifact: async (_ctx, task, key) => {
const parseDeps = deps.buildParseStepsDeps(`${task.id}:artifact-read`);
return parseDeps.readArtifact(task, key);
},
writeArtifact: async (ctx, task, key, content) => {
const writer = (deps.store as unknown as {
writeTaskDocument?: (taskId: string, key: string, content: string) => Promise<void>;
}).writeTaskDocument;
if (!writer) {
await logAudit(task.id, {
type: "artifact-write-unavailable",
message: `Workflow node ${ctx.node.node.id} could not write artifact ${key}: store writer unavailable`,
});
return { outcome: "failure", value: "artifact-write-unavailable" };
}
await writer.call(deps.store, task.id, key, content);
return { outcome: "success", value: "artifact-written", data: { key } };
},
runPlanningSession: (ctx, task) => planningService.runPlanningSession(ctx, task),
runCodingSession: async (ctx, task, prepared) => {
const governingNodeId = ctx.node.context?.[SEAM_GOVERNING_NODE_CONTEXT_KEY];
if (typeof governingNodeId === "string") {
deps.graphSeamGoverningNodeId.set(task.id, governingNodeId);
}
let result: { taskDone: boolean; modifiedFiles: string[]; exit?: ImplementationExit };
try {
result = await deps.runImplementationPhase(task, prepared);
} finally {
deps.graphSeamGoverningNodeId.delete(task.id);
}
/*
FNXC:WorkflowExecutionOwnership 2026-07-29-16:20 (U8 / R4, R5):
THIS is the live implementation node, not the identically-shaped `execute` entry in
`createAuthoritativeWorkflowSeams`. `createDefaultNodeHandlers` prefers the PRIMITIVES
handler whenever `deps.primitives` is set, and `executeWorkflowGraph` always sets it — so
the legacy-seams prompt handler is unreachable for prompt nodes and anything wired only
there never runs. The exit announcement was wired only there; it is announced here now.
Measured, not assumed: instrumenting the seam and `createPromptLikeHandler` produced no
output for a graph run that demonstrably visited `steps#0:step-execute`, while a
module-load write from the same file appeared — so the negative was real and not swallowed
output.
*/
emitWorkflowLifecycleEvent({
type: "NodeCompleted",
taskId: task.id,
at: new Date().toISOString(),
runId: deps.getRunContextFor(task.id)?.runId,
nodeId: typeof governingNodeId === "string" ? governingNodeId : ctx.node.node.id,
outcome: result.taskDone ? "success" : "failure",
...(result.exit ? { exit: result.exit } : {}),
});
if (result.taskDone) {
return { outcome: "success", value: "implemented", data: result };
}
let paused = deps.pausedAborted.has(task.id);
if (!paused) {
try {
paused = Boolean((await deps.store.getTask(task.id)).paused);
} catch {
// Best-effort pause probe; fall through to the failure value.
}
}
/*
FNXC:WorkflowExecutionOwnership 2026-07-29-18:45 (U8 / R4):
THE PENDING-REVIEW ENDING IS A ROUTED OUTCOME, not a transition this phase performs. The
implementation phase used to call `handoffTaskToReview` itself and let the graph discover
the move afterwards; it now reports and stops, and this value routes the run to the
workflow's `review-pending-handoff` node, which performs the handoff and ends the run —
the same two effects in the same order, with the graph as the owner. Checked before the
pause probe because a pending-review stop is not a pause.
*/
if (result.exit === "review-handoff-pending-review") {
return { outcome: "failure", value: "review-pending", data: result };
}
return {
outcome: "failure",
value: paused ? "implementation-paused" : "implementation-incomplete",
data: result,
};
},
runTaskStep: async (ctx, task, stepIndex) => {
const context = ctx.node.context ?? {};
const active = context[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
if (!active || typeof active.stepIndex !== "number") {
return { outcome: "failure" };
}
const live = await deps.store.getTask(task.id);
/*
FNXC:WorkflowResume 2026-06-29-08:53:
`step-execute` is a workflow node and must be idempotent on replay. If the live projection already says this foreach instance is terminal, return success before invoking the step runner so retries/restarts cannot fail a fully completed task on a stale step snapshot.
*/
const liveStatus = live.steps[stepIndex]?.status;
if (liveStatus === "done" || liveStatus === "skipped") {
return {
outcome: "success",
value: "step-already-terminal",
data: { status: liveStatus },
};
}
deps.graphStepActiveContext.set(graphActiveContextKey(task.id, active.instanceId), active);
const stepGoverningNodeId = context[SEAM_GOVERNING_NODE_CONTEXT_KEY];
const seamSkillName = context[SEAM_SKILL_NAME_CONTEXT_KEY];
return await deps.runProjectedGraphTaskStep(
task,
live,
stepIndex,
active,
typeof stepGoverningNodeId === "string" ? stepGoverningNodeId : undefined,
undefined,
typeof seamSkillName === "string" && seamSkillName.trim() ? seamSkillName.trim() : undefined,
);
},
resetTaskStep: async (ctx, task, stepIndex, baselineSha, checkpointId) => {
const active = ctx.node.context?.[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
const branchScoped = typeof active?.worktreePath === "string" && active.worktreePath.length > 0;
let worktreePath = active?.worktreePath ?? deps.rootDir;
if (!branchScoped) {
try {
worktreePath = (await deps.store.getTask(task.id)).worktree || deps.rootDir;
} catch {
// Best-effort worktree resolution; fall back to rootDir.
}
}
const liveSteps = await deps.store.getTask(task.id).then((t) => t.steps).catch(() => []);
return await resetStepToBaseline(
{
store: deps.store,
worktreePath,
sessionRef: { current: null },
reviewType: "code",
blastRadiusGuard: branchScoped
? undefined
: makeAncestryBlastRadiusGuard({
worktreePath,
task: { id: task.id, steps: liveSteps },
stepIndex,
}),
},
{ id: task.id, steps: liveSteps },
stepIndex,
baselineSha,
checkpointId,
);
},
runReview: async (ctx, task, input) => {
if (typeof input.stepIndex === "number") {
const context = ctx.node.context ?? {};
const active = context[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
if (!active || typeof active.stepIndex !== "number") {
return {
outcome: "success",
value: "unavailable",
data: { verdict: "UNAVAILABLE", review: "no active step instance" },
};
}
const config = {
type: input.type,
advisory: context[SPLIT_ACTIVE_CONTEXT_KEY] === true,
} as const;
const seamResult = await deps.createAuthoritativeWorkflowSeams(settings, outputLanguage).stepReview?.(
task,
context,
config,
);
return {
outcome: "success",
value: seamResult?.verdict === "APPROVE" ? "approve" : seamResult?.verdict === "REVISE" ? "revise" : seamResult?.verdict === "RETHINK" ? "rethink" : "unavailable",
data: seamResult ?? { verdict: "UNAVAILABLE", review: "step review unavailable" },
};
}
const live = await deps.store.getTask(task.id);
await deps.persistTokenUsage(task.id);
/*
FNXC:TaskOutputLanguage 2026-08-19-16:25:
The graph passes its fully resolved target, including original input, through this primitive.
Keep the live-description resolver only for legacy direct callers that have no graph snapshot.
*/
await deps.handoffTaskToReview(live, "workflow-graph-review", undefined, outputLanguage ?? resolveTaskOutputLanguage(settings, live.description));
return {
outcome: "success",
value: "in-review",
data: { verdict: "APPROVE", summary: "Task handed off for merge review" },
};
},
runVerification: async () => ({ outcome: "success", value: "verification-skipped", data: {
verdict: "skipped",
} }),
// FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2) — the legacy
// `runWorkflowStep` primitive + the `workflow-step` seam it served were
// removed. Workflow quality gates run as the graph's own optional-group /
// gate nodes (builtin:coding already routes through them), which record
// results into `task.workflowStepResults` directly (U2). No `runWorkflowStep`
// primitive remains in `WorkflowRuntimePrimitives`.
updateSteps: async (_ctx, task, steps) => {
await deps.store.updateTask(task.id, { steps });
return { outcome: "success", value: "steps-updated", data: { count: steps.length } };
},
transitionTask: async (_ctx, task, input) => {
const taskStore = deps.store;
const patch: Partial<TaskDetail> = {};
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40:
Resolve a requested ROLE to this task's own column, because the seam that asks cannot.
`workflow-node-handlers.ts`'s review-handoff seam is a pure function over an IR node and a
task — no store — so it could only name `in-review`. Post-U12 `moveTask` REJECTS a destination
the workflow does not declare, so on a renamed review lane that transition threw
`TransitionRejectionError` and killed the walk mid-run. Not a silent wrong answer for once: a
hard failure in the middle of a workflow, which is why it outranked the rest of the backlog.
Resolved per task from its OWN selection, so there is one authority — the mistake that took
#2843 five review rounds was answering one question with two reads. `column` still wins when
both are supplied, and an unresolvable role falls back to the legacy id rather than failing
the transition, which is exactly the behaviour callers had before.
*/
let targetColumn = input.column;
if (targetColumn === undefined && input.columnRole === "review") {
targetColumn = (await resolveTaskLifecycleColumns(taskStore, task.id))?.review ?? "in-review";
}
/*
FNXC:WorkflowNotifications 2026-06-29-08:50:
Workflow graph lifecycle transitions must use TaskStore move semantics, not raw `updateTask({ column })`, because ntfy/webhook notification delivery is subscribed to `task:moved`. Direct column writes make graph-owned tasks invisible to in-review/done lifecycle notifications and bypass column hooks.
*/
if (targetColumn !== undefined) {
const moveOptions = {
preserveProgress: input.preserveProgress,
moveSource: "engine" as const,
workflowMoveSource: "workflow-graph",
workflowMoveMetadata: {
reason: input.reason,
nodeId: _ctx.node.node.id,
workflowId: _ctx.run.workflowId,
runId: _ctx.run.runId,
},
};
const storeWithMove = taskStore as typeof taskStore & {
moveTask?: typeof taskStore.moveTask;
};
if (typeof storeWithMove.moveTask === "function") {
await storeWithMove.moveTask(task.id, targetColumn, moveOptions);
} else {
patch.column = targetColumn;
}
}
if (input.status !== undefined && input.status !== null) patch.status = input.status;
if (Object.keys(patch).length > 0) {
await taskStore.updateTask(task.id, patch);
}
return { outcome: "success", value: input.reason };
},
requestMerge: async (ctx, task) => {
if (!deps.mergeRequester) {
return { outcome: "failure", value: "merge-unavailable", data: { status: "failed", reason: "merge-unavailable" } };
}
/*
FNXC:WorkflowCancellation 2026-07-15-10:42:
Fail fast on an already-cancelled walk BEFORE any side effect. `ensureWorkflowMergeBoundaryTask` mutates the task row and the requester enqueues a real merge; neither may run for a walk the engine has already abandoned. `merge-cancelled` is deliberately not `data.status: "failed"` — `classifyMergeFailure` would read an unknown reason as `merge-failed` and route a cancellation into bounded auto-merge retry.
*/
if (ctx.signal?.aborted) {
return { outcome: "failure", value: "merge-cancelled" };
}
const mergeBoundary = await deps.ensureWorkflowMergeBoundaryTask(task, {
reason: "workflow-merge-boundary",
nodeId: ctx.node.node.id,
workflowId: ctx.run.workflowId,
runId: ctx.run.runId,
});
/*
FNXC:WorkflowMerge 2026-08-20-00:50:
FN-9157 turns an unprovable boundary into a terminal graph failure. Do not
attach data.status:failed: direct merge-attempt classification rewrites an
unknown reason to merge-failed and re-enters the bounded retry.
*/
if (mergeBoundary.blocked) {
return { outcome: "failure", value: MERGE_BOUNDARY_UNPROVEN_VALUE };
}
const mergeTask = mergeBoundary.task;
/*
FNXC:WorkflowMerge 2026-06-29-23:18:
FN-7261 reached the merge node in fast mode with every legacy implementation step still pending, producing a no-op merge proof for work that never ran. A graph-native workflow may project its checklist at the merge boundary only when node workflow results prove implementation completed; otherwise incomplete legacy steps are authoritative and merge must fail before the merger can create stale no-op proof.
FNXC:WorkflowMerge 2026-06-30-00:38:
Fast default Coding tasks must still execute implementation work. FN-7260/FN-7271 reached merge with no parsed task steps, no foreach instances, and no implementation proof, then finalized through no-op merge. The workflow merge boundary must fail before requesting merge when a coding workflow has not produced implementation evidence; fast mode only bypasses review/verification gates.
*/
const missingImplementationProof = await deps.getWorkflowMergeImplementationProofFailure(mergeTask);
if (missingImplementationProof) {
await deps.store.logEntry(
mergeTask.id,
`Workflow merge blocked before requester: ${missingImplementationProof}`,
undefined,
deps.getRunContextFor(mergeTask.id),
);
return {
outcome: "failure",
value: "implementation-incomplete",
data: { status: "failed", reason: "implementation-incomplete" },
};
}
if (hasNonTerminalWorkflowSteps(mergeTask)) {
await deps.store.logEntry(
mergeTask.id,
"Workflow merge blocked before requester: implementation steps are incomplete",
undefined,
deps.getRunContextFor(mergeTask.id),
);
return {
outcome: "failure",
value: "implementation-incomplete",
data: { status: "failed", reason: "implementation-incomplete" },
};
}
/*
FNXC:WorkflowCancellation 2026-07-15-10:42:
The timeout bounds a wedged merge queue; it is NOT the cancellation path. `ctx.signal` (graph abort) is linked in via `AbortSignal.any` so a hard-cancel collapses the merge node immediately instead of after the full timeout, and is raced separately so the walk returns rather than waiting on a requester that may not settle on abort. Keep both signals live: dropping the timeout re-strands the walk behind a wedged queue, dropping the cancel link restores the 30-minute stall.
*/
const GRAPH_MERGE_TIMEOUT_MS = 30 * 60 * 1000;
const controller = new AbortController();
const mergeSignal = ctx.signal ? AbortSignal.any([ctx.signal, controller.signal]) : controller.signal;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<"timeout">((resolve) => {
timeoutHandle = setTimeout(() => {
controller.abort();
resolve("timeout");
}, GRAPH_MERGE_TIMEOUT_MS);
timeoutHandle.unref?.();
});
let onGraphAbort: (() => void) | undefined;
const cancelled = new Promise<"cancelled">((resolve) => {
if (!ctx.signal) return;
onGraphAbort = () => resolve("cancelled");
ctx.signal.addEventListener("abort", onGraphAbort, { once: true });
});
try {
const result = await Promise.race([deps.mergeRequester(mergeTask.id, { signal: mergeSignal }), timeout, cancelled]);
if (result === "cancelled") {
executorLog.warn(`${mergeTask.id}: workflow merge primitive cancelled by graph abort`);
return { outcome: "failure", value: "merge-cancelled" };
}
if (result === "timeout") {
executorLog.warn(`${mergeTask.id}: workflow merge primitive timed out after ${GRAPH_MERGE_TIMEOUT_MS}ms`);
return { outcome: "failure", value: "merge-timeout", data: { status: "timeout" } };
}
if (result.merged || result.noOp) {
/*
FNXC:WorkflowMerge 2026-06-29-09:24:
The workflow merge primitive owns the normal lifecycle transition after a graph merge node succeeds. Finalize the proven landed task here so `mergeConfirmed` cannot strand a card in `in-progress`; executor preflight recovery is only a fallback for rows already stranded by older runs.
*/
const finalization = await finalizeProvenAutoMergeTask({
store: deps.store,
taskId: mergeTask.id,
result,
rootDir: deps.rootDir,
audit: createRunAuditor(deps.store, {
runId: ctx.run.runId,
agentId: "executor",
taskId: mergeTask.id,
taskLineageId: mergeTask.lineageId,
phase: "workflow-merge",
}),
auditAgentId: "executor",
auditPhase: "workflow-merge",
source: "workflow-graph-merge-finalize",
log: (message) => executorLog.warn(message),
});
if (finalization.outcome === "blocked" || finalization.outcome === "missing") {
return {
outcome: "failure",
value: `merge-finalize-${finalization.outcome}`,
data: { status: "failed", reason: finalization.reason ?? finalization.outcome },
};
}
return {
outcome: "success",
value: result.noOp ? "merge-noop" : "merged",
data: { status: "merged", noOp: result.noOp },
};
}
return {
outcome: "failure",
value: result.reason ?? result.error ?? "merge-failed",
data: { status: "failed", reason: result.reason ?? result.error ?? "merge-failed" },
};
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle);
// FNXC:WorkflowCancellation 2026-07-15-10:42: the graph signal outlives this node; leaving the listener attached leaks one per merge attempt across a retry loop.
if (onGraphAbort) ctx.signal?.removeEventListener("abort", onGraphAbort);
await logAudit(mergeTask.id, {
type: "merge-requested",
message: `Workflow node ${ctx.node.node.id} requested merge`,
});
}
},
abortRun: async (_ctx, task, input) => {
if (input.hardCancel) {
deps.markPausedAborted(task.id, "merge-seam", "workflow-abort-run:merge-seam");
}
await deps.store.updateTask(task.id, {
paused: true,
pausedReason: input.reason,
} as Partial<TaskDetail>);
return { outcome: "success", value: "aborted" };
},
audit: async (ctx: WorkflowPrimitiveContext, input) => {
await logAudit(ctx.run.taskId, input);
},
};
}