U8 PR3: the implementation phase announces HOW it ended — including when the executor moved the card itself (#2507)

Third PR of **U8 — the graph owns execution**. Independent of everything
merged so far; small, green, revertable on its own.

## The problem this makes visible

`result.taskDone` is the entire language the execute seam has for
talking to the graph:

```ts
if (result.taskDone) return { outcome: "success", value: "implemented" };
return { outcome: "failure", value: paused ? "implementation-paused" : "implementation-incomplete" };
```

The endings that one bit cannot express are exactly the ones the
implementation phase **transitions itself**:

- a session that paused *after* the work was already complete →
finalizes to review inline;
- a session that stopped because a step is blocked on a pending review →
hands off to review inline (a pending-review block is a wait, not a
failure; marking it failed deadlocks a row that is both `in-review` and
`failed`).

The graph then sees `taskDone === false`, reports
`implementation-incomplete`, and `handleGraphFailure` compensates with
`alreadyFinalizedToReview` / `completionFinalized` — classifiers whose
entire job is recognising a move the graph did not make.

**That was invisible.** An out-of-band transition and a genuine
implementation failure were indistinguishable in logs, in events, and in
tests. You cannot remove a transition you cannot see, and you cannot
prove you removed it either.

## What lands

A closed `ImplementationExit` enum
(`engine/executor/implementation-exit.ts`) reported from six
completion-adjacent exits in `runImplementation`, announced by the
execute seam as `NodeCompleted.exit` on the U3 lifecycle bus. Two ids
are flagged as out-of-band — the ones where the executor, not the graph,
performs the transition.

**Routing is unchanged, and that is the point.** The seam returns
byte-identically what it returned before for every exit, so this PR
cannot move a card. The routing move needs new IR edges and lands
separately; splitting them is what keeps both independently revertable.
Per R5 an exit id is a **reaction** — nothing branches on one, and
dropping every subscriber must change no outcome (a named U8 test
scenario, asserted here).

`NodeCompleted.exit` is added to the event key allow-list deliberately —
which is exactly what that allow-list is for — and carries closed enum
ids only, never prose.

## Revert-proofs, each observed failing

| Injected change | Result |
|---|---|
| Remove the emit entirely | **6 failures** |
| Let an exit change the returned outcome | **2 failures** (the
routing-unchanged pins) |
| Delete one `reportImplementationExit(...)` call site | **1 failure**
(the wiring ratchet) |

**The third proof exists because of a hole I found in my own tests.**
These tests stub `runImplementationPhase` — the only way to reach all
six exits deterministically — which means deleting a real call site left
the entire file **green**. A stubbed seam can only prove the seam. I'd
also written "every exit is reported — the signal is real, not a
placeholder" in the header, which the tests did not support. Both are
fixed: there is now a ratchet asserting every enum id is wired at a real
call site and that each out-of-band id sits adjacent to the handoff it
describes, and the header says what the tests actually prove.

## Scope

**6 of `runImplementation`'s ~28 dispositions** (per the ownership
ledger merged in #2490), chosen as the ones the routing move needs. The
remaining ~22 report nothing yet — the ledger, not this enum, stays the
record of that gap, and the module says so.

## Verification

- 15 new tests + ledger + graph-boundary + task-done-blocked +
graph-requeue-gate + step-session + review-verdicts + tool-failure-retry
— **9 files, 115 tests green**
- `@fusion/core` `workflow-events` — 20 tests green (allow-list change
covered)
- `pnpm test:gate` green (17/307, 2/10, 1/71); `pnpm lint` clean; `tsc
--noEmit` clean on both packages
- Changeset included (`patch`, `internal`), passes `check:changesets`

## Next

PR4 is the routing move itself: `review-handoff-pending-review` becomes
a graph outcome with its own IR edge, and `alreadyFinalizedToReview`
becomes provably unreachable for that path. The IR edge change will be
its own commit, separate from the seam change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-28 17:35:07 -07:00
committed by GitHub
parent d5030c55ea
commit 9d3e53d0c5
8 changed files with 429 additions and 13 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Surface how an implementation session actually ended, including when the executor moved the card itself.
category: internal
dev: Adds a closed `ImplementationExit` enum (`engine/executor/implementation-exit.ts`) reported from six completion-adjacent exits in `runImplementation` and announced by the execute seam as `NodeCompleted.exit` on the U3 lifecycle bus. Routing is byte-identical for every exit; nothing branches on an exit id (R5 — reactions only).

View File

@@ -19,11 +19,12 @@ in `workflow-events-outbox.pg.test.ts` — a hand-written fake of the lease
predicate would only prove the fake redelivers. predicate would only prove the fake redelivers.
*/ */
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createWorkflowEventBus } from "../workflow-events.js"; import { createWorkflowEventBus, emitWorkflowLifecycleEvent, getWorkflowEventBus, resetWorkflowEventBusForTesting } from "../workflow-events.js";
import { import {
findWorkflowEventShapeViolations, findWorkflowEventShapeViolations,
isIdsOnlyWorkflowEvent, isIdsOnlyWorkflowEvent,
MAX_ID_VALUE_LENGTH, MAX_ID_VALUE_LENGTH,
IMPLEMENTATION_EXITS,
type WorkflowLifecycleEvent, type WorkflowLifecycleEvent,
} from "../types/workflow-events.js"; } from "../types/workflow-events.js";
@@ -296,3 +297,45 @@ describe("workflow event bus — reactions are non-authoritative (R5, KTD-3)", (
expect(survivor).toHaveBeenCalledTimes(1); expect(survivor).toHaveBeenCalledTimes(1);
}); });
}); });
/*
FNXC:WorkflowEvents 2026-07-28-22:30 (U8, PR #2507 review — greptile):
The `exit` key carries a CLOSED vocabulary, and the closed-ness is enforced at the emit boundary
rather than only in the type. The type protects TypeScript producers; the boundary protects the
ones that can actually cause the silent failure — a JS caller, a plugin, or a future seam
emitting an id nobody routes, where the symptom is a card that quietly does not advance.
*/
describe("closed-vocabulary values (exit)", () => {
const base = { type: "NodeCompleted", taskId: "FN-1", at: "2026-07-28T00:00:00.000Z", nodeId: "execute", outcome: "success" };
it("accepts every declared exit id", () => {
for (const exit of IMPLEMENTATION_EXITS) {
expect(findWorkflowEventShapeViolations({ ...base, exit })).toEqual([]);
}
});
it("refuses an exit id that is not in the vocabulary", () => {
/* A perfectly good scalar — which is exactly why value-shape checking alone is not enough. */
expect(findWorkflowEventShapeViolations({ ...base, exit: "review-handoff-invented" })).toEqual([
{ path: "exit", reason: "unknown-enum-value" },
]);
});
it("still accepts NodeCompleted with no exit at all", () => {
expect(findWorkflowEventShapeViolations(base)).toEqual([]);
});
it("drops an event carrying an unrouted exit rather than delivering it", () => {
/* End to end through the bus: a violating payload must never reach a subscriber. */
resetWorkflowEventBusForTesting();
const seen: unknown[] = [];
getWorkflowEventBus().subscribe((e) => { seen.push(e); }, { name: "closed-vocab" });
emitWorkflowLifecycleEvent({ ...base, exit: "not-a-real-exit" } as never);
emitWorkflowLifecycleEvent({ ...base, exit: "complete" } as never);
return getWorkflowEventBus().drain().then(() => {
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({ exit: "complete" });
resetWorkflowEventBusForTesting();
});
});
});

