Files
AgentCompany/tests/integration/process-recovery.spec.ts
T
ZacharyZhang-NY e94939a562 test(connector-id): migrate fixtures and assertions to pi-mono ids
Replaces legacy adapter ids (codex_local / claude_local / gemini_local /
claude_compatible) across ~42 test files with pi-mono equivalents:
- codex_local → openai-codex
- claude_local → claude-pro
- gemini_local → gemini-cli
- claude_compatible → anthropic

Hand-rewrites where fixture semantics changed:
- connector-matching: TASK_TYPE_PREFERENCE ordering rebased (claude-pro
  first for code/content/management; google first for research)
- connector-model-policy: getModels(provider) from pi-ai instead of the
  deleted adapter.module.models array
- session-management + edge-case-session: adapter exemption removed;
  rotation now uniform under pi-mono
- ipc-core-admin-handlers: CSV compat model replaced with pi-ai anthropic
- connectors.spec: lookup by openai-codex id (catalog now starts with claude-pro)
- onboarding-model-pool: listProviderModels(provider) helper; label text
  changed to "ChatGPT Plus/Pro (Codex)"
- database integration: added vault insecure-fallback env + electron mock
- command-paths: uses new @main/connector-helpers normalizeCommandValue
- pi-mono-migrations: added `as string` casts for 3 negative-existence
  assertions verifying legacy ids were removed

Drops:
- pnpm-workspace.yaml packages: [] (was "packages/*"; directory empty)
- obsolete CONNECTOR_STRENGTHS import; .module.models probing

Verification:
- pnpm typecheck → 0 errors
- pnpm lint → 0 errors / 0 warnings
- pnpm build:verify → 0 errors / 0 warnings
- pnpm test:unit → 508/508 pass
- pnpm test:integration → 96/96 pass
- All 6 adapter hard gates pass (NOT FOUND / 0 matches / empty)
- pi-ai / pi-agent-core / pi-coding-agent all resolved @0.67.6
2026-04-17 02:36:53 -04:00

594 lines
17 KiB
TypeScript

