feat(FN-3934): add restart recovery coordinator

Adds a new restart recovery coordinator (FN-3934) to manage task recovery on agent restart, including the core coordinator class, tests, and integration into the in-process runtime; also documents the coordinator in AGENTS.md.

Fusion-Task-Id: FN-3934
This commit is contained in:
Fusion
2026-05-10 10:39:18 -07:00
committed by gsxdsm
parent 64c66c1e5c
commit 46e5f517d9
5 changed files with 158 additions and 21 deletions

View File

@@ -15,6 +15,7 @@ const {
mockSelfHealingCtor,
mockRecoverNoProgressNoTaskDoneFailures,
mockRunStartupRecovery,
mockRecoverInterruptedRuns,
mockExecutorCtor,
mockResumeOrphaned,
mockTaskStoreSettings,
@@ -31,6 +32,7 @@ const {
mockSelfHealingCtor: vi.fn(),
mockRecoverNoProgressNoTaskDoneFailures: vi.fn().mockResolvedValue(0),
mockRunStartupRecovery: vi.fn().mockResolvedValue(undefined),
mockRecoverInterruptedRuns: vi.fn().mockResolvedValue(undefined),
mockExecutorCtor: vi.fn(),
mockResumeOrphaned: vi.fn().mockResolvedValue(undefined),
mockTaskStoreSettings: {} as Record<string, unknown>,
@@ -157,6 +159,14 @@ vi.mock("../../self-healing.js", async () => {
};
});
vi.mock("../../restart-recovery-coordinator.js", async () => {
return {
RestartRecoveryCoordinator: vi.fn().mockImplementation(() => ({
recoverInterruptedRuns: mockRecoverInterruptedRuns,
})),
};
});
// Mock the plugin runner
vi.mock("../../plugin-runner.js", async () => {
return {
@@ -320,11 +330,11 @@ describe("InProcessRuntime", () => {
expect(mockSelfHealingStart).toHaveBeenCalled();
}, 30000);
it("runs self-healing startup recovery immediately after orphan resume on startup", async () => {
it("runs startup recovery immediately after interrupted-run coordination on startup", async () => {
await runtime.start();
expect(mockRecoverNoProgressNoTaskDoneFailures).toHaveBeenCalledTimes(1);
expect(mockResumeOrphaned).toHaveBeenCalledTimes(1);
expect(mockRecoverInterruptedRuns).toHaveBeenCalledTimes(1);
expect(mockResumeOrphaned).not.toHaveBeenCalled();
expect(mockRunStartupRecovery).toHaveBeenCalledTimes(1);
}, 30000);
@@ -333,7 +343,7 @@ describe("InProcessRuntime", () => {
await runtime.start();
expect(mockRecoverNoProgressNoTaskDoneFailures).not.toHaveBeenCalled();
expect(mockRecoverInterruptedRuns).not.toHaveBeenCalled();
expect(mockResumeOrphaned).not.toHaveBeenCalled();
expect(mockRunStartupRecovery).not.toHaveBeenCalled();
}, 30000);
@@ -342,20 +352,17 @@ describe("InProcessRuntime", () => {
mockTaskStoreSettings.enginePaused = true;
await runtime.start();
mockRecoverNoProgressNoTaskDoneFailures.mockClear();
mockRecoverInterruptedRuns.mockClear();
mockResumeOrphaned.mockClear();
mockRunStartupRecovery.mockClear();
mockTaskStoreSettings.enginePaused = false;
await runtime.resumeAfterUnpause();
expect(mockRecoverNoProgressNoTaskDoneFailures).toHaveBeenCalledTimes(1);
expect(mockResumeOrphaned).toHaveBeenCalledTimes(1);
expect(mockRecoverInterruptedRuns).toHaveBeenCalledTimes(1);
expect(mockResumeOrphaned).not.toHaveBeenCalled();
expect(mockRunStartupRecovery).toHaveBeenCalledTimes(1);
expect(mockRecoverNoProgressNoTaskDoneFailures.mock.invocationCallOrder[0]).toBeLessThan(
mockResumeOrphaned.mock.invocationCallOrder[0],
);
expect(mockResumeOrphaned.mock.invocationCallOrder[0]).toBeLessThan(
expect(mockRecoverInterruptedRuns.mock.invocationCallOrder[0]).toBeLessThan(
mockRunStartupRecovery.mock.invocationCallOrder[0],
);
}, 30000);
@@ -364,15 +371,15 @@ describe("InProcessRuntime", () => {
mockTaskStoreSettings.enginePaused = true;
await runtime.start();
mockRecoverNoProgressNoTaskDoneFailures.mockClear();
mockRecoverInterruptedRuns.mockClear();
mockResumeOrphaned.mockClear();
mockRunStartupRecovery.mockClear();
mockTaskStoreSettings.enginePaused = false;
await Promise.all([runtime.resumeAfterUnpause(), runtime.resumeAfterUnpause()]);
expect(mockRecoverNoProgressNoTaskDoneFailures).toHaveBeenCalledTimes(1);
expect(mockResumeOrphaned).toHaveBeenCalledTimes(1);
expect(mockRecoverInterruptedRuns).toHaveBeenCalledTimes(1);
expect(mockResumeOrphaned).not.toHaveBeenCalled();
expect(mockRunStartupRecovery).toHaveBeenCalledTimes(1);
}, 30000);

View File

@@ -33,6 +33,7 @@ import { runtimeLog } from "../logger.js";
import { StuckTaskDetector } from "../stuck-task-detector.js";
import type { UsageLimitPauser } from "../usage-limit-detector.js";
import { SelfHealingManager } from "../self-healing.js";
import { RestartRecoveryCoordinator } from "../restart-recovery-coordinator.js";
import { MeshLeaseManager } from "../mesh-lease-manager.js";
import { PluginRunner } from "../plugin-runner.js";
import { MissionAutopilot } from "../mission-autopilot.js";
@@ -125,6 +126,7 @@ export class InProcessRuntime
private startupRecoveryDeferred = false;
/** Prevent duplicate unpause recovery dispatches from racing each other. */
private resumeAfterUnpauseRunning = false;
private restartRecoveryCoordinator?: RestartRecoveryCoordinator;
/**
* @param config - Runtime configuration
@@ -636,6 +638,7 @@ export class InProcessRuntime
});
this.selfHealingManager.start();
this.stuckTaskDetector.start();
this.restartRecoveryCoordinator = new RestartRecoveryCoordinator(this.taskStore, this.executor);
// 8. Set up event forwarding from TaskStore
this.setupEventForwarding();
@@ -912,13 +915,9 @@ export class InProcessRuntime
}
private async resumeStartupRecoverySequence(): Promise<void> {
// Requeue no-progress no-task_done failures before resumeOrphaned can
// restart other orphaned executions.
await this.selfHealingManager!.recoverNoProgressNoTaskDoneFailures();
// Resume orphaned in-progress tasks before the broader self-healing scan
// so the executor can claim or fast-path eligible tasks first.
await this.executor!.resumeOrphaned();
// Restart recovery decides when interrupted runs can safely resume versus
// when they must be reset to todo for a clean retry.
await this.restartRecoveryCoordinator!.recoverInterruptedRuns();
// Some "stuck" tasks are already orphaned by the time the runtime boots:
// they no longer have a tracked session/worktree, so the stuck detector