Files
Claude-Code-Monitor/docs/superpowers/logs/2026-07-28-worktree-lanes-followups.md
T
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
2026-07-30 14:39:03 +07:00

10 KiB

Worktree-lanes: final-review follow-ups

Base: bf312c6 (branch feat/worktree-lanes). One commit for the whole set.

Follow-up 1: unreadable worktrees were genuinely unremovable

(a) Made an unreadable managed lane genuinely removable

server/lib/worktree.js: removeWorktree unconditionally called git worktree remove --force, which git refuses outright — even with a second --force — when the worktree's OWN .git pointer fails its own validation (a corrupt/garbage .git file). All three assertDestroyable checks pass for this shape (the directory exists, resolves inside LANES_ROOT, and is still listed by the source repo), so the lane was stuck: removeWorktree threw, the route 500'd, the lane row survived.

Fix: wrapped the git worktree remove --force call in a try/catch. On failure, findWorktreeAdminDir(sourceRepo, cwd) locates the worktree's administrative directory under the source repo's common dir (<common>/worktrees/<name>) by reading each entry's gitdir file — that file's content is the absolute path to the worktree's own .git file, read from the source repo's side, so it still resolves correctly even though the worktree's own .git file is corrupt. If found, fs.rmSync deletes only that administrative directory (never the worktree directory itself), which deregisters the worktree from git worktree list. Branch delete and lane-row deletion then proceed exactly as for every other remove. If no matching admin directory is found (paranoid case — should not happen for a lane that passed all three checks), the original git error is rethrown rather than silently continuing, so the failure is never swallowed.

This is a genuine deregistration, not a workaround: it never touches the worktree's own files, and after it runs, git worktree remove --force on the same path correctly reports '...' is not a working tree — proof the repo no longer thinks it manages that directory.

Experiment (raw output)

Built a real corrupt worktree under a throwaway repo and ran the exact sequence a reviewer had already reproduced, then the new fallback:

=== remove --force (expect fail) ===
fatal: validation failed, cannot remove working tree: '/tmp/tmp.4kZLeHvThU/lanes/src__corrupt/.git' is not a .git file, error code 5
exit=128

=== manually delete admin dir ===
(no output)

=== worktree list after manual admin removal ===
worktree /tmp/tmp.4kZLeHvThU/src
HEAD 0a1ed2bb01a8ac88b758180d569997b8c0bb22e8
branch refs/heads/main

=== branch still exists? ===
  feat/corrupt

=== worktree directory + corrupted .git file still present? ===
total 16
drwxrwxr-x 2 smartgiftailab smartgiftailab 4096 ... .
drwxrwxr-x 3 smartgiftailab smartgiftailab 4096 ... ..
-rw-rw-r-- 1 smartgiftailab smartgiftailab    8 ... .git
-rw-rw-r-- 1 smartgiftailab smartgiftailab    6 ... README.md
garbage

=== git worktree remove again now (expect: not a working tree, confirms deregistered) ===
fatal: '/tmp/tmp.4kZLeHvThU/lanes/src__corrupt' is not a working tree
exit=128

This confirms: (1) remove --force genuinely refuses a corrupt worktree, matching the earlier report exactly (error code 5 here vs 7 in the original report — git version difference, same refusal); (2) deleting only .git/worktrees/<name> deregisters the worktree from git worktree list without touching the worktree directory or its files; (3) after deregistration git itself confirms the path is no longer a working tree.

The actual code path (removeWorktree against a real corrupt worktree inside LANES_ROOT, through findWorktreeAdminDir) was then exercised by the new server test below and produced the same result end to end.

(b) Reworded the false promise

destructive.notice.unreadable in client/src/i18n/locales/{en,zh,vi,ko}/lanes.json no longer promises forced success. New English text: "The lane directory cannot be read as a Git worktree. Removal is attempted; if Git itself refuses, its worktree record is cleared directly instead. Either way, the directory itself is never touched." zh/vi/ko were translated with the same meaning (not machine-transliterated word-for-word), matching each locale's existing terminology for "lane"/"worktree"/"record" already used elsewhere in the same file.

docs/API.md (blocked/remove paragraph) and docs/LANES.md (the three safety checks section) both had the same "force-removes an unreadable one" claim; both now describe the attempt-then-fallback behavior and name the corrupt-.git-pointer case explicitly.

(c) Added the two missing modal tests

client/src/components/lanes/__tests__/DestructiveLaneModal.test.tsx:

  • disables RESET when the worktree directory is unreadable — pins unreadable staying in HARD_BLOCKERS.reset.
  • ENABLES remove when the worktree directory is unreadable — pins unreadable staying absent from HARD_BLOCKERS.remove, and that the reworded notice text renders and the modal still echoes back the exact expect block on confirm.

