/** * v2.6 Phase 3 (3.3) — chat/session close-or-archive cleanup hook (coder side). * * Chat/session close + archive + delete all live in apps/server (Docker), which * cannot see the host worktree dirs (/tmp/booworktrees), run git on them, or reach * the warm agent processes the dispatcher pooled in THIS (host systemd) process. So * — exactly like the `worktree-risk` guard — the server signals the coder when a * chat/session closes, and the coder does the real teardown: * 1. dispose the chat's warm-ACP backends (`agentPool.closeChat`) — kills the * goose/qwen child processes for that chat, * 2. close the chat's opencode session on the shared server (`closeSession`), * 3. mark every `agent_sessions` row for the chat 'closed' + (when the session's * last open chat closes) remove the shared session worktree, preflighting * work-at-risk so uncommitted/unmerged work is never silently dropped * (`closeChatBackendState`). * * Idempotent: closing an already-closed chat is a no-op (0 rows, no backend). * * SERVER WIRING (not done here — apps/server, out of this batch's scope): the * server's `POST /api/chats/:id/archive`, `DELETE /api/chats/:id`, and the * session archive/delete routes should fire-and-forget * fetch(`${BOOCODER_URL}/api/chats/${id}/close`, { method: 'POST' }) * after publishing their WS frame (best-effort; the orphan-worktree reaper + * idle-pool eviction are the backstop if the call is missed). */ import type { FastifyInstance } from 'fastify'; import type { Sql } from '../db.js'; import { agentPool, OPENCODE_POOL_KEY } from '../services/agent-pool.js'; import { closeChatBackendState } from '../services/worktrees.js'; import type { AgentSessionHandle } from '../services/agent-backend.js'; export function registerLifecycleRoutes(app: FastifyInstance, sql: Sql): void { // POST /api/chats/:chatId/close — tear down all warm state for a chat tab. app.post<{ Params: { chatId: string }; Querystring: { force?: string } }>( '/api/chats/:chatId/close', async (req) => { const chatId = req.params.chatId; const force = req.query.force === 'true' || req.query.force === '1'; // 1. Close the chat's opencode session on the SHARED server (the server is // not chat-keyed, so agentPool.closeChat won't touch it). Resolve the // stored opencode session id and ask the backend to drop it. const ocRows = await sql<{ agent: string; agent_session_id: string | null; worktree_id: string | null; session_id: string | null }[]>` SELECT agent, agent_session_id, worktree_id, session_id FROM agent_sessions WHERE chat_id = ${chatId} AND backend = 'opencode_server' `; const ocBackend = agentPool.peek(OPENCODE_POOL_KEY, 'opencode'); if (ocBackend) { for (const row of ocRows) { if (!row.agent_session_id) continue; const handle: AgentSessionHandle = { sessionId: row.session_id ?? '', agent: row.agent, backend: 'opencode_server', chatId, worktreeId: row.worktree_id ?? '', agentSessionId: row.agent_session_id, serverPort: null, }; await ocBackend.closeSession(handle).catch((err) => { app.log.warn({ err: err instanceof Error ? err.message : String(err), chatId }, 'lifecycle: opencode closeSession threw'); }); } } // 2. Dispose any warm-ACP backends pooled under this chat (kills the // goose/qwen child + marks its agent row closed via the backend). const disposed = await agentPool.closeChat(chatId); // 3. DB + worktree truth: mark agent rows closed; remove the shared session // worktree iff this was the session's last open chat (preflight at-risk). const result = await closeChatBackendState(sql, chatId, { force }); app.log.info({ chatId, disposed, ...result }, 'lifecycle: chat closed'); return { ok: true, disposed, ...result }; }, ); // POST /api/sessions/:sessionId/close — close every open chat in a session // (session archive/delete). Loops the chat-close path so the same preflight + // teardown applies per chat; the worktree is removed on the last one. app.post<{ Params: { sessionId: string }; Querystring: { force?: string } }>( '/api/sessions/:sessionId/close', async (req) => { const sessionId = req.params.sessionId; const force = req.query.force === 'true' || req.query.force === '1'; const chats = await sql<{ id: string }[]>` SELECT id FROM chats WHERE session_id = ${sessionId} `; const results: { chatId: string; disposed: string[]; worktreeRemoved: boolean; worktreeAtRisk: boolean }[] = []; for (const c of chats) { const ocBackend = agentPool.peek(OPENCODE_POOL_KEY, 'opencode'); if (ocBackend) { const ocRows = await sql<{ agent: string; agent_session_id: string | null; worktree_id: string | null; session_id: string | null }[]>` SELECT agent, agent_session_id, worktree_id, session_id FROM agent_sessions WHERE chat_id = ${c.id} AND backend = 'opencode_server' `; for (const row of ocRows) { if (!row.agent_session_id) continue; await ocBackend.closeSession({ sessionId: row.session_id ?? '', agent: row.agent, backend: 'opencode_server', chatId: c.id, worktreeId: row.worktree_id ?? '', agentSessionId: row.agent_session_id, serverPort: null, }).catch(() => {}); } } const disposed = await agentPool.closeChat(c.id); const r = await closeChatBackendState(sql, c.id, { force }); results.push({ chatId: c.id, disposed, worktreeRemoved: r.worktreeRemoved, worktreeAtRisk: r.worktreeAtRisk }); } app.log.info({ sessionId, chats: results.length }, 'lifecycle: session closed'); return { ok: true, results }; }, ); }