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:
@@ -83,16 +83,20 @@ CREATE TABLE IF NOT EXISTS session_worktrees (
|
||||
base_commit TEXT,
|
||||
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
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'session_worktrees_session_id_fkey'
|
||||
AND confdeltype <> 'c'
|
||||
AND confdeltype = 'c'
|
||||
) THEN
|
||||
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 $$;
|
||||
|
||||
@@ -127,6 +131,80 @@ END $$;
|
||||
-- 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;
|
||||
|
||||
-- ─── 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).
|
||||
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user