View File

@@ -2235,8 +2235,8 @@ export { resolveCreationColumn } from "./workflow-ir.js";
export { resolveWipBudgetColumns } from "./workflow-capacity.js"; export { resolveWipBudgetColumns } from "./workflow-capacity.js";
export { createWorkflowEventBus, getWorkflowEventBus, emitWorkflowLifecycleEvent, resetWorkflowEventBusForTesting } from "./workflow-events.js"; export { createWorkflowEventBus, getWorkflowEventBus, emitWorkflowLifecycleEvent, resetWorkflowEventBusForTesting } from "./workflow-events.js";
export type { WorkflowEventBus, WorkflowEventSubscriber, WorkflowEventSubscription } from "./workflow-events.js"; export type { WorkflowEventBus, WorkflowEventSubscriber, WorkflowEventSubscription } from "./workflow-events.js";
export { findWorkflowEventShapeViolations, isIdsOnlyWorkflowEvent, MAX_ID_VALUE_LENGTH } from "./types/workflow-events.js"; export { findWorkflowEventShapeViolations, isIdsOnlyWorkflowEvent, MAX_ID_VALUE_LENGTH, IMPLEMENTATION_EXITS } from "./types/workflow-events.js";
export type { WorkflowLifecycleEvent, WorkflowLifecycleEventType, WorkflowLifecycleEventBase, TaskTransitionedEvent, NodeEnteredEvent, NodeCompletedEvent, RunSuspendedEvent, RunResumedEvent, WorkflowEventShapeViolation } from "./types/workflow-events.js"; export type { WorkflowLifecycleEvent, WorkflowLifecycleEventType, WorkflowLifecycleEventBase, TaskTransitionedEvent, NodeEnteredEvent, NodeCompletedEvent, RunSuspendedEvent, RunResumedEvent, WorkflowEventShapeViolation, ImplementationExit } from "./types/workflow-events.js";
export { columnsWithFlag, columnHasFlag, resolveReboundTarget, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveLifecycleColumns, resolveTaskLifecycleColumns } from "./workflow-lifecycle-traits.js"; export { columnsWithFlag, columnHasFlag, resolveReboundTarget, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveLifecycleColumns, resolveTaskLifecycleColumns } from "./workflow-lifecycle-traits.js";
export type { LifecycleColumns } from "./workflow-lifecycle-traits.js"; export type { LifecycleColumns } from "./workflow-lifecycle-traits.js";
export { resolveReviewLevelSteps, applyReviewLevelPreset } from "./review-level-preset.js"; export { resolveReviewLevelSteps, applyReviewLevelPreset } from "./review-level-preset.js";

View File

