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>
515 lines
26 KiB
TypeScript
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);
|
|
},
|
|
};
|
|
}
|