feat(coder): v2.6 Phase 3 — lifecycle hardening (idle evict, crash recovery, worktree reaper)
Idle TTL eviction per (chat,agent) + LRU cap (never a busy backend); pure lifecycle-decisions.ts (TDD). Crash recovery lifts openchamber's health-monitor + busy-aware-restart + stale-grace state machine into opencode-server.ts (+ port reclaim) and warm-acp.ts; opencode crash -> fresh sessions, ACP -> re-session/new. F.1 turn-guard + U.6 usage preserved (their tests pass). Orphan worktree reaper (1h grace, superset-style dirty/unpushed preflight, Paseo soft-delete) + close hooks + diff re-baseline after apply_pending. 35 new tests + DB-opt-in reconnect test; 215 coder tests pass; tsc + build clean. Completes v2.6. Follow-ups out of scope: apps/server close-hook caller, 3.7 DiffPanel staging hint, live smokes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
122
apps/coder/src/routes/lifecycle.ts
Normal file
122
apps/coder/src/routes/lifecycle.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
queueCreate,
|
||||
} from '../services/pending_changes.js';
|
||||
import { WriteGuardError } from '../services/write_guard.js';
|
||||
import { rebaselineWorktreeAfterApply } from '../services/worktrees.js';
|
||||
|
||||
const CreateBody = z.object({
|
||||
file_path: z.string().min(1),
|
||||
@@ -117,6 +118,15 @@ export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
}
|
||||
|
||||
const results = await applyAll(sql, sessionId, projectRoot);
|
||||
|
||||
// v2.6 Phase 3 (3.5): re-baseline the session worktree's diff to the applied
|
||||
// state, so the next external-agent turn diffs against applied-not-original
|
||||
// and doesn't re-surface the just-applied changes. Best-effort: a worktree
|
||||
// session may not exist (native-only chat), and a re-baseline hiccup must not
|
||||
// fail the apply the user just requested.
|
||||
if (results.some((r) => r.success)) {
|
||||
await rebaselineWorktreeAfterApply(sql, sessionId).catch(() => {});
|
||||
}
|
||||
return { results };
|
||||
},
|
||||
);
|
||||
@@ -136,6 +146,15 @@ export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
const result = await applyOne(sql, changeId, projectRoot);
|
||||
if (!result.success) {
|
||||
reply.code(422);
|
||||
} else {
|
||||
// v2.6 Phase 3 (3.5): re-baseline the session worktree after a successful
|
||||
// apply so the next external-agent turn diffs against applied-not-original.
|
||||
// Resolve the change's session; best-effort, never fails the apply.
|
||||
const sessRows = await sql<{ session_id: string }[]>`
|
||||
SELECT session_id FROM pending_changes WHERE id = ${changeId}
|
||||
`;
|
||||
const sessionId = sessRows[0]?.session_id;
|
||||
if (sessionId) await rebaselineWorktreeAfterApply(sql, sessionId).catch(() => {});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user