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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user