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:
7
.changeset/fn-9106-non-continuable-step-session.md
Normal file
7
.changeset/fn-9106-non-continuable-step-session.md
Normal 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.
|
||||||
@@ -81,6 +81,13 @@ function createStore(task: Task, settingsOverrides: Record<string, unknown> = {}
|
|||||||
});
|
});
|
||||||
(emitter as any).appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
(emitter as any).appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
||||||
(emitter as any).getGoalStore = vi.fn().mockReturnValue({ listGoals: vi.fn().mockReturnValue([]) });
|
(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).getFusionDir = vi.fn().mockReturnValue("/tmp/test/.fusion");
|
||||||
(emitter as any).clearStaleExecutionStartBranchReferences = vi.fn().mockReturnValue([]);
|
(emitter as any).clearStaleExecutionStartBranchReferences = vi.fn().mockReturnValue([]);
|
||||||
(emitter as any).listWorkflowSteps = vi.fn().mockResolvedValue([]);
|
(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) => {
|
(emitter as any).recordRunAuditEvent = vi.fn().mockImplementation(async (event: any) => {
|
||||||
audits.push(event);
|
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.
|
// 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).getAgentLogCount = vi.fn().mockResolvedValue(0);
|
||||||
(emitter as any).getAgentLogs = vi.fn().mockResolvedValue([]);
|
(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");
|
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);
|
await executor.execute(task);
|
||||||
|
|
||||||
// Pre-fix root cause: the post-done step-session catch in executor.ts marked
|
// 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);
|
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 () => {
|
it("self-heals already wedged post-done non-continuable failures back to clean in-review", async () => {
|
||||||
const wedged = makeTask({
|
const wedged = makeTask({
|
||||||
id: "FN-5889-WEDGED",
|
id: "FN-5889-WEDGED",
|
||||||
|
|||||||
@@ -1571,6 +1571,17 @@ export async function runImplementation(
|
|||||||
if (await deps.handleNonContinuableSessionError(task, false, errorMessage)) {
|
if (await deps.handleNonContinuableSessionError(task, false, errorMessage)) {
|
||||||
return;
|
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);
|
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.logEntry(task.id, `Step-session execution failed: ${errorMessage}`, errorStack ?? errorDetail, deps.getRunContextFor(task.id));
|
||||||
await deps.store.updateTask(task.id, { status: null, error: null });
|
await deps.store.updateTask(task.id, { status: null, error: null });
|
||||||
|
|||||||
Reference in New Issue
Block a user