Files
Claude-Code-Monitor/server/__tests__/lane-lifecycle.test.js
T
nntrivi2001 2c29504c75 test(lanes): stop lane-lifecycle from leaking real tmux + claude processes
Four cases in lane-lifecycle.test.js call /start without stubbing PATH,
so they spawn the real system `claude` binary in a real tmux session
to simulate a stuck/live run. Each then mocks tmux's own exec calls to
fake has-session/kill-session for the app's checks, but never touches
the real spawned process — the mock only fools the app, not the OS.
Two of these leaked past every prior test run undetected (ccam-lane-22,
ccam-lane-24), surfacing in the dashboard's live "Dashboard runs" list
with no DB record and a garbage started_at, and reappearing in a
Workspace split pane pointed at a deleted temp directory.

Stub a lightweight fake `claude` on PATH (same pattern already used
correctly elsewhere in this file) instead of spawning the real CLI, and
explicitly kill the real tmux session in each test's teardown since the
app-level mock never reaches the OS process.
2026-08-14 17:28:54 +07:00

1262 lines
49 KiB
JavaScript

/**
* @file Tests for lane lifecycle operations: preflight (what will be lost?),
* reset, remove, and purge. Each operation is preceded by preflight to confirm
* the counts and blocks before destructive action.
* @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 fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const { execFileSync } = require("node:child_process");
const { PassThrough } = require("node:stream");
const { EventEmitter } = require("node:events");
const TEST_DB = path.join(os.tmpdir(), `dashboard-lifecycle-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lifecycle-"));
process.env.LANES_ROOT = path.join(ROOT, "lanes");
const { createApp, startServer } = require("../index");
const runs = require("../lib/pty-run");
let server;
let BASE;
function request(method, urlPath, body, extraHeaders = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: {
...(payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {}),
...extraHeaders,
},
},
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
let parsed = null;
try {
parsed = JSON.parse(data);
} catch {
/* non-JSON body */
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
function makeRunChild({ exitsOnKill }) {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = new PassThrough();
child.killed = false;
child.kill = function (signal) {
if (signal === "SIGTERM") this.killed = true;
if (exitsOnKill) setImmediate(() => this.emit("exit", 143, signal));
return true;
};
return child;
}
// Puts a fake `claude` binary on PATH so a real `/start` spawns a real tmux
// session running THIS script instead of the system Claude Code CLI. Tests
// that mock tmux's own exec calls (to simulate a stuck/live session) still
// spawn this real process underneath — without the stub, that spawn launches
// the actual `claude` binary and, because the mock replaces the app's own
// kill-session call, the real process is never actually terminated, leaking
// a live tmux session + CLI process for good. Returns the restore function.
function stubClaudeBinary(name) {
const bin = path.join(ROOT, `${name}-bin`);
const claude = path.join(bin, "claude");
fs.mkdirSync(bin, { recursive: true });
fs.writeFileSync(
claude,
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
);
fs.chmodSync(claude, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return () => {
process.env.PATH = originalPath;
};
}
async function waitForProvisioning(id) {
const deadline = Date.now() + 5000;
let response;
while (Date.now() < deadline) {
response = await request("GET", `/api/lanes/${id}`);
if (response.body.lane.status !== "provisioning") return response.body.lane;
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.fail(
`lane ${id} was still provisioning after 5 seconds; last response: ${JSON.stringify(response.body)}`
);
}
// Git helper with clean environment (no hook vars)
const SRC = path.join(ROOT, "src-repo");
const g = (cwd, ...args) => {
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
delete env.GIT_COMMON_DIR;
delete env.GIT_OBJECT_DIRECTORY;
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete env.GIT_PREFIX;
delete env.GIT_NAMESPACE;
delete env.GIT_CONFIG_PARAMETERS;
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
before(async () => {
// Create source repo
fs.mkdirSync(SRC, { recursive: true });
g(SRC, "init", "-b", "main");
g(SRC, "config", "user.email", "test@example.com");
g(SRC, "config", "user.name", "Test");
fs.writeFileSync(path.join(SRC, "README.md"), "hello\n");
fs.writeFileSync(path.join(SRC, ".gitignore"), "node_modules/\n.env\n");
g(SRC, "add", "-A");
g(SRC, "commit", "-m", "init");
// Start server
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
describe("preflight", () => {
it("returns 404 for an unknown lane", async () => {
const r = await request("GET", "/api/lanes/99999/preflight?action=reset");
assert.equal(r.status, 404);
assert.ok(r.body.error);
});
it("returns 400 for an unknown action", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/preflight-unknown-action" });
const id = c.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/preflight?action=frobnicate`);
assert.equal(r.status, 400);
assert.ok(r.body.error);
await request("DELETE", `/api/lanes/${id}`);
});
it("preflight on an adopted lane blocks with 'adopted'", async () => {
const c = await request("POST", "/api/lanes", {
cwd: "/tmp/preflight-adopted",
kind: "adopted",
});
const id = c.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/preflight?action=reset`);
assert.equal(r.status, 200);
assert.ok(r.body.blocked);
assert.ok(r.body.blocked.includes("adopted"));
await request("DELETE", `/api/lanes/${id}`);
});
it("preflight counts dirty, untracked and unpushed for a managed lane", async () => {
// Create a managed lane with a worktree
const wt = require("../lib/worktree");
const dir = path.join(process.env.LANES_ROOT, "src-repo__test-dirty");
// Create the worktree
await wt.addWorktree({
sourceRepo: SRC,
dir,
branch: "feat/test-dirty",
base: "main",
});
// Create the lane
const c = await request("POST", "/api/lanes", {
cwd: dir,
kind: "managed",
source_repo: SRC,
base_branch: "main",
branch: "feat/test-dirty",
slug: "test-dirty",
});
const id = c.body.lane.id;
// One commit of the lane's own work, so `unpushed` has something honest to
// count: with no remote it is measured as base_branch..HEAD, not the whole
// repository history.
fs.writeFileSync(path.join(dir, "lane-work.txt"), "lane work\n");
g(dir, "add", "lane-work.txt");
g(dir, "commit", "-m", "lane work");
// Dirty the worktree
fs.appendFileSync(path.join(dir, "README.md"), "edit\n");
fs.writeFileSync(path.join(dir, "scratch.txt"), "untracked\n");
// Get preflight
const r = await request("GET", `/api/lanes/${id}/preflight?action=reset`);
assert.equal(r.status, 200);
assert.equal(r.body.action, "reset");
assert.equal(r.body.dirty, 1);
assert.equal(r.body.untracked, 1);
// Exactly the one commit ahead of main, not main's own commit as well.
assert.equal(r.body.unpushed, 1);
assert.ok(r.body.head);
assert.ok(Array.isArray(r.body.blocked));
// "unpushed-commits" is a hard blocker (force overrides it); "no-remote" is
// informational only and must not appear in blocked.
assert.ok(r.body.blocked.includes("unpushed-commits"));
assert.ok(!r.body.blocked.includes("no-remote"));
assert.ok(Array.isArray(r.body.warnings));
assert.ok(r.body.warnings.includes("no-remote"));
await request("DELETE", `/api/lanes/${id}`);
});
it("preflight for purge counts only eligible sessions and dependent rows", async () => {
const cwd = "/tmp/preflight-purge";
const c = await request("POST", "/api/lanes", { cwd });
const id = c.body.lane.id;
const { db } = require("../db");
// The completed child is eligible; the active child, lane-bound session, and
// string-prefix sibling each exercise one of purgeCandidateSessions' exclusions.
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, ?)").run(
"preflight-purge-eligible",
`${cwd}/child`,
"completed"
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, ?)").run(
"preflight-purge-active",
`${cwd}/active-child`,
"active"
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, ?)").run(
"preflight-purge-bound",
cwd,
"completed"
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, ?)").run(
"preflight-purge-prefix-sibling",
`${cwd}-other`,
"completed"
);
db.prepare("UPDATE lanes SET session_id = ? WHERE id = ?").run("preflight-purge-bound", id);
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, ?)").run(
"preflight-purge-eligible",
"PostToolUse"
);
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, ?)").run(
"preflight-purge-eligible",
"Stop"
);
db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, ?, ?)").run(
"preflight-purge-eligible",
"test-model",
1
);
const r = await request("GET", `/api/lanes/${id}/preflight?action=purge`);
assert.equal(r.status, 200);
assert.equal(r.body.action, "purge");
assert.equal(r.body.lane, id);
assert.equal(r.body.sessions, 1);
assert.equal(r.body.events, 2);
assert.equal(r.body.tokenRows, 1);
assert.equal(r.body.activeSessionSkipped, true);
await request("DELETE", `/api/lanes/${id}`);
});
it("preflight returns 'missing' block when the lane directory vanishes", async () => {
const wt = require("../lib/worktree");
const dir = path.join(process.env.LANES_ROOT, "src-repo__test-missing");
// Create the worktree
await wt.addWorktree({
sourceRepo: SRC,
dir,
branch: "feat/test-missing",
base: "main",
});
// Create the lane
const c = await request("POST", "/api/lanes", {
cwd: dir,
kind: "managed",
source_repo: SRC,
base_branch: "main",
branch: "feat/test-missing",
slug: "test-missing",
});
const id = c.body.lane.id;
// Remove the directory
fs.rmSync(dir, { recursive: true, force: true });
// Get preflight - should report missing but not crash
const r = await request("GET", `/api/lanes/${id}/preflight?action=reset`);
assert.equal(r.status, 200);
assert.ok(r.body.blocked);
assert.ok(r.body.blocked.includes("missing"));
await request("DELETE", `/api/lanes/${id}`);
});
it("counts only the lane's own commits as unpushed in a local-only repo, not the whole history", async () => {
// Regression: with no remote, `unpushed` used to be `rev-list --count HEAD`,
// so a freshly provisioned worktree in a repo with history reported every
// commit in that repo as unpushed and demanded Force to discard commits a
// `reset --hard <base>` would never touch.
const deepRepo = path.join(ROOT, "deep-repo");
fs.mkdirSync(deepRepo, { recursive: true });
g(deepRepo, "init", "-b", "main");
g(deepRepo, "config", "user.email", "test@example.com");
g(deepRepo, "config", "user.name", "Test");
for (let i = 1; i <= 5; i += 1) {
fs.writeFileSync(path.join(deepRepo, `f${i}.txt`), `${i}\n`);
g(deepRepo, "add", "-A");
g(deepRepo, "commit", "-m", `commit ${i}`);
}
assert.equal(g(deepRepo, "rev-list", "--count", "HEAD").trim(), "5");
assert.equal(g(deepRepo, "remote").trim(), "");
const wt = require("../lib/worktree");
const dir = path.join(process.env.LANES_ROOT, "deep-repo__fresh");
await wt.addWorktree({ sourceRepo: deepRepo, dir, branch: "feat/fresh", base: "main" });
const c = await request("POST", "/api/lanes", {
cwd: dir,
kind: "managed",
source_repo: deepRepo,
base_branch: "main",
branch: "feat/fresh",
slug: "fresh",
});
const id = c.body.lane.id;
const fresh = await request("GET", `/api/lanes/${id}/preflight?action=reset`);
assert.equal(fresh.status, 200);
// Nothing of this lane's own has been committed yet.
assert.equal(fresh.body.unpushed, 0);
assert.ok(!fresh.body.blocked.includes("unpushed-commits"));
// The user is still told nothing is backed up — that is the warning's job.
assert.ok(fresh.body.warnings.includes("no-remote"));
// One commit of its own now counts, and only that one.
fs.writeFileSync(path.join(dir, "mine.txt"), "mine\n");
g(dir, "add", "mine.txt");
g(dir, "commit", "-m", "my work");
const after = await request("GET", `/api/lanes/${id}/preflight?action=reset`);
assert.equal(after.body.unpushed, 1);
assert.ok(after.body.blocked.includes("unpushed-commits"));
assert.ok(!after.body.blocked.includes("no-remote"));
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("managed worktree provisioning", () => {
it("creates a managed lane, returns 202 provisioning, then flips to idle when the worktree lands", async () => {
const created = await request("POST", "/api/lanes/worktree", {
sourceRepo: SRC,
title: "Managed Lane",
base: "main",
});
assert.equal(created.status, 202);
assert.equal(created.body.lane.kind, "managed");
assert.equal(created.body.lane.status, "provisioning");
assert.equal(created.body.lane.branch, "feat/managed-lane");
const lane = await waitForProvisioning(created.body.lane.id);
assert.equal(lane.status, "idle");
assert.equal(lane.base_branch, "main");
assert.equal(fs.existsSync(lane.cwd), true);
assert.equal(g(lane.cwd, "branch", "--show-current").trim(), "feat/managed-lane");
});
it("honours LANE_BASE_BRANCH as the default base when the request omits base", async () => {
g(SRC, "branch", "alt-base");
process.env.LANE_BASE_BRANCH = "alt-base";
try {
const created = await request("POST", "/api/lanes/worktree", {
sourceRepo: SRC,
title: "Base Env Lane",
});
assert.equal(created.status, 202);
const lane = await waitForProvisioning(created.body.lane.id);
assert.equal(lane.status, "idle");
assert.equal(lane.base_branch, "alt-base");
} finally {
delete process.env.LANE_BASE_BRANCH;
}
});
it("honours LANE_BRANCH_PREFIX for the managed lane's feature branch name", async () => {
process.env.LANE_BRANCH_PREFIX = "wt/";
try {
const created = await request("POST", "/api/lanes/worktree", {
sourceRepo: SRC,
title: "Prefix Env Lane",
base: "main",
});
assert.equal(created.status, 202);
assert.equal(created.body.lane.branch, "wt/prefix-env-lane");
const lane = await waitForProvisioning(created.body.lane.id);
assert.equal(lane.status, "idle");
assert.equal(g(lane.cwd, "branch", "--show-current").trim(), "wt/prefix-env-lane");
} finally {
delete process.env.LANE_BRANCH_PREFIX;
}
});
it("rejects a sourceRepo that is not an absolute path or not a git repo", async () => {
const cases = ["relative-repo", path.join(ROOT, "missing-repo"), ROOT];
for (const sourceRepo of cases) {
const response = await request("POST", "/api/lanes/worktree", {
sourceRepo,
title: "Invalid Source",
});
assert.equal(response.status, 400);
assert.equal(response.body.error.code, "EBADSOURCEREPO");
}
});
it("suffixes the slug when the directory already exists", async () => {
fs.mkdirSync(path.join(process.env.LANES_ROOT, "src-repo__collision"), { recursive: true });
const created = await request("POST", "/api/lanes/worktree", {
sourceRepo: SRC,
title: "Collision",
base: "main",
});
assert.equal(created.status, 202);
assert.equal(created.body.lane.slug, "collision-2");
assert.equal(created.body.lane.cwd, path.join(process.env.LANES_ROOT, "src-repo__collision-2"));
const lane = await waitForProvisioning(created.body.lane.id);
assert.equal(lane.status, "idle");
});
it("returns 409 when no unique worktree directory is found in 50 attempts", async () => {
const root = path.join(process.env.LANES_ROOT, "src-repo__collision-cap");
for (let suffix = 1; suffix <= 51; suffix += 1) {
const dir = suffix === 1 ? root : `${root}-${suffix}`;
fs.mkdirSync(dir, { recursive: true });
}
const response = await request("POST", "/api/lanes/worktree", {
sourceRepo: SRC,
title: "Collision Cap",
base: "main",
});
assert.equal(response.status, 409);
assert.equal(response.body.error.code, "EWORKTREEDIRCOLLISION");
assert.equal(
response.body.error.message,
"could not allocate a unique worktree directory after 50 attempts"
);
});
it("marks the lane failed with git's message when provisioning fails", async () => {
const emptyRepo = path.join(ROOT, "empty-repo");
fs.mkdirSync(emptyRepo, { recursive: true });
g(emptyRepo, "init", "-b", "main");
const created = await request("POST", "/api/lanes/worktree", {
sourceRepo: emptyRepo,
title: "Unborn Repo",
base: "main",
});
assert.equal(created.status, 202);
assert.equal(created.body.lane.status, "provisioning");
const lane = await waitForProvisioning(created.body.lane.id);
assert.equal(lane.kind, "managed");
assert.equal(lane.status, "failed");
assert.match(lane.notes, /fatal:|ambiguous argument|unknown revision/i);
assert.equal(fs.existsSync(lane.cwd), false);
// A failed provisioning never created a worktree, so POST remove correctly
// refuses it; DELETE remains the non-destructive "forget this lane row" path.
const removed = await request("DELETE", `/api/lanes/${lane.id}`);
assert.equal(removed.status, 200);
const missing = await request("GET", `/api/lanes/${lane.id}`);
assert.equal(missing.status, 404);
});
it("uses a caller-supplied branch name instead of deriving one from the slug", async () => {
const created = await request("POST", "/api/lanes/worktree", {
sourceRepo: SRC,
title: "Custom Branch",
base: "main",
branch: "custom/my-branch",
});
assert.equal(created.status, 202);
assert.equal(created.body.lane.branch, "custom/my-branch");
const lane = await waitForProvisioning(created.body.lane.id);
assert.equal(lane.status, "idle");
assert.equal(g(lane.cwd, "branch", "--show-current").trim(), "custom/my-branch");
});
it("rejects an invalid caller-supplied branch name before creating anything", async () => {
const response = await request("POST", "/api/lanes/worktree", {
sourceRepo: SRC,
title: "Bad Branch",
base: "main",
branch: "not a valid branch..name",
});
assert.equal(response.status, 400);
assert.equal(response.body.error.code, "EBADBRANCH");
});
});
describe("GET /api/lanes/browse", () => {
it("lists a directory's immediate subdirectories, marking git repos", async () => {
const response = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
assert.equal(response.status, 200);
assert.equal(response.body.path, ROOT);
const names = response.body.entries.map((e) => e.name);
assert.ok(names.includes("src-repo"));
const srcRepoEntry = response.body.entries.find((e) => e.name === "src-repo");
assert.equal(srcRepoEntry.isGitRepo, true);
});
it("reports the parent directory, or null at the filesystem root", async () => {
const response = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
assert.equal(response.body.parent, path.dirname(ROOT));
const rootResponse = await request("GET", "/api/lanes/browse?path=/");
assert.equal(rootResponse.body.parent, null);
});
it("rejects a path that does not exist or is not a directory", async () => {
const missing = await request(
"GET",
`/api/lanes/browse?path=${encodeURIComponent(path.join(ROOT, "does-not-exist"))}`
);
assert.equal(missing.status, 400);
assert.equal(missing.body.error.code, "ENOTFOUND");
const filePath = path.join(ROOT, "a-file.txt");
fs.writeFileSync(filePath, "hi\n");
const notADir = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(filePath)}`);
assert.equal(notADir.status, 400);
assert.equal(notADir.body.error.code, "ENOTADIR");
});
});
describe("destructive lane lifecycle actions", () => {
async function createManagedLane(slug) {
const wt = require("../lib/worktree");
const dir = path.join(process.env.LANES_ROOT, `src-repo__${slug}`);
const branch = `feat/${slug}`;
await wt.addWorktree({ sourceRepo: SRC, dir, branch, base: "main" });
const created = await request("POST", "/api/lanes", {
cwd: dir,
kind: "managed",
source_repo: SRC,
base_branch: "main",
branch,
slug,
});
assert.equal(created.status, 201);
return created.body.lane;
}
function destructiveExpect(report) {
return {
head: report.head,
dirty: report.dirty,
untracked: report.untracked,
unpushed: report.unpushed,
};
}
it("reset requires confirm, restores the branch from base and clears lane state", async () => {
const lane = await createManagedLane("reset-clean");
fs.appendFileSync(path.join(lane.cwd, "README.md"), "changed\n");
fs.writeFileSync(path.join(lane.cwd, "untracked.txt"), "remove me\n");
fs.mkdirSync(path.join(lane.cwd, "node_modules"));
fs.writeFileSync(path.join(lane.cwd, "node_modules", "preserved.txt"), "keep me\n");
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
const unconfirmed = await request("POST", `/api/lanes/${lane.id}/reset`, {});
assert.equal(unconfirmed.status, 400);
assert.equal(unconfirmed.body.error.code, "ECONFIRM");
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
confirm: true,
force: true,
expect: destructiveExpect(preflight.body),
});
assert.equal(reset.status, 200);
assert.equal(reset.body.lane.stage, "idle");
assert.equal(reset.body.lane.status, "idle");
assert.equal(reset.body.lane.run_id, null);
assert.equal(fs.readFileSync(path.join(lane.cwd, "README.md"), "utf8"), "hello\n");
assert.equal(fs.existsSync(path.join(lane.cwd, "untracked.txt")), false);
assert.equal(
fs.readFileSync(path.join(lane.cwd, "node_modules", "preserved.txt"), "utf8"),
"keep me\n"
);
});
it("reset refuses with 409 when unpushed commits exist, and proceeds with force", async () => {
const lane = await createManagedLane("reset-force");
fs.writeFileSync(path.join(lane.cwd, "committed.txt"), "commit me\n");
g(lane.cwd, "add", "committed.txt");
g(lane.cwd, "commit", "-m", "unpushed work");
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
// One commit ahead of main — main's own commit is the base, not lane work.
assert.equal(preflight.body.unpushed, 1);
const refused = await request("POST", `/api/lanes/${lane.id}/reset`, {
confirm: true,
expect: destructiveExpect(preflight.body),
});
assert.equal(refused.status, 409);
assert.equal(refused.body.error.code, "EUNPUSHED");
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
confirm: true,
force: true,
expect: destructiveExpect(preflight.body),
});
assert.equal(reset.status, 200);
assert.equal(g(lane.cwd, "rev-list", "--count", "HEAD").trim(), "1");
});
it("reset returns 409 ESTALE when the head moved since preflight", async () => {
const lane = await createManagedLane("reset-stale");
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
fs.writeFileSync(path.join(lane.cwd, "head-moved.txt"), "moved\n");
g(lane.cwd, "add", "head-moved.txt");
g(lane.cwd, "commit", "-m", "move head");
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
confirm: true,
force: true,
expect: destructiveExpect(preflight.body),
});
assert.equal(reset.status, 409);
assert.equal(reset.body.error.code, "ESTALE");
assert.equal(g(lane.cwd, "log", "-1", "--format=%s").trim(), "move head");
});
it("remove tears down the worktree and the branch, and deletes the lane row", async () => {
const lane = await createManagedLane("remove-managed");
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`);
const removed = await request("POST", `/api/lanes/${lane.id}/remove`, {
confirm: true,
force: true,
expect: destructiveExpect(preflight.body),
});
assert.equal(removed.status, 200);
assert.equal(removed.body.ok, true);
assert.equal(fs.existsSync(lane.cwd), false);
assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), "");
const missing = await request("GET", `/api/lanes/${lane.id}`);
assert.equal(missing.status, 404);
});
it("reset refuses an adopted lane, while remove forgets its row without touching its directory", async () => {
const adoptedDir = path.join(ROOT, "adopted-preserved");
fs.mkdirSync(adoptedDir, { recursive: true });
const adoptedFile = path.join(adoptedDir, "do-not-delete.txt");
fs.writeFileSync(adoptedFile, "real project contents\n");
const created = await request("POST", "/api/lanes", { cwd: adoptedDir });
const lane = created.body.lane;
const resetPreflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
confirm: true,
force: true,
expect: destructiveExpect(resetPreflight.body),
});
assert.equal(reset.status, 400);
assert.equal(reset.body.error.code, "ENOTMANAGED");
assert.equal(fs.existsSync(adoptedDir), true);
assert.equal(fs.readFileSync(adoptedFile, "utf8"), "real project contents\n");
const removePreflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`);
const removed = await request("POST", `/api/lanes/${lane.id}/remove`, {
confirm: true,
force: true,
expect: destructiveExpect(removePreflight.body),
});
assert.equal(removed.status, 200);
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).status, 404);
assert.equal(fs.existsSync(adoptedDir), true);
assert.equal(fs.readFileSync(adoptedFile, "utf8"), "real project contents\n");
});
it("forgets an adopted lane with unpushed commits without force, while managed removal still requires it", async () => {
const adoptedDir = path.join(ROOT, "adopted-unpushed");
fs.mkdirSync(adoptedDir, { recursive: true });
g(adoptedDir, "init");
g(adoptedDir, "config", "user.email", "test@example.com");
g(adoptedDir, "config", "user.name", "Test User");
fs.writeFileSync(path.join(adoptedDir, "README.md"), "adopted\n");
g(adoptedDir, "add", "README.md");
g(adoptedDir, "commit", "-m", "adopted commit");
const adopted = (await request("POST", "/api/lanes", { cwd: adoptedDir })).body.lane;
const adoptedPreflight = await request(
"GET",
`/api/lanes/${adopted.id}/preflight?action=remove`
);
assert.ok(adoptedPreflight.body.unpushed > 0);
const forgotten = await request("POST", `/api/lanes/${adopted.id}/remove`, {
confirm: true,
expect: destructiveExpect(adoptedPreflight.body),
});
assert.equal(forgotten.status, 200);
assert.equal(fs.existsSync(adoptedDir), true);
const managed = await createManagedLane("remove-unpushed-force");
fs.writeFileSync(path.join(managed.cwd, "committed.txt"), "commit me\n");
g(managed.cwd, "add", "committed.txt");
g(managed.cwd, "commit", "-m", "unpushed managed work");
const managedPreflight = await request(
"GET",
`/api/lanes/${managed.id}/preflight?action=remove`
);
assert.ok(managedPreflight.body.unpushed > 0);
const refused = await request("POST", `/api/lanes/${managed.id}/remove`, {
confirm: true,
expect: destructiveExpect(managedPreflight.body),
});
assert.equal(refused.status, 409);
assert.equal(refused.body.error.code, "EUNPUSHED");
await request("DELETE", `/api/lanes/${managed.id}`);
});
it("requires a complete expect object and returns stale diagnostics", async () => {
const lane = await createManagedLane("expect-required");
const resetPreflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
const omitted = await request("POST", `/api/lanes/${lane.id}/reset`, {
confirm: true,
force: true,
});
assert.equal(omitted.status, 400);
assert.equal(omitted.body.error.code, "EEXPECT");
const partial = await request("POST", `/api/lanes/${lane.id}/reset`, {
confirm: true,
force: true,
expect: { head: resetPreflight.body.head },
});
assert.equal(partial.status, 400);
assert.equal(partial.body.error.code, "EEXPECT");
fs.writeFileSync(path.join(lane.cwd, "changed-after-preflight.txt"), "changed\n");
const stale = await request("POST", `/api/lanes/${lane.id}/reset`, {
confirm: true,
force: true,
expect: destructiveExpect(resetPreflight.body),
});
assert.equal(stale.status, 409);
assert.equal(stale.body.error.code, "ESTALE");
assert.deepEqual(stale.body.error.expected, destructiveExpect(resetPreflight.body));
assert.equal(stale.body.error.current.untracked, 1);
const purgePreflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=purge`);
const purgePartial = await request("POST", `/api/lanes/${lane.id}/purge`, {
confirm: true,
expect: { sessions: purgePreflight.body.sessions, events: purgePreflight.body.events },
});
assert.equal(purgePartial.status, 400);
assert.equal(purgePartial.body.error.code, "EEXPECT");
await request("DELETE", `/api/lanes/${lane.id}`);
});
it("waits for the tmux session to exit before resetting its worktree", async () => {
const lane = await createManagedLane("await-real-exit");
fs.writeFileSync(path.join(lane.cwd, "written-by-run.txt"), "run output\n");
const bin = path.join(ROOT, "run-exit-bin");
const claude = path.join(bin, "claude");
fs.mkdirSync(bin, { recursive: true });
fs.writeFileSync(
claude,
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
);
fs.chmodSync(claude, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
try {
const started = await request("POST", `/api/lanes/${lane.id}/start`, {
prompt: "keep running",
});
assert.equal(started.status, 200);
const running = await request("GET", `/api/lanes/${lane.id}`);
const runId = running.body.lane.run_id;
assert.equal(typeof runId, "string");
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
confirm: true,
force: true,
expect: destructiveExpect(preflight.body),
});
assert.equal(reset.status, 200);
assert.equal(runs.getRun(runId).status, "gone");
assert.equal(fs.existsSync(path.join(lane.cwd, "written-by-run.txt")), false);
} finally {
process.env.PATH = originalPath;
}
await request("DELETE", `/api/lanes/${lane.id}`);
});
it("returns ERUNTIMEOUT and leaves the worktree untouched when a tmux session never exits", async () => {
const tmux = require("../lib/tmux");
const lane = await createManagedLane("await-timeout");
const sentinel = path.join(lane.cwd, "must-survive-timeout.txt");
fs.writeFileSync(sentinel, "still here\n");
// Start a run for the lane
const restorePath = stubClaudeBinary("await-timeout");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" });
assert.equal(started.status, 200);
const runId = started.body.lane.run_id;
// Mock tmux so has-session always returns 0 (session exists), simulating a stuck session
tmux.__setExecImpl(async (cmd, args) => {
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
return { code: 0, stdout: "", stderr: "" };
}
if (cmd === "tmux" && args[0] === "list-sessions") {
return { code: 0, stdout: "", stderr: "" };
}
if (cmd === "tmux" && args[0] === "kill-session" && args[1] === "-t" && args[2] === runId) {
return { code: 0, stdout: "", stderr: "" };
}
return { code: 1, stdout: "", stderr: "" };
});
try {
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
confirm: true,
force: true,
expect: destructiveExpect(preflight.body),
});
assert.equal(reset.status, 500);
assert.equal(reset.body.error.code, "ERUNTIMEOUT");
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
} finally {
tmux.__reset();
// The mocked kill-session above only fools the app's own check — the
// real tmux session + claude stub spawned above is still alive and
// must be killed for real, or it leaks past this test run.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
}
await request("DELETE", `/api/lanes/${lane.id}`);
});
it("removes a lane whose worktree was deleted by hand, taking the prune path", async () => {
// The design promises "the lane reports `missing` and only `remove` is
// offered, taking the prune path". Before this, `remove` hit check 2, which
// maps a non-existent path to EOUTSIDEROOT → 400, so the lane row could
// never be removed through the destructive action at all.
const lane = await createManagedLane("remove-hand-deleted");
fs.rmSync(lane.cwd, { recursive: true, force: true });
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`);
assert.equal(preflight.status, 200);
assert.ok(preflight.body.blocked.includes("missing"));
assert.equal(preflight.body.head, null);
const removed = await request("POST", `/api/lanes/${lane.id}/remove`, {
confirm: true,
expect: destructiveExpect(preflight.body),
});
assert.equal(removed.status, 200, JSON.stringify(removed.body));
assert.equal(removed.body.ok, true);
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).status, 404);
// git's stale record and the lane's branch are both gone.
const wt = require("../lib/worktree");
assert.equal(
(await wt.listWorktrees(SRC)).some((w) => w.path === lane.cwd),
false
);
assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), "");
});
it("refuses a second start while the first run is still live, so no tmux session is orphaned", async () => {
// Overwriting run_id while its tmux session is alive orphans that session: a later
// reset kills and awaits only the RECORDED run's session, then `git clean -fd` the
// directory the orphan session is still using.
const tmux = require("../lib/tmux");
const lane = await createManagedLane("start-twice");
// Start a run for the lane
const restorePath = stubClaudeBinary("start-twice");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" });
assert.equal(started.status, 200);
const runId = started.body.lane.run_id;
assert.equal(runs.getRun(runId).status, "running");
// Mock tmux so the session appears live
tmux.__setExecImpl(async (cmd, args) => {
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
return { code: 0, stdout: "", stderr: "" };
}
if (cmd === "tmux" && args[0] === "list-sessions") {
return { code: 0, stdout: "", stderr: "" };
}
return { code: 1, stdout: "", stderr: "" };
});
try {
// Try to start a second run — should be refused
const second = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "me too" });
assert.equal(second.status, 409);
assert.equal(second.body.error.code, "ERUNLIVE");
// The first run is still the recorded one — nothing was overwritten.
const after = await request("GET", `/api/lanes/${lane.id}`);
assert.equal(after.body.lane.run_id, runId);
} finally {
tmux.__reset();
// The real tmux session behind the "first" run is never reset/killed
// in this test, mocked or otherwise — kill it for real so it doesn't
// leak past this test run.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
}
await request("DELETE", `/api/lanes/${lane.id}`);
});
it("start is serialized behind the per-lane lock, not left atomic only by accident", async () => {
// The check-then-spawn in the "start" case has no `await` between reading
// run_id and writing it, so it happens to be atomic today regardless of
// locking. Wrapping it in withLaneLock is what keeps that true even after
// a future edit adds one — provable by holding the SAME lock from outside
// the route and confirming `start` genuinely queues behind it rather than
// running immediately.
const { withLaneLock } = require("../lib/lane-lock");
const lane = await createManagedLane("start-lock-order");
const bin = path.join(ROOT, "start-lock-bin");
const claude = path.join(bin, "claude");
fs.mkdirSync(bin, { recursive: true });
fs.writeFileSync(
claude,
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
);
fs.chmodSync(claude, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
let releaseHeldLock;
const held = withLaneLock(
lane.id,
() =>
new Promise((resolve) => {
releaseHeldLock = resolve;
})
);
// Give the held lock a moment to actually become the chain's current entry.
await new Promise((resolve) => setTimeout(resolve, 20));
let startSettled = false;
const startPromise = request("POST", `/api/lanes/${lane.id}/start`, {
prompt: "queued behind the held lock",
}).then((res) => {
startSettled = true;
return res;
});
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(startSettled, false, "start must still be queued behind the held lock");
// Nothing was spawned while queued.
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).body.lane.run_id, null);
releaseHeldLock();
await held;
const started = await startPromise;
try {
assert.equal(started.status, 200, JSON.stringify(started.body));
const runId = started.body.lane.run_id;
assert.equal(typeof runId, "string");
runs.killRun(runId);
const deadline = Date.now() + 2000;
while (runs.getRun(runId).status !== "gone" && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.equal(runs.getRun(runId).status, "gone");
} finally {
process.env.PATH = originalPath;
}
await request("DELETE", `/api/lanes/${lane.id}`);
});
it("PATCH with an invalid kind returns 400, not a 500 from Express's default handler", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/patch-bad-kind" });
const id = created.body.lane.id;
const patched = await request("PATCH", `/api/lanes/${id}`, { kind: "gremlin" });
assert.equal(patched.status, 400);
assert.equal(patched.body.error.code, "EBADKIND");
// And a valid kind is silently ignored rather than rewriting check 1.
const rejected = await request("PATCH", `/api/lanes/${id}`, { kind: "managed" });
assert.equal(rejected.status, 200);
assert.equal(rejected.body.lane.kind, "adopted");
await request("DELETE", `/api/lanes/${id}`);
});
it("purge deletes the lane's sessions and reports the counts", async () => {
const cwd = path.join(ROOT, "purge-counts");
const created = await request("POST", "/api/lanes", { cwd });
const lane = created.body.lane;
const { db } = require("../db");
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, ?)").run(
"purge-action-session",
cwd,
"completed"
);
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, ?)").run(
"purge-action-session",
"Stop"
);
db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, ?, ?)").run(
"purge-action-session",
"test-model",
3
);
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=purge`);
assert.equal(preflight.body.sessions, 1);
assert.equal(preflight.body.events, 1);
assert.equal(preflight.body.tokenRows, 1);
const purged = await request("POST", `/api/lanes/${lane.id}/purge`, {
confirm: true,
expect: {
sessions: preflight.body.sessions,
events: preflight.body.events,
tokenRows: preflight.body.tokenRows,
bytesEstimate: preflight.body.bytesEstimate,
activeSessionSkipped: preflight.body.activeSessionSkipped,
},
});
assert.equal(purged.status, 200);
assert.deepEqual(purged.body.purged, { sessions: 1, events: 1, tokenRows: 1 });
assert.equal(
db.prepare("SELECT COUNT(*) AS count FROM sessions WHERE id = ?").get("purge-action-session")
.count,
0
);
assert.equal(
db
.prepare("SELECT COUNT(*) AS count FROM events WHERE session_id = ?")
.get("purge-action-session").count,
0
);
assert.equal(
db
.prepare("SELECT COUNT(*) AS count FROM token_usage WHERE session_id = ?")
.get("purge-action-session").count,
0
);
});
});
describe("lane ensure, start mode, lane_id and releasing a finished run", () => {
const { db } = require("../db");
async function adoptedLane(name) {
const cwd = path.join(ROOT, `ensure-${name}`);
fs.mkdirSync(cwd, { recursive: true });
const created = await request("POST", "/api/lanes/ensure", { cwd, title: name });
assert.equal(created.status, 201, JSON.stringify(created.body));
return created.body.lane;
}
it("creates an adopted lane when no lane owns the cwd", async () => {
const cwd = path.join(ROOT, "ensure-fresh");
const r = await request("POST", "/api/lanes/ensure", { cwd, title: "Fresh" });
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.equal(r.body.created, true);
assert.equal(r.body.lane.cwd, cwd);
assert.equal(r.body.lane.kind, "adopted");
assert.equal(r.body.lane.title, "Fresh");
});
it("returns the existing lane for an exact cwd without creating a second one", async () => {
const cwd = path.join(ROOT, "ensure-exact");
const first = await request("POST", "/api/lanes/ensure", { cwd });
assert.equal(first.status, 201);
const second = await request("POST", "/api/lanes/ensure", { cwd, title: "ignored" });
assert.equal(second.status, 200);
assert.equal(second.body.created, false);
assert.equal(second.body.lane.id, first.body.lane.id);
assert.equal(db.prepare("SELECT COUNT(*) AS count FROM lanes WHERE cwd = ?").get(cwd).count, 1);
});
it("returns the owning lane for a cwd nested inside it", async () => {
const parent = path.join(ROOT, "ensure-parent");
const nested = path.join(parent, "packages", "app");
const owner = await request("POST", "/api/lanes/ensure", { cwd: parent });
assert.equal(owner.status, 201);
const r = await request("POST", "/api/lanes/ensure", { cwd: nested });
assert.equal(r.status, 200);
assert.equal(r.body.created, false);
assert.equal(r.body.lane.id, owner.body.lane.id);
assert.equal(r.body.lane.cwd, parent);
// A path-boundary sibling is NOT owned by it.
const sibling = await request("POST", "/api/lanes/ensure", { cwd: `${parent}-sibling` });
assert.equal(sibling.status, 201);
assert.equal(sibling.body.created, true);
assert.notEqual(sibling.body.lane.id, owner.body.lane.id);
});
it("yields exactly one lane for two concurrent ensure calls on the same path", async () => {
const cwd = path.join(ROOT, "ensure-concurrent");
const [a, b] = await Promise.all([
request("POST", "/api/lanes/ensure", { cwd }),
request("POST", "/api/lanes/ensure", { cwd }),
]);
assert.equal(db.prepare("SELECT COUNT(*) AS count FROM lanes WHERE cwd = ?").get(cwd).count, 1);
assert.equal(a.body.lane.id, b.body.lane.id);
assert.deepEqual([a.status, b.status].sort(), [200, 201]);
assert.deepEqual([a.body.created, b.body.created].sort(), [false, true]);
});
it("rejects a relative cwd with 400 EBADCWD", async () => {
const r = await request("POST", "/api/lanes/ensure", { cwd: "relative/path" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADCWD");
});
it("refuses a cross-origin ensure", async () => {
const cwd = path.join(ROOT, "ensure-cross-origin");
const r = await request("POST", "/api/lanes/ensure", { cwd }, { Origin: "http://evil.test" });
assert.equal(r.status, 403);
assert.equal(r.body.error.code, "EBADORIGIN");
assert.equal(db.prepare("SELECT COUNT(*) AS count FROM lanes WHERE cwd = ?").get(cwd).count, 0);
});
it("leaves a lane that already has a live run_id untouched during healing", async () => {
const tmux = require("../lib/tmux");
const lane = await adoptedLane("release-moved-on");
// Create a run for this lane.
const restorePath = stubClaudeBinary("release-moved-on");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
assert.equal(started.status, 200, JSON.stringify(started.body));
const runId = started.body.lane.run_id;
// Mock tmux so the run appears to be live.
tmux.__setExecImpl(async (cmd, args) => {
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
return { code: 0, stdout: "", stderr: "" };
}
if (cmd === "tmux" && args[0] === "list-sessions") {
return { code: 0, stdout: "", stderr: "" };
}
return { code: 1, stdout: "", stderr: "" };
});
try {
// Read the lane — it should NOT clear the run_id since it's still live.
const before = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
assert.equal(before.run_id, runId);
assert.equal(before.status, "running");
// Read again — same result, healing preserves live runs.
const after = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
assert.equal(after.run_id, runId);
assert.equal(after.status, "running");
} finally {
tmux.__reset();
// The app never calls kill-session here (healing preserves the "live"
// run) — kill the real tmux session directly so it doesn't leak.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
}
});
it("clears a stale run_id and sets status to idle when the tmux session is gone", async () => {
const tmux = require("../lib/tmux");
const lane = await adoptedLane("release-stale-run");
// Start a run for this lane.
const restorePath = stubClaudeBinary("release-stale-run");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
assert.equal(started.status, 200, JSON.stringify(started.body));
const runId = started.body.lane.run_id;
assert.equal(typeof runId, "string");
assert.equal(started.body.lane.status, "running");
// Mock tmux so the session appears to be gone (has-session fails with status 1).
tmux.__setExecImpl((args) => {
if (args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
const e = new Error("no such session");
e.status = 1;
throw e;
}
if (args[0] === "list-sessions") {
return "";
}
return "";
});
try {
// Read the lane — it should clear the run_id and set status to idle.
const after = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
assert.equal(after.run_id, null, "run_id should be cleared for stale session");
assert.equal(after.status, "idle", "status should be idle after run is gone");
} finally {
tmux.__reset();
// The app believes the session is already gone and never calls
// kill-session — kill the real tmux session directly so it doesn't leak.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
}
});
});