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.
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— pinsunreadablestaying inHARD_BLOCKERS.reset.ENABLES remove when the worktree directory is unreadable— pinsunreadablestaying absent fromHARD_BLOCKERS.remove, and that the reworded notice text renders and the modal still echoes back the exactexpectblock 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'spost()helper (backingccam lanes addandccam stage) only ever sends aContent-Typeheader, neverOriginorReferer— 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-runningserver/__tests__/lanes-cli.test.js(13/13) after the change — bothccam lanes add --repoand the destructive-lifecycle CLI flows (which callstageindirectly viaclear) 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:server→ 861/861 (baseline 857)cd client && npx vitest run src/components/lanes/__tests__/DestructiveLaneModal.test.tsx→ 16/16 (was 14)npm run test:client→ 299/299 (baseline 297)npm run build→ succeeded (client build,tsc -b && vite build)bash .claude/skills/file-headers/scripts/check-headers.sh→ exit 0node scripts/generate-openapi-yaml.js && git diff --exit-code openapi.yaml→ exit 0, no diffnpm 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-.gitcase, so it was not inaccurate and editing it would have been unrequested scope. - Did not add a CLI-level test reproducing the corrupt-
.gitremoval throughccam lanes remove— the brief asked for "a test proving (a)" which the newworktree.test.jscase already does directly againstremoveWorktree(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 genuinePOST /startcalls racing against real spawned processes) — the codebase's own existing tests avoid ever spawningclaudetwice concurrently for exactly this reason (slow, and the realclaudebinary's behavior isn't what's under test). The lock-holding test proves the same invariant deterministically without that cost.