fix(engine): requeue stale assistant continuations (#2095)
## Summary - detect persisted executor sessions that cannot continue from an assistant message - clear the stale session pointer after the executor lock is released - requeue the task with workflow progress preserved instead of marking it failed ## Test plan - `pnpm --filter @fusion/engine exec vitest run src/__tests__/executor-step-session.test.ts -t "clears a stale assistant-continuation resume session and requeues without marking the task failed" --project=engine-default --silent=passed-only --reporter=dot` - `pnpm --filter @fusion/engine typecheck` - `pnpm build` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved recovery when an assistant continuation session becomes stale by restarting a fresh session with bounded retries, preserving overall task progress. * Clears invalid persisted session/continuation state and defers requeue until coordination cleanup is safe. * When retries are exhausted, tasks are marked failed and the error callback runs (without routing to review). * **Tests** * Added coverage for stale-session recovery, repeated-stale behavior, correct (or skipped) requeue decisions, and progress/error handling paths. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.changeset/calm-taxis-requeue.md
Normal file
7
.changeset/calm-taxis-requeue.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Recover stale executor sessions with bounded fresh-session retries while preserving task progress.
|
||||||
|
category: fix
|
||||||
|
dev: Clears the persisted assistant-last transcript, defers requeue until lock release, and exhausts through the shared recovery budget.
|
||||||
@@ -16,6 +16,8 @@ import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|||||||
import { StepSessionExecutor } from "../step-session-executor.js";
|
import { StepSessionExecutor } from "../step-session-executor.js";
|
||||||
import { executorLog } from "../logger.js";
|
import { executorLog } from "../logger.js";
|
||||||
import { withRateLimitRetry } from "../rate-limit-retry.js";
|
import { withRateLimitRetry } from "../rate-limit-retry.js";
|
||||||
|
import { MAX_RECOVERY_RETRIES } from "../recovery-policy.js";
|
||||||
|
import { executingTaskLock } from "../active-session-registry.js";
|
||||||
import { runVerificationCommand as mockedRunVerificationCommand } from "../verification-utils.js";
|
import { runVerificationCommand as mockedRunVerificationCommand } from "../verification-utils.js";
|
||||||
import {
|
import {
|
||||||
createMockStore,
|
createMockStore,
|
||||||
@@ -246,6 +248,109 @@ describe("Workflow Steps Execution", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears a stale assistant-continuation resume session and requeues without marking the task failed", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const task = {
|
||||||
|
id: "FN-ASSISTANT-STALE",
|
||||||
|
title: "Stale assistant continuation",
|
||||||
|
description: "Test stale assistant continuation recovery",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Preflight", status: "in-progress" as const }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||||
|
sessionFile: "/tmp/stale-session.jsonl",
|
||||||
|
worktree: "/tmp/test/.worktrees/fn-assistant-stale",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
store.getTask.mockResolvedValue(task as any);
|
||||||
|
store.moveTask.mockImplementation(async () => {
|
||||||
|
expect(executingTaskLock.has("FN-ASSISTANT-STALE")).toBe(false);
|
||||||
|
expect((executor as any).activeWorktrees.has("FN-ASSISTANT-STALE")).toBe(false);
|
||||||
|
return task as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const staleSession = {
|
||||||
|
prompt: vi.fn().mockRejectedValue(new Error("Cannot continue from message role: assistant")),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||||
|
state: {},
|
||||||
|
};
|
||||||
|
mockedCreateFnAgent.mockResolvedValue({ session: staleSession } as any);
|
||||||
|
|
||||||
|
const onError = vi.fn();
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||||||
|
const markGraphExecuteSelfRequeued = vi.spyOn(executor as any, "markGraphExecuteSelfRequeued");
|
||||||
|
(executor as any).activeWorktrees.set("FN-ASSISTANT-STALE", new Set([task.worktree]));
|
||||||
|
|
||||||
|
await executor.execute(task as any);
|
||||||
|
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-ASSISTANT-STALE", {
|
||||||
|
sessionFile: null,
|
||||||
|
recoveryRetryCount: 1,
|
||||||
|
nextRecoveryAt: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-ASSISTANT-STALE", {
|
||||||
|
sessionFile: null,
|
||||||
|
status: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("FN-ASSISTANT-STALE", "todo", { preserveResumeState: true });
|
||||||
|
expect(markGraphExecuteSelfRequeued).toHaveBeenCalledWith("FN-ASSISTANT-STALE");
|
||||||
|
expect(executingTaskLock.has("FN-ASSISTANT-STALE")).toBe(false);
|
||||||
|
expect((executor as any).activeWorktrees.has("FN-ASSISTANT-STALE")).toBe(false);
|
||||||
|
expect(store.handoffToReview).not.toHaveBeenCalled();
|
||||||
|
expect(onError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a repeated stale assistant-continuation after the fresh-session retry budget is exhausted", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const task = {
|
||||||
|
id: "FN-ASSISTANT-STALE-EXHAUSTED",
|
||||||
|
title: "Repeated stale assistant continuation",
|
||||||
|
description: "Test bounded stale assistant continuation recovery",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Preflight", status: "in-progress" as const }],
|
||||||
|
currentStep: 0,
|
||||||
|
recoveryRetryCount: MAX_RECOVERY_RETRIES,
|
||||||
|
log: [],
|
||||||
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||||
|
sessionFile: "/tmp/stale-session.jsonl",
|
||||||
|
worktree: "/tmp/test/.worktrees/fn-assistant-stale-exhausted",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
store.getTask.mockResolvedValue(task as any);
|
||||||
|
mockedCreateFnAgent.mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockRejectedValue(new Error("Cannot continue from message role: assistant")),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||||
|
state: {},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
const onError = vi.fn();
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||||||
|
|
||||||
|
await executor.execute(task as any);
|
||||||
|
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-ASSISTANT-STALE-EXHAUSTED", {
|
||||||
|
status: "failed",
|
||||||
|
error: "Cannot continue from message role: assistant",
|
||||||
|
recoveryRetryCount: null,
|
||||||
|
nextRecoveryAt: null,
|
||||||
|
});
|
||||||
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-ASSISTANT-STALE-EXHAUSTED", "todo", expect.anything());
|
||||||
|
expect(onError).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
describe("FN-5436: pending-review skip on no-fn_task_done exit", () => {
|
describe("FN-5436: pending-review skip on no-fn_task_done exit", () => {
|
||||||
it("does not park in-review when code review REVISE requires more executor work", async () => {
|
it("does not park in-review when code review REVISE requires more executor work", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
|
|||||||
@@ -566,6 +566,12 @@ function buildExecuteRequeueLoopHighWaterSignature(live: TaskDetail, previousSig
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INVALID_ASSISTANT_CONTINUATION_PATTERN = /cannot continue from message role:\s*assistant/i;
|
||||||
|
|
||||||
|
function isInvalidAssistantContinuationErrorMessage(errorMessage: string): boolean {
|
||||||
|
return INVALID_ASSISTANT_CONTINUATION_PATTERN.test(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
const TRANSIENT_WORKTREE_TASK_JSON_ENOENT_PATTERN = /ENOENT:\s+no such file or directory,\s+open\s+'([^']+\/\.fusion\/tasks\/([^/]+)\/task\.json)'/;
|
const TRANSIENT_WORKTREE_TASK_JSON_ENOENT_PATTERN = /ENOENT:\s+no such file or directory,\s+open\s+'([^']+\/\.fusion\/tasks\/([^/]+)\/task\.json)'/;
|
||||||
|
|
||||||
export function isTransientMissingTaskJsonError(error: unknown, task: Pick<Task, "id" | "worktree">): boolean {
|
export function isTransientMissingTaskJsonError(error: unknown, task: Pick<Task, "id" | "worktree">): boolean {
|
||||||
@@ -9824,6 +9830,7 @@ export class TaskExecutor {
|
|||||||
// the finally block so this.executing is cleared first (prevents re-dispatch race).
|
// the finally block so this.executing is cleared first (prevents re-dispatch race).
|
||||||
// true = requeue to todo, false = budget exhausted (already marked failed).
|
// true = requeue to todo, false = budget exhausted (already marked failed).
|
||||||
let stuckRequeue: boolean | null = null;
|
let stuckRequeue: boolean | null = null;
|
||||||
|
let staleAssistantContinuationRequeue = false;
|
||||||
let taskDone = false;
|
let taskDone = false;
|
||||||
let reviewAddressingActivated = false;
|
let reviewAddressingActivated = false;
|
||||||
let taskEnv: NodeJS.ProcessEnv | undefined;
|
let taskEnv: NodeJS.ProcessEnv | undefined;
|
||||||
@@ -11793,6 +11800,54 @@ export class TaskExecutor {
|
|||||||
// Dependency added mid-execution — discard worktree and move to triage
|
// Dependency added mid-execution — discard worktree and move to triage
|
||||||
this.depAborted.delete(task.id);
|
this.depAborted.delete(task.id);
|
||||||
await this.handleDepAbortCleanup(task.id, worktreePath);
|
await this.handleDepAbortCleanup(task.id, worktreePath);
|
||||||
|
} else if (isInvalidAssistantContinuationErrorMessage(errorMessage)) {
|
||||||
|
/*
|
||||||
|
FNXC:ExecutorSessionRecovery 2026-07-14-06:03:
|
||||||
|
A stale assistant-last transcript gets a bounded fresh-session retry with the shared recovery backoff. The retry counter must survive the deferred move so repeated fresh-session failures eventually become a visible execution failure instead of cycling through Todo forever.
|
||||||
|
|
||||||
|
FNXC:ExecutorSessionRecovery 2026-07-14-06:19:
|
||||||
|
Deferred self-requeues must mark the workflow graph recovery and release the active worktree slot after the executor lock drops; otherwise graph failure cleanup can overwrite the recovery and the parked task can keep consuming maxWorktrees capacity.
|
||||||
|
*/
|
||||||
|
const liveTask = await this.store.getTask(task.id);
|
||||||
|
const decision = computeRecoveryDecision({
|
||||||
|
recoveryRetryCount: liveTask.recoveryRetryCount,
|
||||||
|
nextRecoveryAt: liveTask.nextRecoveryAt,
|
||||||
|
});
|
||||||
|
if (!decision.shouldRetry) {
|
||||||
|
executorLog.error(`✗ ${task.id} stale assistant-continuation retries exhausted (${MAX_RECOVERY_RETRIES} attempts): ${errorMessage}`);
|
||||||
|
await this.store.logEntry(
|
||||||
|
task.id,
|
||||||
|
`Stale assistant-continuation fresh-session retries exhausted after ${MAX_RECOVERY_RETRIES} attempts: ${errorMessage}`,
|
||||||
|
errorStack ?? errorDetail,
|
||||||
|
this.getRunContextFor(task.id),
|
||||||
|
);
|
||||||
|
await this.store.updateTask(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: errorMessage,
|
||||||
|
recoveryRetryCount: null,
|
||||||
|
nextRecoveryAt: null,
|
||||||
|
});
|
||||||
|
await this.persistTokenUsage(task.id);
|
||||||
|
this.options.onError?.(task, err instanceof Error ? err : new Error(errorMessage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
staleAssistantContinuationRequeue = true;
|
||||||
|
const attempt = decision.nextState.recoveryRetryCount;
|
||||||
|
const delay = formatDelay(decision.delayMs);
|
||||||
|
executorLog.warn(`${task.id} stale assistant-continuation session detected — fresh-session retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay} after executor lock release`);
|
||||||
|
await this.store.logEntry(
|
||||||
|
task.id,
|
||||||
|
`Detected stale assistant-continuation session — fresh-session retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay} with progress preserved: ${errorMessage}`,
|
||||||
|
undefined,
|
||||||
|
this.getRunContextFor(task.id),
|
||||||
|
);
|
||||||
|
await this.store.updateTask(task.id, {
|
||||||
|
sessionFile: null,
|
||||||
|
recoveryRetryCount: decision.nextState.recoveryRetryCount,
|
||||||
|
nextRecoveryAt: decision.nextState.nextRecoveryAt,
|
||||||
|
});
|
||||||
|
return;
|
||||||
} else if (errorMessage.includes("Invalid transition")) {
|
} else if (errorMessage.includes("Invalid transition")) {
|
||||||
// Task was moved by user/process while executor was running — already in desired state
|
// Task was moved by user/process while executor was running — already in desired state
|
||||||
// This check must come before pausedAborted since it's more specific
|
// This check must come before pausedAborted since it's more specific
|
||||||
@@ -12489,6 +12544,54 @@ export class TaskExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Requeue stale assistant-continuation sessions AFTER this.executing is cleared.
|
||||||
|
// Moving the task while the execution guard is still held can cause the scheduler's
|
||||||
|
// task:moved dispatch to no-op, stranding the task in todo with no fresh run.
|
||||||
|
if (staleAssistantContinuationRequeue) {
|
||||||
|
/*
|
||||||
|
FNXC:ExecutorSessionRecovery 2026-07-14-06:26:
|
||||||
|
Claim the process-wide executor lock for deferred cleanup, release it immediately before moveTask emits task:moved, and always drop the claim on errors. This closes the guard-release race without recreating the original no-op dispatch: a fresh retry cannot start while stale state is being cleared, but can claim the task when the committed move event fires.
|
||||||
|
|
||||||
|
FNXC:ExecutorSessionRecovery 2026-07-14-06:34:
|
||||||
|
Release the stale run's activeWorktrees slot before releasing the executor lock. Once the lock is open, the fresh retry may install its own slot while moveTask dispatches; deleting afterward would erase the new run's capacity and liveness tracking.
|
||||||
|
*/
|
||||||
|
const cleanupClaimed = executingTaskLock.tryClaim(task.id);
|
||||||
|
if (!cleanupClaimed) {
|
||||||
|
executorLog.log(`${task.id} stale assistant-continuation requeue skipped — a fresh executor already claimed the task`);
|
||||||
|
} else {
|
||||||
|
let cleanupLockHeld = true;
|
||||||
|
try {
|
||||||
|
const latestTask = await this.store.getTask(task.id);
|
||||||
|
if (latestTask.column === "in-progress" || latestTask.column === "todo") {
|
||||||
|
await this.store.updateTask(task.id, {
|
||||||
|
sessionFile: null,
|
||||||
|
status: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
if (latestTask.column !== "todo") {
|
||||||
|
this.markGraphExecuteSelfRequeued(task.id);
|
||||||
|
this.activeWorktrees.delete(task.id);
|
||||||
|
executingTaskLock.release(task.id);
|
||||||
|
cleanupLockHeld = false;
|
||||||
|
await this.store.moveTask(task.id, "todo", { preserveResumeState: true });
|
||||||
|
} else {
|
||||||
|
this.activeWorktrees.delete(task.id);
|
||||||
|
}
|
||||||
|
executorLog.log(`${task.id} stale assistant-continuation session cleared — requeued to todo with progress preserved`);
|
||||||
|
} else {
|
||||||
|
executorLog.log(`${task.id} stale assistant-continuation requeue skipped — task is now in '${latestTask.column}'`);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
executorLog.error(`Failed to requeue stale assistant-continuation task ${task.id}: ${errorMessage}`);
|
||||||
|
} finally {
|
||||||
|
if (cleanupLockHeld) {
|
||||||
|
executingTaskLock.release(task.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Requeue stuck-killed task AFTER this.executing is cleared.
|
// Requeue stuck-killed task AFTER this.executing is cleared.
|
||||||
// This prevents the race where the scheduler re-dispatches the task
|
// This prevents the race where the scheduler re-dispatches the task
|
||||||
// (via task:moved → execute()) while the old execution guard is still set,
|
// (via task:moved → execute()) while the old execution guard is still set,
|
||||||
|
|||||||
Reference in New Issue
Block a user