@@ -450,8 +450,8 @@ export type { PluginGateVerdict, ColumnPluginGate } from "./plugin-gate-verdict.
export { resolveColumnCapacity, resolveWipBudgetColumns, DEFAULT_WORKFLOW_POOL_ID, resolveCapacityPoolId } from "./workflow-capacity.js"; export { resolveColumnCapacity, resolveWipBudgetColumns, DEFAULT_WORKFLOW_POOL_ID, resolveCapacityPoolId } from "./workflow-capacity.js";
export { createWorkflowEventBus, getWorkflowEventBus, emitWorkflowLifecycleEvent, resetWorkflowEventBusForTesting } from "./workflow-events.js"; export { createWorkflowEventBus, getWorkflowEventBus, emitWorkflowLifecycleEvent, resetWorkflowEventBusForTesting } from "./workflow-events.js";
export type { WorkflowEventBus, WorkflowEventSubscriber, WorkflowEventSubscription } from "./workflow-events.js"; export type { WorkflowEventBus, WorkflowEventSubscriber, WorkflowEventSubscription } from "./workflow-events.js";
export { findWorkflowEventShapeViolations, isIdsOnlyWorkflowEvent, MAX_ID_VALUE_LENGTH } from "./types/workflow-events.js"; export { findWorkflowEventShapeViolations, isIdsOnlyWorkflowEvent, MAX_ID_VALUE_LENGTH, IMPLEMENTATION_EXITS } from "./types/workflow-events.js";
export type { WorkflowLifecycleEvent, WorkflowLifecycleEventType, WorkflowLifecycleEventBase, TaskTransitionedEvent, NodeEnteredEvent, NodeCompletedEvent, RunSuspendedEvent, RunResumedEvent, WorkflowEventShapeViolation } from "./types/workflow-events.js"; export type { WorkflowLifecycleEvent, WorkflowLifecycleEventType, WorkflowLifecycleEventBase, TaskTransitionedEvent, NodeEnteredEvent, NodeCompletedEvent, RunSuspendedEvent, RunResumedEvent, WorkflowEventShapeViolation, ImplementationExit } from "./types/workflow-events.js";
export { columnsWithFlag, columnHasFlag, resolveReboundTarget, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveLifecycleColumns, resolveTaskLifecycleColumns } from "./workflow-lifecycle-traits.js"; export { columnsWithFlag, columnHasFlag, resolveReboundTarget, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveLifecycleColumns, resolveTaskLifecycleColumns } from "./workflow-lifecycle-traits.js";
export type { LifecycleColumns } from "./workflow-lifecycle-traits.js"; export type { LifecycleColumns } from "./workflow-lifecycle-traits.js";
export { resolveReviewLevelSteps, applyReviewLevelPreset } from "./review-level-preset.js"; export { resolveReviewLevelSteps, applyReviewLevelPreset } from "./review-level-preset.js";

View File

