diff --git a/src/main/ipc-recovery-handlers.ts b/src/main/ipc-recovery-handlers.ts new file mode 100644 index 00000000..34080a83 --- /dev/null +++ b/src/main/ipc-recovery-handlers.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { ok, fail, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; + +const recoveryRetrySchema = z.object({ + taskId: z.string(), + agentId: z.string(), + companyId: z.string(), +}); + +const recoveryTaskSchema = z.object({ + taskId: z.string(), +}); + +export function registerRecoveryHandlers({ + registerHandle, + db, + publishDomainChanged, + wakeAgentIfPossible, +}: Pick< + RegisterDomainHandlersDependencies, + | "registerHandle" + | "db" + | "publishDomainChanged" + | "wakeAgentIfPossible" +>) { + registerHandle("recovery:retry", async (_event, payload) => { + const parsed = recoveryRetrySchema.parse(payload); + db.retryInterruptedTask(parsed.taskId, parsed.agentId, parsed.companyId); + try { + wakeAgentIfPossible(parsed.agentId, parsed.companyId, "retry_interrupted"); + } catch { + // Best effort -- agent may have been deleted + } + publishDomainChanged(); + return ok(null); + }); + + registerHandle("recovery:dismiss", async (_event, payload) => { + const parsed = recoveryTaskSchema.parse(payload); + db.dismissInterruptedTask(parsed.taskId); + publishDomainChanged(); + return ok(null); + }); + + registerHandle("recovery:fail", async (_event, payload) => { + const parsed = recoveryTaskSchema.parse(payload); + db.markInterruptedTaskFailed(parsed.taskId); + publishDomainChanged(); + return ok(null); + }); +} diff --git a/src/renderer/components/dashboard/RecoveryBanner.tsx b/src/renderer/components/dashboard/RecoveryBanner.tsx new file mode 100644 index 00000000..ac4f5f86 --- /dev/null +++ b/src/renderer/components/dashboard/RecoveryBanner.tsx @@ -0,0 +1,80 @@ +import { useState, useEffect } from "react"; +import { Warning, ArrowClockwise, X, Trash } from "@phosphor-icons/react"; + +interface InterruptedRun { + runId: string; + taskId: string; + taskTitle: string; + agentId: string; + agentName: string; + connectorId: string; + interruptedAt: string; +} + +export function RecoveryBanner() { + const [runs, setRuns] = useState([]); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + const unsubscribe = window.agentCompany?.subscribe?.((event: any) => { + if (event.type === "recovery-needed" && event.interruptedRuns) { + setRuns(event.interruptedRuns); + } + }); + return () => { + unsubscribe?.(); + }; + }, []); + + if (runs.length === 0 || dismissed) return null; + + const handleRetry = async (run: InterruptedRun) => { + await window.agentCompany?.recoveryRetry(run.taskId, run.agentId, ""); + setRuns(prev => prev.filter(r => r.runId !== run.runId)); + }; + + const handleFail = async (run: InterruptedRun) => { + await window.agentCompany?.recoveryFail(run.taskId); + setRuns(prev => prev.filter(r => r.runId !== run.runId)); + }; + + const handleDismissAll = () => setDismissed(true); + + return ( +
+
+
+ + {runs.length} interrupted task(s) detected from previous session +
+ +
+
+ {runs.map(run => ( +
+
+ {run.taskTitle} + ({run.agentName}) +
+
+ + +
+
+ ))} +
+
+ ); +} diff --git a/tests/unit/crash-recovery.test.ts b/tests/unit/crash-recovery.test.ts new file mode 100644 index 00000000..a1771f72 --- /dev/null +++ b/tests/unit/crash-recovery.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * Tests for crash recovery detection and user notification. + * + * Covers: + * - interruptStaleRuns returns correct count and marks tasks as blocked + * - getInterruptedRunsForRecovery returns correct shape + * - retryInterruptedTask resets task to 'todo' and clears retry_count + * - markInterruptedTaskFailed sets task to 'cancelled' + */ + +interface MockStatement { + run: ReturnType; + get: ReturnType; + all: ReturnType; +} + +interface MockDatabase { + prepare: ReturnType; +} + +/** + * Simulate interruptStaleRuns logic as implemented in database.ts + */ +function simulateInterruptStaleRuns( + db: MockDatabase, + releaseAllWorkspaceLocks: ReturnType, + transactionFn: (fn: () => void) => void, + runningCount: number, +): number { + const ts = new Date().toISOString(); + let interruptedCount = 0; + + transactionFn(() => { + const updateRunsResult = { changes: runningCount }; + // Simulate: update runs set status = 'interrupted' + db.prepare("update-runs-interrupted").run(ts, ts); + interruptedCount = Number(updateRunsResult.changes); + + if (interruptedCount > 0) { + // Mark associated tasks as blocked + db.prepare("update-tasks-blocked").run(ts, ts); + } + + // Clear active_run_id + db.prepare("update-tasks-active-run-null").run(); + // Reset agents to idle + db.prepare("update-agents-idle").run(ts); + releaseAllWorkspaceLocks(); + }); + + return interruptedCount; +} + +/** + * Simulate getInterruptedRunsForRecovery + */ +function simulateGetInterruptedRunsForRecovery( + queryResult: Array<{ + runId: string; + taskId: string; + taskTitle: string; + agentId: string; + agentName: string; + connectorId: string; + interruptedAt: string; + }>, +): typeof queryResult { + return queryResult; +} + +/** + * Simulate retryInterruptedTask + */ +function simulateRetryInterruptedTask( + db: MockDatabase, + taskId: string, +): void { + const ts = new Date().toISOString(); + db.prepare("update-task-todo").run(ts, taskId); + db.prepare("update-task-retry-reset").run(ts, taskId); +} + +/** + * Simulate markInterruptedTaskFailed + */ +function simulateMarkInterruptedTaskFailed( + db: MockDatabase, + taskId: string, +): void { + const ts = new Date().toISOString(); + db.prepare("update-task-cancelled").run(ts, taskId); +} + +describe("Crash Recovery Detection", () => { + let db: MockDatabase; + let releaseAllWorkspaceLocks: ReturnType; + + beforeEach(() => { + db = { + prepare: vi.fn().mockReturnValue({ + run: vi.fn().mockReturnValue({ changes: 0 }), + get: vi.fn().mockReturnValue(undefined), + all: vi.fn().mockReturnValue([]), + }), + }; + releaseAllWorkspaceLocks = vi.fn(); + }); + + describe("interruptStaleRuns", () => { + it("returns 0 when no running runs exist", () => { + const transaction = (fn: () => void) => fn(); + const count = simulateInterruptStaleRuns(db, releaseAllWorkspaceLocks, transaction, 0); + + expect(count).toBe(0); + }); + + it("returns correct count when running runs exist", () => { + const transaction = (fn: () => void) => fn(); + const count = simulateInterruptStaleRuns(db, releaseAllWorkspaceLocks, transaction, 3); + + expect(count).toBe(3); + }); + + it("marks associated tasks as blocked when interrupted runs exist", () => { + const transaction = (fn: () => void) => fn(); + const count = simulateInterruptStaleRuns(db, releaseAllWorkspaceLocks, transaction, 2); + + expect(count).toBe(2); + // The blocked update should have been called (db.prepare was called with blocked query) + expect(db.prepare).toHaveBeenCalled(); + }); + + it("does NOT mark tasks as blocked when no runs were interrupted", () => { + const callLog: string[] = []; + db.prepare = vi.fn().mockImplementation((query: string) => { + callLog.push(query); + return { + run: vi.fn().mockReturnValue({ changes: 0 }), + get: vi.fn(), + all: vi.fn(), + }; + }); + + const transaction = (fn: () => void) => fn(); + const count = simulateInterruptStaleRuns(db, releaseAllWorkspaceLocks, transaction, 0); + + expect(count).toBe(0); + // The "update-tasks-blocked" query should NOT have been called + expect(callLog).not.toContain("update-tasks-blocked"); + }); + + it("releases all workspace locks", () => { + const transaction = (fn: () => void) => fn(); + simulateInterruptStaleRuns(db, releaseAllWorkspaceLocks, transaction, 1); + + expect(releaseAllWorkspaceLocks).toHaveBeenCalled(); + }); + }); + + describe("getInterruptedRunsForRecovery", () => { + it("returns run details for interrupted runs with blocked tasks", () => { + const mockResults = [ + { + runId: "run-1", + taskId: "task-1", + taskTitle: "Build feature", + agentId: "agent-1", + agentName: "Developer", + connectorId: "claude-code", + interruptedAt: "2026-03-22T07:00:00Z", + }, + { + runId: "run-2", + taskId: "task-2", + taskTitle: "Write tests", + agentId: "agent-2", + agentName: "QA Engineer", + connectorId: "codex", + interruptedAt: "2026-03-22T07:00:00Z", + }, + ]; + + const result = simulateGetInterruptedRunsForRecovery(mockResults); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual( + expect.objectContaining({ + runId: "run-1", + taskId: "task-1", + taskTitle: "Build feature", + agentName: "Developer", + interruptedAt: expect.any(String), + }), + ); + }); + + it("returns empty array when no interrupted runs with blocked tasks exist", () => { + const result = simulateGetInterruptedRunsForRecovery([]); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + }); + + describe("retryInterruptedTask", () => { + it("sets task to todo and resets retry_count", () => { + const runCalls: Array<{ query: string; args: unknown[] }> = []; + db.prepare = vi.fn().mockImplementation((query: string) => ({ + run: vi.fn((...args: unknown[]) => { + runCalls.push({ query, args }); + return { changes: 1 }; + }), + })); + + simulateRetryInterruptedTask(db, "task-42"); + + expect(runCalls).toHaveLength(2); + // First call sets status to 'todo' + expect(runCalls[0]!.args).toContain("task-42"); + // Second call resets retry count + expect(runCalls[1]!.args).toContain("task-42"); + }); + }); + + describe("markInterruptedTaskFailed", () => { + it("sets task to cancelled", () => { + const runCalls: Array<{ query: string; args: unknown[] }> = []; + db.prepare = vi.fn().mockImplementation((query: string) => ({ + run: vi.fn((...args: unknown[]) => { + runCalls.push({ query, args }); + return { changes: 1 }; + }), + })); + + simulateMarkInterruptedTaskFailed(db, "task-99"); + + expect(runCalls).toHaveLength(1); + expect(runCalls[0]!.args).toContain("task-99"); + }); + }); +});