test: add error path coverage for recovery system

Add tests for runtime.isAlive, workspace.destroy, and runtime.destroy
error handling to ensure cleanup continues despite partial failures.

Fixes test coverage gaps in validator and cleanup actions.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
harshitsinghbhandari 2026-03-30 23:58:23 +05:30
parent b8c67dca50
commit 282e8ae9fd
2 changed files with 204 additions and 2 deletions

View File

@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { readMetadataRaw } from "../metadata.js";
import { getSessionsDir } from "../paths.js";
import { escalateSession, recoverSession } from "../recovery/actions.js";
import { cleanupSession, escalateSession, recoverSession } from "../recovery/actions.js";
import { runRecovery } from "../recovery/manager.js";
import { getRecoveryLogPath, scanAllSessions } from "../recovery/scanner.js";
import {
@ -13,7 +13,7 @@ import {
type RecoveryAssessment,
type RecoveryContext,
} from "../recovery/types.js";
import type { OrchestratorConfig, PluginRegistry } from "../types.js";
import type { OrchestratorConfig, PluginRegistry, Runtime, Workspace } from "../types.js";
function makeConfig(rootDir: string): OrchestratorConfig {
return {
@ -242,6 +242,118 @@ describe("escalateSession", () => {
});
});
describe("cleanupSession", () => {
let rootDir: string;
afterEach(() => {
if (rootDir) {
rmSync(rootDir, { recursive: true, force: true });
}
});
it("continues cleanup and calls deleteMetadata even when workspace.destroy throws", async () => {
rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`);
mkdirSync(rootDir, { recursive: true });
mkdirSync(join(rootDir, "project"), { recursive: true });
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
const config = makeConfig(rootDir);
const workspacePath = join(rootDir, "worktree");
const mockWorkspace: Workspace = {
name: "worktree",
create: vi.fn(),
destroy: vi.fn().mockRejectedValue(new Error("Workspace destroy failed")),
list: vi.fn(),
exists: vi.fn(),
};
const registry: PluginRegistry = {
register: vi.fn(),
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "workspace") return mockWorkspace;
return null;
}),
list: vi.fn().mockReturnValue([]),
loadBuiltins: vi.fn().mockResolvedValue(undefined),
loadFromConfig: vi.fn().mockResolvedValue(undefined),
};
const assessment = makeAssessment({
action: "cleanup",
classification: "dead",
runtimeAlive: false,
workspaceExists: true,
workspacePath,
rawMetadata: {
...makeAssessment().rawMetadata,
worktree: workspacePath,
},
});
const context = makeContext(rootDir);
const result = await cleanupSession(assessment, config, registry, context);
const sessionsDir = getSessionsDir(config.configPath, config.projects.app.path);
expect(mockWorkspace.destroy).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(existsSync(join(sessionsDir, "app-1"))).toBe(false);
});
it("continues cleanup and calls workspace.destroy and deleteMetadata even when runtime.destroy throws", async () => {
rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`);
mkdirSync(rootDir, { recursive: true });
mkdirSync(join(rootDir, "project"), { recursive: true });
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
const config = makeConfig(rootDir);
const workspacePath = join(rootDir, "worktree");
const mockRuntime: Runtime = {
name: "tmux",
create: vi.fn(),
destroy: vi.fn().mockRejectedValue(new Error("Runtime destroy failed")),
sendMessage: vi.fn(),
getOutput: vi.fn(),
isAlive: vi.fn(),
};
const mockWorkspace: Workspace = {
name: "worktree",
create: vi.fn(),
destroy: vi.fn().mockResolvedValue(undefined),
list: vi.fn(),
exists: vi.fn(),
};
const registry: PluginRegistry = {
register: vi.fn(),
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "workspace") return mockWorkspace;
return null;
}),
list: vi.fn().mockReturnValue([]),
loadBuiltins: vi.fn().mockResolvedValue(undefined),
loadFromConfig: vi.fn().mockResolvedValue(undefined),
};
const assessment = makeAssessment({
action: "cleanup",
classification: "partial",
runtimeAlive: true,
workspaceExists: true,
workspacePath,
rawMetadata: {
...makeAssessment().rawMetadata,
worktree: workspacePath,
},
});
const context = makeContext(rootDir);
const result = await cleanupSession(assessment, config, registry, context);
const sessionsDir = getSessionsDir(config.configPath, config.projects.app.path);
expect(mockRuntime.destroy).toHaveBeenCalled();
expect(mockWorkspace.destroy).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(existsSync(join(sessionsDir, "app-1"))).toBe(false);
});
});
describe("recovery manager and scanner", () => {
let rootDir: string;

View File

@ -121,4 +121,94 @@ describe("recovery validator", () => {
expect(mockOrchestratorAgent.isProcessRunning).toHaveBeenCalled();
expect(mockWorkerAgent.isProcessRunning).not.toHaveBeenCalled();
});
it("sets runtimeAlive to false when runtime.isAlive throws an error", async () => {
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
mkdirSync(rootDir, { recursive: true });
const projectPath = join(rootDir, "project");
mkdirSync(projectPath, { recursive: true });
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
const mockRuntime: Runtime = {
name: "tmux",
create: vi.fn(),
destroy: vi.fn(),
sendMessage: vi.fn(),
getOutput: vi.fn(),
isAlive: vi.fn().mockRejectedValue(new Error("Runtime check failed")),
};
const mockWorkspace: Workspace = {
name: "worktree",
create: vi.fn(),
destroy: vi.fn(),
list: vi.fn(),
exists: vi.fn().mockResolvedValue(true),
};
const mockAgent: Agent = {
name: "mock-agent",
processName: "mock-agent",
getLaunchCommand: vi.fn(),
getEnvironment: vi.fn(),
detectActivity: vi.fn(),
getActivityState: vi.fn(),
isProcessRunning: vi.fn().mockResolvedValue(false),
getSessionInfo: vi.fn(),
};
const registry: PluginRegistry = {
register: vi.fn(),
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "workspace") return mockWorkspace;
if (slot === "agent") return mockAgent;
return null;
}),
list: vi.fn().mockReturnValue([]),
loadBuiltins: vi.fn().mockResolvedValue(undefined),
loadFromConfig: vi.fn().mockResolvedValue(undefined),
};
const config: OrchestratorConfig = {
configPath: join(rootDir, "agent-orchestrator.yaml"),
port: 3000,
readyThresholdMs: 300_000,
defaults: {
runtime: "tmux",
agent: "mock-agent",
workspace: "worktree",
notifiers: ["desktop"],
},
projects: {
app: {
name: "app",
repo: "org/repo",
path: projectPath,
defaultBranch: "main",
sessionPrefix: "app",
},
},
notifiers: {},
notificationRouting: {
urgent: ["desktop"],
action: ["desktop"],
warning: [],
info: [],
},
reactions: {},
};
const scanned: ScannedSession = {
sessionId: "app-1",
projectId: "app",
project: config.projects.app,
sessionsDir: getSessionsDir(config.configPath, projectPath),
rawMetadata: {
worktree: projectPath,
status: "working",
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
},
};
const assessment = await validateSession(scanned, config, registry);
expect(mockRuntime.isAlive).toHaveBeenCalled();
expect(assessment.runtimeAlive).toBe(false);
});
});