From ccf2fdb8eb675b0bbc70c9e5ced2699d7f431343 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 27 Aug 2026 13:06:08 +0700 Subject: [PATCH] feat(lanes): let claude and codex lanes coexist at the same cwd Repo/Adopt mode (POST /api/lanes/ensure) treated lanes.cwd as globally unique, so adopting a path already driven by one CLI silently ignored a different tool choice. Uniqueness moves to the composite (cwd, tool): one directory can now back a claude lane and a codex lane side by side. - server/db.js: drop the column-level UNIQUE on cwd, migrate existing DBs via table rebuild, add idx_lanes_cwd_tool. - resolveLaneByCwd gains an optional tool filter; hook ingestion always pins to "claude" since Codex has no hook mechanism here, so a hook event never touches a codex lane sharing the path. - POST /ensure resolves the existing owner by (cwd, tool) instead of cwd alone, so a different tool creates a sibling lane. - LaneConsolePane's cwd-only lane lookups now also match tool, so a shared cwd doesn't attach a run to the wrong sibling lane. Worktree mode is unaffected (already allocates a unique directory per lane). docs/LANES.md and CLAUDE.md updated to describe the new composite uniqueness. --- CLAUDE.md | 1 + client/src/components/run/LaneConsolePane.tsx | 22 +- docs/LANES.md | 8 +- server/__tests__/lanes-api.test.js | 289 ++++++++++++++++++ .../lanes-cwd-unique-rebuild.test.js | 127 ++++++++ server/db.js | 70 ++++- server/lib/lanes.js | 18 +- server/routes/hooks.js | 4 +- server/routes/lanes.js | 8 +- 9 files changed, 530 insertions(+), 17 deletions(-) create mode 100644 server/__tests__/lanes-cwd-unique-rebuild.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 7f1688e4..1ead9a47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m - **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell. - **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, so no PATCH can flip the CLI under a live pane. Every spawn path (`POST /api/lanes/:id/start`, `POST /api/run`) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (`sandbox`, `approval`, `resumeLast`) are validated against the fixed allowlists in `server/lib/pty-run.js` before they reach an argv, and the bypass flags (`--dangerously-bypass-approvals-and-sandbox`, `--dangerously-bypass-hook-trust`) are never emitted. `GET /api/run/binary?tool=` resolves the probed binary name from that same allowlist — the query string reaches `which`. - **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it. +- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`, `resolveLaneForWork`) always pins it to `"claude"` because Codex has no hook mechanism in this codebase, so a hook event must never touch a `codex` lane sharing the path. Worktree mode is unaffected — it already allocates a unique directory per lane. See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states. diff --git a/client/src/components/run/LaneConsolePane.tsx b/client/src/components/run/LaneConsolePane.tsx index 8e3d0ea3..50989b05 100644 --- a/client/src/components/run/LaneConsolePane.tsx +++ b/client/src/components/run/LaneConsolePane.tsx @@ -217,15 +217,22 @@ export function LaneConsolePane({ // args.laneId — RunSetup always supplies this pane's laneId (a // required prop), which would otherwise silently start a run in the // wrong lane whenever the user types a cwd different from the one - // this pane currently shows. - const ownedLane = lanes.find((l) => l.cwd === effectiveCwd); + // this pane currently shows. A cwd can now back both a claude lane + // and a codex lane, so match this pane's own tool too (laneTool, + // which already defaults to "claude" when no lane is selected — + // the same default RunSetup renders) — otherwise the wrong sibling + // lane could be picked. + const ownedLane = lanes.find((l) => l.cwd === effectiveCwd && l.tool === laneTool); let targetLaneId: number; if (ownedLane) { targetLaneId = ownedLane.id; if (ownedLane.id !== laneId) onLaneIdChange(ownedLane.id); } else { try { - const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd }); + const ensureResult = await api.lanes.ensure({ + cwd: effectiveCwd, + tool: laneTool, + }); targetLaneId = ensureResult.lane.id; onLaneIdChange(ensureResult.lane.id); onLaneCreated(ensureResult.lane); @@ -287,7 +294,7 @@ export function LaneConsolePane({ setBusy(null); } }, - [busy, t, lanes, laneId, onLaneIdChange, onLaneCreated, attachToRun, refreshList] + [busy, t, lanes, laneId, laneTool, onLaneIdChange, onLaneCreated, attachToRun, refreshList] ); const onResumeFromHistory = useCallback( @@ -301,10 +308,13 @@ export function LaneConsolePane({ if (item.cwd) { const effectiveCwd = item.cwd; - let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id; + // A cwd can now back both a claude lane and a codex lane — match + // this history item's own tool, the ground truth for which lane it + // belongs to, rather than an arbitrary lane at that cwd. + let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd && l.tool === item.tool)?.id; if (!targetLaneId) { - const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd }); + const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd, tool: item.tool }); targetLaneId = ensureResult.lane.id; onLaneCreated(ensureResult.lane); } diff --git a/docs/LANES.md b/docs/LANES.md index 1531f31a..7b5a7b4d 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -384,12 +384,12 @@ curl -X POST http://localhost:4820/api/lanes/ensure \ } ``` -- If a lane already owns the `cwd`, returns that lane with `created: false` and HTTP `200`. -- If the `cwd` is a subdirectory of an existing lane, returns the parent lane that owns it. -- If no lane owns the `cwd`, creates an **adopted** lane and returns it with `created: true` and HTTP `201`. +- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (`claude` or `codex`, default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one `claude` lane and one `codex` lane at a time. This applies to Repo/Adopt mode only; Worktree mode always gets its own directory. +- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it. +- If no lane owns the `(cwd, tool)` pair, creates an **adopted** lane and returns it with `created: true` and HTTP `201`. - A relative or missing `cwd` returns `400 EBADCWD`. -Two concurrent calls for the same path always yield one lane: the database's `UNIQUE` constraint on `lanes.cwd` decides the winner, and the loser re-reads and returns the winning lane. +Two concurrent calls for the same `(cwd, tool)` pair always yield one lane: the database's composite `UNIQUE` index on `lanes(cwd, tool)` decides the winner, and the loser re-reads and returns the winning lane. ### Reading a lane's working copy: `GET /api/lanes/:id/git` diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index af5b286a..69e9acd7 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -928,6 +928,49 @@ describe("detection attributes work to the lane the work touched", () => { await request("DELETE", `/api/lanes/${worked.body.lane.id}`); await request("DELETE", `/api/lanes/${session.body.lane.id}`); }); + + it("a hook event at a shared cwd only touches the claude lane, never the codex lane", async () => { + const claudeLane = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/lane-dual-tool", + tool: "claude", + }); + const codexLane = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/lane-dual-tool", + tool: "codex", + }); + assert.notEqual(claudeLane.body.lane.id, codexLane.body.lane.id); + + await request("POST", "/api/hooks/event", { + hook_type: "PostToolUse", + data: { + session_id: "sess-dual-tool", + cwd: "/tmp/lane-dual-tool", + tool_name: "Bash", + tool_input: { command: "npm run test:server" }, + }, + }); + + assert.equal( + (await request("GET", `/api/lanes/${claudeLane.body.lane.id}`)).body.lane.detected_stage, + "tests" + ); + assert.equal( + (await request("GET", `/api/lanes/${claudeLane.body.lane.id}`)).body.lane.session_id, + "sess-dual-tool" + ); + assert.equal( + (await request("GET", `/api/lanes/${codexLane.body.lane.id}`)).body.lane.detected_stage, + null, + "the codex lane sharing this cwd must not absorb a claude hook event" + ); + assert.equal( + (await request("GET", `/api/lanes/${codexLane.body.lane.id}`)).body.lane.session_id, + null + ); + + await request("DELETE", `/api/lanes/${claudeLane.body.lane.id}`); + await request("DELETE", `/api/lanes/${codexLane.body.lane.id}`); + }); }); describe("GET /:id/ci/status", () => { @@ -1007,4 +1050,250 @@ describe("/api/lanes tool tests", () => { assert.equal(res.status, 400); assert.equal(res.body.error.code, "EBADTOOL"); }); + + it("POST /api/lanes/ensure creates a second lane for the same cwd on a different tool", async () => { + const first = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/claude-lane", + tool: "claude", + }); + assert.equal(first.status, 201); + + const second = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/claude-lane", + tool: "codex", + }); + assert.equal(second.status, 201); + assert.equal(second.body.created, true); + assert.notEqual(second.body.lane.id, first.body.lane.id); + assert.equal(second.body.lane.cwd, first.body.lane.cwd); + assert.equal(second.body.lane.tool, "codex"); + assert.equal(first.body.lane.tool, "claude"); + }); + + it("POST /api/lanes/ensure re-ensuring the same (cwd, tool) pair is a no-op", async () => { + const first = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/claude-lane-2", + tool: "claude", + }); + assert.equal(first.status, 201); + + const second = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/claude-lane-2", + tool: "claude", + }); + assert.equal(second.status, 200); + assert.equal(second.body.created, false); + assert.equal(second.body.lane.id, first.body.lane.id); + }); + + it("POST /api/lanes/ensure re-ensuring with the same tool is a no-op", async () => { + const first = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/same-tool-lane", + tool: "codex", + }); + assert.equal(first.status, 201); + + const second = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/same-tool-lane", + tool: "codex", + }); + assert.equal(second.status, 200); + assert.equal(second.body.created, false); + assert.equal(second.body.lane.tool, "codex"); + }); + + it("POST /api/lanes/ensure defaults to 'claude' tool when tool is not specified", async () => { + const res = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/default-tool-lane", + }); + assert.equal(res.status, 201); + assert.equal(res.body.lane.tool, "claude"); + }); + + it("GET /api/lanes returns both lanes for a shared cwd with distinct IDs and tools", async () => { + const claudeLane = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/dual-list-cwd", + tool: "claude", + }); + const codexLane = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/dual-list-cwd", + tool: "codex", + }); + const claudeId = claudeLane.body.lane.id; + const codexId = codexLane.body.lane.id; + + const list = await request("GET", "/api/lanes"); + assert.equal(list.status, 200); + + const dualCwdLanes = list.body.lanes.filter((l) => l.cwd === "/tmp/dual-list-cwd"); + assert.equal(dualCwdLanes.length, 2, "expected both lanes to appear in the list"); + + const found = { + claude: dualCwdLanes.find((l) => l.tool === "claude"), + codex: dualCwdLanes.find((l) => l.tool === "codex"), + }; + assert.ok(found.claude, "claude lane must be in the list"); + assert.ok(found.codex, "codex lane must be in the list"); + assert.equal(found.claude.id, claudeId); + assert.equal(found.codex.id, codexId); + assert.notEqual(found.claude.id, found.codex.id); + + await request("DELETE", `/api/lanes/${claudeId}`); + await request("DELETE", `/api/lanes/${codexId}`); + }); + + it("DELETE one lane of a (cwd, tool) pair leaves the sibling lane untouched", async () => { + const claudeLane = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/dual-delete-cwd", + tool: "claude", + }); + const codexLane = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/dual-delete-cwd", + tool: "codex", + }); + const claudeId = claudeLane.body.lane.id; + const codexId = codexLane.body.lane.id; + + // Delete the codex lane + const deleteRes = await request("DELETE", `/api/lanes/${codexId}`); + assert.equal(deleteRes.status, 200); + + // Verify the codex lane is gone + assert.equal((await request("GET", `/api/lanes/${codexId}`)).status, 404); + + // Verify the claude lane still exists and is untouched + const claudeAfterDelete = await request("GET", `/api/lanes/${claudeId}`); + assert.equal(claudeAfterDelete.status, 200); + assert.equal(claudeAfterDelete.body.lane.tool, "claude"); + assert.equal(claudeAfterDelete.body.lane.cwd, "/tmp/dual-delete-cwd"); + + await request("DELETE", `/api/lanes/${claudeId}`); + }); + + it("POST /api/lanes/ensure rejects an invalid tool even when two valid lanes already exist at that cwd", async () => { + const claudeLane = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/dual-invalid-tool-cwd", + tool: "claude", + }); + await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/dual-invalid-tool-cwd", + tool: "codex", + }); + + const badRes = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/dual-invalid-tool-cwd", + tool: "cursor", // invalid + }); + assert.equal(badRes.status, 400); + assert.equal(badRes.body.error.code, "EBADTOOL"); + + await request("DELETE", `/api/lanes/${claudeLane.body.lane.id}`); + }); + + it("POST /api/lanes/worktree with tool parameter creates a lane with the specified tool", async () => { + const sourceRepo = require("path").join( + require("os").tmpdir(), + `ccam-worktree-src-${Date.now()}` + ); + const fs = require("fs"); + fs.mkdirSync(sourceRepo, { recursive: true }); + require("child_process").execSync("git init", { cwd: sourceRepo }); + + const res = await request("POST", "/api/lanes/worktree", { + sourceRepo, + slug: "test-codex-worktree", + tool: "codex", + }); + + assert.equal(res.status, 202, "worktree create should return 202 Accepted"); + assert.equal(res.body.lane.tool, "codex"); + + // Cleanup + fs.rmSync(sourceRepo, { recursive: true, force: true }); + + // Note: the actual provisioning happens asynchronously, so we don't verify it here. + // Just verify the lane was created with the right tool. + if (res.body.lane.id) { + await request("DELETE", `/api/lanes/${res.body.lane.id}`); + } + }); + + it("POST /api/lanes/worktree defaults to 'claude' tool when tool is not specified", async () => { + const sourceRepo = require("path").join( + require("os").tmpdir(), + `ccam-worktree-default-${Date.now()}` + ); + const fs = require("fs"); + fs.mkdirSync(sourceRepo, { recursive: true }); + require("child_process").execSync("git init", { cwd: sourceRepo }); + + const res = await request("POST", "/api/lanes/worktree", { + sourceRepo, + slug: "test-default-worktree", + }); + + assert.equal(res.status, 202); + assert.equal(res.body.lane.tool, "claude"); + + // Cleanup + fs.rmSync(sourceRepo, { recursive: true, force: true }); + + if (res.body.lane.id) { + await request("DELETE", `/api/lanes/${res.body.lane.id}`); + } + }); + + it("POST /api/lanes/ensure with tool parameter preserves title on re-ensure", async () => { + const first = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/ensure-title-cwd", + tool: "claude", + title: "My Claude Lane", + }); + assert.equal(first.body.lane.title, "My Claude Lane"); + + // Re-ensure without title should preserve the original + const second = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/ensure-title-cwd", + tool: "claude", + }); + assert.equal(second.status, 200); + assert.equal(second.body.created, false); + assert.equal(second.body.lane.title, "My Claude Lane"); + + await request("DELETE", `/api/lanes/${first.body.lane.id}`); + }); + + it("two lanes at the same cwd with different tools have independent session_id tracking", async () => { + const claudeLane = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/dual-session-cwd", + tool: "claude", + }); + const codexLane = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/dual-session-cwd", + tool: "codex", + }); + + const claudeId = claudeLane.body.lane.id; + const codexId = codexLane.body.lane.id; + + // Simulate a hook event that should only update the claude lane + await request("POST", "/api/hooks/event", { + hook_type: "PostToolUse", + data: { + session_id: "sess-dual-session-test", + cwd: "/tmp/dual-session-cwd", + tool_name: "Bash", + tool_input: { command: "npm run test:server" }, + }, + }); + + const claudeAfter = await request("GET", `/api/lanes/${claudeId}`); + const codexAfter = await request("GET", `/api/lanes/${codexId}`); + + assert.equal(claudeAfter.body.lane.session_id, "sess-dual-session-test"); + assert.equal(codexAfter.body.lane.session_id, null); + + await request("DELETE", `/api/lanes/${claudeId}`); + await request("DELETE", `/api/lanes/${codexId}`); + }); }); diff --git a/server/__tests__/lanes-cwd-unique-rebuild.test.js b/server/__tests__/lanes-cwd-unique-rebuild.test.js new file mode 100644 index 00000000..c4939435 --- /dev/null +++ b/server/__tests__/lanes-cwd-unique-rebuild.test.js @@ -0,0 +1,127 @@ +/** + * @file Verifies the legacy `lanes_new` rebuild in db.js (triggered when the + * lanes table's stored SQL still contains the old single-column + * `cwd TEXT NOT NULL UNIQUE`) drops that constraint in favor of the composite + * `idx_lanes_cwd_tool` unique index, preserves existing rows, and recreates + * idx_lanes_session / idx_lanes_slot. + * + * Regression coverage for the codex+claude coexistence feature: a cwd used to + * back at most one lane; now one cwd can back a `claude` lane and a `codex` + * lane at once, which requires dropping the old column-level UNIQUE. + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); +const Database = require("better-sqlite3"); + +let TEST_DB; + +before(() => { + TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-legacy-${Date.now()}-${process.pid}.db`); + process.env.DASHBOARD_DB_PATH = TEST_DB; + + // Hand-build a legacy lanes table using the original single-column UNIQUE + // cwd, none of the columns added by later migrations (kind, source_repo, + // base_branch, slug, tool, detected_*, slot, ports, active_feature_id). + // This is the exact shape the "cwd TEXT NOT NULL UNIQUE" detector is + // looking for. + const raw = new Database(TEST_DB); + raw.pragma("foreign_keys = OFF"); + raw.exec(` + CREATE TABLE lanes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT '', + cwd TEXT NOT NULL UNIQUE, + branch TEXT, + pipeline TEXT NOT NULL DEFAULT 'default', + session_id TEXT, + run_id TEXT, + stage TEXT NOT NULL DEFAULT 'idle', + stage_since TEXT, + status TEXT NOT NULL DEFAULT 'idle', + gate_decision TEXT, + ci_status TEXT, + needs_action TEXT, + links TEXT NOT NULL DEFAULT '{}', + stages TEXT NOT NULL DEFAULT '{}', + notes TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ); + `); + raw + .prepare("INSERT INTO lanes (title, cwd) VALUES (?, ?)") + .run("legacy lane", "/tmp/legacy-lane-cwd"); + raw.close(); +}); + +after(() => { + try { + delete require.cache[require.resolve("../db")]; + } catch {} + for (const suffix of ["", "-wal", "-shm"]) { + try { + fs.unlinkSync(TEST_DB + suffix); + } catch {} + } +}); + +describe("legacy lanes_new rebuild (pre-tool, single-column cwd UNIQUE DB)", () => { + it("loads db.js against the legacy DB without throwing", () => { + delete require.cache[require.resolve("../db")]; + assert.doesNotThrow(() => require("../db")); + }); + + it("preserves the pre-existing lane row and defaults tool to claude", () => { + const { db } = require("../db"); + const row = db.prepare("SELECT * FROM lanes WHERE cwd = ?").get("/tmp/legacy-lane-cwd"); + assert.ok(row, "expected the legacy lane row to survive the rebuild"); + assert.equal(row.title, "legacy lane"); + assert.equal(row.tool, "claude"); + }); + + it("recreates idx_lanes_session and idx_lanes_slot", () => { + const { db } = require("../db"); + for (const name of ["idx_lanes_session", "idx_lanes_slot"]) { + const idx = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?") + .get(name); + assert.ok(idx, `expected ${name} to exist after the rebuild`); + } + }); + + it("drops the single-column cwd UNIQUE, allowing a second lane at the same cwd on a different tool", () => { + const { db } = require("../db"); + const legacy = db.prepare("SELECT id FROM lanes WHERE cwd = ?").get("/tmp/legacy-lane-cwd"); + assert.doesNotThrow(() => { + db.prepare("INSERT INTO lanes (title, cwd, tool) VALUES (?, ?, ?)").run( + "codex sibling", + "/tmp/legacy-lane-cwd", + "codex" + ); + }); + const both = db + .prepare("SELECT id, tool FROM lanes WHERE cwd = ? ORDER BY id") + .all("/tmp/legacy-lane-cwd"); + assert.equal(both.length, 2); + assert.equal(both[0].id, legacy.id); + assert.equal(both[0].tool, "claude"); + assert.equal(both[1].tool, "codex"); + }); + + it("still enforces uniqueness within the same (cwd, tool) pair", () => { + const { db } = require("../db"); + assert.throws(() => { + db.prepare("INSERT INTO lanes (title, cwd, tool) VALUES (?, ?, ?)").run( + "duplicate claude", + "/tmp/legacy-lane-cwd", + "claude" + ); + }, /UNIQUE/); + }); +}); diff --git a/server/db.js b/server/db.js index e590d81c..1f661bb4 100644 --- a/server/db.js +++ b/server/db.js @@ -414,7 +414,7 @@ db.exec(` CREATE TABLE IF NOT EXISTS lanes ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL DEFAULT '', - cwd TEXT NOT NULL UNIQUE, + cwd TEXT NOT NULL, branch TEXT, pipeline TEXT NOT NULL DEFAULT 'default', session_id TEXT, @@ -585,6 +585,74 @@ try { ).run(); } +// Migrate: cwd used to be single-column UNIQUE (one directory = one lane). +// Codex and Claude Code lanes now coexist at the same adopted path, so +// uniqueness moves to (cwd, tool) instead. SQLite can't ALTER TABLE to drop a +// column-level UNIQUE, so rebuild the table when the old constraint is still +// there — same rename-copy-drop pattern as the `agents` status rebuild above. +// A fresh DB already gets the new column definition from CREATE TABLE. +{ + const laneTableInfo = db + .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='lanes'") + .get(); + if ( + laneTableInfo && + laneTableInfo.sql && + laneTableInfo.sql.includes("cwd TEXT NOT NULL UNIQUE") + ) { + db.exec(` + PRAGMA foreign_keys = OFF; + BEGIN; + CREATE TABLE lanes_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT '', + cwd TEXT NOT NULL, + branch TEXT, + pipeline TEXT NOT NULL DEFAULT 'default', + session_id TEXT, + run_id TEXT, + stage TEXT NOT NULL DEFAULT 'idle', + stage_since TEXT, + status TEXT NOT NULL DEFAULT 'idle', + gate_decision TEXT, + ci_status TEXT, + needs_action TEXT, + links TEXT NOT NULL DEFAULT '{}', + stages TEXT NOT NULL DEFAULT '{}', + notes TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + kind TEXT NOT NULL DEFAULT 'adopted', + source_repo TEXT, + base_branch TEXT, + slug TEXT, + tool TEXT NOT NULL DEFAULT 'claude', + detected_stage TEXT, + detected_signal TEXT, + detected_at TEXT, + slot INTEGER, + ports TEXT NOT NULL DEFAULT '{}', + active_feature_id INTEGER REFERENCES lane_features(id) ON DELETE SET NULL + ); + INSERT INTO lanes_new SELECT + id, title, cwd, branch, pipeline, session_id, run_id, stage, stage_since, + status, gate_decision, ci_status, needs_action, links, stages, notes, + created_at, updated_at, kind, source_repo, base_branch, slug, tool, + detected_stage, detected_signal, detected_at, slot, ports, active_feature_id + FROM lanes; + DROP TABLE lanes; + ALTER TABLE lanes_new RENAME TO lanes; + COMMIT; + PRAGMA foreign_keys = ON; + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_lanes_session ON lanes(session_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_slot ON lanes(slot) WHERE slot IS NOT NULL; + `); + } +} +db.prepare("CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_cwd_tool ON lanes(cwd, tool)").run(); + // Migrate: link agent rows to a workflow run. Workflow inner-agents are already // ingested as subagents (same subagents/ dir); these columns add the grouping + // phase that the run journal provides. Additive, safe on existing DBs. diff --git a/server/lib/lanes.js b/server/lib/lanes.js index 7cbe2c4d..6ae18188 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -401,12 +401,20 @@ function recoverInterruptedProvisioning() { /** * Longest path-boundary prefix match. `/tmp/wt` must NOT capture * `/tmp/wt-sibling`, and a nested lane must beat its parent. + * + * `tool`, when given, narrows the candidates to that lane's tool before the + * prefix comparison — needed now that one cwd can back both a `claude` lane + * and a `codex` lane (see `idx_lanes_cwd_tool`). Omitted, it matches any tool, + * which stays correct for callers that already know they hold the lane's own + * cwd (there's only ever one match for that exact path per tool, and this + * caller shape doesn't care which). */ -function resolveLaneByCwd(cwd) { +function resolveLaneByCwd(cwd, tool) { if (!cwd || typeof cwd !== "string") return null; const target = cwd.replace(/\/+$/, ""); let best = null; for (const lane of listLanes()) { + if (tool && lane.tool !== tool) continue; const base = lane.cwd.replace(/\/+$/, ""); if (target === base || target.startsWith(`${base}/`)) { if (!best || base.length > best.cwd.length) best = lane; @@ -446,14 +454,18 @@ function absolutePathsIn(toolInput) { * * Only stage inference uses this. `session_id` and `needs_action` stay on the * session's own lane, because those genuinely are session-scoped facts. + * + * Hooks-only, and hooks are only ever emitted by the Claude Code CLI (Codex + * has no equivalent hook mechanism here) — so both lookups are pinned to the + * `claude` lane at each path, never the `codex` one that might share it. */ function resolveLaneForWork(sessionCwd, toolInput) { let best = null; for (const candidate of absolutePathsIn(toolInput)) { - const lane = resolveLaneByCwd(candidate); + const lane = resolveLaneByCwd(candidate, "claude"); if (lane && (!best || lane.cwd.length > best.cwd.length)) best = lane; } - return best || resolveLaneByCwd(sessionCwd); + return best || resolveLaneByCwd(sessionCwd, "claude"); } function classifyLiveness({ status, stage, ageSec }, deadSec = DEAD_SEC) { diff --git a/server/routes/hooks.js b/server/routes/hooks.js index 2c1093e2..ae51fa48 100644 --- a/server/routes/hooks.js +++ b/server/routes/hooks.js @@ -169,7 +169,9 @@ const LANE_DONE_HOOKS = new Set(["Stop", "SessionEnd"]); function touchLaneFromHook(hookType, data) { try { if (!data || !data.cwd) return; - const lane = lanesLib.resolveLaneByCwd(data.cwd); + // Hooks are only ever emitted by the Claude Code CLI, so pin to the + // `claude` lane at this cwd — never the `codex` lane that might share it. + const lane = lanesLib.resolveLaneByCwd(data.cwd, "claude"); if (!lane) return; // Infer the lane's stage from this one tool event, best-effort. A real diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 4231b252..ec7c8bb8 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -265,7 +265,11 @@ router.post("/ensure", sameOriginGuard, (req, res) => { return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } }); } - const owner = lanesLib.resolveLaneByCwd(cwd); + // A cwd can now back one lane per tool (idx_lanes_cwd_tool) — "does a lane + // already own this (cwd, tool) pair" is the real question, so a request for + // a different tool than an existing lane at this cwd falls through to + // creating that tool's own lane instead of adopting the wrong one. + const owner = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude"); if (owner) { // First-write wins on the title: an ensure-with-title on an existing lane // is how callers like Sagi adopt the lane, so don't clobber a real title @@ -292,7 +296,7 @@ router.post("/ensure", sameOriginGuard, (req, res) => { // The cwd UNIQUE constraint is the arbiter: someone else won the race, so // re-read and return THEIR lane rather than reporting a conflict. if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) { - const winner = lanesLib.resolveLaneByCwd(cwd); + const winner = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude"); if (winner) return res.json({ lane: payload(winner), created: false }); } return res.status(500).json({ error: { code: err.code, message: err.message } });