Regression test for (a)

server/__tests__/worktree.test.js: new test builds a real worktree, writes garbage into its .git file (reproducing the exact corruption), asserts git status inside it fails (sanity), calls removeWorktree directly, then asserts: the source repo no longer lists it, its branch is gone, and the directory plus a file written into it still exist afterward untouched.

Follow-up 2: start was atomic by accident, not by invariant

server/routes/lanes.js: the start case read lane.run_id and later wrote a new one with no await in between — atomic today only because nothing yields the event loop in that stretch. Wrapped the whole check-then-spawn in withLaneLock(lane.id, async () => {...}), re-fetching the lane inside the lock (a concurrent remove could have deleted the row while queued, so a missing case now returns 404 ENOLANE instead of dereferencing a null lane — a real edge case introduced by the lock itself, not present before). The 409 ERUNLIVE code and message are unchanged.

Regression test (server/__tests__/lane-lifecycle.test.js, "start is serialized behind the per-lane lock..."): holds the same lane's lock directly from the test (withLaneLock(lane.id, () => new Promise(...))), fires POST .../start while that lock is held, confirms the request has NOT settled 50ms later and that no run_id was written, then releases the held lock and confirms start only proceeds (spawns, returns 200) after release. This proves the route genuinely shares the per-lane lock rather than proving only that the current zero-await code happens to be atomic.

Follow-up 3: openapi.yaml drift check in CI

.github/workflows/ci.yml: added a step to the existing "🧹 Check Formatting" job — npm run openapi:yaml (regenerate) followed by git diff --exit-code openapi.yaml. No new job, no git hook (the pre-commit hook is already slow, per instruction). Verified locally: regenerating produces zero diff against the currently committed file.

Follow-up 4: missing sameOriginGuard on two mutating routes

server/routes/lanes.js: added sameOriginGuard to POST /api/lanes/ (lane creation) and POST /api/lanes/:id/stage (stage reporting) — previously the only two mutating lane routes without it.

Checked both callers before changing anything:

  • bin/ccam.js's post() helper (backing ccam lanes add and ccam stage) only ever sends a Content-Type header, never Origin or Referer — the guard passes any request with no Origin header through unconditionally (same rule already relied on by every other guarded lane route). Verified end to end by re-running server/__tests__/lanes-cli.test.js (13/13) after the change — both ccam lanes add --repo and the destructive-lifecycle CLI flows (which call stage indirectly via clear) still pass.
  • grep -rn "api/lanes" mcp/ scripts/ — no hits. Nothing else calls these routes.

No legitimate caller was broken; no need to weaken the guard or stop and ask.

Regression tests added to server/__tests__/lanes-api.test.js: "rejects cross-origin lane creation" and "rejects cross-origin stage reporting", mirroring the existing cross-origin PATCH/DELETE tests exactly (assert 403 EBADORIGIN, and for stage, that the lane's stage was not changed).

Commands run, with tallies

  • node --test server/__tests__/worktree.test.js → 19/19 (was 18)
  • node --test server/__tests__/lane-lifecycle.test.js → 29/29 (was 28)
  • node --test server/__tests__/lanes-api.test.js → 22/22 (was 20)
  • node --test server/__tests__/lanes-cli.test.js → 13/13 (unchanged, re-run as a caller check for follow-up 4)
  • npm run test:server861/861 (baseline 857)
  • cd client && npx vitest run src/components/lanes/__tests__/DestructiveLaneModal.test.tsx → 16/16 (was 14)
  • npm run test:client299/299 (baseline 297)
  • npm run build → succeeded (client build, tsc -b && vite build)
  • bash .claude/skills/file-headers/scripts/check-headers.sh → exit 0
  • node scripts/generate-openapi-yaml.js && git diff --exit-code openapi.yaml → exit 0, no diff
  • npm run format:check → all files pass

Left out / deferred, with reasons

  • Did not touch the "Each verb → remove" bullet in docs/LANES.md (a different paragraph from the one named in the brief) — it only describes the ordinary managed-removal steps and makes no claim about the corrupt-.git case, so it was not inaccurate and editing it would have been unrequested scope.
  • Did not add a CLI-level test reproducing the corrupt-.git removal through ccam lanes remove — the brief asked for "a test proving (a)" which the new worktree.test.js case already does directly against removeWorktree (the same function the route and CLI both call); adding a second, slower end-to-end CLI version would duplicate coverage without proving anything new.
  • Did not add a real concurrent-claude-process test for follow-up 2 (two genuine POST /start calls racing against real spawned processes) — the codebase's own existing tests avoid ever spawning claude twice concurrently for exactly this reason (slow, and the real claude binary's behavior isn't what's under test). The lock-holding test proves the same invariant deterministically without that cost.