@@ -68,10 +68,53 @@ export interface NodeEnteredEvent extends WorkflowLifecycleEventBase {
} }
/** A node finished with a routing outcome ("success" / "failure" / …). */ /** A node finished with a routing outcome ("success" / "failure" / …). */
/*
FNXC:WorkflowEvents 2026-07-28-22:10 (U8 / R4, R5, PR #2507 review — greptile):
THE CLOSED EXIT VOCABULARY, AND WHY IT LIVES HERE.
It was first declared in `@fusion/engine` with the public event field typed `exit?: string`, so
the contract permitted values no consumer handles. That is a small typing gap today, with one
producer — and an expensive one later, because this bus is becoming the lifecycle backbone
("node transitions should emit events and event subscribers handle moving things through the
lifecycle"). A producer emitting an exit nobody routes fails SILENTLY: the card simply does not
advance, and nothing anywhere reports a problem. Close it while there is one producer.
The union therefore lives with the contract, in core, not with its first producer in the engine
— core cannot import from the engine, and more to the point a public contract that defers its
vocabulary to a consumer is not a contract. `engine/executor/implementation-exit.ts` re-exports
it and keeps the engine-side POLICY (which exits are executor-performed) where policy belongs.
Adding a value means editing this list, which is the same deliberate act the key allow-list
demands — and `IMPLEMENTATION_EXITS` is checked at the EMIT BOUNDARY too, so a JS producer or a
plugin cannot slip an unrouted id past the type system.
*/
export const IMPLEMENTATION_EXITS = [
/** fn_task_done (or implicit completion): handed back to the graph, which owns what follows. */
"complete",
/** Completion reached on a retry session after the agent first failed to signal done. */
"complete-after-retry",
/** Completion proven from live modified files when the session ended without a done signal. */
"complete-from-live-files",
/** OUT OF BAND: paused after the work was complete; the executor finalized to review itself. */
"review-handoff-paused-after-completion",
/** OUT OF BAND: stopped on a pending-review block; the executor parked it in review itself. */
"review-handoff-pending-review",
] as const;
/** How a node's work actually ended, when the routing outcome is coarser than the endings. */
export type ImplementationExit = (typeof IMPLEMENTATION_EXITS)[number];
export interface NodeCompletedEvent extends WorkflowLifecycleEventBase { export interface NodeCompletedEvent extends WorkflowLifecycleEventBase {
type: "NodeCompleted"; type: "NodeCompleted";
nodeId: string; nodeId: string;
outcome: string; outcome: string;
/*
FNXC:WorkflowEvents 2026-07-28-20:20 (U8 / R4, R5):
Optional finer-grained ending, for nodes whose routing outcome is coarser than the ways they
can actually end. The execute seam is the motivating case: `success | failure` cannot express
"the executor finalized this card to review itself", so that ending was invisible.
*/
exit?: ImplementationExit;
} }
/** A run parked at a seam it cannot cross yet (capacity, manual hold). */ /** A run parked at a seam it cannot cross yet (capacity, manual hold). */
@@ -146,7 +189,7 @@ const COMMON_REQUIRED_EVENT_KEYS = ["type", "taskId", "at"] as const;
const ALLOWED_EVENT_KEYS: Record<WorkflowLifecycleEventType, readonly string[]> = { const ALLOWED_EVENT_KEYS: Record<WorkflowLifecycleEventType, readonly string[]> = {
TaskTransitioned: [...COMMON_EVENT_KEYS, "from", "to", "nodeId", "moveSource"], TaskTransitioned: [...COMMON_EVENT_KEYS, "from", "to", "nodeId", "moveSource"],
NodeEntered: [...COMMON_EVENT_KEYS, "nodeId", "column"], NodeEntered: [...COMMON_EVENT_KEYS, "nodeId", "column"],
NodeCompleted: [...COMMON_EVENT_KEYS, "nodeId", "outcome"], NodeCompleted: [...COMMON_EVENT_KEYS, "nodeId", "outcome", "exit"],
RunSuspended: [...COMMON_EVENT_KEYS, "nodeId", "reason", "fromColumn", "toColumn"], RunSuspended: [...COMMON_EVENT_KEYS, "nodeId", "reason", "fromColumn", "toColumn"],
RunResumed: [...COMMON_EVENT_KEYS, "nodeId", "releasedBy"], RunResumed: [...COMMON_EVENT_KEYS, "nodeId", "releasedBy"],
}; };
@@ -166,6 +209,11 @@ const REQUIRED_EVENT_KEYS: Record<WorkflowLifecycleEventType, readonly string[]>
RunResumed: [...COMMON_REQUIRED_EVENT_KEYS, "nodeId"], RunResumed: [...COMMON_REQUIRED_EVENT_KEYS, "nodeId"],
}; };
/** Keys whose values are a closed vocabulary rather than a free id. */
const CLOSED_VALUE_SETS: Record<string, readonly string[] | undefined> = {
exit: IMPLEMENTATION_EXITS,
};
/** A single ids-only rule violation. `path` locates it for the failure message. */ /** A single ids-only rule violation. `path` locates it for the failure message. */
export interface WorkflowEventShapeViolation { export interface WorkflowEventShapeViolation {
path: string; path: string;
@@ -175,7 +223,8 @@ export interface WorkflowEventShapeViolation {
| "unsupported-type" | "unsupported-type"
| "unknown-key" | "unknown-key"
| "unknown-type" | "unknown-type"
| "missing-required-key"; | "missing-required-key"
| "unknown-enum-value";
} }
function checkScalar(path: string, value: unknown, out: WorkflowEventShapeViolation[]): void { function checkScalar(path: string, value: unknown, out: WorkflowEventShapeViolation[]): void {
@@ -222,6 +271,18 @@ export function findWorkflowEventShapeViolations(event: unknown): WorkflowEventS
value.forEach((entry, i) => checkScalar(`${key}[${i}]`, entry, violations)); value.forEach((entry, i) => checkScalar(`${key}[${i}]`, entry, violations));
continue; continue;
} }
/*
FNXC:WorkflowEvents 2026-07-28-22:15 (U8, PR #2507 review — greptile):
The closed-set keys are checked for MEMBERSHIP, not merely scalar-ness. The type alone
protects TypeScript producers; this protects the ones that matter — a JS caller, a plugin,
and a future seam emitting an id nobody routes. Refusing it here turns a silent
card-does-not-advance into a logged drop at the boundary.
*/
const closedSet = CLOSED_VALUE_SETS[key];
if (closedSet && value !== undefined && !closedSet.includes(value as never)) {
violations.push({ path: key, reason: "unknown-enum-value" });
continue;
}
checkScalar(key, value, violations); checkScalar(key, value, violations);
} }
/* /*

View File

@@ -0,0 +1,196 @@
/*
FNXC:WorkflowExecutionOwnership 2026-07-28-20:40 (U8 / R4, R5, R12 — workflow-owned lifecycle):
The execute seam tells the graph one bit: `result.taskDone`. The endings that bit cannot express
are exactly the ones the implementation phase transitions ITSELF — a session that paused after
the work was complete, and a session that stopped on a pending-review block. Both hand the card
to review inline; the graph then sees `taskDone === false`, reports `implementation-incomplete`,
and `handleGraphFailure` compensates with `alreadyFinalizedToReview`. Until now nothing anywhere
recorded which of those happened: an out-of-band transition and a genuine implementation failure
were indistinguishable in logs, in events, and in tests.
These tests pin the properties that make the exit signal safe to build the routing move on:
1. Every exit the phase reports is forwarded with its own id, AND every id in the enum has a
real call site in `runImplementation`. The second half is not pedantry: these tests stub
`runImplementationPhase`, so without it deleting a `reportImplementationExit(...)` call
leaves all of them green — verified by deleting one. A stubbed seam can only prove the seam.
2. ROUTING IS UNCHANGED. For every exit the seam returns byte-identically what it returned
before, so this PR cannot move a card. That is the property that lets the reporting and the
routing land as separate, independently revertable changes.
3. DROPPING EVERY SUBSCRIBER CHANGES NO OUTCOME (R5, and a named U8 scenario). An exit id is a
reaction; if anything downstream ever depends on one arriving, the bus has quietly become a
second source of truth, which is the failure mode this whole program exists to remove.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import type { Settings, Task } from "@fusion/core";
import {
getWorkflowEventBus,
resetWorkflowEventBusForTesting,
type WorkflowLifecycleEvent,
} from "@fusion/core";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
import {
OUT_OF_BAND_IMPLEMENTATION_EXITS,
isOutOfBandImplementationExit,
type ImplementationExit,
} from "../executor/implementation-exit.js";
const SEAM_TASK = { id: "FN-U8-EXIT", title: "exit vocabulary", column: "in-progress" } as Task;
/**
* Drive the execute seam with a stubbed implementation phase. Stubbing the phase rather than
* running a real agent session is the only way to exercise all six exits deterministically; the
* exits themselves are wired at their real call sites in `runImplementation` and covered by the
* completion/handoff suites.
*/
function seamHarness(phaseResult: { taskDone: boolean; modifiedFiles: string[]; exit?: ImplementationExit }) {
const store = createMockStore();
store.getTask.mockResolvedValue({ ...SEAM_TASK, paused: false });
const executor = new TaskExecutor(store, "/tmp/test");
const runImplementationPhase = vi
.spyOn(executor as never as { runImplementationPhase: () => unknown }, "runImplementationPhase")
.mockResolvedValue(phaseResult);
const seams = executor.createAuthoritativeWorkflowSeams({} as Settings);
return { executor, store, seams, runImplementationPhase };
}
function captureEvents(): { events: WorkflowLifecycleEvent[]; drain: () => Promise<void> } {
const events: WorkflowLifecycleEvent[] = [];
getWorkflowEventBus().subscribe((event) => { events.push(event); }, { name: "exit-test" });
return { events, drain: () => getWorkflowEventBus().drain() };
}
/** Every exit, with the outcome/value the seam returned for it BEFORE this change. */
const EXITS: Array<{
exit: ImplementationExit;
taskDone: boolean;
expected: { outcome: string; value: string };
}> = [
{ exit: "complete", taskDone: true, expected: { outcome: "success", value: "implemented" } },
{ exit: "complete-after-retry", taskDone: true, expected: { outcome: "success", value: "implemented" } },
{ exit: "complete-from-live-files", taskDone: true, expected: { outcome: "success", value: "implemented" } },
{ exit: "review-handoff-paused-after-completion", taskDone: false, expected: { outcome: "failure", value: "implementation-incomplete" } },
{ exit: "review-handoff-pending-review", taskDone: false, expected: { outcome: "failure", value: "implementation-incomplete" } },
];
describe("execute seam announces the implementation phase's exit", () => {
beforeEach(() => {
resetExecutorMocks();
resetWorkflowEventBusForTesting();
});
afterEach(() => resetWorkflowEventBusForTesting());
it.each(EXITS)("reports $exit on the lifecycle bus", async ({ exit, taskDone }) => {
const { seams } = seamHarness({ taskDone, modifiedFiles: [], exit });
const bus = captureEvents();
await seams.execute!(SEAM_TASK, undefined);
await bus.drain();
const completed = bus.events.filter((e) => e.type === "NodeCompleted");
expect(completed).toHaveLength(1);
expect(completed[0]).toMatchObject({
type: "NodeCompleted",
taskId: SEAM_TASK.id,
nodeId: "execute",
outcome: taskDone ? "success" : "failure",
exit,
});
});
/*
The property that makes this PR safe to land ahead of the routing move: naming an exit must not
reroute anything. If any of these drift, a card is moving somewhere new and that belongs in the
PR that adds the IR edge, not in this one.
*/
it.each(EXITS)("returns the pre-existing routing outcome for $exit", async ({ exit, taskDone, expected }) => {
const { seams } = seamHarness({ taskDone, modifiedFiles: [], exit });
await expect(seams.execute!(SEAM_TASK, undefined)).resolves.toEqual(expected);
});
it("returns the same outcome when the phase reports no exit at all", async () => {
/* The ~22 uninstrumented dispositions still report nothing; they must be unaffected. */
const { seams } = seamHarness({ taskDone: false, modifiedFiles: [] });
const bus = captureEvents();
const outcome = await seams.execute!(SEAM_TASK, undefined);
await bus.drain();
expect(outcome).toEqual({ outcome: "failure", value: "implementation-incomplete" });
const completed = bus.events.filter((e) => e.type === "NodeCompleted");
expect(completed).toHaveLength(1);
expect(completed[0]).not.toHaveProperty("exit");
});
/*
R5, and a named U8 test scenario: "A dropped event subscriber changes no execution outcome
(proves reactions are non-authoritative)."
*/
it("produces identical outcomes with NO subscribers at all", async () => {
for (const { exit, taskDone, expected } of EXITS) {
resetWorkflowEventBusForTesting();
const { seams } = seamHarness({ taskDone, modifiedFiles: [], exit });
expect(getWorkflowEventBus().subscriberCount()).toBe(0);
await expect(seams.execute!(SEAM_TASK, undefined)).resolves.toEqual(expected);
}
});
it("is not derailed by a throwing subscriber", async () => {
const { seams } = seamHarness({ taskDone: true, modifiedFiles: [], exit: "complete" });
getWorkflowEventBus().subscribe(() => { throw new Error("subscriber blew up"); }, { name: "boom" });
await expect(seams.execute!(SEAM_TASK, undefined)).resolves.toEqual({
outcome: "success",
value: "implemented",
});
await getWorkflowEventBus().drain();
});
/*
FNXC:WorkflowExecutionOwnership 2026-07-28-21:05 (U8 / R12):
The wiring ratchet. Everything above drives a STUBBED implementation phase, which is the only
way to reach all six exits deterministically — but it means the real call sites are not
exercised. Deleting `reportImplementationExit?.("review-handoff-pending-review")` from
`runImplementation` left this whole file green (measured, not assumed), so the enum would have
drifted into a vocabulary that describes endings nothing actually reports. This asserts each id
is wired, and that the two out-of-band ids sit with the handoff they describe.
*/
it("every exit id has a real call site in runImplementation", () => {
const source = readFileSync(new URL("../executor.ts", import.meta.url), "utf8")
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
const ALL_EXITS: ImplementationExit[] = [
"complete",
"complete-after-retry",
"complete-from-live-files",
"review-handoff-paused-after-completion",
"review-handoff-pending-review",
];
const missing = ALL_EXITS.filter((exit) => !source.includes(`reportImplementationExit?.("${exit}")`));
expect(missing).toEqual([]);
/* Each out-of-band id must accompany an inline review handoff — that pairing IS its meaning. */
for (const exit of OUT_OF_BAND_IMPLEMENTATION_EXITS) {
const idx = source.indexOf(`reportImplementationExit?.("${exit}")`);
expect(source.slice(idx, idx + 400)).toContain("handoffTaskToReview(");
}
});
it("classifies exactly the two executor-performed transitions as out-of-band", () => {
/*
The ledger this unit closes: an out-of-band exit is one where the EXECUTOR moved the card.
If a third appears without a routing move, U8 has gone backwards.
*/
expect([...OUT_OF_BAND_IMPLEMENTATION_EXITS]).toEqual([
"review-handoff-paused-after-completion",
"review-handoff-pending-review",
]);
expect(isOutOfBandImplementationExit("complete")).toBe(false);
expect(isOutOfBandImplementationExit(undefined)).toBe(false);
});
});

