Skip to content

lane-worktrees: review findings deferred from #168/#169 (incl. five --yes data-loss paths found after the two-round cap) #170

Closed

Description

@brettheap

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 (pid is 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 only pytest-of-$USER/pytest-* with a dead .lock pid and tests/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 /proc scan marks itself available before reading processes, then silently skips failed cwd and fd reads. A live same-account process with inaccessible /proc entries 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 timeout and 3 ssh-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 comm is 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 wait runs 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 --prune delete 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 .git misses 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 in rescues.tsv... --expire --yes can 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.

Refs #162 #168 #169

Lane: openRepoTools-3

🤖 Generated with Claude Code

Activity

  1. changed the title [-]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)[/+] on Oct 5, 2026
  2. brettheap commented on Oct 6, 2026

    @brettheap
    ContributorAuthor

    Copilot after the cap, on the #121 merge heads (87da332, 45fa45d)

    Pushing the merges of main at 00971b9 (#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.

    id file:line scenario one-line fix
    G9 lane-worktrees lane_facts ~462 (#168 @87da332) a lane-trees row with no tree: 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 treat a nonempty row without an id as an unreadable inventory (exit 2) instead of skipping it
    G10 lane-worktrees _workspace_hygiene ~3443 (#169 @45fa45d) the workspace .gitignore is read as strict UTF-8 and only OSError is caught; one non-UTF-8 byte aborts the daily report through the catch-all open it with errors="surrogateescape", as every other reader in the command does
    G11 lane-worktrees report --post owner/repo#N ~3529 (#169 @45fa45d) the temporary body file is written as strict UTF-8; a report row holding a surrogate-escaped path raises UnicodeEncodeError before gh runs, bypassing the post-failure fallback write it with errors="surrogateescape"
    G12 lane-worktrees report --post <file> ~3544 (#169 @45fa45d) the same for the local report file: the supported Latin-1 path case becomes an internal-error exit and the daily run leaves only its stamp write it with errors="surrogateescape"
    G13 lanes-edit.sh bytecode_in_pathspec ~1869-1871 (#169 @45fa45d) both git reads send stderr away and the awk exits 0, so a failed read (a malformed magic pathspec: both git calls exit 128) prints nothing and pathspec-check reports the pathspec clean keep each git status; when either read fails, pathspec-check and the publish refusal refuse instead of passing
    G14 tests/run.sh run_suite terminal branch ~192 (#169 @45fa45d) with a terminal on stdin no SUITE_PGID is kept, so a child the suite left running is not stopped before the EXIT trap removes the run root and releases the lock foreground a dedicated process group on the terminal (or keep the group id) and stop it before cleanup
    G15 docs/README-lanes.md "The estate report" ~3702 (#169 @45fa45d) the manual says the report reads snapshots only under <estate>/.lane-state (or the override), but the code (_lane_parents) also reads .lane-state beside every discovered checkout, which is what covers nested checkouts document the per-checkout parents the report reads

    Refs #162 #168 #169

    Lane: openRepoTools-3

    🤖 Generated with Claude Code

  3. brettheap commented on Oct 6, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoTools-1, session df82f553-7803-49c4-8ddf-3f9acdcb8ee0@Eagle, 2026-10-06T16:20:32Z, for #170

    Logged in lanes/log/openRepoTools-1.md at 9069bcf26c8506d2c2a2c8295ff34f2afe2144ec.

    The three reads (lane-collision-protocol Rule 1):

    1. existing `CLAIMED —` comments on opensoft/openRepoTools#170:
       none
    2. `gh pr list --repo opensoft/openRepoTools --state all --search "#170"`, kept where the row or its body names `#170`:
    174	lane-worktrees: close #170's --yes data-loss and gate holes, self-check every removal, switch --yes back on	feat/lane-worktrees-residue	OPEN
    169	lane-worktrees: the daily estate report, and nothing new lands beside the code (#162, part 2)	feat/lane-worktrees-report	OPEN
    168	lane-worktrees sweep: retire a lane's abandoned worktrees, rescue first, never a live writer (#162, part 1 of 2)	feat/lane-worktrees-sweep	OPEN
    3. `git ls-remote --heads git@github.com:opensoft/openRepoTools` branches naming `170` as a whole token:
       none
    
  4. brettheap commented on Oct 7, 2026

    @brettheap
    ContributorAuthor

    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)

  5. brettheap commented on Oct 7, 2026

    @brettheap
    ContributorAuthor

    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 the Closes #170 line and landed afterwards as 2fb854f (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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions