feat(coder): re-key agent_sessions to (chat_id, agent) + worktrees table (P1.5-b)

The tab (a chat) is the context unit: two opencode tabs in one session are two independent agent contexts sharing one worktree. agent_sessions re-keys from (session_id, agent) to (chat_id, agent) — chat_id FK ON DELETE CASCADE (closing a tab ends its context); worktree_id and session_id become informational SET NULL columns. New worktrees table (one-per-session, survives session delete via session_id SET NULL) supersedes session_worktrees, which is defanged (CASCADE dropped) not yet removed. chat_id is threaded end-to-end: tasks.chat_id added, written by the coder message + skills routes from the frontend tab, read by runOpenCodeServerTask which falls back to resolve-or-create a chat for session-less creators (arena/MCP/new_task/generic) so ensureSession never gets a null key. Idempotent migration with a backfill-verify gate (0-row assertion after the test session was deleted). config_hash fingerprint logic preserved; one-worktree-per-session unchanged; runExternalAgent untouched. Column rename worktree_path -> path repointed at all five readers (server delete-guard, risk/stash endpoints, ensureSessionWorktree). Supersedes the earlier (worktree_id) draft.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 00:04:35 +00:00
parent f1a85627e4
commit cb1846c0d5
9 changed files with 170 additions and 44 deletions

View File

@@ -78,8 +78,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
chat_id: string | null;
}[]>`
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id
FROM tasks
WHERE state = 'pending'
ORDER BY created_at
@@ -110,6 +111,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
chat_id: string | null;
}): Promise<void> {
const taskId = task.id;
@@ -511,6 +513,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
chat_id: string | null;
},
installPath: string | null,
): Promise<void> {
@@ -543,10 +546,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId}
`;
// Resolve session + chat (mirrors runExternalAgent).
// Resolve session + chat. P1.5-b: the chat (tab) is the context key, so the
// chat_id MUST be non-null and stable before ensureSession. The coder message
// route + skills route stamp task.chat_id with the frontend tab's chat — use
// it directly. Session-less creators (arena, MCP, new_task, generic
// /api/tasks) leave it null; fall back to resolving/creating a real chat so
// ensureSession never receives a degenerate (null, agent) key.
let sessionId: string;
let chatId: string;
if (task.session_id) {
if (task.chat_id && task.session_id) {
sessionId = task.session_id;
chatId = task.chat_id;
} else if (task.session_id) {
sessionId = task.session_id;
const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
@@ -587,7 +598,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// Persistent, session-keyed worktree (shared across turns; NOT torn down
// per turn — Phase 3 reaps it). Captures base_commit for a stable diff.
const { worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
const { worktreeId, worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
signal: ac.signal,
});
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
@@ -680,7 +691,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const handle = await backend.ensureSession(sessionId, {
agent,
model,
chatId,
worktreePath,
worktreeId,
projectId: task.project_id,
});
const result = await backend.prompt(handle, task.input, {