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:
@@ -119,16 +119,18 @@ export async function cleanupWorktree(
|
||||
// ─── v2.6: session-keyed persistent worktree ────────────────────────────────
|
||||
|
||||
export interface SessionWorktree {
|
||||
/** P1.5-b: the `worktrees.id` — stored on agent_sessions informationally. */
|
||||
worktreeId: string;
|
||||
worktreePath: string;
|
||||
baseCommit: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.6: create-or-reuse ONE worktree per BooCode session (shared across all
|
||||
* agents/turns in the session), recorded in `session_worktrees`. Unlike the
|
||||
* per-task `createWorktree`, this persists — it is NOT torn down per turn
|
||||
* (cleanup is Phase 3). Captures the project's current HEAD as `base_commit`
|
||||
* so the accumulating diff has a stable baseline across turns.
|
||||
* v2.6 / P1.5-b: create-or-reuse ONE worktree per BooCode session (shared across
|
||||
* all tabs/agents in the session), recorded in `worktrees` (was the superseded
|
||||
* `session_worktrees`). Persists — NOT torn down per turn (cleanup is Phase 3) —
|
||||
* and now survives session delete (`worktrees.session_id` is ON DELETE SET NULL).
|
||||
* Captures the project's current HEAD as `base_commit` for a stable diff baseline.
|
||||
*
|
||||
* Distinct path namespace (`session-<id>` branch, `/sess-<id>` dir) so it never
|
||||
* collides with the per-task worktrees that arena/new_task/MCP still use.
|
||||
@@ -139,11 +141,13 @@ export async function ensureSessionWorktree(
|
||||
sessionId: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<SessionWorktree> {
|
||||
const [existing] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
|
||||
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
|
||||
const [existing] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
|
||||
SELECT id, path, base_commit FROM worktrees
|
||||
WHERE session_id = ${sessionId} AND status = 'active'
|
||||
LIMIT 1
|
||||
`;
|
||||
if (existing) {
|
||||
return { worktreePath: existing.worktree_path, baseCommit: existing.base_commit };
|
||||
return { worktreeId: existing.id, worktreePath: existing.path, baseCommit: existing.base_commit };
|
||||
}
|
||||
|
||||
const worktreePath = `${WORKTREE_BASE}/sess-${sessionId}`;
|
||||
@@ -167,17 +171,28 @@ export async function ensureSessionWorktree(
|
||||
throw new Error(`Failed to create session worktree: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
|
||||
// Persist. ON CONFLICT keeps the first writer's row if two turns race the create.
|
||||
await sql`
|
||||
INSERT INTO session_worktrees (session_id, worktree_path, base_commit)
|
||||
VALUES (${sessionId}, ${worktreePath}, ${baseCommit})
|
||||
ON CONFLICT (session_id) DO NOTHING
|
||||
// Insert-or-get: WHERE NOT EXISTS keeps the first writer's row if two turns race
|
||||
// the create (the partial unique on active path also backstops it).
|
||||
const [inserted] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
|
||||
INSERT INTO worktrees (session_id, path, branch, base_commit, status)
|
||||
SELECT ${sessionId}, ${worktreePath}, ${branchName}, ${baseCommit}, 'active'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'
|
||||
)
|
||||
RETURNING id, path, base_commit
|
||||
`;
|
||||
const [row] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
|
||||
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
|
||||
if (inserted) {
|
||||
return { worktreeId: inserted.id, worktreePath: inserted.path, baseCommit: inserted.base_commit };
|
||||
}
|
||||
// Lost the race — another turn inserted first; read its row.
|
||||
const [row] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
|
||||
SELECT id, path, base_commit FROM worktrees
|
||||
WHERE session_id = ${sessionId} AND status = 'active'
|
||||
LIMIT 1
|
||||
`;
|
||||
return {
|
||||
worktreePath: row?.worktree_path ?? worktreePath,
|
||||
worktreeId: row!.id,
|
||||
worktreePath: row?.path ?? worktreePath,
|
||||
baseCommit: row?.base_commit ?? baseCommit,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user