/**
* Integration tests for process loss detection and recovery.
*
* Tests worker crash detection (EXEC-07), lock release, agent reset,
* context preservation via session handoff (D-02), and database consistency.
*
* Uses a real AppDatabase with temporary directories (no mocks except electron safeStorage).
*/
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp, rm } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// Mock electron safeStorage — same pattern as atomic-checkout.test.ts
import { vi } from "vitest";
vi.mock("electron", () => ({
safeStorage: {
isEncryptionAvailable: () => false,
getSelectedStorageBackend: () => "basic_text",
encryptString: (value: string) => Buffer.from(value, "utf8"),
decryptString: (value: Buffer) => value.toString("utf8"),
},
}));
import { AppDatabase } from "../../src/main/database";
import { createSessionManagementService } from "../../src/main/session-management-service";
import type { ConnectorId } from "../../src/shared/types";
type RawDb = {
db: {
prepare: (sql: string) => {
run: (...args: unknown[]) => void;
get: (...args: unknown[]) => unknown;
};
};
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface ScaffoldResult {
companyId: string;
workspaceId: string;
agentId: string;
goalId: string;
projectId: string;
connectorId: ConnectorId;
}
/** Creates a full entity scaffold: company, workspace, connector, agent, goal, project. */
function scaffold(db: AppDatabase): ScaffoldResult {
const connectorId: ConnectorId = "gemini-cli";
// Connector
db.saveConnector({
id: connectorId,
command: "gemini",
model: "gemini-2.5-pro",
envBindingText: "",
notes: "Non-exempt connector for session rotation tests",
});
// Company
const companyId = db.saveCompany({
name: "Recovery Test Corp",
description: "Process recovery integration tests",
status: "active",
});
// Workspace
const workspaceId = db.saveWorkspace({
companyId,
name: "Primary Workspace",
localPath: "/tmp/recovery-test",
repoUrl: "https://github.com/example/recovery-test",
repoRef: "main",
isPrimary: true,
});
// Agent
const agentId = db.saveAgent({
companyId,
name: "RecoveryAgent",
role: "IC",
title: "Engineer",
status: "idle",
reportsTo: null,
connectorId,
workspaceId,
model: null,
capabilities: "Full-stack development, testing, debugging",
budgetMonthlyUsd: 100,
});
// Goal
const goalId = db.saveGoal({
companyId,
title: "Build login page",
description: "Implement login page with authentication",
ownerAgentId: agentId,
status: "active",
});
// Project
const projectId = db.saveProject({
companyId,
goalId,
name: "Auth Module",
description: "Authentication module project",
leadAgentId: agentId,
status: "active",
targetDate: null,
});
return { companyId, workspaceId, agentId, goalId, projectId, connectorId };
}
/**
* Simulates a worker crash by calling the same DB operations that
* worker-service.ts performs on process exit: interruptStaleRuns().
*
* This mirrors worker-service.ts exit handler logic (lines 111-230).
*/
function simulateWorkerCrash(db: AppDatabase): number {
return db.interruptStaleRuns();
}
/**
* Creates a task, run, checks out the task, and marks the run as running.
* Optionally claims the workspace lock.
*/
function createAndStartRun(
db: AppDatabase,
opts: {
companyId: string;
taskId?: string;
agentId: string;
workspaceId: string;
connectorId: ConnectorId;
taskTitle?: string;
taskDescription?: string;
claimWorkspace?: boolean;
},
): { runId: string; taskId: string } {
const taskId =
opts.taskId ??
db.saveTask({
companyId: opts.companyId,
title: opts.taskTitle ?? "Test Task",
description:
opts.taskDescription ??
"A test task for recovery testing with sufficient length for validation purposes.",
assigneeAgentId: opts.agentId,
workspaceId: opts.workspaceId,
priority: "medium",
status: "todo",
});
const { id: runId } = db.createRun({
companyId: opts.companyId,
taskId,
agentId: opts.agentId,
workspaceId: opts.workspaceId,
connectorId: opts.connectorId,
});
db.checkoutTask(taskId, runId);
db.markRunStatus(runId, "running", "Starting execution");
if (opts.claimWorkspace !== false) {
db.claimWorkspaceLock(opts.workspaceId, runId);
}
// Set agent status to "running" to simulate active execution
(db as unknown as RawDb).db
.prepare("UPDATE agents SET status = 'running' WHERE id = ?")
.run(opts.agentId);
return { runId, taskId };
}
/**
* Verifies database consistency after crash recovery:
* - No runs in "running" status
* - No agents in "running" status
* - No orphaned checkout_run_ids pointing to non-existent runs
*/
function verifyDbConsistency(db: AppDatabase): {
runningRuns: number;
runningAgents: number;
orphanedCheckouts: number;
} {
const runningRuns = (
(db as unknown as RawDb).db
.prepare("SELECT count(*) as cnt FROM runs WHERE status = 'running'")
.get() as { cnt: number }
).cnt;
const runningAgents = (
(db as unknown as RawDb).db
.prepare("SELECT count(*) as cnt FROM agents WHERE status = 'running'")
.get() as { cnt: number }
).cnt;
const orphanedCheckouts = (
(db as unknown as RawDb).db
.prepare(
`SELECT count(*) as cnt FROM tasks t
LEFT JOIN runs r ON t.checkout_run_id = r.id
WHERE t.checkout_run_id IS NOT NULL AND r.id IS NULL`,
)
.get() as { cnt: number }
).cnt;
return { runningRuns, runningAgents, orphanedCheckouts };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
let tempDir: string;
let db: AppDatabase;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-recovery-"));
db = new AppDatabase(tempDir);
db.init();
});
afterEach(async () => {
db?.close();
await rm(tempDir, { recursive: true, force: true });
});
// =========================================================================
// Process Recovery -- Worker Crash Detection
// =========================================================================
describe("Process Recovery -- Worker Crash Detection", () => {
it("marks all running runs as interrupted on worker crash", () => {
const s = scaffold(db);
// Create a second agent for a second run
const agent2Id = db.saveAgent({
companyId: s.companyId,
name: "Agent2",
role: "IC",
title: "Engineer 2",
status: "idle",
reportsTo: null,
connectorId: s.connectorId,
workspaceId: s.workspaceId,
model: null,
capabilities: "Backend development",
budgetMonthlyUsd: 100,
});
const run1 = createAndStartRun(db, {
companyId: s.companyId,
agentId: s.agentId,
workspaceId: s.workspaceId,
connectorId: s.connectorId,
taskTitle: "Task 1",
claimWorkspace: false,
});
const run2 = createAndStartRun(db, {
companyId: s.companyId,
agentId: agent2Id,
workspaceId: s.workspaceId,
connectorId: s.connectorId,
taskTitle: "Task 2",
claimWorkspace: false,
});
const interruptedCount = simulateWorkerCrash(db);
expect(interruptedCount).toBe(2);
expect(db.getRun(run1.runId).status).toBe("interrupted");
expect(db.getRun(run2.runId).status).toBe("interrupted");
});
it("releases task locks after crash", () => {
const s = scaffold(db);
const { runId, taskId } = createAndStartRun(db, {
companyId: s.companyId,
agentId: s.agentId,
workspaceId: s.workspaceId,
connectorId: s.connectorId,
});
// Verify lock is held before crash
const taskBefore = (db as unknown as RawDb).db
.prepare(
"SELECT checkout_run_id FROM tasks WHERE id = ?",
)
.get(taskId) as { checkout_run_id: string | null };
expect(taskBefore.checkout_run_id).toBe(runId);
simulateWorkerCrash(db);
// interruptStaleRuns clears active_run_id but does not directly clear checkout_run_id.
// The checkout_run_id is cleared by finishRun or releaseTaskLock.
// However, after crash, the task is set to "blocked" and a subsequent
// stale lock recovery pass handles the checkout_run_id.
// For this test, verify the task status is blocked (the recovery mechanism).
const task = db.getTask(taskId);
expect(task.status).toBe("blocked");
});
it("resets agents from running to idle after crash", () => {
const s = scaffold(db);
createAndStartRun(db, {
companyId: s.companyId,
agentId: s.agentId,
workspaceId: s.workspaceId,
connectorId: s.connectorId,
});
// Verify agent is running before crash
expect(db.getAgent(s.agentId).status).toBe("running");
simulateWorkerCrash(db);
expect(db.getAgent(s.agentId).status).toBe("idle");
});
it("releases workspace locks after crash", () => {
const s = scaffold(db);
createAndStartRun(db, {
companyId: s.companyId,
agentId: s.agentId,
workspaceId: s.workspaceId,
connectorId: s.connectorId,
claimWorkspace: true,
});
// Verify workspace is locked before crash
const wsBefore = (db as unknown as RawDb).db
.prepare(
"SELECT lock_holder_run_id FROM workspaces WHERE id = ?",
)
.get(s.workspaceId) as { lock_holder_run_id: string | null };
expect(wsBefore.lock_holder_run_id).not.toBeNull();
simulateWorkerCrash(db);
// Verify workspace lock is released
const wsAfter = (db as unknown as RawDb).db
.prepare(
"SELECT lock_holder_run_id FROM workspaces WHERE id = ?",
)
.get(s.workspaceId) as { lock_holder_run_id: string | null };
expect(wsAfter.lock_holder_run_id).toBeNull();
// Verify we can re-claim the workspace (lock is truly released)
const agent2Id = db.saveAgent({
companyId: s.companyId,
name: "Agent2",
role: "IC",
title: "Recovery Agent",
status: "idle",
reportsTo: null,
connectorId: s.connectorId,
workspaceId: s.workspaceId,
model: null,
capabilities: "Testing",
budgetMonthlyUsd: 100,
});
const taskId2 = db.saveTask({
companyId: s.companyId,
title: "Recovery Task",
description: "A recovery task after crash to verify workspace lock is released properly.",
assigneeAgentId: agent2Id,
workspaceId: s.workspaceId,
priority: "medium",
status: "todo",
});
const { id: newRunId } = db.createRun({
companyId: s.companyId,
taskId: taskId2,
agentId: agent2Id,
workspaceId: s.workspaceId,
connectorId: s.connectorId,
});
expect(db.claimWorkspaceLock(s.workspaceId, newRunId)).toBe(true);
});
it("tasks set to blocked after crash", () => {
const s = scaffold(db);
const { taskId } = createAndStartRun(db, {
companyId: s.companyId,
agentId: s.agentId,
workspaceId: s.workspaceId,
connectorId: s.connectorId,
});
// Verify task is in_progress before crash
expect(db.getTask(taskId).status).toBe("in_progress");
simulateWorkerCrash(db);
// Task should be blocked (prevents heartbeat re-queuing)
expect(db.getTask(taskId).status).toBe("blocked");
});
});
// =========================================================================
// Process Recovery -- Context Preservation (D-02)
// =========================================================================
describe("Process Recovery -- Context Preservation (D-02)", () => {
it("task retains agent assignment after crash", () => {
const s = scaffold(db);
const { taskId } = createAndStartRun(db, {
companyId: s.companyId,
agentId: s.agentId,
workspaceId: s.workspaceId,
connectorId: s.connectorId,
});
// Verify assignment before crash
expect(db.getTask(taskId).assigneeAgentId).toBe(s.agentId);
simulateWorkerCrash(db);
// Agent assignment must be preserved for retry context
expect(db.getTask(taskId).assigneeAgentId).toBe(s.agentId);
});
it("session handoff markdown generated on crash", () => {
const s = scaffold(db);
const { taskId, runId } = createAndStartRun(db, {
companyId: s.companyId,
agentId: s.agentId,
workspaceId: s.workspaceId,
connectorId: s.connectorId,
});
// Create a task session (simulating what happens during normal execution)
const sessionId = db.createNewSession({
taskId,
companyId: s.companyId,
agentId: s.agentId,
adapterType: "gemini-cli",
sessionParamsJson: null,
sessionDisplayId: null,
});
// Add token usage to simulate real work
db.incrementSessionUsage(sessionId, 5000, 2000, runId);
// Create session management service to test rotation
const sessionService = createSessionManagementService({
db,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
// Get the session and evaluate rotation with maxRuns:1 override
// (so it triggers rotation since we did 1 run)
const session = db.getActiveTaskSession(taskId);
expect(session).not.toBeNull();
const rotationResult = sessionService.evaluateRotation(
session!,
"gemini-cli",
{ maxRuns: 1 },
);
expect(rotationResult.shouldRotate).toBe(true);
expect(rotationResult.reason).toBe("max_runs");
// Generate handoff markdown
const handoff = sessionService.generateHandoff(taskId, session!);
expect(handoff).toContain("# Session Handoff");
expect(handoff).toContain("Session stats:");
});
it("database consistency verified after crash", () => {
const s = scaffold(db);
createAndStartRun(db, {
companyId: s.companyId,
agentId: s.agentId,
workspaceId: s.workspaceId,
connectorId: s.connectorId,
});
simulateWorkerCrash(db);
const consistency = verifyDbConsistency(db);
expect(consistency.runningRuns).toBe(0);
expect(consistency.runningAgents).toBe(0);
expect(consistency.orphanedCheckouts).toBe(0);
});
});
// =========================================================================
// Process Recovery -- Multiple Concurrent Runs
// =========================================================================
describe("Process Recovery -- Multiple Concurrent Runs", () => {
it("all concurrent runs get interrupted", () => {
const s = scaffold(db);
// Create 3 agents for 3 concurrent runs
const agents = [s.agentId];
for (let i = 2; i <= 3; i++) {
agents.push(
db.saveAgent({
companyId: s.companyId,
name: `Agent${i}`,
role: "IC",
title: `Engineer ${i}`,
status: "idle",
reportsTo: null,
connectorId: s.connectorId,
workspaceId: s.workspaceId,
model: null,
capabilities: "Development tasks",
budgetMonthlyUsd: 100,
}),
);
}
const runs = agents.map((agentId, i) =>
createAndStartRun(db, {
companyId: s.companyId,
agentId,
workspaceId: s.workspaceId,
connectorId: s.connectorId,
taskTitle: `Concurrent Task ${i + 1}`,
claimWorkspace: false, // only one can hold workspace lock
}),
);
const interruptedCount = simulateWorkerCrash(db);
expect(interruptedCount).toBe(3);
for (const run of runs) {
expect(db.getRun(run.runId).status).toBe("interrupted");
}
});
it("all task locks released for concurrent runs", () => {
const s = scaffold(db);
const agents = [s.agentId];
for (let i = 2; i <= 3; i++) {
agents.push(
db.saveAgent({
companyId: s.companyId,
name: `Agent${i}`,
role: "IC",
title: `Engineer ${i}`,
status: "idle",
reportsTo: null,
connectorId: s.connectorId,
workspaceId: s.workspaceId,
model: null,
capabilities: "Development tasks",
budgetMonthlyUsd: 100,
}),
);
}
const runs = agents.map((agentId, i) =>
createAndStartRun(db, {
companyId: s.companyId,
agentId,
workspaceId: s.workspaceId,
connectorId: s.connectorId,
taskTitle: `Concurrent Task ${i + 1}`,
claimWorkspace: false,
}),
);
simulateWorkerCrash(db);
// All tasks should be blocked (crash recovery sets them to blocked)
for (const run of runs) {
const task = db.getTask(run.taskId);
expect(task.status).toBe("blocked");
}
// Verify DB consistency across all entities
const consistency = verifyDbConsistency(db);
expect(consistency.runningRuns).toBe(0);
expect(consistency.runningAgents).toBe(0);
expect(consistency.orphanedCheckouts).toBe(0);
});
});