View File

@@ -14,6 +14,8 @@ import { existsSync, lstatSync, realpathSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises"; import { readFile, rm, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, AsyncMissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core"; import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, AsyncMissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core";
import { getUnmetSchedulingDependencies } from "./scheduler.js"; import { getUnmetSchedulingDependencies } from "./scheduler.js";
import type { ImplementationExit, ImplementationExitReporter } from "./executor/implementation-exit.js";
import { emitWorkflowLifecycleEvent } from "@fusion/core";
import { RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, resolveExecutorFallbackModel } from "@fusion/core"; import { RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, resolveExecutorFallbackModel } from "@fusion/core";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js"; import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
import { mergeEffectiveSettings } from "./effective-settings.js"; import { mergeEffectiveSettings } from "./effective-settings.js";
@@ -6916,10 +6918,14 @@ export class TaskExecutor {
private async runImplementationPhase( private async runImplementationPhase(
task: Task, task: Task,
prepared?: PreparedWorktree, prepared?: PreparedWorktree,
): Promise<{ taskDone: boolean; modifiedFiles: string[] }> { ): Promise<{ taskDone: boolean; modifiedFiles: string[]; exit?: ImplementationExit }> {
let captured: { taskDone: boolean; modifiedFiles: string[] } = { taskDone: false, modifiedFiles: [] }; let captured: { taskDone: boolean; modifiedFiles: string[]; exit?: ImplementationExit } = { taskDone: false, modifiedFiles: [] };
const graphCompletion: GraphCompletionCallback = (info) => { const graphCompletion: GraphCompletionCallback = (info) => {
captured = { taskDone: true, modifiedFiles: info.modifiedFiles }; captured = { ...captured, taskDone: true, modifiedFiles: info.modifiedFiles };
};
/* Recorded independently of `graphCompletion`: the out-of-band exits never call it. */
const reportExit: ImplementationExitReporter = (exit) => {
captured = { ...captured, exit };
}; };
const executionTask = prepared const executionTask = prepared
? { ? {
@@ -6928,7 +6934,7 @@ export class TaskExecutor {
branch: prepared.branchName || task.branch, branch: prepared.branchName || task.branch,
} }
: task; : task;
await this.runImplementation(executionTask, graphCompletion); await this.runImplementation(executionTask, graphCompletion, reportExit);
return captured; return captured;
} }
@@ -7753,7 +7759,7 @@ export class TaskExecutor {
if (typeof seamThinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(seamThinkingLevel)) { if (typeof seamThinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(seamThinkingLevel)) {
this.graphSeamThinkingLevel.set(seamTask.id, seamThinkingLevel as ThinkingLevel); this.graphSeamThinkingLevel.set(seamTask.id, seamThinkingLevel as ThinkingLevel);
} }
let result: { taskDone: boolean; modifiedFiles: string[] }; let result: { taskDone: boolean; modifiedFiles: string[]; exit?: ImplementationExit };
try { try {
result = await this.runImplementationPhase(seamTask); result = await this.runImplementationPhase(seamTask);
} finally { } finally {
@@ -7780,6 +7786,29 @@ export class TaskExecutor {
compensating classifiers are the acceptance test — they become unreachable, and then compensating classifiers are the acceptance test — they become unreachable, and then
deletable, exactly when the last out-of-band transition is gone. deletable, exactly when the last out-of-band transition is gone.
*/ */
/*
FNXC:WorkflowExecutionOwnership 2026-07-28-20:25 (U8 / R4, R5):
Announce the exit on the U3 lifecycle bus. Until this, the two out-of-band review
handoffs left NO trace anywhere that the executor — not the graph — moved the card;
they surfaced as an ordinary `implementation-incomplete` failure that
`handleGraphFailure` then quietly compensated for. An operator could not tell the two
apart, and neither could a test.
Emission is deliberately AFTER the phase and BEFORE the return, and it changes nothing:
the outcome/value below are byte-identical to what this seam returned before, for every
exit, which `executor-implementation-exit-events.test.ts` pins by driving each exit and
asserting the seam's return. Per R5 an exit id is a REACTION — dropping every subscriber
must change no execution outcome, and that is asserted too.
*/
emitWorkflowLifecycleEvent({
type: "NodeCompleted",
taskId: seamTask.id,
at: new Date().toISOString(),
runId: this.getRunContextFor(seamTask.id)?.runId,
nodeId: typeof governingNodeId === "string" ? governingNodeId : "execute",
outcome: result.taskDone ? "success" : "failure",
...(result.exit ? { exit: result.exit } : {}),
});
if (result.taskDone) { if (result.taskDone) {
return { outcome: "success", value: "implemented" }; return { outcome: "success", value: "implemented" };
} }
@@ -11602,6 +11631,16 @@ export class TaskExecutor {
an implementation pass whose completion nothing owns can no longer be constructed. an implementation pass whose completion nothing owns can no longer be constructed.
*/ */
graphCompletion: GraphCompletionCallback, graphCompletion: GraphCompletionCallback,
/*
FNXC:WorkflowExecutionOwnership 2026-07-28-20:15 (U8 / R4, R5):
Optional exit reporter. `graphCompletion` can only say "done"; the endings it cannot express
are the ones the executor transitions itself (see `executor/implementation-exit.ts`). This
names them so they are OBSERVABLE before they are moved — it changes no routing and nothing
branches on it, by R5: an exit id is a reaction, and a dropped reaction must never cost a
state change. Optional so the ~22 uninstrumented dispositions stay silent rather than
forcing a 3k-line diff; the ownership ledger is the record of that gap, not this callback.
*/
reportImplementationExit?: ImplementationExitReporter,
): Promise<void> { ): Promise<void> {
// FN-4811 follow-up (FN-4814/FN-4809/FN-4811 production failure): claim a // FN-4811 follow-up (FN-4814/FN-4809/FN-4811 production failure): claim a
@@ -12552,6 +12591,7 @@ export class TaskExecutor {
this.clearCompletedTaskWatchdog(task.id); this.clearCompletedTaskWatchdog(task.id);
executorLog.log(`✓ ${task.id} implementation complete — graph interpreter owns the remaining lifecycle`); executorLog.log(`✓ ${task.id} implementation complete — graph interpreter owns the remaining lifecycle`);
const liveModified = (await this.store.getTask(task.id).catch(() => task)).modifiedFiles ?? []; const liveModified = (await this.store.getTask(task.id).catch(() => task)).modifiedFiles ?? [];
reportImplementationExit?.("complete-from-live-files");
graphCompletion({ modifiedFiles: liveModified }); graphCompletion({ modifiedFiles: liveModified });
return; return;
} else { } else {
@@ -13405,6 +13445,7 @@ export class TaskExecutor {
FN-6644/FN-6641: the graceful-session-exit handoff must also record durable completed-finalize state because a later teardown can re-mark the abort as `hard-cancel`. The classifier uses that durable handoff marker, not the volatile provenance alone, to keep completed no-commit tasks from being re-parked failed. FN-6644/FN-6641: the graceful-session-exit handoff must also record durable completed-finalize state because a later teardown can re-mark the abort as `hard-cancel`. The classifier uses that durable handoff marker, not the volatile provenance alone, to keep completed no-commit tasks from being re-parked failed.
*/ */
this.markCompletionFinalized(task.id); this.markCompletionFinalized(task.id);
reportImplementationExit?.("review-handoff-paused-after-completion");
await this.handoffTaskToReview(task, "paused-after-completion"); await this.handoffTaskToReview(task, "paused-after-completion");
this.clearCompletedTaskWatchdog(task.id); this.clearCompletedTaskWatchdog(task.id);
this.signalTaskComplete(task); this.signalTaskComplete(task);
@@ -13472,6 +13513,7 @@ export class TaskExecutor {
// at the implementation-complete boundary and hand control back. // at the implementation-complete boundary and hand control back.
this.clearCompletedTaskWatchdog(task.id); this.clearCompletedTaskWatchdog(task.id);
executorLog.log(`✓ ${task.id} implementation complete — graph interpreter owns the remaining lifecycle`); executorLog.log(`✓ ${task.id} implementation complete — graph interpreter owns the remaining lifecycle`);
reportImplementationExit?.("complete");
graphCompletion({ modifiedFiles }); graphCompletion({ modifiedFiles });
return; return;
} else { } else {
@@ -13536,6 +13578,7 @@ export class TaskExecutor {
// the task in review without setting status=failed; otherwise the // the task in review without setting status=failed; otherwise the
// merge/review queue deadlocks on a task that is both in-review and // merge/review queue deadlocks on a task that is both in-review and
// failed. // failed.
reportImplementationExit?.("review-handoff-pending-review");
await this.handoffTaskToReview(task, "executor-exit-while-review-pending"); await this.handoffTaskToReview(task, "executor-exit-while-review-pending");
pendingReviewParked = true; pendingReviewParked = true;
break; break;
@@ -13754,6 +13797,7 @@ export class TaskExecutor {
// executeWorkflowGraph, KTD-5) — nothing to gate before handoff. // executeWorkflowGraph, KTD-5) — nothing to gate before handoff.
this.clearCompletedTaskWatchdog(task.id); this.clearCompletedTaskWatchdog(task.id);
executorLog.log(`✓ ${task.id} implementation complete (retry) — graph interpreter owns the remaining lifecycle`); executorLog.log(`✓ ${task.id} implementation complete (retry) — graph interpreter owns the remaining lifecycle`);
reportImplementationExit?.("complete-after-retry");
graphCompletion({ modifiedFiles }); graphCompletion({ modifiedFiles });
return; return;
} else if (terminallyParked) { } else if (terminallyParked) {
@@ -13987,7 +14031,8 @@ export class TaskExecutor {
FN-6644/FN-6641: the finally-block handoff must record durable completed-finalize state because a later teardown can overwrite provenance to `hard-cancel`. The classifier must still resolve that completed no-commit tail failure benignly without weakening genuine pause or active hard-cancel behavior. FN-6644/FN-6641: the finally-block handoff must record durable completed-finalize state because a later teardown can overwrite provenance to `hard-cancel`. The classifier must still resolve that completed no-commit tail failure benignly without weakening genuine pause or active hard-cancel behavior.
*/ */
this.markCompletionFinalized(task.id); this.markCompletionFinalized(task.id);
await this.handoffTaskToReview(task, "paused-after-completion"); reportImplementationExit?.("review-handoff-paused-after-completion");
await this.handoffTaskToReview(task, "paused-after-completion");
this.signalTaskComplete(task); this.signalTaskComplete(task);
} else if (finalizationDecision === "blocked") { } else if (finalizationDecision === "blocked") {
await this.persistTokenUsage(task.id); await this.persistTokenUsage(task.id);

View File

@@ -0,0 +1,64 @@
/*
FNXC:WorkflowExecutionOwnership 2026-07-28-20:10 (U8 / R4, R5 — workflow-owned lifecycle):
THE IMPLEMENTATION PHASE'S EXIT VOCABULARY.
`runImplementation` can end in many ways, and the graph is told about exactly one bit of it:
`result.taskDone`. That is the whole language the execute seam has (`executor.ts` — the seam
maps it to `"implemented"` or `"implementation-incomplete"`), and it is why the implementation
phase transitions cards ITSELF for the endings the boolean cannot express:
- a session that paused AFTER the work was already complete finalizes to review inline;
- a session that stopped because a step is blocked on a pending review hands off to review
inline, because it cannot continue and review is not an error bucket.
The graph then sees `taskDone === false`, reports `implementation-incomplete`, and
`handleGraphFailure` compensates with `alreadyFinalizedToReview` / `completionFinalized` —
classifiers whose entire job is recognising a move the graph did not make. Dual ownership, and
today it is INVISIBLE: nothing anywhere records that the executor, not the graph, moved the card.
This module names those endings so they can be observed before they are moved. Each id is a
closed enum value, never prose — these ids travel on the U3 lifecycle bus to plugin subscribers
under its ids-only rule.
WHAT THIS DELIBERATELY DOES NOT DO. It does not change routing. The execute seam returns exactly
the outcome and value it returned before, for every exit, and `executor-implementation-exit-
events.test.ts` pins that. An exit id is a REACTION under R5 — a dropped event must cost a
notification and never a state change — so nothing downstream may branch on one until the
routing move lands with its own IR edges. Reporting first, moving second, is what keeps the two
changes independently revertable.
COVERAGE IS PARTIAL ON PURPOSE. `runImplementation` has ~28 lifecycle dispositions (measured by
`executor-lifecycle-ownership-ledger.test.ts`) and this instruments the six completion-adjacent
ones — the three graph handbacks and the three inline review handoffs. Those are the exits U8's
routing move needs; the rest report nothing yet and the ledger, not this enum, is the record of
that gap.
*/
/*
FNXC:WorkflowExecutionOwnership 2026-07-28-22:20 (U8, PR #2507 review — greptile):
THE UNION MOVED TO CORE. It was declared here and the public `NodeCompletedEvent.exit` was typed
`string`, so the contract permitted ids no consumer routes — and that failure is silent (the card
does not advance; nothing reports anything). A public contract cannot defer its vocabulary to one
of its producers, so `ImplementationExit` now lives beside the event that carries it, is checked
at the emit boundary against `IMPLEMENTATION_EXITS`, and is re-exported here for the call sites.
What stays in the engine is POLICY, not contract: which of those endings are ones the EXECUTOR
performed rather than the graph. That is a statement about this engine's current ownership split,
it changes as U8 lands its routing moves, and core has no business knowing it.
*/
import type { ImplementationExit as CoreImplementationExit } from "@fusion/core";
export type { ImplementationExit } from "@fusion/core";
/** The exits where the EXECUTOR performs the lifecycle transition instead of the graph. */
export const OUT_OF_BAND_IMPLEMENTATION_EXITS: readonly CoreImplementationExit[] = [
"review-handoff-paused-after-completion",
"review-handoff-pending-review",
];
export function isOutOfBandImplementationExit(exit: CoreImplementationExit | undefined): boolean {
return exit !== undefined && OUT_OF_BAND_IMPLEMENTATION_EXITS.includes(exit);
}
/** Reporter threaded into `runImplementation`; each instrumented exit calls it exactly once. */
export type ImplementationExitReporter = (exit: CoreImplementationExit) => void;