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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
Reference in New Issue
Block a user