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
+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/${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}`);
});
});