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.
This commit is contained in:
2026-08-27 13:06:08 +07:00
parent f8fba0a51d
commit ccf2fdb8eb
9 changed files with 530 additions and 17 deletions
+1
View File
@@ -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. - **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`. - **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. - **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. See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
+16 -6
View File
@@ -217,15 +217,22 @@ export function LaneConsolePane({
// args.laneId — RunSetup always supplies this pane's laneId (a // args.laneId — RunSetup always supplies this pane's laneId (a
// required prop), which would otherwise silently start a run in the // required prop), which would otherwise silently start a run in the
// wrong lane whenever the user types a cwd different from the one // wrong lane whenever the user types a cwd different from the one
// this pane currently shows. // this pane currently shows. A cwd can now back both a claude lane
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd); // 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; let targetLaneId: number;
if (ownedLane) { if (ownedLane) {
targetLaneId = ownedLane.id; targetLaneId = ownedLane.id;
if (ownedLane.id !== laneId) onLaneIdChange(ownedLane.id); if (ownedLane.id !== laneId) onLaneIdChange(ownedLane.id);
} else { } else {
try { try {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd }); const ensureResult = await api.lanes.ensure({
cwd: effectiveCwd,
tool: laneTool,
});
targetLaneId = ensureResult.lane.id; targetLaneId = ensureResult.lane.id;
onLaneIdChange(ensureResult.lane.id); onLaneIdChange(ensureResult.lane.id);
onLaneCreated(ensureResult.lane); onLaneCreated(ensureResult.lane);
@@ -287,7 +294,7 @@ export function LaneConsolePane({
setBusy(null); setBusy(null);
} }
}, },
[busy, t, lanes, laneId, onLaneIdChange, onLaneCreated, attachToRun, refreshList] [busy, t, lanes, laneId, laneTool, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
); );
const onResumeFromHistory = useCallback( const onResumeFromHistory = useCallback(
@@ -301,10 +308,13 @@ export function LaneConsolePane({
if (item.cwd) { if (item.cwd) {
const effectiveCwd = 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) { 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; targetLaneId = ensureResult.lane.id;
onLaneCreated(ensureResult.lane); onLaneCreated(ensureResult.lane);
} }
+4 -4
View File
@@ -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 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, returns the parent lane that owns it. - 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`, creates an **adopted** lane and returns it with `created: true` and HTTP `201`. - 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`. - 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` ### Reading a lane's working copy: `GET /api/lanes/:id/git`
+289
View File
@@ -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/${worked.body.lane.id}`);
await request("DELETE", `/api/lanes/${session.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", () => { describe("GET /:id/ci/status", () => {
@@ -1007,4 +1050,250 @@ describe("/api/lanes tool tests", () => {
assert.equal(res.status, 400); assert.equal(res.status, 400);
assert.equal(res.body.error.code, "EBADTOOL"); 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}`);
});
}); });
@@ -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ĩ <vinnt@smartgift.vn>
*/
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/);
});
});
+69 -1
View File
@@ -414,7 +414,7 @@ db.exec(`
CREATE TABLE IF NOT EXISTS lanes ( CREATE TABLE IF NOT EXISTS lanes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
cwd TEXT NOT NULL UNIQUE, cwd TEXT NOT NULL,
branch TEXT, branch TEXT,
pipeline TEXT NOT NULL DEFAULT 'default', pipeline TEXT NOT NULL DEFAULT 'default',
session_id TEXT, session_id TEXT,
@@ -585,6 +585,74 @@ try {
).run(); ).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 // Migrate: link agent rows to a workflow run. Workflow inner-agents are already
// ingested as subagents (same subagents/ dir); these columns add the grouping + // ingested as subagents (same subagents/ dir); these columns add the grouping +
// phase that the run journal provides. Additive, safe on existing DBs. // phase that the run journal provides. Additive, safe on existing DBs.
+15 -3
View File
@@ -401,12 +401,20 @@ function recoverInterruptedProvisioning() {
/** /**
* Longest path-boundary prefix match. `/tmp/wt` must NOT capture * Longest path-boundary prefix match. `/tmp/wt` must NOT capture
* `/tmp/wt-sibling`, and a nested lane must beat its parent. * `/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; if (!cwd || typeof cwd !== "string") return null;
const target = cwd.replace(/\/+$/, ""); const target = cwd.replace(/\/+$/, "");
let best = null; let best = null;
for (const lane of listLanes()) { for (const lane of listLanes()) {
if (tool && lane.tool !== tool) continue;
const base = lane.cwd.replace(/\/+$/, ""); const base = lane.cwd.replace(/\/+$/, "");
if (target === base || target.startsWith(`${base}/`)) { if (target === base || target.startsWith(`${base}/`)) {
if (!best || base.length > best.cwd.length) best = lane; 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 * Only stage inference uses this. `session_id` and `needs_action` stay on the
* session's own lane, because those genuinely are session-scoped facts. * 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) { function resolveLaneForWork(sessionCwd, toolInput) {
let best = null; let best = null;
for (const candidate of absolutePathsIn(toolInput)) { 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; 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) { function classifyLiveness({ status, stage, ageSec }, deadSec = DEAD_SEC) {
+3 -1
View File
@@ -169,7 +169,9 @@ const LANE_DONE_HOOKS = new Set(["Stop", "SessionEnd"]);
function touchLaneFromHook(hookType, data) { function touchLaneFromHook(hookType, data) {
try { try {
if (!data || !data.cwd) return; 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; if (!lane) return;
// Infer the lane's stage from this one tool event, best-effort. A real // Infer the lane's stage from this one tool event, best-effort. A real
+6 -2
View File
@@ -265,7 +265,11 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } }); 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) { if (owner) {
// First-write wins on the title: an ensure-with-title on an existing lane // 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 // 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 // The cwd UNIQUE constraint is the arbiter: someone else won the race, so
// re-read and return THEIR lane rather than reporting a conflict. // re-read and return THEIR lane rather than reporting a conflict.
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) { 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 }); if (winner) return res.json({ lane: payload(winner), created: false });
} }
return res.status(500).json({ error: { code: err.code, message: err.message } }); return res.status(500).json({ error: { code: err.code, message: err.message } });