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:
@@ -224,8 +224,8 @@ export function registerMessageRoutes(
|
|||||||
// External provider: create a task for the dispatcher
|
// External provider: create a task for the dispatcher
|
||||||
const projectId = sessionRows[0]!.project_id;
|
const projectId = sessionRows[0]!.project_id;
|
||||||
const [task] = await sql<{ id: string; state: string }[]>`
|
const [task] = await sql<{ id: string; state: string }[]>`
|
||||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id)
|
||||||
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
|
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}, ${chatId})
|
||||||
RETURNING id, state
|
RETURNING id, state
|
||||||
`;
|
`;
|
||||||
reply.code(202);
|
reply.code(202);
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ export function registerSkillRoutes(
|
|||||||
|
|
||||||
const taskInput = `${body}\n\n---\n\n${userText}`;
|
const taskInput = `${body}\n\n---\n\n${userText}`;
|
||||||
const [task] = await sql<{ id: string; state: string }[]>`
|
const [task] = await sql<{ id: string; state: string }[]>`
|
||||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id)
|
||||||
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
|
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}, ${chatId})
|
||||||
RETURNING id, state
|
RETURNING id, state
|
||||||
`;
|
`;
|
||||||
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): vo
|
|||||||
'/api/sessions/:sessionId/worktree-risk',
|
'/api/sessions/:sessionId/worktree-risk',
|
||||||
async (req) => {
|
async (req) => {
|
||||||
const rows = await sql<{ worktree_path: string }[]>`
|
const rows = await sql<{ worktree_path: string }[]>`
|
||||||
SELECT worktree_path FROM session_worktrees WHERE session_id = ${req.params.sessionId}
|
SELECT path AS worktree_path FROM worktrees WHERE session_id = ${req.params.sessionId}
|
||||||
`;
|
`;
|
||||||
const reports = [];
|
const reports = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -33,7 +33,7 @@ export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): vo
|
|||||||
'/api/sessions/:sessionId/worktree-stash',
|
'/api/sessions/:sessionId/worktree-stash',
|
||||||
async (req) => {
|
async (req) => {
|
||||||
const rows = await sql<{ worktree_path: string }[]>`
|
const rows = await sql<{ worktree_path: string }[]>`
|
||||||
SELECT worktree_path FROM session_worktrees WHERE session_id = ${req.params.sessionId}
|
SELECT path AS worktree_path FROM worktrees WHERE session_id = ${req.params.sessionId}
|
||||||
`;
|
`;
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|||||||
@@ -83,16 +83,20 @@ CREATE TABLE IF NOT EXISTS session_worktrees (
|
|||||||
base_commit TEXT,
|
base_commit TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
);
|
);
|
||||||
-- Migrate existing FK to CASCADE (idempotent: drops the old constraint if present).
|
-- P1.5-b: DEFANG the CASCADE — a session delete must no longer wipe its worktree
|
||||||
|
-- row. This table is SUPERSEDED by `worktrees` below; all readers are repointed
|
||||||
|
-- this phase, so the row just persists (dead) on session delete until a later
|
||||||
|
-- cleanup drops the table. session_id is this table's PRIMARY KEY, so it cannot be
|
||||||
|
-- nullable → SET NULL is invalid and NO ACTION/RESTRICT would block deletes; the
|
||||||
|
-- only valid defang is to drop the FK with no replacement. Idempotent: only fires
|
||||||
|
-- while the FK is still ON DELETE CASCADE ('c').
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
SELECT 1 FROM pg_constraint
|
SELECT 1 FROM pg_constraint
|
||||||
WHERE conname = 'session_worktrees_session_id_fkey'
|
WHERE conname = 'session_worktrees_session_id_fkey'
|
||||||
AND confdeltype <> 'c'
|
AND confdeltype = 'c'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE session_worktrees DROP CONSTRAINT session_worktrees_session_id_fkey;
|
ALTER TABLE session_worktrees DROP CONSTRAINT session_worktrees_session_id_fkey;
|
||||||
ALTER TABLE session_worktrees ADD CONSTRAINT session_worktrees_session_id_fkey
|
|
||||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
|
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
@@ -127,6 +131,80 @@ END $$;
|
|||||||
-- v2.6: config fingerprint for stale-session detection (auto-recover on model change).
|
-- v2.6: config fingerprint for stale-session detection (auto-recover on model change).
|
||||||
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;
|
||||||
|
|
||||||
|
-- ─── P1.5-b (corrected): worktrees entity + re-key agent_sessions to (chat_id, agent) ───
|
||||||
|
-- The TAB (a chat) is the context unit: two opencode tabs in one session = two
|
||||||
|
-- independent contexts sharing one worktree. So agent_sessions keys on
|
||||||
|
-- (chat_id, agent), NOT (worktree_id, agent) or (session_id, agent). The
|
||||||
|
-- `worktrees` table is one-per-session (selectable later) and only referenced
|
||||||
|
-- informationally by agent_sessions.worktree_id (SET NULL); chat_id is the key.
|
||||||
|
--
|
||||||
|
-- PREREQUISITE: the unmigratable test session (35 chats, 1 agent_sessions row that
|
||||||
|
-- maps to no single chat) is DELETED before this runs, so agent_sessions is empty
|
||||||
|
-- and the chat_id backfill is N/A. If a row with NULL chat_id remains, the verify
|
||||||
|
-- gate below RAISEs and aborts — delete the offending session first.
|
||||||
|
|
||||||
|
-- worktree as a first-class entity; survives session delete (session_id SET NULL).
|
||||||
|
CREATE TABLE IF NOT EXISTS worktrees (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID REFERENCES sessions(id) ON DELETE SET NULL,
|
||||||
|
project_id UUID,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
branch TEXT,
|
||||||
|
base_commit TEXT,
|
||||||
|
slug TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS worktrees_active_path_uidx ON worktrees(path) WHERE status='active';
|
||||||
|
|
||||||
|
-- Migrate any surviving session_worktrees rows → worktrees (idempotent; 0 rows
|
||||||
|
-- after the test-session delete, kept for generality / fresh-DB safety).
|
||||||
|
INSERT INTO worktrees (session_id, path, branch, base_commit, status)
|
||||||
|
SELECT sw.session_id, sw.worktree_path, 'session-' || sw.session_id, sw.base_commit, 'active'
|
||||||
|
FROM session_worktrees sw
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM worktrees w WHERE w.session_id = sw.session_id AND w.status='active');
|
||||||
|
|
||||||
|
-- Dispatch hint: which chat (tab) a task belongs to. The coder message route and
|
||||||
|
-- skills route set it from the frontend tab; session-less creators (arena, MCP,
|
||||||
|
-- new_task, generic /api/tasks) leave it NULL and the dispatcher creates a chat.
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Re-key columns on agent_sessions.
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS chat_id UUID;
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS worktree_id UUID;
|
||||||
|
|
||||||
|
-- BACKFILL-VERIFY GATE: the new PK is (chat_id, agent), so chat_id must be
|
||||||
|
-- non-null on every row before the swap. With the test session deleted this is a
|
||||||
|
-- 0-row assertion; if any row has NULL chat_id (an unmigratable pre-existing row),
|
||||||
|
-- abort loudly rather than create a degenerate (NULL, agent) key.
|
||||||
|
DO $$
|
||||||
|
DECLARE n int;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO n FROM agent_sessions WHERE chat_id IS NULL;
|
||||||
|
IF n > 0 THEN
|
||||||
|
RAISE EXCEPTION 'P1.5-b: % agent_sessions row(s) have NULL chat_id — delete the unmigratable session(s) before applying', n;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Swap PK (session_id,agent) → (chat_id,agent) + FKs (run-once, guarded on the new
|
||||||
|
-- FK's absence). chat_id CASCADEs from chats (closing a tab ends its context);
|
||||||
|
-- worktree_id is informational SET NULL; session_id defanged to nullable SET NULL.
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_sessions_chat_id_fkey') THEN
|
||||||
|
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_pkey;
|
||||||
|
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_session_id_fkey;
|
||||||
|
ALTER TABLE agent_sessions ALTER COLUMN session_id DROP NOT NULL;
|
||||||
|
ALTER TABLE agent_sessions ALTER COLUMN chat_id SET NOT NULL;
|
||||||
|
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_pkey PRIMARY KEY (chat_id, agent);
|
||||||
|
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_chat_id_fkey
|
||||||
|
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_worktree_id_fkey
|
||||||
|
FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this).
|
-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this).
|
||||||
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
|
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,15 @@ export interface EnsureSessionOpts {
|
|||||||
agent: string;
|
agent: string;
|
||||||
/** Resolved model id. */
|
/** Resolved model id. */
|
||||||
model: string;
|
model: string;
|
||||||
|
/** P1.5-b: the chat (tab) this turn belongs to. agent_sessions is keyed
|
||||||
|
* (chat_id, agent) — the tab/chat is the context unit. Always non-null:
|
||||||
|
* the dispatcher creates a chat for session-less tasks before calling. */
|
||||||
|
chatId: string;
|
||||||
/** Shared per-session worktree (one per `sessions.id`, not per pane). */
|
/** Shared per-session worktree (one per `sessions.id`, not per pane). */
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
|
/** P1.5-b: the `worktrees.id` for this session's worktree — stored on the
|
||||||
|
* agent_sessions row informationally (NOT the key). */
|
||||||
|
worktreeId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +54,10 @@ export interface AgentSessionHandle {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
backend: AgentBackendKind;
|
backend: AgentBackendKind;
|
||||||
|
/** P1.5-b: the chat (tab) this session is keyed on (with agent). */
|
||||||
|
chatId: string;
|
||||||
|
/** P1.5-b: the worktree this session's chat runs in (informational link). */
|
||||||
|
worktreeId: string;
|
||||||
/** Provider's own session id (resume token); null until the backend assigns one. */
|
/** Provider's own session id (resume token); null until the backend assigns one. */
|
||||||
agentSessionId: string | null;
|
agentSessionId: string | null;
|
||||||
/** opencode HTTP server port; null for ACP backends. */
|
/** opencode HTTP server port; null for ACP backends. */
|
||||||
|
|||||||
@@ -423,9 +423,12 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
if (!this.client) throw new Error('opencode-server: client not ready after ensureServer');
|
if (!this.client) throw new Error('opencode-server: client not ready after ensureServer');
|
||||||
|
|
||||||
const configHash = sessionConfigHash(opts.model);
|
const configHash = sessionConfigHash(opts.model);
|
||||||
|
// P1.5-b: agent_sessions is keyed (chat_id, agent) — the tab/chat is the
|
||||||
|
// context unit (two tabs in one session = two contexts sharing one worktree).
|
||||||
|
// session_id + worktree_id are retained as informational (SET NULL) columns.
|
||||||
const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>`
|
const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>`
|
||||||
SELECT agent_session_id, status, config_hash FROM agent_sessions
|
SELECT agent_session_id, status, config_hash FROM agent_sessions
|
||||||
WHERE session_id = ${sessionId} AND agent = ${opts.agent}
|
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
|
||||||
`;
|
`;
|
||||||
let agentSessionId = row?.agent_session_id ?? null;
|
let agentSessionId = row?.agent_session_id ?? null;
|
||||||
|
|
||||||
@@ -447,10 +450,12 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
agentSessionId = created.data.id;
|
agentSessionId = created.data.id;
|
||||||
await this.sql`
|
await this.sql`
|
||||||
INSERT INTO agent_sessions
|
INSERT INTO agent_sessions
|
||||||
(session_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
|
(chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
|
||||||
VALUES
|
VALUES
|
||||||
(${sessionId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
|
(${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
|
||||||
ON CONFLICT (session_id, agent) DO UPDATE SET
|
ON CONFLICT (chat_id, agent) DO UPDATE SET
|
||||||
|
session_id = EXCLUDED.session_id,
|
||||||
|
worktree_id = EXCLUDED.worktree_id,
|
||||||
backend = 'opencode_server',
|
backend = 'opencode_server',
|
||||||
agent_session_id = EXCLUDED.agent_session_id,
|
agent_session_id = EXCLUDED.agent_session_id,
|
||||||
server_port = EXCLUDED.server_port,
|
server_port = EXCLUDED.server_port,
|
||||||
@@ -462,7 +467,7 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
await this.sql`
|
await this.sql`
|
||||||
UPDATE agent_sessions
|
UPDATE agent_sessions
|
||||||
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash}
|
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash}
|
||||||
WHERE session_id = ${sessionId} AND agent = ${opts.agent}
|
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,6 +503,8 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
sessionId,
|
sessionId,
|
||||||
agent: opts.agent,
|
agent: opts.agent,
|
||||||
backend: 'opencode_server',
|
backend: 'opencode_server',
|
||||||
|
chatId: opts.chatId,
|
||||||
|
worktreeId: opts.worktreeId,
|
||||||
agentSessionId: ocSessionId,
|
agentSessionId: ocSessionId,
|
||||||
serverPort: this.port,
|
serverPort: this.port,
|
||||||
};
|
};
|
||||||
@@ -593,7 +600,7 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
}
|
}
|
||||||
await this.sql`
|
await this.sql`
|
||||||
UPDATE agent_sessions SET status = 'closed'
|
UPDATE agent_sessions SET status = 'closed'
|
||||||
WHERE session_id = ${handle.sessionId} AND agent = ${handle.agent}
|
WHERE chat_id = ${handle.chatId} AND agent = ${handle.agent}
|
||||||
`.catch(() => {});
|
`.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,8 +78,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
mode_id: string | null;
|
mode_id: string | null;
|
||||||
thinking_option_id: string | null;
|
thinking_option_id: string | null;
|
||||||
session_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
|
FROM tasks
|
||||||
WHERE state = 'pending'
|
WHERE state = 'pending'
|
||||||
ORDER BY created_at
|
ORDER BY created_at
|
||||||
@@ -110,6 +111,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
mode_id: string | null;
|
mode_id: string | null;
|
||||||
thinking_option_id: string | null;
|
thinking_option_id: string | null;
|
||||||
session_id: string | null;
|
session_id: string | null;
|
||||||
|
chat_id: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const taskId = task.id;
|
const taskId = task.id;
|
||||||
|
|
||||||
@@ -511,6 +513,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
mode_id: string | null;
|
mode_id: string | null;
|
||||||
thinking_option_id: string | null;
|
thinking_option_id: string | null;
|
||||||
session_id: string | null;
|
session_id: string | null;
|
||||||
|
chat_id: string | null;
|
||||||
},
|
},
|
||||||
installPath: string | null,
|
installPath: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -543,10 +546,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
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 sessionId: string;
|
||||||
let chatId: 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;
|
sessionId = task.session_id;
|
||||||
const chats = await sql<{ id: string }[]>`
|
const chats = await sql<{ id: string }[]>`
|
||||||
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
|
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
|
// Persistent, session-keyed worktree (shared across turns; NOT torn down
|
||||||
// per turn — Phase 3 reaps it). Captures base_commit for a stable diff.
|
// 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,
|
signal: ac.signal,
|
||||||
});
|
});
|
||||||
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
|
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, {
|
const handle = await backend.ensureSession(sessionId, {
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
|
chatId,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
|
worktreeId,
|
||||||
projectId: task.project_id,
|
projectId: task.project_id,
|
||||||
});
|
});
|
||||||
const result = await backend.prompt(handle, task.input, {
|
const result = await backend.prompt(handle, task.input, {
|
||||||
|
|||||||
@@ -119,16 +119,18 @@ export async function cleanupWorktree(
|
|||||||
// ─── v2.6: session-keyed persistent worktree ────────────────────────────────
|
// ─── v2.6: session-keyed persistent worktree ────────────────────────────────
|
||||||
|
|
||||||
export interface SessionWorktree {
|
export interface SessionWorktree {
|
||||||
|
/** P1.5-b: the `worktrees.id` — stored on agent_sessions informationally. */
|
||||||
|
worktreeId: string;
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
baseCommit: string | null;
|
baseCommit: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v2.6: create-or-reuse ONE worktree per BooCode session (shared across all
|
* v2.6 / P1.5-b: create-or-reuse ONE worktree per BooCode session (shared across
|
||||||
* agents/turns in the session), recorded in `session_worktrees`. Unlike the
|
* all tabs/agents in the session), recorded in `worktrees` (was the superseded
|
||||||
* per-task `createWorktree`, this persists — it is NOT torn down per turn
|
* `session_worktrees`). Persists — NOT torn down per turn (cleanup is Phase 3) —
|
||||||
* (cleanup is Phase 3). Captures the project's current HEAD as `base_commit`
|
* and now survives session delete (`worktrees.session_id` is ON DELETE SET NULL).
|
||||||
* so the accumulating diff has a stable baseline across turns.
|
* 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
|
* 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.
|
* collides with the per-task worktrees that arena/new_task/MCP still use.
|
||||||
@@ -139,11 +141,13 @@ export async function ensureSessionWorktree(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
opts?: { signal?: AbortSignal },
|
opts?: { signal?: AbortSignal },
|
||||||
): Promise<SessionWorktree> {
|
): Promise<SessionWorktree> {
|
||||||
const [existing] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
|
const [existing] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
|
||||||
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
|
SELECT id, path, base_commit FROM worktrees
|
||||||
|
WHERE session_id = ${sessionId} AND status = 'active'
|
||||||
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
if (existing) {
|
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}`;
|
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()}`);
|
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.
|
// Insert-or-get: WHERE NOT EXISTS keeps the first writer's row if two turns race
|
||||||
await sql`
|
// the create (the partial unique on active path also backstops it).
|
||||||
INSERT INTO session_worktrees (session_id, worktree_path, base_commit)
|
const [inserted] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
|
||||||
VALUES (${sessionId}, ${worktreePath}, ${baseCommit})
|
INSERT INTO worktrees (session_id, path, branch, base_commit, status)
|
||||||
ON CONFLICT (session_id) DO NOTHING
|
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 }[]>`
|
if (inserted) {
|
||||||
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
|
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 {
|
return {
|
||||||
worktreePath: row?.worktree_path ?? worktreePath,
|
worktreeId: row!.id,
|
||||||
|
worktreePath: row?.path ?? worktreePath,
|
||||||
baseCommit: row?.base_commit ?? baseCommit,
|
baseCommit: row?.base_commit ?? baseCommit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -432,16 +432,18 @@ export function registerSessionRoutes(
|
|||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const force = req.query.force === 'true' || req.query.force === '1';
|
const force = req.query.force === 'true' || req.query.force === '1';
|
||||||
|
|
||||||
// Session-delete work-loss guard. CASCADE on session_worktrees means the
|
// Session-delete work-loss guard. The check MUST run BEFORE the DELETE:
|
||||||
// DELETE below auto-wipes the worktree row, so the safety check MUST run
|
// worktrees.session_id is ON DELETE SET NULL (P1.5-b), so once the session
|
||||||
// BEFORE it (paths read while the row still exists, pre-CASCADE).
|
// is gone the worktree rows no longer point back to it — read them while
|
||||||
|
// the link still exists.
|
||||||
//
|
//
|
||||||
// Optimization: read session_worktrees from our own (shared) DB first.
|
// Optimization: read worktrees (P1.5-b — was session_worktrees) from our
|
||||||
// No row => chat-only session => nothing on disk => delete immediately,
|
// own (shared) DB first. No row => chat-only session => nothing on disk =>
|
||||||
// zero round-trip. Only worktree-backed sessions pay the host git check.
|
// delete immediately, zero round-trip. Only worktree-backed sessions pay
|
||||||
|
// the host git check.
|
||||||
if (!force) {
|
if (!force) {
|
||||||
const worktrees = await sql<{ worktree_path: string }[]>`
|
const worktrees = await sql<{ path: string }[]>`
|
||||||
SELECT worktree_path FROM session_worktrees WHERE session_id = ${id}
|
SELECT path FROM worktrees WHERE session_id = ${id}
|
||||||
`;
|
`;
|
||||||
if (worktrees.length > 0) {
|
if (worktrees.length > 0) {
|
||||||
// Worktree dirs live on the host; only BooCoder can run git on them.
|
// Worktree dirs live on the host; only BooCoder can run git on them.
|
||||||
|
|||||||
Reference in New Issue
Block a user