Files
AgentCompany/tests/integration/process-recovery.spec.ts
T
ZacharyZhang-NY f041336721 refactor: nuke dead exports, dead files, dead DB column, dead zero-value wrappers
Full-codebase ts-prune + manual audit sweep. Every change below is a pure
deletion (or a 2-line re-wire where a dead function was supposed to be
alive). No scope additions.

Dead wrappers / phantom features
  - shouldAutoApproveApproval: zero-value wrapper over shouldAutoApprove.
    Four call sites now import shouldAutoApprove from execution-policy
    directly.
  - evaluateRotation(session, connectorId, thresholdsOverride): the
    connectorId arg was never read (underscored), and thresholdsOverride
    was only exercised by the test suite — no production caller. Signature
    collapses to evaluateRotation(session). DEFAULT_THRESHOLDS /
    RotationThresholds exports and their tests go with it.
  - installLogRedactor: defensive console wrapper that was never wired
    from worker-entry. buildRedactor handles the known threat (env-value
    leaks); the pattern-based redactTokens stays for tests and future
    composition.

Dead files
  - resume-interrupted-tasks.ts: helper function never imported.
  - service-graph-orchestration-deps.ts: three factory closures
    superseded by the inline closures in create-orchestration-service.

Dead exports
  - listConnectorsByAuthKind (shared/connector-catalog)
  - getLocalePromptInstruction + whole localization.ts file
  - ImportantDocType
  - main-side ConnectorAuthKind (duplicated the shared one; not referenced)
  - CheckpointEngineService, RunHousekeepingService types
  - WorkspaceResolution type
  - cmdOrCtrlSymbol
  - AgentEventPush, CredentialPayload type aliases
  - parseApprovalPayloadJson, parseEnvValue, ProviderCard
  - getSOP — buildSOPSection reads the SOPS map directly; the individual
    getter had no callers. Deduped to a flat array.
  - renderWakeReason in wake-reasons/en.ts: byte-for-byte duplicate of the
    one in wake-reasons/index.ts.

Forgotten wiring (not cleanup, but adjacent)
  - registerAgentStandupRoute: the GET /api/agents/me/standup endpoint is
    advertised in every system prompt and wake-reason doc, but the route
    handler was never registered in api-server/index.ts. Agents have been
    hitting 404 since Pi Mono. Registered.

Dead DB column
  - v44 migration drops runs.context_snapshot_json. Column was added by
    v16-v25, never written or read, and v43's deliberate preservation
    was an interim measure. Same idempotent table-rebuild pattern as
    v41/v43.

Test tree
  - Phantom-feature threshold override tests removed (3). The rest of
    the rotation suite re-expressed against the real maxRuns=20 floor.

495/495 unit tests pass. typecheck + build clean.
2026-04-18 23:37:37 -04:00

585 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,
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({
companyId: s.companyId,
agentId: s.agentId,
adapterType: "gemini-cli",
});
// Drive the session past the maxRuns=20 threshold so rotation fires.
for (let i = 0; i < 20; i++) {
db.incrementSessionUsage(sessionId, 1000, 500, `${runId}-${i}`);
}
const sessionService = createSessionManagementService({
db,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
const session = db.getSessionById(sessionId);
expect(session).not.toBeNull();
const rotationResult = sessionService.evaluateRotation(session!);
expect(rotationResult.shouldRotate).toBe(true);
expect(rotationResult.reason).toBe("max_runs");
// Generate handoff markdown (agent-scoped, single-argument signature).
const handoff = sessionService.generateHandoff(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);
});
});