FN-9106: Route non-continuable step sessions through retry recovery

Recover poisoned step-session transcripts before the generic failure sink can retain them.

- Apply bounded fresh-session recovery to incomplete step-session execution failures.
- Preserve completed-work handling ahead of retry classification.
- Add regression coverage and production-shaped executor fixtures.
- Document the published patch behavior in a changeset.

Files changed:
 .changeset/fn-9106-non-continuable-step-session.md |  7 ++++
 .../post-done-continuation-no-wedge.test.ts        | 48 +++++++++++++++++++++-
 packages/engine/src/executor/run-implementation.ts | 11 +++++
 3 files changed, 65 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-9106

Fusion-Task-Lineage: 537ea415-96dd-4b66-ab5d-84dc4603388b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-15 17:13:20 -07:00
parent 43a42d8b7b
commit 802a4249fb
3 changed files with 65 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Non-continuable agent sessions now recover cleanly in step-session runs instead of failing the task.
category: fix
dev: Pairs run-implementation.ts step-session error handling with handleNonContinuableSessionRetry.

View File

@@ -81,6 +81,13 @@ function createStore(task: Task, settingsOverrides: Record<string, unknown> = {}
});
(emitter as any).appendAgentLog = vi.fn().mockResolvedValue(undefined);
(emitter as any).getGoalStore = vi.fn().mockReturnValue({ listGoals: vi.fn().mockReturnValue([]) });
/*
FNXC:EngineTests 2026-08-15-23:48:
Graph principal admission (FN-8764/FN-8821) reads store.getRootDir() before it creates a
session. Keep this fixture production-shaped so the test reaches non-continuable handling
instead of terminally parking at steps#0:step-execute on a missing store method.
*/
(emitter as any).getRootDir = vi.fn().mockReturnValue("/tmp/test");
(emitter as any).getFusionDir = vi.fn().mockReturnValue("/tmp/test/.fusion");
(emitter as any).clearStaleExecutionStartBranchReferences = vi.fn().mockReturnValue([]);
(emitter as any).listWorkflowSteps = vi.fn().mockResolvedValue([]);
@@ -169,6 +176,13 @@ function createSelfHealingStore(tasks: Task[], settingsOverrides: Record<string,
(emitter as any).recordRunAuditEvent = vi.fn().mockImplementation(async (event: any) => {
audits.push(event);
});
/*
FNXC:EngineTests 2026-08-15-23:48:
Graph principal admission (FN-8764/FN-8821) reads store.getRootDir() before it creates a
session. Self-healing fixtures retain the same TaskStore shape to prevent an admission
exception from making executor-session coverage vacuous.
*/
(emitter as any).getRootDir = vi.fn().mockReturnValue("/tmp/test");
// FNXC:EngineTests 2026-07-17-06:30: graph tool-failure cursor reads getAgentLogCount at execute entry.
(emitter as any).getAgentLogCount = vi.fn().mockResolvedValue(0);
(emitter as any).getAgentLogs = vi.fn().mockResolvedValue([]);
@@ -250,7 +264,13 @@ describe("FN-5866 reliability interactions: post-done continuation no wedge", ()
throw new Error("Cannot continue from message role: assistant");
});
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError, agentStore: { getAgent: vi.fn().mockResolvedValue(null) } as any });
/*
FNXC:EngineTests 2026-08-15-23:48:
Let executor-test-helpers supply its routing agent store. Principal admission requires
agentStore.listAgents() before a step session exists; the legacy getAgent-only bag holds
this case at workflow-principal-role-pool-exhausted and makes the continuation assertion vacuous.
*/
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
await executor.execute(task);
// Pre-fix root cause: the post-done step-session catch in executor.ts marked
@@ -298,6 +318,32 @@ describe("FN-5866 reliability interactions: post-done continuation no wedge", ()
expect((task.log ?? []).some((entry: any) => entry.action.includes("Detected stale assistant-continuation session — fresh-session retry"))).toBe(true);
});
it("requeues incomplete step-session work with a fresh session when the session is not continuable", async () => {
const task = makeTask({
id: "FN-9106-STEP-SESSION-INCOMPLETE",
steps: [{ name: "Implement", status: "in-progress" as const }],
sessionFile: "/tmp/test/.fusion/sessions/FN-9106-STEP-SESSION-INCOMPLETE.json",
});
const store = createStore(task, { runStepsInNewSessions: true });
const onError = vi.fn();
mockExecuteAll.mockRejectedValue(new Error("Cannot continue from message role: assistant"));
const executor = new TaskExecutor(store, "/tmp/test", { onError });
await executor.execute(task);
expect(task.column).toBe("todo");
expect(task.status).toBeUndefined();
expect(task.error).toBeUndefined();
expect(task.sessionFile).toBeNull();
expect(task.recoveryRetryCount).toBe(1);
expect(task.nextRecoveryAt).toEqual(expect.any(String));
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo", { preserveResumeState: true });
expect(store.handoffToReview).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
expect((task.log ?? []).some((entry: any) => entry.action.includes("Non-continuable session — fresh-session retry"))).toBe(true);
});
it("self-heals already wedged post-done non-continuable failures back to clean in-review", async () => {
const wedged = makeTask({
id: "FN-5889-WEDGED",

View File

@@ -1571,6 +1571,17 @@ export async function runImplementation(
if (await deps.handleNonContinuableSessionError(task, false, errorMessage)) {
return;
}
/*
FNXC:PostDoneContinuation 2026-08-15-23:48:
Step-session failures must classify non-continuable transcripts in the same order as
single-session execution: completed work wins first; incomplete work gets the bounded
fresh-session retry (clearing sessionFile with recovery backoff) before this generic
resume sink can retain the poisoned transcript. Exhaustion clears its counters and
deliberately falls through to the existing sink.
*/
if (await deps.handleNonContinuableSessionRetry(task, errorMessage)) {
return;
}
executorLog.error(`✗ ${task.id} step-session execution failed:`, errorDetail);
await deps.store.logEntry(task.id, `Step-session execution failed: ${errorMessage}`, errorStack ?? errorDetail, deps.getRunContextFor(task.id));
await deps.store.updateTask(task.id, { status: null, error: null });