Repository navigation
lane-worktrees: review findings deferred from #168/#169 (incl. five --yes data-loss paths found after the two-round cap) #170
Description
Review findings on the #162 sweep PRs (#168 and #169) that were not taken in their review rounds. Each one is a design question, not a defect within the issue's scope, so it is filed here instead of being fixed under the two-round cap (AGENTS.md, Brett Heap's ruling of 2026-09-16).
1. --include-sandboxes takes tmp.* with no proof that a suite made it (#168, Copilot round 1, lane-worktrees sandbox_items)
Every account-owned
tmp.*directory older than an hour becomes eligible without evidence that a suite created it or its owner died (pidis zero). A manual mktemp checkout or saved scratch can therefore be deleted without rescue. Require positive suite ownership and dead-owner evidence; report unidentified temporary directories without removing them.
Why it was not taken. #162's own table defines this row as "killed-suite sandboxes (/tmp/tmp.* and pytest-of-$USER/* whose owning pid is gone) with --include-sandboxes → remove (they hold no work)". A mktemp -d directory records no owner pid, so the only evidence there can be is the one the sweep already uses: no live process stands in it, holds a file open there, or names it in its environment, and it is older than sandbox_min_age_minutes. The row is opt-in.
What would settle it. The issue's word on one of these:
- (a) keep the row as specified;
- (b) list
tmp.*and never remove it, so onlypytest-of-$USER/pytest-*with a dead.lockpid andtests/run.sh's own<UTC>-<pid>run roots are removed; - (c) remove a
tmp.*only when it holds a recognisable suite shape.
PR #169's tests/run.sh roots every temp directory of a run under ${XDG_STATE_HOME}/openRepoTools/tmp/<UTC>-<pid>/, which carries its pid, so (b) costs this repository's own suites nothing from then on.
2. Same-account processes whose /proc entries cannot be read (#168, Copilot round 2, lane-worktrees ProcScan)
The
/procscan marks itself available before reading processes, then silently skips failed cwd and fd reads. A live same-account process with inaccessible/procentries can therefore appear absent, allowing its tree to be removed. Distinguish processes that exited from unreadable potential writers, and report unknown liveness for the latter.
Why it was not taken. Measured on Eagle on 2026-10-05:
- of 13,241 same-uid entries in
/proc, 13,016 had no readable cwd because they had already exited (ENOENT: zombies, which this scan correctly skips); - 9 were EACCES: 6
timeoutand 3ssh-agent, which are not dumpable and so are unreadable even to their owner.
There is always at least one such process, and a scan cannot tell which tree an unreadable process stands in. Reading every one of them as "liveness unknown" would therefore keep every tree on every sweep. The writers this scan exists for (claude, git, python, a shell) are dumpable, and a timeout wrapper's child, the real command, is readable and is found.
What would settle it. One of:
- (a) leave it as is;
- (b) report the unreadable same-uid pids in the table as a note, without reading them as live;
- (c) treat an unreadable process as a holder only when its
commis in a writer set (claude, node, git, python3, bash, zsh, sh).
3. tests/run.sh: the leader is reaped before the KILL deadline (#169, Copilot round 3, stop_group)
The unbounded
waitruns before the ten-second grace loop. If pytest (or the stubbed suite leader) traps or ignores TERM, the wrapper blocks here indefinitely, keeps the workstation lock, and never reaches the promised KILL fallback. Poll the process group first, KILL it after the deadline, and only then reap the leader.
This is a real defect in #169's own stop_group (da7bc59), found in round 3. It is filed rather than fixed under the two-round cap. pytest does not ignore TERM, so the hang needs a suite leader that traps it.
The fix:
- poll
group_alive(which already ignores zombies) up to the deadline first; - KILL the group;
- only then
wait "$SUITE_PID".
The case for it: a stub leader that traps TERM, with the wrapper required to exit within about 15 s.
Found after the two-round cap: real defects, filed rather than fixed
Items 4 to 8 are data-loss paths under --yes or --expire --yes. They should be fixed before the sweep's first --yes on real trees; until then the dry run is safe and is all #163's gate reads. Each has a sketch of its fix.
4. A clone's tag-only commits (#168 round 3, tree_guards)
A clone can hold unpublished commits reachable only from a local tag. This guard checks branches and stash...
Fix: include refs/tags/ in the clone's publication check (each tag's commit must be contained in refs/remotes/origin/). Keep the clone when a ref cannot be checked.
5. A submodule's unpublished branches and stash (#168 round 3, tree_guards)
Checking only a submodule's HEAD misses unpublished branches and stashes ... under the parent clone's
.git/modules/
Fix: for each submodule, run the clone checks (every refs/heads/ and the stash on a remote), and keep the parent when one fails or cannot be read.
6. A mirror-style remote.origin.fetch with --prune (#168 round 3, _fetch_all)
A mirror-style
remote.origin.fetch, such as+refs/heads/*:refs/heads/*, lets this preflight--prunedelete local-only branches before classification or rescue.
Fix: fetch with an explicit refspec and refmap: git fetch --prune --refmap= origin '+refs/heads/*:refs/remotes/origin/*', as status does at lines 996-999.
7. Scratch that contains discovered trees (#168 round 3, _scratch)
Checking only the scratch directory's own
.gitmisses discovered worktrees nested beneath it...
Fix: keep a scratch directory while any tree in self.trees (FOREIGN, --live, or otherwise) or any .git lies under it, and let the tree table retire those children first.
8. An origin-less bundle-only rescue is not in the ledger (#168 round 3, expiry_rows)
An origin-less detached worktree can be removed through
bundle+remove, but only_push()records rescues inrescues.tsv...--expire --yescan delete the only durable copy.
Fix: record bundle-only rescues in rescues.tsv (origin -, branch bundle:<file>). archive_ledger then reads such a row as "no other copy" and the archive is kept. Add a case combining origin-less removal with expiry.
9. The report drops ignored FILES from evidence detection (#169 round 4)
This filter discards every ignored file and retains only ignored directories. Consequently an ignored
junit-results.xml... is never considered.
Fix: keep every ignored entry for the evidence match, and derive a directory-only list for du.
10. The report's .gitignore repair command is unquoted (#169 round 4)
Fix: shlex.quote the .gitignore path in the printed command.
11. The daily report's directory inherits the umask (#169 round 5, lane-start)
The automatic report contains absolute paths, repository/branch names, and lane ownership ... readable by other local accounts.
Fix: mkdir -m 700 the state and reports directories, and start the writer under umask 077.
Taken despite the cap: tests/test_run_wrapper.py's unconditional flock-file assertion (#169 round 4), because it would have failed the macOS landing gate. That is a CI defect, not a review item (30cc63a).
From the Opus adversarial review of 3c0b188
Reproduced with probes against the suite's own Estate fixture. B1-B3 (gate exit 0 for a lane with only FOREIGN trees; non-UTF-8 path and unreadable pytest-of-$USER tracebacks; unescaped porcelain) and #169's B7 (set -m under a tty) were fixed in the PRs. --yes and --expire --yes are now switched off (exit 2 unless LANE_WORKTREES_ENABLE_YES=1) until this issue lands. The rest:
| id | file:line | scenario | one-line fix |
|---|---|---|---|
| A1 | lanes-edit.sh:5234-5246; lane-worktrees:381-384 | A18(b) bypass — log_sync returns 0 on every failure path and lane-worktrees honours an inherited LANES_NO_FETCH, so a lane rebound on another host after this host's last register fetch reads as here and the tree is removed |
under --yes fetch the register directly, refuse 2 on failure, unset inherited LANES_NO_FETCH |
| A2 | lane-worktrees:1062-1063, 2098-2107, snapshot_tree 1118 |
--skip-worktree/--assume-unchanged edits destroyed; git status shows clean; git add -A skips them |
keep when git ls-files -v shows S/lowercase tags with content differing from HEAD |
| A3 | lane-worktrees:1197-1205, 1231-1233 | a clone inside a venv (.venv/src/<pkg> from pip -e git+) is deleted — the nested-repo walk prunes venv/.venv/node_modules before looking for .git |
look for .git before pruning venv/.venv/node_modules |
| A4 | lane-worktrees:1637-1643, 1990-2003 | prune drops the only pointer to detached commits — a missing directory whose .git/worktrees/<id>/HEAD names an unpushed commit |
read the admin HEAD, rescue if origin lacks it |
| A5 | lane-worktrees:1689-1691, 2107, 2381 | reflog-only commits lost on remove / delete_local_branch |
scan git log -g HEAD for commits origin lacks and bundle them |
| A6 | lane-worktrees ignored_paths 1228/1234 |
ignored files under ANY ancestor named like a build/cache dir are dropped unarchived (docker/build/prod.env deleted while top.env beside it was archived) |
apply BUILD_NAMES/CACHE_NAMES to the ignored entry's own basename only |
| A7 | lane-worktrees:1698-1719 | push+remove pushes the lane's unreviewed commit onto a teammate's OPEN PR branch (ignores merge.state == "open") |
when an open PR names the branch or its Lane: trailer is another lane's, push to rescue/<lane>/… instead |
| A8 | lane-worktrees judge 763-766, 2126-2128 |
a register LANDED line beats a live gh OPEN: a typo'd LANDED #21 while gh says #21 OPEN deletes the remote branch and closes the PR | never delete remote while gh answers OPEN |
| A9 | lane-worktrees:2014-2033 | untracked un-ignored secrets (gcp-service-account.json) are committed and pushed by the WIP rescue |
bundle-only for untracked, at least secret-shaped, files |
| A10 | lane-worktrees:2165, 2392 | --no-verify on push skips git-lfs's pre-push upload, so a rescued branch is pointers only, then rmtree deletes the only copy |
drop --no-verify when filter.lfs is configured, or git lfs push and verify |
| A11 | lane-worktrees estate_repos 826-831, gather 1346 |
load-bearing detection blind spots: never enters a plain directory inside a repo (finds 0 of xFactory/xFactories/* on Eagle), dependents outside the estate root unseen, an empty estate_root reads no dependents |
walk all dirs for .git skipping only caches; refuse rmtree when the dependents scan was partial |
| B4 | lane-worktrees:1526-1529 | a misspelt --live path is accepted silently |
exit 64 when it matches no tree |
| B5 | lane-worktrees:1547, 1587 | trees this session recorded skip the transcript check; --live is only demanded when the holder reads as this session |
demand the writer count whenever this session recorded a tree |
| B6 | lane-worktrees _note 2307-2319 |
removals continue after a NOTED write fails | stop after the first NOT WRITTEN |
| C1 | tests/test_lane_worktrees.py:589-625 | never asserts the remote delete after "LANDED beats OPEN" | assert the remote branch's fate |
| C4 | tests/test_lane_worktrees.py:492-514 | rescue-before-removal is proved only negatively | add the positive ordering proof |
| C5 | tests | no case for ProcScan.available=False |
add one |
| C6 | tests/test_lane_worktrees.py:967 | pid 999999 assumed dead flakes under pid_max 4194304 | pick a pid proved dead |
| E1 | lanes-edit.sh bytecode_in_pathspec |
a staged non-ASCII .pyc passes the bytecode check because diff --cached quotes it |
use -z or core.quotePath=false |
| E2 | lanes-edit.sh bytecode_in_pathspec |
parses git add --dry-run English |
pin LC_ALL=C |
| E4 | lane-worktrees:236-248 | rmtree onerror chmods a symlinked clone's TARGET |
lchmod / skip symlinks |
| E5 | lane-worktrees MergeEvidence.prs |
gh pr list --limit 1000 |
page or query by head branch |
| E6 | lane-worktrees _note |
the hand-written NOTED fallback prints the path unescaped | quote it |
| E7 | lane-worktrees note_line |
does not pass LANES_SESSION |
pass it |
| B8 | lane-worktrees (#169) 2947, 3007 | the report reads no .lane-state on the real estate (xFactory/xFactories/.lane-state exists); its orphan/aging sections say "none" instead of "could not read" |
derive lane-state parents from every lane's recorded dir (or walk for .lane-state), and say what was not read |
| C2 | tests/test_lane_worktrees_report.py:96-103 | builds the nested lane under a non-repo group/, so the real shape is untested |
nest it under a repository |
| C3 | tests/test_lane_worktrees_report.py:392-398, 381-416 | report tests / sleep(1) checks pass vacuously |
assert on a positive signal |
| E8 | lane-start:3939 | the daily report is not setsid'd (pane kill → SIGHUP after the stamp is taken) | setsid / nohup the detached report |
| E9 | AGENTS.md:280-283 | the by-hand one-liner leaves caches | add -p no:cacheprovider and PYTHONPYCACHEPREFIX |
| E10 | lane-worktrees:3296 | unguarded os.listdir of the estate root |
go through _ls |
| (d) | lane-worktrees:3105 | git log --format=%cs needs git ≥ 2.21 |
use %cd --date=short |
Copilot after the cap, on the adversarial-review heads
| id | file:line | scenario | one-line fix |
|---|---|---|---|
| G1 | lane-worktrees _discover ~1479 (#168 @296e2f0) |
a permission or other failure listing a lane worktree root is read as an absent root; with an empty inventory the dry run can exit 0 and clear #163's gate | ignore only FileNotFoundError; any other listing failure sets a refusal (exit 2) |
| G2 | lane-worktrees branch_items ~2636 (#168 @296e2f0) |
every OPEN PR branch is left out of the retire count, even a lane-owned one holding commits origin lacks, so --branches --dry-run --porcelain can return 0 and clear the gate |
keep the delete protection, but count a lane-owned branch unless origin's refs show its tip; a failed read must not clear the gate |
| G3 | lane-worktrees Report._closed_lanes ~3224 (#169 @0cf1051) |
a failed git grep origin/<branch> (exit 128) is read as "no events", so log-only ENDED lanes drop out and the orphan section reads clean |
treat only exit 1 as no matches; record any other exit in self.unreadable |
| G4 | lanes-edit.sh bytecode_refusal_text ~1871 (#169 @0cf1051) |
the offered .gitignore repair command interpolates the workspace path unquoted |
quote the path (printf %q), as the venv command does |
| G5 | lane-worktrees worktree_registrations ~1035 (#168 @0b7a5c6) |
when both worktree list calls fail the result equals a successful empty read, so discovery can report nothing to retire and exit 0 |
return None on failure; discovery refuses (exit 2); act-time callers stop that act |
| G6 | lane-worktrees _other_lanes ~1501 (#168 @0b7a5c6) |
other lanes' claims are read only under this lane's control-root parent and the global override; a lane in a nested estate keeps its .lane-state beside its own recorded dir, so its claim is missed and the tree is not contested |
resolve each other lane's control root (lane_control_root) before granting ownership or foreign inclusion; refuse if unreadable |
| G7 | lane-worktrees branch_items ~2672 (#168 @0b7a5c6) |
a lane-owned branch is counted unfinished from its configured upstream alone; a branch made from origin/main and published under its own name still tracks main, so --branches returns 3 though origin holds every commit |
check publication in origin's refs (refs/remotes/origin/<branch> contains the tip) before setting retire; keep unknown states counted |
| G8 | tests/run.sh ~193/197 (#169 @1484603) | forwarded pytest arguments come after the wrapper's --basetemp, so a caller's own --basetemp wins and temp files land outside the run root the EXIT trap removes |
put the enforced options after the forwarded arguments (or refuse --basetemp), and update the argv assertion |
A fifth (296e2f0: root/trailer adoption also took standalone clones, contrary to the manual) was a regression of the review fix itself and was fixed in 0b7a5c6.
Lane: openRepoTools-3
🤖 Generated with Claude Code
Activity
Take Copilot round 2's defects in the sweep
[-]lane-worktrees: review findings deferred from #168/#169 (sandbox ownership evidence)[/-][+]lane-worktrees: review findings deferred from #168/#169 (incl. five --yes data-loss paths found after the two-round cap)[/+]Switch --yes off until #170; keep the gate and exit contract honest
Document the --yes switch, lane-root ownership and porcelain escaping
brettheap commentedon Oct 6, 2026
Copilot after the cap, on the #121 merge heads (
87da332,45fa45d)Pushing the merges of
mainat00971b9(#121) re-ran Copilot on both PRs; both are past their two-round cap, so these are filed here rather than fixed (AGENTS.md, Brett Heap's ruling of 2026-09-16). They continue the G table above. None was introduced by the merges themselves: each line is the same as at the previous heads.lane_facts~462 (#168 @87da332)lane-treesrow with notree:id is skipped in silence; if it names a tree outside the scanned roots, discovery misses it and the dry run can exit 0 and clear #163's gate_workspace_hygiene~3443 (#169 @45fa45d).gitignoreis read as strict UTF-8 and onlyOSErroris caught; one non-UTF-8 byte aborts the daily report through the catch-allerrors="surrogateescape", as every other reader in the command does--post owner/repo#N~3529 (#169 @45fa45d)UnicodeEncodeErrorbeforeghruns, bypassing the post-failure fallbackerrors="surrogateescape"--post <file>~3544 (#169 @45fa45d)errors="surrogateescape"bytecode_in_pathspec~1869-1871 (#169 @45fa45d)awkexits 0, so a failed read (a malformed magic pathspec: both git calls exit 128) prints nothing andpathspec-checkreports the pathspec cleanpathspec-checkand the publish refusal refuse instead of passingrun_suiteterminal branch ~192 (#169 @45fa45d)SUITE_PGIDis kept, so a child the suite left running is not stopped before the EXIT trap removes the run root and releases the lock<estate>/.lane-state(or the override), but the code (_lane_parents) also reads.lane-statebeside every discovered checkout, which is what covers nested checkoutsRefs #162 #168 #169
Lane: openRepoTools-3
🤖 Generated with Claude Code
brettheap commentedon Oct 6, 2026
CLAIMED — lane openRepoTools-1, session df82f553-7803-49c4-8ddf-3f9acdcb8ee0@Eagle, 2026-10-06T16:20:32Z, for #170
Logged in
lanes/log/openRepoTools-1.mdat9069bcf26c8506d2c2a2c8295ff34f2afe2144ec.The three reads (lane-collision-protocol Rule 1):
Keep a tree whose submodule has a skip-worktree or assume-unchanged e…
Never push a lane's commits onto a branch gh still answers OPEN, what…
Start no daily report when its directories cannot be made 0700
lane-worktrees sweep: retire a lane's abandoned worktrees, rescue fir…
lane-worktrees: the daily estate report, and nothing new lands beside…
List every tmp.* sandbox and never remove it; a bare repository is a …
Read other lanes' claims in this sweep's estate only; with no tree, a…
Merge origin/feat/lane-worktrees-residue (#174 at 4ea84c8) into feat/…
Merge #174's head (4ea84c8) into feat/lane-end-closeout: drop the swi…
Read a lane directory that is no repository as having no registration…
A directory is an absence only with no .git entry of its own; a .git …
lane-worktrees report, lanes-edit.sh, tests/run.sh, lane-start: #170'…
brettheap commentedon Oct 7, 2026
RELEASED — lane openRepoTools-1, session df82f553-7803-49c4-8ddf-3f9acdcb8ee0@Eagle, 2026-10-07T02:09:56Z, for #170 — CLOSED by PR #179 (main 2fb854f, lane 3's landing under Brett's identical-tree ruling); part 1 landed as #174 (main 5eb3d5f)
Merge origin/main (#179 squash) into feat/lane-end-closeout
lane-end's close-out gate: refuse while the sweep still finds the lan…
brettheap commentedon Oct 7, 2026
Record correction (lane openRepoTools-1, 2026-10-07): this issue was closed at 2026-10-07T01:19:20Z by #174's squash commit
5eb3d5f, whose title "lane-worktrees: close #170's --yes data-loss and gate holes…" GitHub read as a closing keyword (close #170). #179 carried theCloses #170line and landed afterwards as2fb854f(02:08Z), so both halves are on main; only the closer and time in lane 1's RELEASED log line ("CLOSED by PR #179") are wrong. Found by lane openRepoTools-2's check of the landing facts.