import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { readFileSync, existsSync } from 'node:fs'; import { rm, mkdir } from 'node:fs/promises'; import { resolve } from 'node:path'; import postgres from 'postgres'; import { ensureSessionWorktree, closeChatBackendState, rebaselineWorktreeAfterApply, } from '../worktrees.js'; import { reapOrphanWorktrees } from '../orphan-worktree-reaper.js'; import { hostExec } from '../host-exec.js'; /** * v2.6 Phase 3 (3.6) — reconnect-after-restart integration test. * * Proves the DB-truth side of crash/restart recovery: a BooCoder restart wipes the * in-memory pool, but the persistent `worktrees` + `agent_sessions` rows survive, * so the "next turn" re-resolves the SAME worktree (reattach, no new dir) and the * agent-session row is still there to resume from. Also exercises the chat-close * hook (3.3), the apply re-baseline (3.5), and the orphan reaper (3.4) end-to-end * against a real git repo + postgres. * * Requires DATABASE_URL (DB-opt-in; skips cleanly otherwise) AND git on PATH. Runs: * DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/coder test */ describe.runIf(!!process.env.DATABASE_URL)('reconnect after restart (Phase 3)', () => { let sql: ReturnType; const stamp = Date.now(); const projectDir = `/tmp/boocode-reconnect-proj-${stamp}`; let projectId: string; let sessionId: string; let chatId: string; beforeAll(async () => { sql = postgres(process.env.DATABASE_URL!, { max: 3 }); // Both schemas land in the one boochat DB: server owns sessions/chats/projects, // coder owns worktrees/agent_sessions (FK targets must pre-exist → server first). const serverSchema = resolve(__dirname, '../../../../server/src/schema.sql'); const coderSchema = resolve(__dirname, '../../schema.sql'); await sql.unsafe(readFileSync(serverSchema, 'utf8')); await sql.unsafe(readFileSync(coderSchema, 'utf8')); // A real git repo with one commit so worktree add / diff / rev-parse work. await mkdir(projectDir, { recursive: true }); await hostExec( `cd ${projectDir} && git init -q && git config user.email t@t && git config user.name t ` + `&& echo hello > README.md && git add -A && git commit -qm init`, { timeoutMs: 20_000 }, ); const [project] = await sql<{ id: string }[]>` INSERT INTO projects (name, path, status) VALUES ('reconnect-test', ${projectDir}, 'open') RETURNING id `; projectId = project!.id; const [session] = await sql<{ id: string }[]>` INSERT INTO sessions (project_id, name, model, status) VALUES (${projectId}, 'recon', 'm', 'open') RETURNING id `; sessionId = session!.id; const [chat] = await sql<{ id: string }[]>` INSERT INTO chats (session_id, name, status) VALUES (${sessionId}, 'tab', 'open') RETURNING id `; chatId = chat!.id; }); afterAll(async () => { if (sql) { // Best-effort worktree cleanup before dropping rows. const rows = await sql<{ path: string }[]>`SELECT path FROM worktrees WHERE session_id = ${sessionId}`.catch(() => []); for (const r of rows) { await hostExec(`git -C ${projectDir} worktree remove ${r.path} --force`, { timeoutMs: 10_000 }).catch(() => {}); } await sql`DELETE FROM agent_sessions WHERE chat_id = ${chatId}`.catch(() => {}); await sql`DELETE FROM worktrees WHERE session_id = ${sessionId}`.catch(() => {}); await sql`DELETE FROM chats WHERE id = ${chatId}`.catch(() => {}); await sql`DELETE FROM sessions WHERE id = ${sessionId}`.catch(() => {}); await sql`DELETE FROM projects WHERE id = ${projectId}`.catch(() => {}); await sql.end({ timeout: 5 }); } await rm(projectDir, { recursive: true, force: true }); }); it('reattaches the SAME worktree across a simulated restart (no new dir)', async () => { // "Turn 1" — first ensureSessionWorktree creates the worktree + row. const first = await ensureSessionWorktree(sql, projectDir, sessionId); expect(existsSync(first.worktreePath)).toBe(true); expect(first.baseCommit).toBeTruthy(); // Simulate an agent_sessions row written by turn 1 (opencode). await sql` INSERT INTO agent_sessions (chat_id, session_id, worktree_id, agent, backend, agent_session_id, status, last_active_at) VALUES (${chatId}, ${sessionId}, ${first.worktreeId}, 'opencode', 'opencode_server', 'oc-sess-1', 'active', clock_timestamp()) ON CONFLICT (chat_id, agent) DO NOTHING `; // "Restart" = brand-new resolution with NO in-memory state. ensureSessionWorktree // must return the EXISTING row (same id + path), proving reattach not re-create. const second = await ensureSessionWorktree(sql, projectDir, sessionId); expect(second.worktreeId).toBe(first.worktreeId); expect(second.worktreePath).toBe(first.worktreePath); expect(second.baseCommit).toBe(first.baseCommit); // The agent_sessions row survived the "restart" with its resume handle intact. const [row] = await sql<{ agent_session_id: string; status: string }[]>` SELECT agent_session_id, status FROM agent_sessions WHERE chat_id = ${chatId} AND agent = 'opencode' `; expect(row!.agent_session_id).toBe('oc-sess-1'); }); it('re-baselines the worktree diff after apply (3.5)', async () => { const wt = await ensureSessionWorktree(sql, projectDir, sessionId); const baseBefore = wt.baseCommit; // Make a change in the worktree (as an external agent would). await hostExec(`cd ${wt.worktreePath} && echo change >> README.md`, { timeoutMs: 10_000 }); const r = await rebaselineWorktreeAfterApply(sql, sessionId); expect(r.rebaselined).toBe(true); expect(r.newBaseCommit).toBeTruthy(); expect(r.newBaseCommit).not.toBe(baseBefore); const [row] = await sql<{ base_commit: string }[]>` SELECT base_commit FROM worktrees WHERE session_id = ${sessionId} AND status = 'active' `; expect(row!.base_commit).toBe(r.newBaseCommit); // Idempotent: a second re-baseline with no new edits is a no-op. const r2 = await rebaselineWorktreeAfterApply(sql, sessionId); expect(r2.rebaselined).toBe(false); }); it('chat-close hook closes agent rows + removes the worktree on the last chat (3.3)', async () => { // Sanity: an active worktree + agent row exist from the prior tests. const beforeWt = await sql<{ id: string }[]>`SELECT id FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'`; expect(beforeWt.length).toBe(1); const result = await closeChatBackendState(sql, chatId); expect(result.agentRowsClosed).toBeGreaterThanOrEqual(1); // chatId is the session's only chat → worktree removed (it was clean after the // re-baseline commit), not at-risk. expect(result.worktreeAtRisk).toBe(false); expect(result.worktreeRemoved).toBe(true); const [agentRow] = await sql<{ status: string }[]>` SELECT status FROM agent_sessions WHERE chat_id = ${chatId} AND agent = 'opencode' `; expect(agentRow!.status).toBe('closed'); const activeWt = await sql<{ id: string }[]>`SELECT id FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'`; expect(activeWt.length).toBe(0); // archived, no longer active }); it('orphan reaper leaves a live worktree alone and reaps a row-less dir (3.4)', async () => { // Recreate a live worktree for this session (the close test archived the old one). const live = await ensureSessionWorktree(sql, projectDir, sessionId); expect(existsSync(live.worktreePath)).toBe(true); // A live worktree (active row) with grace 0 must NOT be reaped. const r1 = await reapOrphanWorktrees(sql, console as never, 0, Date.now()); expect(r1.reaped).not.toContain(live.worktreePath); // Now archive its row (simulating a leaked dir) and reap again — it becomes an // orphan and is reclaimed (it's clean → not at-risk). await sql`UPDATE worktrees SET status = 'archived' WHERE id = ${live.worktreeId}`; const r2 = await reapOrphanWorktrees(sql, console as never, 0, Date.now()); expect(r2.reaped).toContain(live.worktreePath); expect(existsSync(live.worktreePath)).toBe(false); }); });