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
+15 -3
View File
@@ -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) {