From 5ba0b0cffc2f5534ae74bf1b2f91091b20d4d410 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 19 Aug 2026 19:34:53 -0700 Subject: [PATCH] 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) --- .changeset/fn-9168-merge-boundary-audit.md | 7 ++ AGENTS.md | 1 + docs/run-audit.md | 1 + .../__tests__/executor-graph-boundary.test.ts | 33 ++++++-- .../merge-boundary-unproven-park.test.ts | 78 +++++++++++++++++- .../emit-merge-boundary-unproven-audit.ts | 82 +++++++++++++++++++ .../src/executor/handle-graph-failure.ts | 23 +++++- .../route-graph-merge-failure-to-retry.ts | 36 +++++++- .../src/executor/workflow-merge-boundary.ts | 38 +++++++-- .../src/run-audit/run-audit-catalogue.ts | 5 +- packages/engine/src/util/run-audit.ts | 10 +++ 11 files changed, 293 insertions(+), 21 deletions(-) create mode 100644 .changeset/fn-9168-merge-boundary-audit.md create mode 100644 packages/engine/src/executor/emit-merge-boundary-unproven-audit.ts diff --git a/.changeset/fn-9168-merge-boundary-audit.md b/.changeset/fn-9168-merge-boundary-audit.md new file mode 100644 index 0000000000..d5ff80dd4e --- /dev/null +++ b/.changeset/fn-9168-merge-boundary-audit.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Record merge-boundary proof parks in the run-audit history. +category: feature +dev: Adds `task:merge-boundary-unproven-parked` at both terminal park sites with closed reason codes and a bounded, failure-isolated emit seam. diff --git a/AGENTS.md b/AGENTS.md index 60d330b0ff..66c3f0503c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -307,6 +307,7 @@ Scoped exception (FN-5819/FN-8823): while project auto-merge is On, shared-branc - Workspace (Phase D U1): self-healing emits `task:reconcile-workspace-partial-land` when it re-enqueues a partial/zero-landed workspace task's per-repo land (or parks it `failed` for proven branch absence or exhausted `evidence-unavailable` branch reads), and `task:reconcile-workspace-partial-land-no-action` when `autoMerge:false`, user-pause, a live sub-repo worktree (workspace-aware liveness), or `evidence-unavailable` blocks that backward move. The bounded evidence-exhaustion reason is `evidence-unavailable-exhausted`; audit metadata remains ids/counts/outcomes-only. - Workspace (Phase D U1): self-healing emits `task:reclaim-phantom-workspace-land-lease` when it clears a leaked `workspace-repo-land` lease whose owning task is terminal/dead and older than the FN-6736 staleness floor. Archived-role and soft-deleted owners are terminal; live merging, executing, or merge-pending owners are untouched. - FN-9164: `worktree:workspace-repo-base-branch` records per-repo base resolution with exactly `taskId`, `repoRelPath`, `stage`, `source`, `outcome`, and optional `fallbackReason`; branch/ref names are deliberately excluded from metadata and `target`, living only in the durable entry and task log. +- FN-9168: `task:merge-boundary-unproven-parked` is emitted once per terminal merge-boundary-unproven park at the bounded-retry router and reachable graph terminal-merge park. Metadata is ids/counts/fixed outcomes only (`taskId`, `nodeId`, `failureValue`, `source`, optional `reasonCode`/`missingInstanceCount`, `priorColumn`, `priorStatus`, `outcome`) and never boundary reason prose, foreach instance IDs, or error text. `emitMergeBoundaryUnprovenParked` swallows absent/throwing/rejecting sinks and time-bounds a hung one with `MERGE_BOUNDARY_UNPROVEN_AUDIT_EMIT_TIMEOUT_MS`; late settlement is swallowed and the unref'd timer is cleared, so best-effort telemetry never alters, delays, aborts, or wedges the terminal park. - FN-9058: `worktree:workspace-main-checkout-edit` records workspace completion guard evidence with ids/counts/fixed outcomes only: task/repo IDs, file/commit counts, evidence or warning reason enum, `taskDoneRetryCount`, and `blocked`/`warned`/`skipped`; never paths, file content, or commit prose. - FN-9059: workspace coordination emits `workspace-lease:*` events for lease acquisition, renewal, release, `fence-published`, `fence-superseded`, `reclaimed`, and `reclaim-refused`, plus `workspace-land-intent:*` events for write-ahead intent lifecycle and `resolve-refused`. Metadata is ids, SHAs, counts, and fixed outcomes only; it never includes a credential-bearing remote URL. - FN-9056: self-healing emits `task:reconcile-orphaned-workspace-worktree` when it reclaims a complete-lane or conservatively-idle failed/soft-deleted workspace entry. It vetoes raw/canonical active paths, task-session/executor/merge liveness, pauses and scheduled recovery; archived rows remain archive-lifecycle-owned. It runs `git worktree prune` even for already-gone paths and deletes only safely-discardable canonical `fusion/` branches. Duplicate, foreign, unowned, or outside-root claims are skipped without git work; one entry-scoped `MAX_STARVATION_DROPS` budget plus settlement bounds retries. Metadata is ids/counts/fixed outcomes: task/repo/path, success/reason/lane, worktree/prune/branch outcomes, and attempt. diff --git a/docs/run-audit.md b/docs/run-audit.md index 31a7412274..14a1a43106 100644 --- a/docs/run-audit.md +++ b/docs/run-audit.md @@ -35,6 +35,7 @@ Events that close a task's delivery: blocked/advanced completion parks, already- | `task:no-commits-finalize-blocked-incomplete-steps` | Finalize is blocked for a zero-commit task with incomplete workflow steps (FN-6461 lane). | | `task:empty-merge-finalize-blocked-no-landed-proof` | The AI empty-merge lane vetoes a zero-diff no-op finalize with no landed proof (FN-8141). | | `task:finalize-unproven-blocked` | Finalize is blocked because finalization has not been proven against the landing truth. | +| `task:merge-boundary-unproven-parked` | A workflow merge boundary could not be proven and its terminal park is recorded with best-effort, time-bounded telemetry that never blocks or stalls the park. | | `task:finalize-lost-work-blocked` | Finalize is blocked because it would discard work (lost-work guard). | | `task:auto-recover-stale-merger-status` | Self-healing clears a stale merger status left on a finalize path. | diff --git a/packages/engine/src/__tests__/executor-graph-boundary.test.ts b/packages/engine/src/__tests__/executor-graph-boundary.test.ts index 12718354aa..fc7a249de7 100644 --- a/packages/engine/src/__tests__/executor-graph-boundary.test.ts +++ b/packages/engine/src/__tests__/executor-graph-boundary.test.ts @@ -222,19 +222,36 @@ describe("U5a — IR-driven merge boundary (scenario 1)", () => { expect(store.moveTask).toHaveBeenCalledWith("FN-B1", "in-review", expect.anything()); }); - it("keeps incomplete foreach coverage and zero expected steps blocked", async () => { - for (const steps of [ - [{ id: "0", title: "Implement", status: "pending" as const }], - [], - ]) { + it("maps each incomplete merge-boundary proof to a redacted audit code", async () => { + const cases = [ + { + steps: [{ id: "0", title: "Implement", status: "pending" as const }], + workflowStepResults: [], + code: "no-node-result", + missingInstanceCount: 1, + }, + { + steps: [{ id: "0", title: "Implement", status: "pending" as const }], + workflowStepResults: [{ workflowStepId: "review", workflowStepName: "Review", source: "node" as const, phase: "pre-merge" as const, status: "pending" as const, completedAt: "2026-01-01" }], + code: "non-terminal-node-result", + missingInstanceCount: 1, + }, + { + steps: [{ id: "0", title: "Implement", status: "pending" as const }], + workflowStepResults: [{ workflowStepId: "review", workflowStepName: "Review", source: "node" as const, phase: "pre-merge" as const, status: "passed" as const, completedAt: "2026-01-01" }], + code: "missing-foreach-instances", + missingInstanceCount: 1, + }, + ]; + for (const { steps, workflowStepResults, code, missingInstanceCount } of cases) { const { executor, liveTask } = makeExecutor({ - selection: { workflowId: "custom:foreach", stepIds: [] }, ir: foreachIr(), steps, workflowStepResults: [], + selection: { workflowId: "custom:foreach", stepIds: [] }, ir: foreachIr(), steps, workflowStepResults, }); const result = await executor.ensureWorkflowMergeBoundaryTask( liveTask, { reason: "workflow-merge-boundary", nodeId: "merge", workflowId: "custom:foreach", runId: "r1" }, - ) as { blocked?: { reason: string } }; - expect(result.blocked?.reason).toBe("no pre-merge node result recorded"); + ) as { blocked?: { code: string; missingInstanceCount: number } }; + expect(result.blocked).toMatchObject({ code, missingInstanceCount }); } }); }); diff --git a/packages/engine/src/__tests__/merge-boundary-unproven-park.test.ts b/packages/engine/src/__tests__/merge-boundary-unproven-park.test.ts index 27f4337c18..e21b1a55a4 100644 --- a/packages/engine/src/__tests__/merge-boundary-unproven-park.test.ts +++ b/packages/engine/src/__tests__/merge-boundary-unproven-park.test.ts @@ -3,6 +3,7 @@ import { MERGE_BOUNDARY_UNPROVEN_VALUE, classifyMergePrimitiveResult, runWorkflo import { graphFailureValue, isMergeGraphFailure } from "../executor/graph-failure-pure.js"; import { isTerminalMergeGraphFailureValue } from "../executor/task-predicates.js"; import { routeGraphMergeFailureToRetry } from "../executor/route-graph-merge-failure-to-retry.js"; +import { MERGE_BOUNDARY_UNPROVEN_AUDIT_EMIT_TIMEOUT_MS } from "../executor/emit-merge-boundary-unproven-audit.js"; import { shouldHoldActiveFileScopeLease } from "../scheduler.js"; const task = { id: "FN-9157", column: "in-review", steps: [], dependencies: [], log: [], createdAt: "2026-08-20T00:00:00.000Z", updatedAt: "2026-08-20T00:00:00.000Z", title: "t", description: "", prompt: "# t" } as any; @@ -36,7 +37,7 @@ describe("FN-9157 merge-boundary-unproven terminal routing", () => { store: { updateTask, logEntry } as any, getRunContextFor: () => undefined, mergeRequester, - ensureWorkflowMergeBoundaryTask: vi.fn().mockResolvedValue({ task: live, blocked: { reason: "no pre-merge node result recorded" } }), + ensureWorkflowMergeBoundaryTask: vi.fn().mockResolvedValue({ task: live, blocked: { reason: "no pre-merge node result recorded", code: "no-node-result", missingInstanceCount: 0 } }), persistTokenUsage: vi.fn(), }, live, graphResult(), undefined); expect(handled).toBe(true); @@ -45,4 +46,79 @@ describe("FN-9157 merge-boundary-unproven terminal routing", () => { expect(logEntry).toHaveBeenCalledWith("FN-9157", expect.stringContaining("retry parked task"), undefined, undefined); expect(shouldHoldActiveFileScopeLease({ ...live, status: "failed" }, [])).toBe(false); }); + + it("emits redacted audit metadata for parked and already-terminal retry boundaries", async () => { + const live = { ...task, status: undefined }; + const updateTask = vi.fn(async (_id, patch) => ({ ...live, ...patch })); + const recordRunAuditEvent = vi.fn().mockResolvedValue(undefined); + const base = { + store: { updateTask, logEntry: vi.fn(), recordRunAuditEvent } as any, + getRunContextFor: () => undefined, + mergeRequester: vi.fn(), + persistTokenUsage: vi.fn(), + }; + await expect(routeGraphMergeFailureToRetry({ + ...base, + ensureWorkflowMergeBoundaryTask: vi.fn().mockResolvedValue({ task: live, blocked: { reason: "foreach step instances incomplete at merge boundary: missing secret-a, secret-b", code: "missing-foreach-instances", missingInstanceCount: 2 } }), + }, live, graphResult(), undefined)).resolves.toBe(true); + expect(recordRunAuditEvent).toHaveBeenCalledTimes(1); + expect(recordRunAuditEvent).toHaveBeenLastCalledWith(expect.objectContaining({ + mutationType: "task:merge-boundary-unproven-parked", target: "FN-9157", + metadata: expect.objectContaining({ taskId: "FN-9157", source: "retry-boundary", reasonCode: "missing-foreach-instances", missingInstanceCount: 2, outcome: "parked" }), + })); + expect(JSON.stringify(recordRunAuditEvent.mock.calls[0][0].metadata)).not.toContain("secret-a"); + + const terminal = { ...live, status: "failed", error: "existing" }; + await expect(routeGraphMergeFailureToRetry({ + ...base, + ensureWorkflowMergeBoundaryTask: vi.fn().mockResolvedValue({ task: terminal, blocked: { reason: "no pre-merge node result recorded", code: "no-node-result", missingInstanceCount: 0 } }), + }, terminal, graphResult(), undefined)).resolves.toBe(true); + expect(recordRunAuditEvent.mock.calls[1][0].metadata.outcome).toBe("already-terminal"); + expect(updateTask).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["absent", undefined], + ["rejects", vi.fn().mockRejectedValue(new Error("audit sink down"))], + ["throws", vi.fn(() => { throw new Error("audit sink boom"); })], + ])("keeps the terminal park intact when the audit sink %s", async (_name, recordRunAuditEvent) => { + const live = { ...task, status: undefined }; + const updateTask = vi.fn(async (_id, patch) => ({ ...live, ...patch })); + const persistTokenUsage = vi.fn(); + await expect(routeGraphMergeFailureToRetry({ + store: { updateTask, logEntry: vi.fn(), recordRunAuditEvent } as any, + getRunContextFor: () => undefined, + mergeRequester: vi.fn(), + ensureWorkflowMergeBoundaryTask: vi.fn().mockResolvedValue({ task: live, blocked: { reason: "no pre-merge node result recorded", code: "no-node-result", missingInstanceCount: 0 } }), + persistTokenUsage, + }, live, graphResult(), undefined)).resolves.toBe(true); + expect(updateTask).toHaveBeenCalledWith("FN-9157", expect.objectContaining({ status: "failed", error: expect.stringContaining("MERGE_BOUNDARY_UNPROVEN:") }), undefined); + expect(persistTokenUsage).toHaveBeenCalledWith("FN-9157"); + }); + + it("bounds a hung audit sink without skipping token usage", async () => { + vi.useFakeTimers(); + try { + const live = { ...task, status: undefined }; + const updateTask = vi.fn(async (_id, patch) => ({ ...live, ...patch })); + const persistTokenUsage = vi.fn(); + const handled = routeGraphMergeFailureToRetry({ + store: { updateTask, logEntry: vi.fn(), recordRunAuditEvent: vi.fn(() => new Promise(() => {})) } as any, + getRunContextFor: () => undefined, + mergeRequester: vi.fn(), + ensureWorkflowMergeBoundaryTask: vi.fn().mockResolvedValue({ task: live, blocked: { reason: "no pre-merge node result recorded", code: "no-node-result", missingInstanceCount: 0 } }), + persistTokenUsage, + }, live, graphResult(), undefined); + let settled = false; + void handled.then(() => { settled = true; }); + await Promise.resolve(); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(MERGE_BOUNDARY_UNPROVEN_AUDIT_EMIT_TIMEOUT_MS); + await expect(handled).resolves.toBe(true); + expect(persistTokenUsage).toHaveBeenCalledWith("FN-9157"); + expect(updateTask).toHaveBeenCalledWith("FN-9157", expect.objectContaining({ error: expect.stringContaining("MERGE_BOUNDARY_UNPROVEN:") }), undefined); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/packages/engine/src/executor/emit-merge-boundary-unproven-audit.ts b/packages/engine/src/executor/emit-merge-boundary-unproven-audit.ts new file mode 100644 index 0000000000..9f52204a1e --- /dev/null +++ b/packages/engine/src/executor/emit-merge-boundary-unproven-audit.ts @@ -0,0 +1,82 @@ +import type { TaskStore } from "@fusion/core"; +import { executorLog } from "../logger.js"; +import { generateSyntheticRunId } from "../util/run-audit.js"; +import type { MergeBoundaryUnprovenReasonCode } from "./workflow-merge-boundary.js"; + +export const MERGE_BOUNDARY_UNPROVEN_AUDIT_EMIT_TIMEOUT_MS = 2_000; + +type MergeBoundaryUnprovenParkedAuditPayload = { + taskId: string; + nodeId: string; + failureValue: string; + source: "retry-boundary" | "graph-terminal-park"; + reasonCode?: MergeBoundaryUnprovenReasonCode; + missingInstanceCount?: number; + priorColumn: string; + priorStatus: string | null | undefined; + outcome: "parked" | "already-terminal"; + runId?: string; +}; + +/** + * FNXC:RunAudit 2026-08-20-02:00: + * FN-9168 requires observability never to regress or stall delivery. The merge-boundary park is + * terminal and correct on its own, so absent, throwing, rejecting, or never-settling audit sinks + * are swallowed and time-bounded here. Callers await only ordering, never success: an unbounded + * await after the terminal write would wedge the executor branch, skipping token persistence and + * its return. Failure isolation is swallow-log-and-bound, with no retry, backoff, or queueing. + */ +export async function emitMergeBoundaryUnprovenParked( + store: TaskStore | null | undefined, + payload: MergeBoundaryUnprovenParkedAuditPayload, +): Promise { + const sink = store?.recordRunAuditEvent; + if (typeof sink !== "function") return; + + let sinkPromise: Promise; + try { + sinkPromise = Promise.resolve(sink.call(store, { + taskId: payload.taskId, + agentId: "executor", + runId: payload.runId ?? generateSyntheticRunId("merge-boundary-unproven-park", payload.taskId), + domain: "database", + mutationType: "task:merge-boundary-unproven-parked", + target: payload.taskId, + metadata: { + taskId: payload.taskId, + nodeId: payload.nodeId, + failureValue: payload.failureValue, + source: payload.source, + ...(payload.reasonCode === undefined ? {} : { reasonCode: payload.reasonCode }), + ...(payload.missingInstanceCount === undefined ? {} : { missingInstanceCount: payload.missingInstanceCount }), + priorColumn: payload.priorColumn, + priorStatus: payload.priorStatus ?? null, + outcome: payload.outcome, + }, + })); + } catch { + executorLog.warn("[run-audit] failed to record task:merge-boundary-unproven-parked"); + return; + } + + // Observe late rejection before the bounded wait returns so it cannot become unhandled. + void sinkPromise.catch(() => undefined); + await new Promise((resolve) => { + const timer = setTimeout(() => { + executorLog.warn("[run-audit] timed out recording task:merge-boundary-unproven-parked"); + resolve(); + }, MERGE_BOUNDARY_UNPROVEN_AUDIT_EMIT_TIMEOUT_MS); + timer.unref?.(); + void sinkPromise.then( + () => { + clearTimeout(timer); + resolve(); + }, + () => { + clearTimeout(timer); + executorLog.warn("[run-audit] failed to record task:merge-boundary-unproven-parked"); + resolve(); + }, + ); + }); +} diff --git a/packages/engine/src/executor/handle-graph-failure.ts b/packages/engine/src/executor/handle-graph-failure.ts index c1aec50591..522e5703f8 100644 --- a/packages/engine/src/executor/handle-graph-failure.ts +++ b/packages/engine/src/executor/handle-graph-failure.ts @@ -30,6 +30,8 @@ import { getPromptPath } from "../execution/spec-staleness.js"; import { moveTaskToReplanColumn, resolveReplanTargetColumn } from "../execution/replan-target.js"; import { executorLog } from "../logger.js"; import { generateSyntheticRunId, type EngineRunContext } from "../util/run-audit.js"; +import { MERGE_BOUNDARY_UNPROVEN_VALUE } from "../workflows/workflow-merge-nodes.js"; +import { emitMergeBoundaryUnprovenParked } from "./emit-merge-boundary-unproven-audit.js"; import { PAUSE_ABORT_PARK_ERROR_MARKER, PAUSE_ABORT_PARK_OPERATOR_MARKER } from "../self-healing.js"; import { graphFailureValue, @@ -932,9 +934,28 @@ export async function handleGraphFailure( const message = `Workflow graph terminal merge failure at node '${failedNode ?? "unknown"}' (${failureValue}) — operator action required`; executorLog.warn(`${task.id}: ${message}`); await deps.store.logEntry(task.id, message, undefined, deps.getRunContextFor(task.id)); - if (live.status == null && live.error == null) { + const outcome = live.status == null && live.error == null ? "parked" as const : "already-terminal" as const; + if (outcome === "parked") { await deps.store.updateTask(task.id, { error: message, status: "failed" }, deps.getRunContextFor(task.id)); } + if (failureValue === MERGE_BOUNDARY_UNPROVEN_VALUE) { + /* + FNXC:RunAudit 2026-08-20-02:00: + FN-9168 records this reachable graph-terminal merge-boundary-unproven park exactly once. + Other terminal merge failures remain unchanged. The bounded emitter contains audit failure + and hangs after the status write, so observability cannot alter or wedge the park. + */ + await emitMergeBoundaryUnprovenParked(deps.store, { + taskId: task.id, + nodeId: failedNode ?? "unknown", + failureValue, + source: "graph-terminal-park", + priorColumn: live.column, + priorStatus: live.status, + outcome, + runId: deps.getRunContextFor(task.id)?.runId, + }); + } await deps.persistTokenUsage(task.id); return; } diff --git a/packages/engine/src/executor/route-graph-merge-failure-to-retry.ts b/packages/engine/src/executor/route-graph-merge-failure-to-retry.ts index 2eec63ba85..9693dc2b83 100644 --- a/packages/engine/src/executor/route-graph-merge-failure-to-retry.ts +++ b/packages/engine/src/executor/route-graph-merge-failure-to-retry.ts @@ -13,6 +13,8 @@ import { graphFailureValue } from "./graph-failure-pure.js"; import type { EngineRunContext } from "../util/run-audit.js"; import { executorLog } from "../logger.js"; import { MERGE_BOUNDARY_UNPROVEN_VALUE } from "../workflows/workflow-merge-nodes.js"; +import { emitMergeBoundaryUnprovenParked } from "./emit-merge-boundary-unproven-audit.js"; +import type { MergeBoundaryUnprovenReasonCode } from "./workflow-merge-boundary.js"; export type RouteGraphMergeFailureToRetryDeps = { store: TaskStore; @@ -21,7 +23,14 @@ export type RouteGraphMergeFailureToRetryDeps = { ensureWorkflowMergeBoundaryTask: ( live: TaskDetail, opts: { reason: string; nodeId: string; workflowId: string; runId: string }, - ) => Promise<{ task: TaskDetail; blocked?: { reason: string } }>; + ) => Promise<{ + task: TaskDetail; + blocked?: { + reason: string; + code: MergeBoundaryUnprovenReasonCode; + missingInstanceCount: number; + }; + }>; persistTokenUsage: (taskId: string) => Promise; }; @@ -52,15 +61,36 @@ export async function routeGraphMergeFailureToRetry( work, rather than silently retaining an in-review blocker. */ if (mergeBoundary.blocked) { - const reason = mergeBoundary.blocked.reason; + const { reason, code, missingInstanceCount } = mergeBoundary.blocked; await deps.store.logEntry(live.id, `Workflow merge boundary retry parked task: ${reason}`, undefined, deps.getRunContextFor(live.id)); - if (mergeBoundary.task.status !== "failed" || !mergeBoundary.task.error) { + const outcome = mergeBoundary.task.status !== "failed" || !mergeBoundary.task.error + ? "parked" as const + : "already-terminal" as const; + if (outcome === "parked") { await deps.store.updateTask( live.id, { status: "failed", error: `${MERGE_BOUNDARY_UNPROVEN_VALUE.toUpperCase().replaceAll("-", "_")}: ${reason}` }, deps.getRunContextFor(live.id), ); } + /* + FNXC:RunAudit 2026-08-20-02:00: + FN-9168 records exactly one terminal merge-boundary-unproven park here. The boundary + helper's blocked return is not a park and remains silent; its bounded audit seam contains + failure and hangs, so telemetry cannot delay or alter this terminal write or return path. + */ + await emitMergeBoundaryUnprovenParked(deps.store, { + taskId: live.id, + nodeId: failedNode, + failureValue: MERGE_BOUNDARY_UNPROVEN_VALUE, + source: "retry-boundary", + reasonCode: code, + missingInstanceCount, + priorColumn: live.column, + priorStatus: live.status, + outcome, + runId: deps.getRunContextFor(live.id)?.runId, + }); await deps.persistTokenUsage(live.id); return true; } diff --git a/packages/engine/src/executor/workflow-merge-boundary.ts b/packages/engine/src/executor/workflow-merge-boundary.ts index 50048d8eea..c5d844dd4b 100644 --- a/packages/engine/src/executor/workflow-merge-boundary.ts +++ b/packages/engine/src/executor/workflow-merge-boundary.ts @@ -17,9 +17,18 @@ export type WorkflowMergeBoundaryProof = { missingInstanceIds: string[]; }; +export type MergeBoundaryUnprovenReasonCode = + | "no-node-result" + | "non-terminal-node-result" + | "missing-foreach-instances"; + export type WorkflowMergeBoundaryResult = { task: TaskDetail; - blocked?: { reason: string }; + blocked?: { + reason: string; + code: MergeBoundaryUnprovenReasonCode; + missingInstanceCount: number; + }; }; export type WorkflowMergeBoundaryDeps = { @@ -81,13 +90,28 @@ export async function ensureWorkflowMergeBoundaryTask( */ const mergeProof = await deps.evaluateWorkflowMergeBoundary(live, metadata.runId); if (mergeProof.hasForeachStepExecute && !mergeProof.complete) { - const reason = !mergeProof.hasRelevantNodeResult - ? "no pre-merge node result recorded" + const blocked = !mergeProof.hasRelevantNodeResult + ? { reason: "no pre-merge node result recorded", code: "no-node-result" as const } : !mergeProof.allResultsTerminal - ? `non-terminal pre-merge node result ${mergeProof.nonTerminalResult?.workflowStepId ?? "unknown"} (${mergeProof.nonTerminalResult?.status ?? "unknown"})` - : `foreach step instances incomplete at merge boundary: missing ${mergeProof.missingInstanceIds.join(", ")}`; - await deps.store.logEntry(live.id, `Workflow merge boundary blocked: ${reason}`, undefined, deps.getRunContextFor(live.id)); - return { task: live, blocked: { reason } }; + ? { + 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)) { diff --git a/packages/engine/src/run-audit/run-audit-catalogue.ts b/packages/engine/src/run-audit/run-audit-catalogue.ts index d18e16af20..f61d32016c 100644 --- a/packages/engine/src/run-audit/run-audit-catalogue.ts +++ b/packages/engine/src/run-audit/run-audit-catalogue.ts @@ -43,6 +43,7 @@ export const DELIVERY_PIPELINE_RUN_AUDIT_EVENTS_LITERALS = [ "task:no-commits-finalize-blocked-incomplete-steps", "task:empty-merge-finalize-blocked-no-landed-proof", "task:finalize-unproven-blocked", + "task:merge-boundary-unproven-parked", "task:finalize-lost-work-blocked", "task:auto-recover-stale-merger-status", @@ -69,7 +70,7 @@ export const DELIVERY_PIPELINE_RUN_AUDIT_EVENTS_LITERALS = [ ] as const; /** - * The 32-event literal union of the curated catalogue. `as const` preserves the literal element + * The 31-event literal union of the curated catalogue. `as const` preserves the literal element * tuples so the notes map can be keyed by exactly the catalogued events (rather than widening to the * full `DatabaseMutationType` union). The exported array below is still typed as * `Readonly`, so member-validity remains compile-time-enforced: assigning @@ -109,6 +110,8 @@ export const DELIVERY_PIPELINE_RUN_AUDIT_EVENT_NOTES: Readonly