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
+69 -1
View File
@@ -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.