From c45a452a14a1c2e46d2008652547e9529e6cc154 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:28:26 +0000 Subject: [PATCH 01/42] docs: the triad split's lane-tooling side, surface by surface (T006, T007, T009, T010, T018) docs/triad-lane-tooling-analysis.md: for every lane tool, the installer and its receipt, the hooks, the manuals, the tests, CI and the brett-wip register/inventory/handoff data, what the assembly/spec/code split of opensoft/openRepoTools changes, at file:line on c4864ac, with the plan task each change belongs to and whether it is a patch or a proposal. No shipped command changes: no lane tool reads docs/, openspec/ or specs/ at run time (the adoption plan's cross-leg follow-ups for them are comments, usage text, a URL or the object-key grammar), and every fix the triad does need changes today's behaviour, so each is a proposal with a sketch. The suite's test roots (T009/T010) are lane 1's PR #190 (c139e88); this branch's earlier draft of them was withdrawn before commit, and the document records the overlap, one gap and one measured defect in #190 for lane 1. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/triad-lane-tooling-analysis.md | 541 ++++++++++++++++++++++++++++ 1 file changed, 541 insertions(+) create mode 100644 docs/triad-lane-tooling-analysis.md diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md new file mode 100644 index 00000000..eec4e6fd --- /dev/null +++ b/docs/triad-lane-tooling-analysis.md @@ -0,0 +1,541 @@ +# The triad split, lane-tooling side: what moves, what breaks, what to change + +**Date:** 2026-10-07 (finished 2026-10-08 on base `c4864ac`) · **Lane:** openRepoTools-3 · **Branch:** `feat/triad-lane-tooling`, +made from `origin/main` at `c4864ac` · **Slice:** (d), the lane-tooling side of the +assembly/spec/code split of `opensoft/openRepoTools`. + +**Plan read:** branch `004-migrate-to-triad` at `ff6f6a7` (the head of PR #187), read with +`git show` only: `specs/004-migrate-to-triad/{plan,tasks,spec,installer-design,research,data-model,quickstart,verification,adopter-rehearsal}.md` +and `openspec/changes/migrate-to-triad/{adoption-plan.yaml,design.md}`. Brett Heap confirmed +the leg names and their public visibility on 2026-10-07: `openRepoTools-spec` mounted at +`spec/` and `openRepoTools-code` mounted at `code/`. + +Every `file:line` below is on main `c4864ac` unless it names another revision. Main moved +to `837928a` while this was finished (#187 landed the plan, its `specs/004-migrate-to-triad/` and +`openspec/changes/migrate-to-triad/` byte-identical to `ff6f6a7`; #188 the openRepoShape repin); +that changed only `specs/`, `openspec/`, `contracts/openreposhape-pin.yaml` and the +`upstream/openRepoShape` gitlink, so no line cited here from a tool or a test moved, and the +branch stays on `c4864ac`. A claim +marked **measured** was run on a throwaway fixture in this lane's scratch directory; one +marked **derived** follows from the code or from git's documented behaviour and was not run. +This document decides nothing. Plan tasks are T006 (root collisions and leg guidance), T007 +(installer entry point), T009 (separate roots in fixtures and hygiene), T010 (CI and pin +checks) and T018 (feature selection, park/resume and bindings). + +--- + +## 0. Summary + +1. **None of the shipped lane tools reads `docs/`, `openspec/` or `specs/` at run time.** The + adoption plan's cross-leg follow-ups for `lane-start`, `lanes-edit.sh`, `openRepoTools` and + `claude-current` (adoption-plan.yaml:325-328 and :334) are comments, usage text, a URL, or + the object-key grammar. Each tool finds its helpers **beside itself**: `$SCRIPT_DIR`, + `$selfdir` or `__file__`. All of those helpers move to `code/` together, so that lookup + works unchanged. The only code that really reads across legs is in the **tests**. Lane 1's + PR #190 (`feat/test-roots-for-the-triad` at `c139e88`) gives the suite those roots, so this + branch carries no test change of its own (§6). +2. **The installer is the most fragile surface.** The README one-liners and every + already-installed copy's own `openRepoTools --install` fetch files by **repository-root + path** from `opensoft/openRepoTools@main` (openRepoTools:70-71, :258-275, :1370-1394). Once + the root split lands, those paths sit under the `code` gitlink. The result: the + one-liners stop working, every installed copy's re-install **refuses** with nothing + placed, and `wip init` **silently** seeds from openRepoShape `main` instead of the pinned + commit (openRepoTools:2516). No patch made before cutover can avoid this without changing + today's behaviour. The T007 entry point has to land **in the root-split PR itself** (§4.10). +3. **Lanes keep working only if their `dir` stays at the assembly root**, and three things + change around them. First, `#n` expands against the assembly. Second, an OpenSpec claim + keyed on `opensoft/openRepoTools` searches the wrong repository's pull requests. Third, a + code-leg worktree can't be seen by the lane's sweep, the `lane-end` gate or the + `lane-handoff` poll unless it was made with `lane-worktrees add --checkout /code`. + The daily report never counts a submodule leg as a canonical checkout. And one live lane + outside this repository runs the **checkout copy** `~/projects/openRepoTools/lanes-edit.sh`, + which disappears from that path at the split (§4.4, §4.5, §4.15). +4. **The code leg needs its own `.gitattributes` and `.gitignore`.** Git applies no + superproject attribute to a path inside a submodule (**measured**, §4.13). The plan puts both + files at the root only. Two hygiene caps are also full: `AGENTS.md` is at its cap + (316/316) and `README.md` is at its cap (486/486). So T006's "add the AGENTS-shape line" + and "merge shape/README.md" fail the hygiene suite unless those caps are raised with dated + entries. + +--- + +## 1. Scope and method + +**In scope:** the lane tools (`lane-start`, `lane-end`, `lane-worktrees`, `lanes-edit.sh`, +`lanes-index`, `lane-handoff`, `lane-rename`, `lane`, `lanes`, `link-estates`, `repos.tsv`), +`openRepoTools --install` and `wip init`, the hooks it merges, the documents that name +checkout paths, the tests and CI that assume one root, and the register/handoff data in +`opensoft/brett-wip` that names `/workspace/projects/openRepoTools` (read, never written). + +**Out of scope:** the estate verbs `park`, `resume` and `status`. They find estates by +`$PROJECTS_DIR` and run `make park`/`make resume`, and they already know three-leg roots +(AGENTS.md rule 4). `claude-current` and `claude-restart-check` appear only where the +installer places them. + +**How:** I read every named file (the installer and `wip init` parts of `openRepoTools`, +`lane-start`, `link-estates`, `tests/conftest.py`, `tests/run.sh`, `.github/workflows/tests.yml` +and AGENTS.md in full; the large tools by their path-bearing sections). I grepped every shipped +file for self-location (`BASH_SOURCE`, `SCRIPT_DIR`, `selfdir`, `__file__`), cross-leg directory +names, hook and settings writes, and `project.yaml` readers. I measured git's behaviour around +submodules and worktrees on a fixture of three local bare repositories. I also read lane 1's +Gate B receipt in `brett-wip/migration/openRepoTools/adopter-fix-20261007-iM20tv/`, whose +standard table is 45 spec / 49 code / 6 root of 100 tracked paths at `c4864ac`. That matches +`git ls-files | wc -l` = 100 here. + +--- + +## 2. What the plan documents say, checked + +| Claim (where) | Checked on `c4864ac` | +| --- | --- | +| No change to lane-start, lane-worktrees, lane-end, link-estates or repos.tsv beyond placing them in code (adoption-plan.yaml:163-230, 259-268) | True of the placement. Not enough for the bindings: see §4.4, §4.5, §4.15. `lane-worktrees` and `lanes-index` are absent from the committed plan (it predates them) and present in lane 1's regenerated rehearsal plan as code. | +| lane-start and lanes-edit.sh name `docs/` (adoption-plan.yaml:326-327); lanes-edit.sh names `openspec/` (:334) | They are named, but never read: §3. | +| lane-handoff names `openspec/` and the follow-ups omit it | lane-handoff:2439 is a comment citing `openspec/changes/add-supervised-context-restart/design.md`. It is not a read. | +| lane-start reads estate `project.yaml` legs (lane-start:1271-1293); lanes-edit.sh too (lanes-edit.sh:5039-5121) | True. Both readers already handle an assembly like openRepoTools's: §4.2, §4.5. | +| repos.tsv:52 has only `openRepoTools` | True. There are no `-spec`/`-code` rows. | +| Paired layout `worktrees//{spec,code}` (plan.md:207-208) has no code on main | True. No lane tool makes, polls or claims a tree there: §4.4. | +| installer-design.md: resolve, select, delegate; one immutable code identity checked against `contracts/code-pin.yaml`; `fetch_from_repo` :258, `collect_commands` :962, `collect_skills` :1370, `wip_shape_ref` :2502, `wip_fetch_template_file` :2519; REPO/REF :70-71 | Every line number holds. `code-pin` has 0 hits in tracked files. | +| installer-design.md:61-64 counts 15 names / 27 files / 29 artifacts | **Stale.** `INSTALLABLES` (openRepoTools:338) holds **17** names. With 3 skills and 3 command files at 2 paths each, that is **29 placed files + 2 hook entries = 31 artifacts**, which is the installer's own statement (openRepoTools:389-396). Placing by rename (#172, `3660224`) landed after the design. It changes the placement mechanics, not the sources. | + +--- + +## 3. The adoption plan's cross-leg follow-ups, line by line + +Lane 1's Gate B receipt (`logs/runB-cross-leg-grep.log`) lists file names only. Each hit +falls into one of these kinds: **comment**, **usage** (text a command prints), **URL**, +**key** (an object key, not a path), **fixture** (data a test builds), or **READ** (a file +opened at run time). + +| Follow-up | Hit on `c4864ac` | Kind | Change needed | +| --- | --- | --- | --- | +| claude-current names `docs/` (:325) | claude-current:89 | comment | none | +| | claude-current:123 | usage (`--help` prints `docs/README-claude-current.md`) | Text only: the path is repository-relative, so after the split it means `spec/docs/…` from the root or `../spec/docs/…` from `code/`. Changing it changes `--help`'s bytes, so this is a **proposal** for the T014 docs batch, not a patch. | +| lane-start names `docs/` (:326) | lane-start:294, :617 | comment | none | +| lanes-edit.sh names `docs/` (:327) | lanes-edit.sh:10387 | comment | none | +| openRepoTools names `docs/` (:328) | openRepoTools:1218 (comment); :1342 (URL `code.claude.com/docs/en/hooks`) | comment, URL | none | +| tests/run.sh names `docs/` (:329) | tests/run.sh:36 | comment | none | +| test_install_skill_and_hook.py (:330) | :73, :1339 | URL | none | +| test_lane_helpers.sh names `docs/` (:331) | :3738, :6228 `cat "$SRC_DIR/docs/README-lanes.md"` | **READ** | #190: `LANE_MANUAL`, skipped when the run has no spec root | +| test_lane_start_claude_current.py (:332) | :15 | docstring | none | +| "… and 1 more" (:333) = test_repo_hygiene.py | :1183, :1214-1217, :1233, :2390, :2624 | **READ** | #190: `doc()` → `ROOTS.path_for` | +| lanes-edit.sh names `openspec/` (:334) | :280, :2770 (comments); :2814-2818, :2895-2897 (grammar `owner/repo:openspec/changes/`); :12110 (comment) | key | No path change. The **repository half** of the key changes meaning: §4.5. | +| test_lane_helpers.sh names `openspec/` (:335) | :1280, :2086, :3781 (object keys of a fake `opensoft/repoE`); :13343 (comment) | fixture, comment | none | +| test_park_resume_commands.py names `specs/` (:336) | :350, a `feature_directory: worktrees/…/spec/specs/…` string in a three-leg record the test writes | fixture | none | +| (not listed) lane-handoff names `openspec/` | lane-handoff:2439 | comment | none | + +--- + +## 4. Surface by surface + +### 4.1 How every tool finds its helpers (all tools) + +| Tool | Self-location (`c4864ac`) | Helpers found beside itself | +| --- | --- | --- | +| lane-start | :339-341 `SCRIPT_DIR` from `readlink -f` | lanes-edit.sh :600-604, claude-current :650, lanes :868, lane :917, lane-worktrees :4307 | +| lane-end | :259-261 | lanes-edit.sh :520, lane-worktrees :532 | +| lanes-edit.sh | :634-639 | lanes-index :2256, repos.tsv :2453, lane-start :9189 | +| lane-handoff | :119-120 | lanes-edit.sh :399, lane-start :685 | +| lane / lanes / lane-rename | lane:114-115 / lanes:102-103 / lane-rename:73-74 | lanes-edit.sh (lane:188, lanes:191, lane-rename:155), lane-start (lane:1166) | +| lanes-index | :325 `dirname(realpath(__file__))` | lanes-edit.sh | +| lane-worktrees | :441, :5278 | lanes-edit.sh, status | +| openRepoTools | :72 `SELF`; :971, :1372, :2504, :2521 `selfdir` | the 17 installables, `skills/`, `commands/`, `contracts/openreposhape-pin.yaml`, `upstream/openRepoShape/templates/…` | + +**Under the triad:** every one of these helpers is code-leg content (adoption-plan.yaml:107-323), +so `code/` finds `code/` exactly as `` finds `` today. Installed +copies in `~/.local/bin` are unaffected. **No change; T006 confirms it.** + +### 4.2 `lane-start` + +| Line | What it does | Under the triad | +| --- | --- | --- | +| :608 | `PROJECTS_ROOT=${PROJECTS_ROOT:-$HOME/projects}` | unchanged | +| :1116-1357 | The lane's directory, by six rungs: `--dir`, the swap record, the lane log's `dir`, `$PROJECTS_ROOT/`, the estate `project.yaml` legs (:1291-1318), then a checkout named for the home and proved by its origin (:1319-1328) | Lanes openRepoTools-1, -2 and -3 record `dir /workspace/projects/openRepoTools` (§4.15), so rungs 2 and 3 answer. If the assembly stays at that path (an in-place adoption of the live checkout), **nothing changes**. A lane homed at `opensoft/openRepoTools-code` (none exists) would be resolved by rung 5: the reader at :1301-1309 matches `repository:` against the home and resolves `path: code` against the manifest's directory. The shell suite already covers rung 5 on a declared `legs:` manifest (tests/test_lane_helpers.sh:5691-5704). IRRS, IRSS and MedxEHR carry the same block on this workstation today. | +| :1118-1128 | "the harness keys a session to the directory it was started in" (Evidence 3) | **This is why a lane's `dir` should stay at the assembly root.** Re-homing a lane into `code/` changes the Claude project folder (`-workspace-projects-openRepoTools` → `-workspace-projects-openRepoTools-code`). That strands the lane's memory, and `--resume ` stops finding the transcript. **T018 proposal:** bindings keep `dir` = assembly root, and nothing is rebound. | +| :1180-1199 | The home is written from the directory's `origin` | At the assembly root the origin is still `opensoft/openRepoTools`, so the home is unchanged. | +| :4294-4340 | Starts the daily report (`lane-worktrees sweep --all --dry-run --report`) for `$PROJECTS_ROOT` | Inherits the report's blind spot: §4.4. | +| hooks | lane-start writes **no** hook and no `settings.json` (`grep` finds only comments at :142, :1783) | none | + +**Patch:** none needed. **Proposal (T018):** the bindings decision above, plus a fixture test +that runs rung 5 against an openRepoTools-shaped manifest (§7). + +### 4.3 `lane-end` + +The close-out gate (:195-211, :1908-2045) runs `lane-worktrees sweep --branches +--include-scratch --include-caches --dry-run --porcelain`. It then reads every tree with +`git status --ignored` (:1927-1945) and every entry under `${dir%/*}/.lane-worktrees/` +(:2024-2045). Its roots and its trees are lane-worktrees'. **Under the triad it is as blind as +the sweep (§4.4).** A lane with unpushed work in a paired `worktrees//code` tree, or +in any code-leg tree it never recorded, passes the gate. **Patch:** none. **Proposal:** the +lane-worktrees roots change in §4.4 closes this too. + +### 4.4 `lane-worktrees`: sweep inventory, `add`, and the daily report + +**Measured** on a fixture (assembly `A` with `code` and `spec` submodules, and a worktree +of the code leg at `A/worktrees/001-x/code`): + +- `git -C A/code rev-parse --show-toplevel` → `A/code`, and `--show-superproject-working-tree` → `A`; +- `A/code/.git` is a **file**, `gitdir: ../.git/modules/code`; +- `git worktree add` inside the submodule succeeds (git 2.43); +- `git -C A worktree list` lists **only `A`**. The code leg's tree appears only under + `git -C A/code worktree list`, whose main entry is spelled `A/.git/modules/code`; +- `git -C A status` shows `?? worktrees/`. The shape's assembly `.gitignore` template + (`templates/assembly-root/.gitignore` at the pinned openRepoShape) does not ignore it. + +| Line | What it reads | Under the triad | +| --- | --- | --- | +| :2165-2173 `_roots` | `/.claude/worktrees` and `${dir%/*}/.lane-worktrees/` | Unchanged for `dir` = assembly. The paired `A/worktrees//{spec,code}` is **in neither root**. | +| :2175-2248 `_discover` | inventory sidecars + `git worktree list` of the lane's checkout and of every inventoried `checkout` | A code-leg tree is seen **only if** an inventory row names `checkout: /code`. A tree made by `make resume`/Speckit in the paired layout is never seen. | +| :2122-2163 `_lane_claims` | trailer claims under `.claude/worktrees` only | Paired trees are never claimed. | +| :2709-2713, :2759-2763 | scratch and caches under `.lane-worktrees/` | unchanged | +| :5381-5557 `add` | `git worktree add` of `--checkout` or the lane's `dir`, at `${dir%/*}/.lane-worktrees//`, then `set-lane-tree … --checkout ` | Without `--checkout`, it makes a worktree of the **assembly**, with empty, uninitialised `code/` and `spec/` (a worktree does not initialise submodules). **With `--checkout /code` it works today:** `:5448-5451` accepts it because `--show-toplevel` is the submodule root (measured above). | +| :1091-1125 `estate_repos` | walks into a repository only through `worktrees`, `.worktrees`, `.claude`, or a child with `.git` | `A/worktrees/` has no `.git`, so `A/worktrees//code` is never reached (derived). | +| :4884-4907, :4887 `Report.build` | `clones = [r … if os.path.isdir(r/.git)]` → canonical checkouts → branches, root-main, aging | A leg's `.git` is a file, so **a submodule leg is never canonical**. The code leg's branches, which are where all lane-tooling work happens after the split, never reach the daily report. This is already true today for every leg of IRRS, IRSS, MedxEHR and openDox on this workstation. | +| :4760-4770 `_under_container` | `worktrees`, `.worktrees`, `*-worktrees`, `.lane-worktrees/` | Recognises `A/worktrees`. Unaffected. | + +**Patch:** none. Every fix changes what the sweep or the report says **today** about the +estate's existing triads, and the brief rules out behaviour changes. **Proposals (T018):** + +1. **Guidance, no code:** under the triad a lane makes a code tree with + `lane-worktrees add --checkout /code`. That records the tree + with the code leg as its checkout, so every reader above sees it. +2. `_roots` adds `/worktrees` when `/project.yaml` names legs. Trees there are + claimed only by the HEAD `Lane:` trailer rule `_lane_claims` already applies to + `.claude/worktrees`, because that directory is per-feature, not per-lane. The same root + goes into `lanes-edit.sh:12699-12705` (`lane_worktree_roots`) and + `lane-handoff:1994-1996`, so the three readers cannot disagree. +3. `Report.build` treats a checkout whose `.git` is a file pointing into + `/.git/modules/` as canonical when the superproject is canonical. + This changes today's report for IRRS and the other existing triads, so it needs a ruling. + +### 4.5 `lanes-edit.sh` + +| Line | What it does | Under the triad | +| --- | --- | --- | +| :2754-2765 `alias_lookup`; :2776-2847 `canon_object` | resolves a key's repository through repos.tsv. A spelled `owner/repo` is **always accepted** (:2834) | `opensoft/openRepoTools-code#n` works with no repos.tsv row. The bare `openRepoTools-code#n` is refused until the row exists (§4.9). | +| :2791-2813 | `#n` expands against the lane's **home** | Home stays `opensoft/openRepoTools`, so `#n` always means the **assembly's** issue or PR. The code leg numbers its PRs from #1, so `#3` from an openRepoTools lane is the assembly's #3, never the code leg's. **T018 proposal:** code-leg and spec-leg objects are spelled in full. The lane-collision protocol text should say so. | +| :2814-2818, :2895-2897 | `owner/repo:openspec/changes/` keys | Rule 1's sibling reads run `gh pr list --repo --search ` (:10249-10272). A change keyed on `opensoft/openRepoTools` therefore searches the **assembly's** PRs, while spec PRs land in `openRepoTools-spec`. **T018 proposal:** at cutover, each open claim on `opensoft/openRepoTools:openspec/changes/` is released and re-claimed as `opensoft/openRepoTools-spec:openspec/changes/`, through `lanes-edit.sh release`/`claim`. The log is append-only and is never rewritten. | +| :5053-5105 `project_legs_file`, `same_project` | manifests at `$PROJECTS_ROOT/*/project.yaml` and `*/*/project.yaml` | `/workspace/projects/openRepoTools/project.yaml` is depth 1, so a claim on `opensoft/openRepoTools-code#n` from a lane homed at the assembly is "legs of one project.yaml — not a crossing" (:5120-5122). This works with no change. | +| :12555-12595 `tree_id_for`, `lane_tree_put` | sidecar per tree: absolute `path`, `checkout`, branch, head … | Formats are unchanged. Rows written before cutover stay **true**: they describe worktrees of the monorepo history, which is the assembly's own history (FR-001). | +| :12699-12705 `lane_worktree_roots` | `/.claude/worktrees`, `${dir%/*}/.lane-worktrees/` | Same gap as §4.4, proposal 2. | +| :16791-16829 `set-lane-tree` | takes an absolute path and an **unvalidated** `--checkout` | Records a code-leg tree today: `--checkout /code`. | +| :14973-15030 `session-start`, `guard` | the two hook verbs; the guard compares window, session name and register row, never the cwd | unchanged (§4.11) | +| :594 `LANES_LANE_DIR` | leg detection from the lane's checkout | unchanged | + +**Patch:** none. A behaviour change in object keys or roots needs a ruling. **Proposals:** as +in the table. + +### 4.6 `lanes-index` + +It reads the register through `lanes-edit.sh` beside itself (:325) and keeps its store and +config under XDG (`:160-166`). It has no repository-relative path. **No change.** + +### 4.7 `lane-handoff` + +Helpers beside itself (:119-120, :399, :685). The writers poll covers +`$dir/.claude/worktrees/*` and `$parent/.lane-worktrees/$lane/*` (:1892-1996), and has the +same gap as §4.4. The only `openspec/` reference is a comment (:2439). **No change; one +proposal (§4.4, item 2).** + +### 4.8 `lane`, `lanes`, `lane-rename` + +`lanes` with no arguments narrows to "the checkout around the cwd" by its +`git rev-parse --show-toplevel` and that checkout's `origin` (lanes:394-420, lane:440-460). +**Under the triad** a person standing in `code/` gets `--dir /code --repo +opensoft/openRepoTools-code --prefix openRepoTools-code`. No lane matches, and it offers +`lane-start openRepoTools-code 1` (derived). From the assembly root it is unchanged. +`lane-rename` has no path of its own. **Proposal (T018):** when `--show-superproject-working-tree` +is non-empty and that superproject's `project.yaml` names the toplevel as a leg, narrow to the +superproject's directory and origin. This changes today's output inside IRRS/IRSS legs, so it +needs a ruling. Patch sketch: + +```bash +here_root="$(git rev-parse --show-toplevel 2>/dev/null || :)" +super="$(git rev-parse --show-superproject-working-tree 2>/dev/null || :)" +if [ -n "$super" ] && [ -f "$super/project.yaml" ]; then here_root="$super"; fi +``` + +### 4.9 `link-estates` and `repos.tsv` + +`link-estates` finds the workspace through `workspace.yaml` and never from its own location +(:89-295). It links `~/projects//handoffs` → `/handoffs/` for each +estate directory that exists (:399-409), and `~/projects/xFactory/lanes-edit.sh` → +`$OPENREPOTOOLS_BIN_DIR/lanes-edit.sh` (:421). **Under the triad:** +`/workspace/projects/openRepoTools/handoffs` is an untracked symlink in the live checkout today, +and it stays one at the assembly root. The hook link points at the installed copy. **No +change.** + +`repos.tsv`:52 maps `openRepoTools` only. Its own rule (repos.tsv:16-23) is that a row is +added "when a new repo first appears in a LANDING line", resolved with `gh repo view`. Rows +for repositories that do not exist yet would break that rule. **Proposal (cutover, after Gate +C creates the legs):** two rows, `openRepoTools-code opensoft/openRepoTools-code` and +`openRepoTools-spec opensoft/openRepoTools-spec`, in the code leg's `repos.tsv`. + +### 4.10 `openRepoTools --install`, `wip init` and the `installed.tsv` receipt + +**Where each artifact comes from today.** `SELF` is this file (openRepoTools:72). When it is a +file, `selfdir` is its directory. + +| Artifact | Local source (when `SELF` is a file) | Remote source | Code | +| --- | --- | --- | --- | +| The 17 `INSTALLABLES` (openRepoTools, park, resume, status, lane, lanes, lane-handoff, lane-rename, lanes-edit.sh, lane-start, lane-end, link-estates, repos.tsv, claude-current, claude-restart-check, lanes-index, lane-worktrees) | `$selfdir/` | `fetch_from_repo `: `gh api repos/$REPO/contents/?ref=$REF`, else `raw.githubusercontent.com/$REPO/$REF/` | :338, :962-990, :258-275 | +| 3 skills × 2 destinations | `$selfdir/skills//SKILL.md` | `fetch_from_repo skills//SKILL.md` | :1289-1290, :1370-1381 | +| 3 command files × 2 destinations | `$selfdir/commands/.md` | `fetch_from_repo commands/.md` | :1302-1303, :1387-1394 | +| `SessionStart` entry | constant `~/projects/xFactory/lanes-edit.sh session-start \|\| true` | none | :1320, :1324-1333 | +| `UserPromptSubmit` guard | constant `~/projects/xFactory/lanes-edit.sh guard` | none | :1352, :1355-1363 | +| `wip init` pin | `$selfdir/contracts/openreposhape-pin.yaml` | `fetch_from_repo contracts/openreposhape-pin.yaml`, **else the literal `main`** | :2502-2517 | +| `wip init` templates | `$selfdir/upstream/openRepoShape/templates/workspace-root/…` | `gh api`/raw from `opensoft/openRepoShape` at the pin | :2519-2544 | + +**The receipt** (`installed.tsv`, rows written at openRepoTools:561-598) is +` `. It records **no source**, so a code-leg source changes +none of its rows. Readers accept four **or more** fields (`receipt_rows_except` :641-645, +`receipt_verdict` :808-819). A fifth column for the code identity, which T008 asks to be +recorded ("record its actual code identity"), can be appended without breaking an older +reader. + +**What happens in each context once the root split has landed** (derived; the GitHub +behaviour for paths under a gitlink is to be measured in T008): + +| Context | Today | After the split, with no T007 | +| --- | --- | --- | +| a. `code/openRepoTools --install` in an assembly checkout | — | `selfdir` = `code/`, all 23 files local. **Works unchanged.** | +| b. `./openRepoTools --install` at the assembly root | the monorepo file | No file of that name until T007. | +| c. README one-liners (README.md:228, :236) | fetch root `openRepoTools`, which then fetches the rest by root path | The root path `openRepoTools` 404s until T007 exists. With a T007 entry point that only `exec`s the code installer, the code installer still fetches `park`, `skills/…` and the rest from `$REPO@$REF` = the **assembly**, where those paths are under `code` and 404. **The entry point must also hand the delegate one immutable code identity.** | +| d. Installed copy `~/.local/bin/openRepoTools --install` | `selfdir` = `~/.local/bin`, so it copies the **17 already-installed files over themselves** (:976-980; an installed copy never updated its commands) and fetches skills and commands from `opensoft/openRepoTools@main` | `collect_skills` gets a 404 and returns 1. `install_commands` dies at :1125 with "could not fetch one of the 3 skills…", **exit 2, nothing placed**. A safe refusal, but on every workstation. | +| e. Installed copy `openRepoTools wip init` | pin fetched from the monorepo | `contracts/openreposhape-pin.yaml` is not at the assembly root (the assembly has `contracts/{shape,spec,code}-pin.yaml`), so `wip_shape_ref` prints **`main`** (:2516). **Silent drift**: templates come from openRepoShape `main`, not from the pin. | +| f. `--version` | `openRepoTools (opensoft/openRepoTools @ main)` | unchanged text, now naming the assembly | + +**Why no patch made now can save (d) and (e).** The population that would need the fix is +every copy already installed. Those copies get new code only from an install that succeeds, +and the only one that can succeed after cutover is (a) or the T007 one-liner. A +pre-cutover change that probes `contracts/code-pin.yaml` first would add a network request to +every remote install today. It would also have to tell a 404 from a transport failure +(installer-design.md:31-33), which `fetch_from_repo` cannot do: it returns a bare status +(:258-275). That is T007 proper. + +**T007 proposal, as a sketch** (bash 3.2; root file `openRepoTools`; it delegates and adds no +install mechanics): + +```bash +#!/usr/bin/env bash +# openRepoTools (assembly root): resolve ONE code identity, then run the code leg's installer. +set -euo pipefail +REPO="${OPENREPOTOOLS_REPO:-opensoft/openRepoTools}"; REF="${OPENREPOTOOLS_REF:-main}" +SELF="${BASH_SOURCE[0]:-$0}" +die() { printf '\nREFUSED: %s\n' "$1" >&2; exit 2; } +# 1. A local adopted checkout: the gitlink and contracts/code-pin.yaml must agree. +if [ -f "$SELF" ]; then + here="$(cd -- "$(dirname -- "$SELF")" && pwd)" + if [ -f "$here/project.yaml" ]; then + link="$(git -C "$here" ls-tree HEAD code | awk '$2 == "commit" { print $3 }')" + pin="$(sed -n 's/^commit:[[:space:]]*"\{0,1\}\([0-9a-f]\{40\}\)"\{0,1\}[[:space:]]*$/\1/p' "$here/contracts/code-pin.yaml")" + [ -n "$link" ] && [ "$link" = "$pin" ] || die "code gitlink ($link) and contracts/code-pin.yaml ($pin) disagree" + [ "$(git -C "$here/code" rev-parse HEAD 2>/dev/null)" = "$pin" ] || die "code/ is not checked out at the pin $pin: git submodule update --init code" + exec "$here/code/openRepoTools" "$@" + fi +fi +# 2. Remote: REF resolved ONCE; the pin read at that commit; the gitlink checked; the code +# installer run with the public variables naming the code identity, so every payload it +# fetches comes from that one commit (gh first, curl second, as fetch_from_repo does). +commit="$(gh api "repos/$REPO/commits/$REF" --jq .sha)" || die "…" +# … read contracts/code-pin.yaml at $commit (source_repository, commit), check the gitlink +# `gh api repos/$REPO/contents/code?ref=$commit --jq .sha` equals it, fetch +# openRepoTools from $code_repo at $code_commit into a temp file, then: +OPENREPOTOOLS_REPO="$code_repo" OPENREPOTOOLS_REF="$code_commit" exec bash "$tmp" "$@" +``` + +The **code installer** then needs installer-design.md's code-side resolution for context (d), +because an installed copy defaults `REPO` to the assembly again: before `collect_skills`, if +`$REPO@$REF` carries `contracts/code-pin.yaml`, payloads come from the pinned code repository +and commit. A pin that is present but malformed refuses. Only an authoritative 404 means a +legacy monorepo. **Landing order:** the entry point has to be in the root-split PR's tree. If +it lands later, the README one-liners are broken for everyone in between. + +### 4.11 Hooks + +Only `openRepoTools --install` writes hooks: two entries merged into +`$CLAUDE_USER_DIR/settings.json` (`plan_hook_merge` :1760-1880, `place_skill_and_hook` :1976). +Both commands name `~/projects/xFactory/lanes-edit.sh`, which `link-estates` points at the +**installed** `lanes-edit.sh` (link-estates:421). `lane-start` and every other tool write no +hook (grep: comments only). `lanes-edit.sh guard` (:15000-15030) compares window, session +name and register row, never the cwd or the repository layout. **No change.** + +### 4.12 Documents that name checkout paths + +| Where | What | Under the triad | Task | +| --- | --- | --- | --- | +| README.md:157 | `docs/README-lanes.md`, `docs/README-claude-current.md` | From the root README these are `spec/docs/…` | T006 / T014 docs batch | +| README.md:228, :236 | the one-liners for the root `openRepoTools` | stay byte-identical (`RAW_INSTALL_LINE`, test_repo_hygiene.py:105-107, is printed by openRepoShape's `--install`); they need T007 | T007 | +| README.md:417-449, :467-468 | pin-bump procedure with `upstream/openRepoShape`, `contracts/openreposhape-pin.yaml` | From the root these are `code/upstream/…`, `code/contracts/…`, or the procedure moves to the code leg's guidance | T006 | +| AGENTS.md:22, :44 | `docs/README-lanes.md`, `openspec/changes/skip-no-lane-guard/` | `spec/…` from the root | T006 | +| AGENTS.md:200-224, :233, :247-250, :285, :291-292 | `upstream/openRepoShape`, `contracts/openreposhape-pin.yaml`, `git submodule update --init upstream/openRepoShape`, `tests/run.sh`, `.github/workflows/tests.yml`, `tests/test_lane_helpers*.{sh,py}` | All are code-leg paths: from the root, `cd code` first | T006 | +| docs/README-lanes.md:573-594, :3979-3997 | `tests/run.sh` | code-leg path | T006 | +| docs/README-lanes.md:1793 | example `STARTED` line, `dir ~/projects…` | an example; true as long as the assembly stays at its path | none | +| docs/README-claude-current.md:172 | "looks for `claude-current` beside itself" | true in `code/` | none | +| ideation/brainstorm/supervised-context-restart-durable-intent.md:31 | `directory: /workspace/projects/openRepoTools` (an example intent record) | spec-leg prose; stays true if the assembly keeps that path | none | + +**Hygiene caps that T006 will trip:** test_repo_hygiene.py:1735-1736 caps AGENTS.md at 316 +lines, and it **is** 316. test_repo_hygiene.py:2232-2233 caps README.md at 486, and it **is** +486. The adoption follow-ups "add the line `Read AGENTS-shape.md first…` to the existing +AGENTS.md" (adoption-plan.yaml:339) and "merge shape/README.md into README.md" (:341) each +add lines. Each cap needs a dated entry in the same PR, following the convention the test's +docstring keeps. + +### 4.13 Tests + +Lane 1's **PR #190** (`feat/test-roots-for-the-triad` at `c139e88`, based on `c4864ac`, +T009/T010) is the suite's roots. It was read with `git diff c4864ac..c139e88 -- tests/` and +was not run here. This table says, for each assumption, what #190 does about it. + +| Test | Assumption (`c4864ac`) | Under the triad | Change | +| --- | --- | --- | --- | +| tests/conftest.py:29 | `REPO = parents[1]`, one root | `REPO` is the code root, and stays so | #190: `ROOTS = resolve_roots(os.environ, SUITE_ROOT)`, `REPO = CODE_ROOT`, `ROOTS.path_for(rel)`. The roots come from `OPENREPOTOOLS_{CODE,ASSEMBLY,SPEC}_ROOT`, or are found through `discover_assembly` (`contracts/code-pin.yaml` + `project.yaml` + the gitlink). `OPENREPOTOOLS_COMPOSED=1` is the composed mode. | +| tests/conftest.py:48-58 | `UPSTREAM = REPO/upstream/openRepoShape` | code leg, unchanged (FR-004) | #190: `UPSTREAM = DEPENDENCY_ROOT`. Standalone it skips as today; composed it fails. | +| test_repo_hygiene.py: 23 reads of README.md, AGENTS.md, CLAUDE.md, docs/README-lanes.md (:849, :860, :887, :911, :918, :942, :954, :957, :988, :1010, :1012, :1038, :1048, :1183, :1217, :1221, :1233, :1246, :1735, :2232, :2390, :2624, :3074) | `REPO / …` | README, AGENTS and CLAUDE are assembly files; README-lanes is a spec file | #190: `doc()` and `code_first()` | +| test_repo_hygiene.py:234, :2453, :2650, :2691 | `git ls-files` at REPO | The code leg's own index only | #190: `tracked_files()` over `ROOTS.tracked_roots()`. The composed run also scans the assembly and the spec leg. | +| test_repo_hygiene.py:2681 | `REPO/.gitattributes` carries `* text=auto eol=lf` | **Fails in the code leg**, because the plan puts `.gitattributes` at the root only (adoption-plan.yaml:41-48). Git applies **no** superproject attribute inside a submodule: **measured**, `git -C A/code check-attr -a lane-start` prints nothing while `git -C A check-attr -a project.yaml` prints `text: auto`, `eol: lf`. The installer's `cmp -s` byte checks and test_openrepotools_command.py's byte comparisons depend on that rule. | #190 reads it from the code root **deliberately**, for the same reason. That makes the **T006** proposal mandatory: the code leg carries its own `.gitattributes` (`* text=auto eol=lf`) and `.gitignore` (`__pycache__/`, `*.py[cod]`, `.pytest_cache/`, as at the root today). Without the ignore file, a bare `python3 -m pytest` in `code/` leaves the submodule dirty in the assembly's status. | +| test_openrepotools_command.py:1398 | README.md | assembly | #190: `ROOTS.path_for("README.md")` | +| test_install_*.py, test_wip_init_command.py:42-43, test_upstream_pin.py:28, :102 | `openRepoTools`, `skills/`, `commands/`, `upstream/…`, `.gitmodules`, `contracts/…` at REPO | all code-leg paths | none needed; #190 also edits test_upstream_pin.py | +| tests/test_lane_helpers.sh:50, :3738, :6228 | `SRC_DIR` = repo root; manual read from `$SRC_DIR/docs/README-lanes.md` | The read comes back **empty**. 13 `has` assertions then fail (:3739-3744, :6229-6239, :6580), and 3 `hasnt` assertions pass with nothing to check (:3745, :6241, :6579) | #190: `LANE_MANUAL="${OPENREPOTOOLS_LANE_MANUAL-$SRC_DIR/docs/README-lanes.md}"`. The two quoting blocks are skipped, by name, when it is set and empty. **Not covered:** the third read, inside the `--retire` block (:6579-6580 on `c4864ac`, :6595-6596 in #190), still reads `$ln_doc` after #190's `if`/`else`. With no spec root `ln_doc` is never assigned, and the suite runs under `set -u` (:42), so the whole shell suite **aborts** there with `ln_doc: unbound variable`. **Measured** on the same construct: `bash -c 'set -uo pipefail; has() { :; }; has d "$ln_doc" x; echo after'` prints that error, exits 127 and never reaches `after`. This happens only in a standalone code leg; today's layout is unaffected. | +| tests/test_lane_helpers_suite.py:157-191 | runs the shell suite at REPO | needs the spec root handed to it | #190: `OPENREPOTOOLS_LANE_MANUAL` from `ROOTS.root_of("spec")` | +| tests/run.sh:75-80, :235-236 | cds to the repo root; venv named `openRepoTools` | code root; the venv name is a constant | #190: makes relative `OPENREPOTOOLS_*_ROOT` absolute before the `cd` | + +**One gap in #190 that the lane tooling's layout raises** (read, not run): +`discover_assembly` stops at the first directory above the code checkout that carries the +assembly markers, and requires that directory to mount this checkout. A code worktree in +plan.md's paired layout, `/worktrees//code`, is therefore a leg with no +assembly. Its manual and front-door reads **skip** unless the roots are named, although the +paired spec worktree sits beside it at `../spec`. + +### 4.14 CI (`.github/workflows/tests.yml`) + +Every job checks out **this repository at its root** (`actions/checkout`, :55, :83, :129, +:184, :235, :291), with `submodules: true` (:57, :85, :186, :293) or `false` (:131, :237). +In the code leg the same file runs the code leg's own tests, with the nested +`upstream/openRepoShape` intact. With no spec or assembly context, #190's document reads +skip in a standalone run. Composed (`OPENREPOTOOLS_COMPOSED=1`), the run is refused before its +first test. #190 changes no workflow. The bash-3.2 list (:254-272) and `test_the_macos_job_parses_every_bash_file…` +(test_repo_hygiene.py:166-194) are code-relative and unaffected. + +**T010 proposal, as a sketch:** each job that runs the full suite adds two read-only +checkouts and names them: + +```yaml + - uses: actions/checkout@ + with: { path: code, submodules: true } + - uses: actions/checkout@ + with: { repository: opensoft/openRepoTools-spec, ref: , path: spec } + - uses: actions/checkout@ + with: { repository: opensoft/openRepoTools, ref: main, path: assembly, submodules: false } + - run: bash tests/run.sh -k 'guard_launch_mode or repo_hygiene' + working-directory: code + env: + OPENREPOTOOLS_SPEC_ROOT: ${{ github.workspace }}/spec # #190's names + OPENREPOTOOLS_ASSEMBLY_ROOT: ${{ github.workspace }}/assembly + OPENREPOTOOLS_COMPOSED: "1" +``` + +"Matching" is lane 1's decision: the spec commit the assembly's `contracts/spec-pin.yaml` +names at assembly `main`, or the paired feature branch of the same name. `tests-no-submodule` +(:127-149) keeps proving that a fork's first run skips rather than fails. The assembly's own +`validate.yml` from the shape checks manifest and pins. + +### 4.15 The register, logs, inventory and handoffs in `brett-wip` (read only) + +`grep -rcE '/workspace/projects/openRepoTools|~/projects/openRepoTools|\$HOME/projects/openRepoTools'` +at brett-wip `c311a78`: + +| File | Hits | What they are | Carry-over proposal | +| --- | ---: | --- | --- | +| lanes/LANES.md | 0 | Rows carry home `opensoft/openRepoTools` | none: the assembly keeps the name | +| lanes/log/openRepoTools-3.md / -1.md / -2.md | 32 / 11 / 11 | `dir /workspace/projects/openRepoTools` on STARTED/RESUMED/PAUSED lines (29 + 1, 11, 11 of them) | Append-only, never rewritten. They stay valid **if the assembly keeps this path**. If it moves, run one `lane-start --dir openRepoTools ` per lane at its breakpoint; that writes the new `dir`. | +| `/workspace/projects/.lane-state/openRepoTools-{1,2,3}/trees/*.yaml` (beside, not in, brett-wip) | 18 / 8 / 14 sidecars | every row `checkout: /workspace/projects/openRepoTools`; 19 trees under `.claude/worktrees` | They stay true: they describe monorepo-history worktrees of the same repository. Translate each tree's delta through T016/T017 receipts, then retire it with `lane-worktrees sweep --yes`. The sweep's "landed" test compares against the assembly's default branch, where a monorepo branch's content never lands after the split, so expect **rescue to a bundle**, not "landed". | +| handoffs/openRepoTools/session-handoff-2026-09-13-lane-openRepoTools-3.md, migration-handoff-2026-10-06.md, …-2026-10-02-…-2.md, …-2026-09-16-…-1.md | 56, 42, 26, 24 | historical reads and paths | none: each live lane's next Rule 3 top block restates the current paths | +| handoffs/xFactory/session-handoff-2026-09-05-lane-opsXfactory-1.md | 11 | **"register writes via … `~/projects/openRepoTools/lanes-edit.sh`"**, the **checkout copy** | **Act before cutover:** this lane is live (PAUSED 2026-10-07T11:12:58Z, swap). That path disappears when the live checkout pulls the assembly main. Point its next handoff at the installed copy (`~/.local/bin/lanes-edit.sh` or `~/projects/xFactory/lanes-edit.sh`), and tell its owner. | +| handoffs/xFactory/attachments/opsXfactory-1/land-444.sh | 2 | `LANES_EDIT="${LANES_EDIT:-$HOME/projects/openRepoTools/lanes-edit.sh}"` (:329) | Same. A script that defaults to the checkout copy breaks at the split. | +| handoffs/xFactory/attachments/opsXfactory-1/briefs/DELTA-land-{444-r2,444-r4,444-r5-MERGE-WORKER,462}.md | 1 each | the same checkout-copy path in briefs | same | +| handoffs/xFactory/session-handoff-2026-09-0{2,5}…, -09-10b…, -09-12… (5 files); lanes/log/openxfactory-1.md (4), openXfactory-5.md (1), openXfactory-3.md (1) | 1-4 | mentions of the checkout and of its `lanes-edit.sh` | historical. Check whether any live lane still runs the checkout copy. | +| migration/openRepoTools/prep-20261007T100559Z/{worktrees.tsv, inventory-raw.json, status-openRepoTools.txt}; adopter-fix-20261007-iM20tv/{README.md, logs/lane-worktrees-add.log} | 9, 9, 1; 1, 1 | migration evidence | none: evidence of its time | + +Outside brett-wip, nothing calls a tool by its checkout path. `~/.claude/settings.json` and +the shell rc files have no `projects/openRepoTools` (`grep` exited 1), and neither does +`~/.agents`. In `workBenches` there is one hit, a fixture string at +`devcontainer.test/test-claude-profile-lane-default.sh:282` and `:293`: text a fake prints, +naming a cwd. That is not a tool call. + +--- + +## 5. What does not need to change, and why + +- **How the tools find their helpers** (§4.1): every helper moves to `code/` with the tool. +- **`lane-start`'s directory rungs and `lanes-edit.sh`'s crossing test** (§4.2, §4.5): both + already read `legs:` the way openRepoShape writes them, and the shell suite covers both + (tests/test_lane_helpers.sh:1458-1469 for crossings, :5691-5704 for rung 5). +- **The workspace resolver, the register, logs and handoffs** (`link-estates:89-295`, + byte-identical in four helpers): they are found through `workspace.yaml`, never from a + checkout path. +- **`installed.tsv`**: it records destinations, not sources (§4.10). +- **Both hook entries**: they name the `~/projects/xFactory/lanes-edit.sh` link to the + installed copy (§4.11). +- **`lanes-index`, `lane-rename`, `link-estates`**: no repository-relative path. +- **`set-lane-tree` and the inventory format**: absolute paths. An unvalidated `--checkout` + already admits a code-leg tree. +- **`tests/run.sh`**: the lock, the run root and the venv name are not layout facts. +- **The dependency unit**: `upstream/openRepoShape`, `.gitmodules` and + `contracts/openreposhape-pin.yaml` move together to code, so `test_upstream_pin.py`, + `NEEDS_UPSTREAM` and `wip init`'s local template path stay true there. + +--- + +## 6. What this branch carries + +**One commit: this document** (T006 / T007 / T009 / T010 / T018). No shipped command changed. +Every behaviour change proposed in §4 changes what a command prints or does **today**, for +this repository or for the estate's existing triads. Each is written above as a proposal with +a sketch, for a ruling. + +**Overlap with #190, and why this branch carries no test change.** An earlier draft of this +branch made the suite's roots shape-aware. Lane 1's PR #190 does the same work, more +completely, under other names. The draft was dropped before it was committed; its diff is +kept only in this lane's scratch directory. + +| File | This branch's draft | #190 | Disposition | +| --- | --- | --- | --- | +| tests/conftest.py | `manifest_legs`, `checkout_roots`, `ASSEMBLY`, `SPEC`, `source()`; `$OPENREPOTOOLS_TEST_{ASSEMBLY,SPEC}_ROOT`; a missing root **fails** | `ROOTS`, `resolve_roots`, `ROOTS.path_for`; `$OPENREPOTOOLS_{CODE,ASSEMBLY,SPEC}_ROOT` and `_COMPOSED`; standalone **skips**, composed **refuses** | dropped; #190's definitions stand | +| tests/test_checkout_roots.py (new) | 9 cases on throwaway layouts, the paired layout among them | tests/test_test_roots.py (new, 573 lines) | dropped; the paired layout is a gap in #190 (§4.13) | +| tests/test_repo_hygiene.py | 23 reads through `source()` | `doc()`, `code_first()`, `tracked_files()` | dropped | +| tests/test_openrepotools_command.py | 1 read through `source()` | `ROOTS.path_for("README.md")` | dropped | +| tests/test_lane_helpers.sh, test_lane_helpers_suite.py | `SPEC_DIR` from `$OPENREPOTOOLS_TEST_SPEC_ROOT` (written, never committed) | `LANE_MANUAL` from `$OPENREPOTOOLS_LANE_MANUAL`, with skips | dropped; one read #190 leaves open (§4.13) | + +--- + +## 7. Decisions for lane 1 (the migration owner) + +1. **Bindings (T018):** keep every lane's `dir` at the assembly root and its home at + `opensoft/openRepoTools`, and spell leg objects in full. The alternative is lanes homed on a + leg, which changes the Claude project folder, and with it memory and `--resume`. +2. **Open OpenSpec claims (T018):** re-key `opensoft/openRepoTools:openspec/changes/` to + `opensoft/openRepoTools-spec:…` at cutover, by release and claim. +3. **Worktree visibility (T018):** adopt §4.4 proposal 1 (guidance) now. Rule on proposals 2 + and 3, which change the sweep and the daily report for existing triads too. +4. **Checkout-copy callers:** opsXfactory-1's handoff and its `land-444.sh` call + `~/projects/openRepoTools/lanes-edit.sh`. Re-point them before the live checkout pulls the + split. +5. **T007 landing order:** the root entry point goes in the root-split PR, and the code + installer's pin resolution covers already-installed re-installs (context d) and + `wip_shape_ref`'s silent `main` (context e). +6. **T006 contents:** the code leg's own `.gitattributes` and `.gitignore`; dated cap entries + for AGENTS.md (316) and README.md (486); the root guidance's `code/` prefixes (§4.12). +7. **T010 context:** which spec and assembly commits a code CI run pairs with, and whether the + code leg's CI carries the two extra checkouts or the assembly's CI runs the composed suite. +8. **Measure in T008:** what `gh api …/contents/` and `raw.githubusercontent.com` answer + for a path under a gitlink. §4.10 derives a 404 and does not claim it. +9. **Standalone skip, or standalone fail (#190):** there are two readings of FR-008. #190 + skips a document read in a standalone code leg and refuses only in composed mode. That + is sound only if the composed job is **required** on every code PR (T010); otherwise a + code-only CI is green with the manual never read. This branch's dropped draft failed in + both modes, which keeps a code-only CI red until T010 gives it context. Choose one, and + write it into T010. +10. **Paired worktrees in #190:** decide whether `discover_assembly` should also accept + `/worktrees//code`, with the spec root at `../spec` (§4.13), and + move the third manual read (#190's test_lane_helpers.sh:6595-6596) inside its `if`, or + a standalone code-leg run aborts on `ln_doc: unbound variable` (§4.13, measured). From 2aa8a59f16891e329b0696f4f6e31e17374348dd Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:19:46 +0000 Subject: [PATCH 02/42] =?UTF-8?q?WIP=20(untested):=20T018=20worktree=20vis?= =?UTF-8?q?ibility=20for=20triad=20legs=20=E2=80=94=20the=20legs=20reader,?= =?UTF-8?q?=20the=20sweep's=20leg=20and=20paired-tree=20discovery,=20lane-?= =?UTF-8?q?end's=20leg=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkpoint for an account swap, on the coordinator's word. NOTHING HERE HAS BEEN RUN UNDER tests/run.sh: `bash -n` passes for lanes-edit.sh and lane-end, and lane-worktrees parses as Python; one manual run of `lanes-edit.sh project-legs` against openRepoShape's assembly-root project.yaml template (pinned submodule) printed the spec and code legs, exit 0, with no workspace configured; 8 for a directory with no manifest; 64 for a relative path. DONE (T018, plan proposal 2 of docs/triad-lane-tooling-analysis.md §4.4): - lanes-edit.sh: `project_leg_paths_file` (lane-start rung 5's awk, the seven deciding lines verbatim, printing every leg) and `assembly_legs`; the read-only subcommand `project-legs ` (0 rows, 8 none, 1 manifest unreadable, 64 usage), exempt from the workspace guard; listed in the unknown-subcommand refusal and the usage header. - lane-worktrees sweep: where the lane's dir has a project.yaml naming legs, `Leg` rows for each leg and each initialized nested submodule (porcelain kind `leg`, a LEGS section, a summary suffix), counted only where the leg's HEAD's own Lane: trailer is this lane's and it holds work, or unreadable; every leg's worktree registrations enumerated; `/worktrees` scanned as the paired root and claimed by the same HEAD-trailer rule as .claude/worktrees; legs' branches under --branches and probed under --fetch. No project.yaml: no helper call and no new code path's output. - lane-end: `leg` porcelain rows counted (retire) or listed (not counted), "leg checkout(s)" in the gate's tally, no `--yes` offered when legs are all that is counted. REMAINS, in order: (1) tests/test_triad_lane_tooling.py — assembly fixture from the pinned template bytes with legs as real repositories and bare local remotes, fake gh exiting 1; single-repository golden comparisons against c45a452's tools (CI checks out depth 1, so the old bytes need a decision: skip where c45a452 is absent, or captured goldens); the shared-awk identity check against lane-start; (2) README-lanes.md and usage text for (a); (3) commit (b) lane-handoff paired-root poll + recording the leg checkout, lane-reconcile's leg registrations; (4) (c) the daily report's legs; (5) (d) add recording the leg; (6) the shell suite once at the end. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lane-end | 35 +++++ lane-worktrees | 361 ++++++++++++++++++++++++++++++++++++++++++++++++- lanes-edit.sh | 96 ++++++++++++- 3 files changed, 487 insertions(+), 5 deletions(-) diff --git a/lane-end b/lane-end index cde8c280..91a9cb33 100755 --- a/lane-end +++ b/lane-end @@ -1971,6 +1971,7 @@ fi gate_holder=""; gate_refused=""; gate_lines=""; gate_named="" gate_trees=0; gate_branches=0; gate_scratch=0; gate_caches=0; gate_residue=0; gate_foreign=0 +gate_legs=0; gate_leg_lines="" # T018: an assembly's leg checkouts, counted and listed gate_person=0 # counted rows `--yes` leaves too: a person's to settle gate_lf=' ' @@ -2012,6 +2013,25 @@ while IFS="$TAB" read -r g_kind g_a g_b g_c g_d g_e g_f; do gate_named="${gate_named}$(gate_real "$g_path")${gate_lf}" gate_caches=$((gate_caches + 1)) gate_lines="${gate_lines} cache ${g_a} ${g_path} (${g_e:-0} bytes)${gate_lf}" ;; + # T018 (opensoft/openRepoTools#186): A LEG CHECKOUT OF AN ASSEMBLY — only + # where the lane's checkout is one; a single repository prints no such row. + # The sweep counts it (`retire`) where its HEAD's own Lane: trailer is this + # lane's and it holds work origin lacks or edits no commit carries, or + # where it could not be read; one that holds work and is not this lane's + # is listed below the table and counted for nobody, as the assembly's own + # checkout is. No sweep acts on one, so its remedy is a person's. + leg) + g_path="$(printf '%b' "${g_c:-}")" + g_head="${g_e:-?}" + if [ "${g_b:-}" = retire ]; then + gate_legs=$((gate_legs + 1)) + gate_lines="${gate_lines} leg ${g_a} ${g_path} [${g_d:-?} ${g_head:0:12}]${gate_lf} ${g_f:-}${gate_lf}" + else + case "${g_a:-}" in + clean | absent) : ;; + *) gate_leg_lines="${gate_leg_lines} leg ${g_a} ${g_path} [${g_d:-?} ${g_head:0:12}]${gate_lf} ${g_f:-}${gate_lf}" ;; + esac + fi ;; esac done <&2 + fi if [ "$inventory_only" = 1 ]; then note "--inventory-only changed nothing: the close-out gate passed, so the lane ends as any other does" fi @@ -2114,6 +2139,10 @@ else *) note "lane $LANE is NOT ended: the close-out gate could not be read — \`$gate_shown --dry-run --porcelain\` exited $gate_rc, and a read that failed is never an answer (Amendment 7(d))." ;; esac if [ -n "$gate_lines" ]; then printf '%s' "$gate_lines" >&2; fi + if [ -n "$gate_leg_lines" ]; then + note "and the assembly's leg checkout(s) below hold work that is not this lane's by its HEAD's own Lane: trailer; they are listed and NOT counted:" + printf '%s' "$gate_leg_lines" >&2 + fi if [ "$gate_foreign" -gt 0 ]; then note "($gate_foreign FOREIGN tree(s) beside these are not this lane's, and were not counted.)" fi @@ -2129,6 +2158,9 @@ else gate_act=0 if [ "$gate_rc" = 3 ]; then gate_act=1; fi if [ "$gate_rc" = 0 ] && [ "$gate_caches" -gt 0 ]; then gate_act=1; fi + # T018: A LEG IS NO SWEEP'S TO RETIRE, so where legs are all that is + # counted the act offered is the person's, not `--yes`. + if [ "$gate_legs" -gt 0 ] && [ $((gate_trees + gate_branches + gate_scratch + gate_caches)) = 0 ]; then gate_act=0; fi if [ "$gate_act" = 1 ]; then note "the one act that retires them (--yes removes only what is on origin or in a bundle, never a live writer's tree):" if [ "$gate_holder" = live ]; then @@ -2143,6 +2175,9 @@ else if [ "$gate_residue" -gt 0 ]; then note "residue is no sweep's to retire: move it to \${XDG_STATE_HOME:-~/.local/state}/openRepoTools/lanes/$LANE/scratch/ or remove it by hand" fi + if [ "$gate_legs" -gt 0 ]; then + note "a leg checkout is the assembly's and no sweep acts on one: commit and push its work from inside it (git -C status, git -C push), then end the lane again" + fi note "or end the lane and leave them where they are, in its inventory, with the reason written down:" note " $prog $LANE$rflag$fflag --inventory-only" exit "$gate_exit" diff --git a/lane-worktrees b/lane-worktrees index c2da8d88..9e836565 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -1352,6 +1352,7 @@ class Tree: self.reg: dict = {} # its registration, where git has one self.held_back = "" # the bundle-only commit of its untracked files self.lost_bundle = "" # the bundle of commits only its reflog named + self.leg = None # T018: the assembly's leg that registers it @property def slice(self) -> str: @@ -1924,6 +1925,230 @@ def ignored_paths(path: str): return out +# ================================================== an assembly's legs (T018) +# +# opensoft/openRepoTools#186, plan task T018 - A TRIAD'S CHECKOUTS. A checkout +# is an ASSEMBLY when its own `project.yaml` declares a leg other than itself: +# the standard's own rule, read by `lanes-edit.sh project-legs` - the one +# reader every lane tool asks, whose grammar is `lane-start`'s rung 5 line for +# line - and never here a second time. Its legs (`spec/`, `code/`) are +# submodules: their `.git` is a FILE, `git -C worktree list` names +# none of their worktrees, and a feature's paired trees under +# `/worktrees//{spec,code}` are worktrees of the LEGS' +# repositories, so before this every reader below was blind to them. +# +# A CHECKOUT WITH NO `project.yaml` IS ASKED NOTHING: no helper call, no read, +# no row - which is every single-repository checkout, whose sweep, report and +# `add` are therefore byte for byte what they were. + + +class Leg: + """One checkout an assembly mounts: a leg its `project.yaml` declares, or + a dependency NESTED in one - an initialized submodule of the leg's own + (`code/upstream/openRepoShape`). It is the ASSEMBLY'S, shared by every + lane the assembly homes, as the assembly's own checkout is, so a sweep + NEVER acts on one: it is read - for the worktrees git registers in it, for + its branches, and for its own dirty and unpushed state - and counted for + this lane only where its HEAD's own `Lane:` trailer is this lane's.""" + + def __init__(self, path: str, decl: str, repository: str, assembly: str, + nested_in: str = ""): + self.path = path + self.real = real(path) + self.decl = decl # its path as declared, under the assembly + self.repository = repository # as the manifest names it ('' when nested) + self.assembly = assembly + self.nested_in = nested_in # the leg it is a dependency of + self.exists = os.path.isdir(path) + self.checkout = False # a checkout of its own is there + self.read_error = "" + self.branch = "" + self.detached = False + self.head = "" + self.dirty = 0 + self.upstream = "" + self.ahead = None + self.on_origin = None + self.origin_url = "" + self.claimed = False # its HEAD's own Lane: trailer is this lane's + self.disposition = "" + self.reason = "" + self.retire = False + + def what(self) -> str: + if self.nested_in: + return f"a dependency nested in the {self.nested_in} leg ({self.decl})" + return (f"the {self.decl} leg of {os.path.join(self.assembly, 'project.yaml')}" + + (f" ({self.repository})" if self.repository else "")) + + def label(self) -> str: + if not self.exists or not self.checkout: + return "(not checked out)" if not self.read_error else "(unreadable)" + if self.detached: + return f"(detached) {short(self.head)}" + if not self.head: + return f"{self.branch} (unborn)" + return f"{self.branch} {short(self.head)}" + + +def project_legs(helper, checkout: str) -> tuple: + """([(repository, path as declared, where it is checked out)], why-not): + the legs `/project.yaml` declares, through `lanes-edit.sh + project-legs`. A checkout with no `project.yaml` is asked nothing; a + manifest that could not be read is never "no legs" (Amendment 7(d)).""" + if not checkout or not os.path.lexists(os.path.join(checkout, "project.yaml")): + return [], "" + proc = helper.call("project-legs", checkout) + if proc.returncode == 8: + return [], "" + if proc.returncode != 0: + return [], (f"`lanes-edit.sh project-legs {checkout}` exited {proc.returncode}: " + + _first_line(proc.stderr)) + rows = [] + for line in proc.stdout.splitlines(): + cols = line.split("\t") + if len(cols) >= 3 and cols[1] and cols[2]: + rows.append((cols[0], cols[1], cols[2])) + return rows, "" + + +def probe_leg(leg: Leg) -> None: + """Is a checkout of its own there? An uninitialized leg is an empty + directory of the assembly's, and git answers the ASSEMBLY there - which is + no checkout of the leg's. A `.git` of its own that git cannot read is a + read that failed, never an absent leg.""" + if not leg.exists: + return + proc = git(leg.path, "rev-parse", "--show-toplevel") + if proc.returncode == 0 and real(proc.stdout.strip()) == leg.real: + leg.checkout = True + elif os.path.lexists(os.path.join(leg.path, ".git")): + leg.read_error = ("it has a .git of its own and git could not read it: " + + (_first_line(proc.stderr) if proc.returncode != 0 else + f"git answers {proc.stdout.strip()} there")) + + +def assembly_checkouts(helper, assembly: str) -> tuple: + """([Leg], declared, why-not) - every leg `/project.yaml` + declares, and every dependency nested in one (the initialized submodules + `git submodule status --recursive` names in each leg), in that order; + `declared` is whether the manifest declares any leg at all, which is what + makes `/worktrees` the paired root.""" + rows, why = project_legs(helper, assembly) + if why: + return [], False, why + out = [] + for repository, decl, path in rows: + leg = Leg(path, decl, repository, assembly) + probe_leg(leg) + out.append(leg) + if not leg.checkout: + continue + sub = git(path, "submodule", "status", "--recursive") + if sub.returncode != 0: + return out, True, (f"the dependencies nested in the {decl} leg ({path}) could not be " + "listed (git submodule status failed): " + _first_line(sub.stderr)) + for line in sub.stdout.splitlines(): + if not line.strip() or line.startswith("-"): + continue # declared and not initialized: nothing to read + parts = line[1:].split(" ", 1) + if len(parts) < 2: + continue + rel = parts[1] + if rel.endswith(")") and " (" in rel: + rel = rel[:rel.rindex(" (")] + nested = Leg(os.path.normpath(os.path.join(path, rel)), f"{decl}/{rel}", "", + assembly, nested_in=decl) + probe_leg(nested) + out.append(nested) + return out, bool(rows), "" + + +def read_leg(leg: Leg, lane: str) -> None: + """What git says about one leg checkout NOW. A read that failed is said, + never converted into a clean-looking value (R22, Amendment 7(d)).""" + if not leg.checkout or leg.read_error: + return + branch = git(leg.path, "symbolic-ref", "-q", "--short", "HEAD") + if branch.returncode == 0: + leg.branch = branch.stdout.strip() + elif branch.returncode == 1: + leg.detached = True + else: + leg.read_error = "HEAD could not be read: " + _first_line(branch.stderr) + return + head = git(leg.path, "rev-parse", "--verify", "-q", "HEAD") + if head.returncode == 0: + leg.head = head.stdout.strip() + elif not leg.branch: + leg.read_error = "HEAD names no commit" + return + status = git(leg.path, "status", "--porcelain=v1", "--ignore-submodules=none", + "--untracked-files=normal") + if status.returncode != 0: + leg.read_error = "git status failed: " + _first_line(status.stderr) + return + leg.dirty = sum(1 for ln in status.stdout.splitlines() if ln.strip()) + leg.origin_url = git_out(leg.path, "config", "--get", "remote.origin.url") or "" + if leg.head: + contains = git(leg.path, "for-each-ref", "--count=1", "--contains", leg.head, + "--format=%(refname)", "refs/remotes/origin/") + if contains.returncode != 0: + leg.read_error = ("whether origin holds its HEAD could not be read: " + + _first_line(contains.stderr)) + return + leg.on_origin = bool(contains.stdout.strip()) + if leg.branch: + up = git(leg.path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + leg.upstream = up.stdout.strip() if up.returncode == 0 else "" + if leg.upstream and leg.head: + n = git_out(leg.path, "rev-list", "--count", "@{u}..HEAD") + leg.ahead = int(n) if n and n.isdigit() else None + leg.claimed = bool(leg.head) and lane.lower() in own_trailers(leg.path) \ + and head_lane_trailer(leg.path).lower() == lane.lower() + + +def decide_leg(leg: Leg) -> None: + """A leg's row. It is COUNTED - the sweep's exit 3, `lane-end`'s gate - + where its HEAD's own `Lane:` trailer is this lane's and it holds work + origin lacks or edits no commit carries, or where it could not be read; + otherwise it is listed and counted for nobody, as the assembly's own + checkout is. No `--yes` acts on one.""" + what = leg.what() + if leg.read_error: + leg.disposition, leg.retire = "unreadable", True + leg.reason = (f"{what}: git could not be read through it ({leg.read_error}); a read that " + "failed is never a clean leg (Amendment 7(d)) - read it by hand: " + f"git -C {leg.path} status") + return + if not leg.exists or not leg.checkout: + leg.disposition, leg.retire = "absent", False + leg.reason = f"{what}: not checked out here, so it holds nothing to read" + return + parts = [] + if leg.dirty: + parts.append(f"{leg.dirty} dirty or untracked path(s)") + if leg.head and leg.on_origin is False: + parts.append("its HEAD carries commit(s) no origin ref holds" + + (f" ({leg.ahead} ahead of {leg.upstream})" if leg.upstream and leg.ahead + else "") + + ("" if leg.origin_url else "; it has no origin remote")) + if not parts: + leg.disposition, leg.retire = "clean", False + leg.reason = (f"{what}: clean, and its HEAD is on origin" if leg.head else + f"{what}: clean, on an unborn branch with nothing committed") + return + leg.disposition = ("dirty+unpushed" if len(parts) == 2 else + "dirty" if leg.dirty else "unpushed") + leg.retire = leg.claimed + leg.reason = what + ": " + "; ".join(parts) + ( + "; its HEAD's own Lane: trailer is this lane's, so it is COUNTED - commit and push from " + "inside it: a sweep never acts on a leg checkout, which is the assembly's" + if leg.claimed else + "; listed and NOT counted - a leg checkout is the assembly's, shared by every lane it " + "homes, and its HEAD's own Lane: trailer is not this lane's") + + # ============================================================ the sweep class Options: @@ -1993,6 +2218,8 @@ class Sweep: self.halt = "" # why no further act is taken self.refused_check = "" # the self-check's refusal: exit 2 self.usage = "" # a usage error found once the trees are known: 64 + self.legs: list = [] # T018: the assembly's leg checkouts, where it is one + self.paired_root = "" # T018: /worktrees, where it is one # ------------------------------------------------------------- reading def gather(self) -> None: @@ -2051,6 +2278,7 @@ class Sweep: self.procs.unknown[:6]) + (" ..." if len(self.procs.unknown) > 6 else "") + "): a tree their command " "line or parent's directory places them in is of unknown liveness and kept") + self._assembly() self._discover() self.usage = self._live_usage() if self.usage: @@ -2066,6 +2294,8 @@ class Sweep: self._fetch_all() for tree in self.trees: read_tree(tree) + for leg in self.legs: + read_leg(leg, self.lane) self._dependents() # EVERY CONTROL ROOT THE WHOLE-ESTATE WALK FOUND IS A PLACE ANOTHER LANE'S # CLAIM MAY BE (#174, Copilot round 2): `host/vendor/.lane-state`, with @@ -2082,6 +2312,26 @@ class Sweep: tree.merge = self.evidence.judge(tree.path, slug_of(tree.origin_url), tree.branch, tree.head) + def _assembly(self) -> None: + """T018: WHERE THE LANE'S CHECKOUT IS AN ASSEMBLY, its legs and the + dependencies nested in them are read, and `/worktrees` - the + paired `worktrees//{spec,code}` the standard's three-leg root + keeps its feature trees in - is a root of the table. A checkout with no + `project.yaml` is asked nothing. A manifest, or a leg's own submodule + list, that could not be read refuses: which checkouts the assembly + holds would be unknown, never none (Amendment 7(d)).""" + d = self.facts.dir + if not d or not os.path.isdir(d): + return + legs, declared, why = assembly_checkouts(self.helper, d) + self.legs = legs + if declared: + self.paired_root = os.path.join(d, "worktrees") + if why: + self.refusal = self.refusal or ( + f"the legs of the assembly {d} could not be read, so which checkouts and " + f"worktrees it holds is unknown (Amendment 7(d)): {why}") + def _dependents(self) -> None: """What leans on the trees. WHERE A REMOVAL WOULD DELETE A DIRECTORY - a clone the table could take, or `--include-scratch` - the whole @@ -2146,7 +2396,7 @@ class Sweep: # A CLONE IS NEVER ADOPTED: a lane creates worktrees, never clones, # so a standalone clone under its root stays FOREIGN - a finding for # a person's word, as #162's first protocol line has it. - if t.owner != "foreign" or t.foreign_kind in ("other-lane", "clone"): + if t.owner != "foreign" or t.foreign_kind in ("other-lane", "clone", "paired-clone"): continue if lane_root and under(t.path, lane_root) and t.real != real(lane_root): t.owner = "lane-root" @@ -2154,6 +2404,14 @@ class Sweep: and self.lane.lower() in own_trailers(t.path) \ and head_lane_trailer(t.path).lower() == self.lane.lower(): t.owner = "trailer" + # T018: THE PAIRED ROOT IS PER FEATURE, NOT PER LANE, exactly as + # `.claude/worktrees` is per checkout - so a tree there is this + # lane's by its HEAD's own trailer, the same rule, and by nothing else. + elif self.paired_root and t.exists and under(t.path, self.paired_root) \ + and t.real != real(self.paired_root) \ + and self.lane.lower() in own_trailers(t.path) \ + and head_lane_trailer(t.path).lower() == self.lane.lower(): + t.owner = "trailer" claimed = [t for t in self.trees if t.owner in ("lane-root", "trailer")] if self.facts.state == "NONE" and claimed: self.refusal = self.refusal or ( @@ -2175,6 +2433,10 @@ class Sweep: def _discover(self) -> None: seen: dict = {} lane_dir = real(self.facts.dir) if self.facts.dir else "" + # T018: A LEG CHECKOUT IS NO TREE OF THE TABLE, as the lane's own + # checkout is none: it is the assembly's, and its own row says what it + # holds. A record naming one is that row's, never a tree to remove. + leg_by_real = {g.real: g for g in self.legs if g.checkout} for rec in self.facts.inventory: path = rec.get("path") or "" if not path or rec.get("schema") not in ("1",): @@ -2188,6 +2450,8 @@ class Sweep: continue if lane_dir and real(path) == lane_dir: continue + if leg_by_real and real(path) in leg_by_real: + continue t = Tree(path, "inventory") t.sidecar = rec t.checkout = rec.get("checkout") if rec.get("checkout") not in ("", "unknown") else "" @@ -2201,6 +2465,13 @@ class Sweep: for t in list(seen.values()): if t.checkout and os.path.isdir(t.checkout) and t.checkout not in checkouts: checkouts.append(t.checkout) + # T018: EVERY LEG'S REGISTRATIONS TOO, LAST, so a tree registered in a + # leg's repository is the leg's: `git -C worktree list` + # names none of them, and a code tree recorded under any other checkout + # was the only one a sweep ever saw. + for g in self.legs: + if g.checkout and g.real not in [real(c) for c in checkouts]: + checkouts.append(g.path) regs_by_real: dict = {} for co in checkouts: regs = worktree_registrations(co) @@ -2216,8 +2487,10 @@ class Sweep: for rp, (co, reg) in regs_by_real.items(): t = seen.get(rp) if t is None: - t = Tree(reg["path"], "foreign", "registered") + t = Tree(reg["path"], "foreign", + "leg" if real(co) in leg_by_real else "registered") seen[t.real] = t + t.leg = leg_by_real.get(real(co)) t.registered = True t.checkout = t.checkout or co t.locked = bool(reg.get("locked")) @@ -2245,8 +2518,47 @@ class Sweep: continue kind = "clone" if os.path.isdir(os.path.join(full, ".git")) else "lane-root" seen[real(full)] = Tree(full, "foreign", kind) + if self.paired_root: + self._paired(seen) self.trees = sorted(seen.values(), key=lambda t: (t.owner != "inventory", t.path)) + def _paired(self, seen: dict) -> None: + """T018: THE ASSEMBLY'S PAIRED FEATURE TREES, `/worktrees/ + /` (and `` itself), that no + registration above named - a registration lost, or a clone where a + worktree belongs. A directory that cannot be listed refuses, as a lane + root does (#170 G1): an unread root is never an empty one.""" + root = self.paired_root + try: + names = sorted(os.listdir(root)) + except (FileNotFoundError, NotADirectoryError): + return + except OSError as exc: + self.refusal = self.refusal or ( + f"the assembly's worktree root {root} could not be listed " + f"({exc.strerror or exc}), so which feature trees stand in it is unknown") + return + decls = [g.decl for g in self.legs if not g.nested_in] + for name in names: + fdir = os.path.join(root, name) + if not os.path.isdir(fdir) or os.path.islink(fdir): + continue + try: + os.listdir(fdir) + except OSError as exc: + self.refusal = self.refusal or ( + f"the assembly's feature directory {fdir} could not be listed " + f"({exc.strerror or exc}), so which trees stand in it is unknown") + continue + for full in [fdir] + [os.path.join(fdir, d) for d in decls]: + if not os.path.isdir(full) or os.path.islink(full) or real(full) in seen: + continue + if not os.path.lexists(os.path.join(full, ".git")): + continue + kind = ("paired-clone" if os.path.isdir(os.path.join(full, ".git")) + else "paired") + seen[real(full)] = Tree(full, "foreign", kind) + def _other_lanes(self, extra: list = ()) -> None: """A FOREIGN tree another lane's inventory names is THAT lane's. @@ -2365,6 +2677,11 @@ class Sweep: if t.owner == "inventory" and t.checkout and os.path.isdir(t.checkout) \ and real(t.checkout) not in [real(c) for c in checkouts]: checkouts.append(t.checkout) + # T018: AND EVERY LEG'S, where the lane's checkout is an assembly: after + # the split a lane's branches are made in its code leg, not in the root. + for g in self.legs: + if g.checkout and g.real not in [real(c) for c in checkouts]: + checkouts.append(g.path) return checkouts def _fetch_all(self) -> None: @@ -2372,6 +2689,7 @@ class Sweep: probes = [t.path if t.exists else t.checkout for t in self.trees] if self.opts.branches: probes += self._branch_checkouts() + probes += [g.path for g in self.legs if g.checkout] # T018 for probe in probes: if not probe or not os.path.isdir(probe): continue @@ -2496,6 +2814,8 @@ class Sweep: # ---------------------------------------------------------- deciding def decide(self) -> None: + for leg in self.legs: + decide_leg(leg) for tree in self.trees: self._decide(tree) if tree.owner in ("lane-root", "trailer") and tree.disposition != "foreign": @@ -2525,6 +2845,8 @@ class Sweep: "clone": "a standalone CLONE under this lane's root (a lane creates " "worktrees, never clones)", "other-lane": f"lane {t.other_lane}'s inventory names it"}.get(t.foreign_kind, "") + if t.foreign_kind in ("leg", "paired", "paired-clone"): + where = self._leg_where(t) put("foreign", f"not in lane {self.lane}'s inventory (#97); {where}", False) if t.lean: t.reason += "; LOAD-BEARING: " + _lean_text(t.lean) @@ -2688,9 +3010,28 @@ class Sweep: if t.remote_tip else f"origin has no {t.remote_branch}") + ": pushed as is, then removed") + def _leg_where(self, t: Tree) -> str: + """T018: where a tree of an assembly's legs stands, for its FOREIGN row.""" + paired = bool(self.paired_root) and under(t.path, self.paired_root) + if t.foreign_kind == "paired-clone": + return ("a standalone CLONE in the assembly's paired feature worktrees " + f"({self.paired_root}); a feature's trees are worktrees of its legs") + if paired: + return (f"in the assembly's paired feature worktrees ({self.paired_root})" + + (f", registered in {t.leg.what()}" if t.leg else "") + + "; its HEAD carries no Lane: trailer of this lane's as its own") + return (f"registered in {t.leg.what() if t.leg else 'a leg of the assembly'}, outside " + "every root a lane's trees are claimed in") + def _branches(self) -> None: + leg_by_real = {g.real: g for g in self.legs if g.checkout} for co in self._branch_checkouts(): items = branch_items(co, self.lane, self.evidence) + leg = leg_by_real.get(real(co)) + if leg is not None: + # T018: WHICH CHECKOUT, since a branch name says nothing of it. + for it in items: + it.detail = f"in {leg.what()}" + (f" · {it.detail}" if it.detail else "") if self.opts.fetch: common = git_out(co, "rev-parse", "--git-common-dir") or "" if common and not os.path.isabs(common): @@ -2851,7 +3192,8 @@ class Sweep: # ------------------------------------------------------------ output def retire_count(self) -> int: return (sum(1 for t in self.trees if t.retire) - + sum(1 for i in self.items if i.retire)) + + sum(1 for i in self.items if i.retire) + + sum(1 for g in self.legs if g.retire)) def render(self) -> None: f = self.facts @@ -2883,6 +3225,14 @@ class Sweep: res = f" -> {t.result}" if t.result else "" say(f" {t.disposition:<22} {t.path}") say(f" {'':<22} {t.label()} · {t.reason}{res}") + if self.legs: + # T018: THE ASSEMBLY'S OWN CHECKOUTS, read and never acted on. + say("") + say(f"LEGS (the assembly's own checkouts, from {os.path.join(f.dir, 'project.yaml')}; " + "read, and never acted on)") + for g in self.legs: + say(f" {g.disposition:<22} {g.path}") + say(f" {'':<22} {g.label()} · {g.reason}") kinds = (("branch", "BRANCHES (--branches)"), ("scratch", "SCRATCH (--include-scratch)"), ("cache", "CACHES (--include-caches)"), ("sandbox", "SANDBOXES (--include-sandboxes)"), ("link", "BROKEN LINKS (--links)")) @@ -2902,6 +3252,8 @@ class Sweep: live = sum(1 for t in self.trees if t.disposition == "live") say(f"SUMMARY: {len(self.trees)} tree(s): {self.retire_count()} to retire · " f"{live} live · {foreign} foreign" + + (f" · {len(self.legs)} leg checkout(s), " + f"{sum(1 for g in self.legs if g.retire)} counted" if self.legs else "") + (f" · archive {tilde(self.archive)}" if self.archive else "")) def porcelain(self) -> None: @@ -2914,6 +3266,9 @@ class Sweep: prow("tree", t.disposition, "retire" if t.retire else "-", t.path, t.branch or ("(detached)" if t.detached else "-"), t.head or "-", t.reason + (f" -> {t.result}" if t.result else "")) + for g in self.legs: # T018 + prow("leg", g.disposition, "retire" if g.retire else "-", g.path, + g.branch or ("(detached)" if g.detached else "-"), g.head or "-", g.reason) for i in self.items: prow(i.kind, i.disposition, "retire" if i.retire else "-", i.path, i.detail or "-", str(i.size), i.reason + (f" -> {i.result}" if i.result else "")) diff --git a/lanes-edit.sh b/lanes-edit.sh index aadd2db1..b7b34305 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -360,6 +360,7 @@ # [--dirty ] [--unpushed ] [--writer ] \ # [--generation ] [--operation ] # lanes-edit.sh lane-tree-now +# lanes-edit.sh project-legs # an assembly's legs, by path (T018) # lanes-edit.sh lane-reconcile # lanes-edit.sh worktrees [ | --all] [--index] [--fetch] # lanes-edit.sh pathspec-check ... # opensoft/openRepoTools#162 @@ -5066,6 +5067,70 @@ project_legs_file() { }' "$1" } +# ------------------------------------------- an assembly's legs, by path +# +# opensoft/openRepoTools#186, plan task T018 — WHERE A TRIAD'S LEGS ARE CHECKED +# OUT, for the lane tooling that has to look inside one: `lane-worktrees`' sweep, +# its daily report and its `add`, `lane-handoff`'s writer poll, and +# `lane-reconcile` below. A checkout IS AN ASSEMBLY when its own `project.yaml` +# declares a leg other than itself — the standard's own rule (openRepoShape's +# `templates/assembly-root/project.yaml`: a top-level `legs:` list of `- role:` / +# `repository:` / `path:`, the assembly's own leg at `path: "."`). A checkout +# with no such file, or one naming no leg but itself, is every single +# repository, and every reader asks it nothing more than it did before. +# +# THE READING IS `lane-start`'s RUNG 5, LINE FOR LINE, and never a second +# grammar: rung 5 resolves ONE leg's path for the home it is given, and this +# prints every leg the block declares, as ``. The seven +# lines that decide what a leg is are byte-identical to that reader's, and +# `tests/test_triad_lane_tooling.py` holds them so. As there, ONLY `repository:` +# and `path:` are read, and only as navigation: the manifest CONFERS NOTHING, +# and `role:` is never read. +project_leg_paths_file() { # + [ -f "$1" ] || return 1 + awk ' + /^[A-Za-z_][A-Za-z0-9_]*:/ { inlegs = ($0 ~ /^legs:/); repo = ""; path = ""; next } + !inlegs { next } + /^[ \t]*-[ \t]/ { repo = ""; path = "" } + /^[ \t]+repository:[ \t]*/ { v = $0; sub(/^[ \t]+repository:[ \t]*/, "", v) + sub(/[ \t]+$/, "", v); gsub(/^"|"$/, "", v); gsub(/^'"'"'|'"'"'$/, "", v); repo = v } + /^[ \t]+path:[ \t]*/ { v = $0; sub(/^[ \t]+path:[ \t]*/, "", v) + sub(/[ \t]+$/, "", v); gsub(/^"|"$/, "", v); gsub(/^'"'"'|'"'"'$/, "", v); path = v } + repo != "" && path != "" { print repo "\t" path; repo = ""; path = "" }' "$1" +} + +# THE LEGS OF ONE CHECKOUT, one a line: ``, the last being the declared path +# resolved against the checkout as rung 5 resolves it. The assembly's own `.` +# is left out: it IS the checkout. 0 with legs; 8 with none — no +# `project.yaml`, or one naming no leg but the checkout itself; 1 where the +# manifest is there and could not be read, which is never "no legs" (R22, +# Amendment 7(d)). +assembly_legs() { # + al_dir="${1-}"; al_dir="${al_dir%/}"; al_rows=""; al_out=""; al_tab="$(printf '\t')" + [ -n "$al_dir" ] || return 8 + al_m="$al_dir/project.yaml" + [ -e "$al_m" ] || [ -L "$al_m" ] || return 8 + { [ -f "$al_m" ] && [ -r "$al_m" ]; } || return 1 + al_rows="$(project_leg_paths_file "$al_m")" || return 1 + while IFS="$al_tab" read -r al_repo al_path; do + al_path="${al_path%/}" + case "$al_path" in + ''|.|./.) continue ;; + /*) al_abs="$al_path" ;; + ./*) al_abs="$al_dir/${al_path#./}" ;; + *) al_abs="$al_dir/$al_path" ;; + esac + al_out="$al_out$al_repo$al_tab$al_path$al_tab$al_abs +" + done <, a leg a line + # 8 no legs: no project.yaml there, or one naming no leg but the checkout itself + # 1 a project.yaml is there and could not be read — which is never "no legs" + # 64 a usage error of this subcommand's own + project-legs) + pla="${1-}"; [ -n "$pla" ] || die "usage: project-legs " 64 + [ "$#" -le 1 ] || die "project-legs takes one directory: project-legs " 64 + case "$pla" in /*) : ;; *) die "project-legs takes an ABSOLUTE path: a relative one means whatever the calling process's directory happens to be, and that is never the checkout being asked about" 64 ;; esac + pla_out=""; pla_rc=0 + pla_out="$(assembly_legs "$pla")" || pla_rc=$? + case "$pla_rc" in + 0) printf '%s\n' "$pla_out" ;; + 8) exit 8 ;; + *) die "${pla%/}/project.yaml is there and could not be read, so which legs that checkout declares is NOT known — which is not the same as none (R22, Amendment 7(d))" 1 ;; + esac + ;; + lane-reconcile) lane="${1-}"; [ -n "$lane" ] || die "usage: lane-reconcile " 64 [ "$#" -le 1 ] || die "lane-reconcile takes one lane: lane-reconcile " 64 @@ -17041,6 +17133,6 @@ EOF ;; *) - die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|retire-rows|archive-rows|migrate-state-cells|commit|rename-lane|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|binding|request-handoff|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|duplicate-holder|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|register-row-local|retired-identity|canon-lane|resolve-home|managed-projection|lane-state|set-lane-state|lane-trees|set-lane-tree|lane-tree-now|lane-reconcile|lane-holders|legacy-restart-check|publish-handoff|restart-intent|set-restart-intent|worktrees|pathspec-check)" 2 + die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|retire-rows|archive-rows|migrate-state-cells|commit|rename-lane|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|binding|request-handoff|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|duplicate-holder|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|register-row-local|retired-identity|canon-lane|resolve-home|managed-projection|lane-state|set-lane-state|lane-trees|set-lane-tree|lane-tree-now|project-legs|lane-reconcile|lane-holders|legacy-restart-check|publish-handoff|restart-intent|set-restart-intent|worktrees|pathspec-check)" 2 ;; esac From 5fcdf6a17961af1d1382d27e39dbf0aef5368fe5 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:51:44 +0000 Subject: [PATCH 03/42] =?UTF-8?q?T018=20(a):=20the=20triad=20guidance=20fo?= =?UTF-8?q?r=20the=20sweep=20and=20lane-end's=20gate=20=E2=80=94=20README-?= =?UTF-8?q?lanes,=20usage=20text,=20the=20dated=20=C2=A74.4=20note;=202aa8?= =?UTF-8?q?a59's=20code=20now=20tested?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan task T018 (opensoft/openRepoTools#186), slice (d2), part (a). This commit carries the guidance for the code 2aa8a59 added: a "A triad: its legs, their worktrees and the paired trees (T018)" section and a `leg` row in the porcelain block of docs/README-lanes.md; a "A TRIAD" paragraph in `lane-worktrees --help`; four lines in lane-end's CLOSE-OUT GATE header (`lane-end --help`); and a dated subsection at the end of §4.4 of docs/triad-lane-tooling-analysis.md, including the note "outputs that change regardless of shape" the coordinator asked for (the unknown-subcommand list and usage header of lanes-edit.sh, and the --help texts), for lane 1's ruling. THE INVARIANT: a checkout with no project.yaml declaring a leg other than itself is asked nothing, so a single repository's sweep rows, gate verdict, exit codes, register lines and inventory rows are unchanged. Only usage text moves for every shape, as listed in the §4.4 note. THE PROOF, run under tests/run.sh with this tree (tools as committed here and in 2aa8a59): tests/run.sh -k "t018 or repo_hygiene or test_lane_worktrees" -> 1 failed, 299 passed, 1006 deselected in 1002.77s The 299 include every test of tests/test_lane_worktrees.py and tests/test_lane_worktrees_report.py, the hygiene module, and the T018 tests: the single-repository golden comparison against c45a452's stored outputs and the assembly cases (template bytes, legs as real repositories behind bare local origins, fake gh exiting 1). The one failure was the hygiene check on host-absolute paths, tripped by the golden fixture's `/home/projects/`; that fixture and its test module are the next commit, WIP, with the fix. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 59 +++++++++++++++++++++++++++++ docs/triad-lane-tooling-analysis.md | 33 ++++++++++++++++ lane-end | 4 ++ lane-worktrees | 10 +++++ 4 files changed, 106 insertions(+) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 7c0d16ca..34339f40 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3834,6 +3834,7 @@ lane refused (only when refused) tree (branch, scratch, cache, sandbox, link) +leg (an assembly's checkouts only, T018) summary ``` @@ -3863,6 +3864,64 @@ the table and in `DISPOSITION.md`, which is written even when an error no read caught stops the run), and **2** when the self-check refused a removal; usage is 64, a `--live` path that names no tree included. +### A triad: its legs, their worktrees and the paired trees (T018) + +**Plan task T018 of the triad migration (opensoft/openRepoTools#186).** A lane's +checkout is an **assembly** when its own `project.yaml` declares a leg other than +itself — the standard's rule, as openRepoShape's `templates/assembly-root/project.yaml` +writes it: a top-level `legs:` list of `- role:` / `repository:` / `path:`, the +assembly's own leg at `path: "."`. One reader answers it for every lane tool: + +```sh +lanes-edit.sh project-legs # TAB TAB +``` + +It is `lane-start`'s rung-5 reading of `legs:`, line for line (a test holds the two +equal), and reads `repository:` and `path:` only — the manifest confers nothing, +and `role:` is never read. 0 with legs; 8 with none (no `project.yaml`, or one naming +no leg but the checkout itself); 1 where the manifest is there and cannot be read, +which is never "no legs"; 64 usage. It touches no workspace. + +**Why the sweep needed it.** A leg is a submodule: its `.git` is a file, +`git -C worktree list` names none of its worktrees, and a feature's +trees in the standard's three-leg layout, `/worktrees//{spec,code}`, +are worktrees of the LEGS' repositories. So a code tree was invisible to the sweep, +to `lane-end`'s gate, to the handoff's poll and to the daily report unless it had +been recorded with the code leg as its checkout — and a lane with unpushed work in +one passed `lane-end`. + +**Where the lane's checkout is an assembly**, `lane-worktrees sweep` also: + +* reads every leg, and every dependency nested in one (an initialized submodule of + the leg's own, as `code/upstream/openRepoShape` will be), as a **`leg` row** — + `clean`, `dirty`, `unpushed`, `dirty+unpushed`, `absent` (not checked out here) + or `unreadable`. A leg is the assembly's, shared by every lane the assembly + homes, as the assembly's own checkout is: **no `--yes` acts on one**. It is + COUNTED (exit 3, and `lane-end`'s gate) only where its HEAD's own `Lane:` trailer + is this lane's and it holds edits or commits origin lacks — the rule the sweep + already applies to `.claude/worktrees` — or where it could not be read; one + that holds another lane's work, or nobody's, is listed and counted for nobody; +* lists the worktrees git registers in **every leg** — a FOREIGN row names the leg + it is registered in — so a tree recorded with the leg as its checkout is the + lane's as any recorded tree is; +* takes **`/worktrees`** as a root: a tree there is the lane's by its + HEAD's own `Lane:` trailer and by nothing else, because the paired root, like + `.claude/worktrees`, is per feature and not per lane; a standalone clone there is + FOREIGN; a directory there that cannot be listed refuses, exit **2**; +* with `--branches`, reads every leg's branches too, each row naming its leg; with + `--fetch` (and under `--yes`), asks each leg's origin. + +A manifest, or a leg's own submodule list, that cannot be read refuses, exit +**2**. **A checkout with no `project.yaml` is asked nothing** — no helper call, no +read, no row — so a single repository's sweep, gate, handoff, report and `add` are +byte for byte what they were; `tests/test_triad_lane_tooling.py` holds every one of +them to the output `c45a452`'s tools printed. + +**`lane-end`'s gate** counts a `leg` row marked `retire` as a "leg checkout" it +names, with the remedy a person's (commit and push from inside it; no `--yes` is +offered when legs are all it counted), and lists a leg that holds work and is not +this lane's below the table as NOT counted — whether the gate passes or not. + ### Two protocol lines this act assumes (proposed for the amendment that ratifies it) 1. **A lane creates worktrees, never clones.** Every tree a lane works in is a diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index eec4e6fd..a39e0a41 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -207,6 +207,39 @@ estate's existing triads, and the brief rules out behaviour changes. **Proposals `/.git/modules/` as canonical when the superproject is canonical. This changes today's report for IRRS and the other existing triads, so it needs a ruling. +#### 2026-10-09: what this branch now implements of §4.4 (T018), and what moves for every shape + +**On the coordinator's brief for slice (d2)** (worktree visibility for triad legs, +plan task T018), proposals 1–3 above are implemented as **shape-gated** changes, +one commit each with its tests (`tests/test_triad_lane_tooling.py`). The gate is +the standard's own rule: the lane's checkout (or the checkout a tool polls) is an +**assembly** when its own `project.yaml` declares a leg other than itself. One +reader answers it for every tool, `lanes-edit.sh project-legs `, whose +seven deciding awk lines are `lane-start`'s rung-5 lines verbatim (a test holds +them equal). A checkout with no `project.yaml` is asked nothing, so on a single +repository every output, exit code, register line and inventory row is the one +`c45a452`'s tools produce: the test stores that "before" as a golden file +generated once from `c45a452`'s own tools (`tests/fixtures/t018/`), because CI +checks out at depth 1, and compares every touched command's output on a single +repository — one with a submodule that is no leg, and one with a `project.yaml` +naming no leg but itself — against it. + +**Outputs that change regardless of shape (for lane 1's ruling).** Two kinds of +text move on every checkout, single repository included, because they are usage +text and not behaviour; the coordinator directed both kept and listed here: + +1. `lanes-edit.sh`'s refusal of an unknown subcommand (exit 2) now lists + `project-legs` among the subcommands it answers to, and its usage header (what + `lanes-edit.sh` with no argument prints) gains the `project-legs` line. + `tests/test_repo_hygiene.py` requires the refusal to list every arm. +2. The `--help` text of the touched commands gains the triad guidance: + `lane-worktrees --help` (a "A TRIAD" paragraph), `lane-end --help` (four lines + in THE CLOSE-OUT GATE), and the commands later commits touch, each named in + that commit's message. + +No exit code, register line, inventory row, sweep row, gate verdict or report +line moves for a checkout with no `project.yaml`. + ### 4.5 `lanes-edit.sh` | Line | What it does | Under the triad | diff --git a/lane-end b/lane-end index 91a9cb33..05ec8b84 100755 --- a/lane-end +++ b/lane-end @@ -208,6 +208,10 @@ # /`, that the table does not name as a tree, scratch or a cache is # RESIDUE - ignored leftovers the sweep's rows never reach - and refuses # even where the sweep answered 0, as a cache row does. +# Where the lane's checkout is an ASSEMBLY (T018, opensoft/openRepoTools#186) +# the sweep also prints a `leg` row per leg checkout: one it counts is named +# as a leg checkout whose remedy is a person's — no sweep acts on a leg — and +# one that holds work and is not this lane's is listed and NOT counted. # `--inventory-only` is the one way past it, and `--sweep` is not built. # # THE HANDOFF FRAGMENT diff --git a/lane-worktrees b/lane-worktrees index 9e836565..509db03c 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -107,6 +107,16 @@ stdout; `--dry-run` makes nothing. A tree made any other way is FOREIGN until recorded, 2 refused (nothing made), 1 made and neither recorded nor taken back out (the commands are printed), 64 usage. +A TRIAD (opensoft/openRepoTools#186, T018). Where the lane's checkout is an +ASSEMBLY - its `project.yaml` declares a leg other than itself, read by +`lanes-edit.sh project-legs` - the sweep also reads each leg and each +dependency nested in one as a `leg` row (never acted on; counted only where +its HEAD's own `Lane:` trailer is this lane's and it holds work, or where it +could not be read), lists the worktrees git registers in every leg, and takes +`/worktrees` - the paired `worktrees//{spec,code}` - as a +root whose trees are the lane's by that same trailer rule. A checkout with no +`project.yaml` is asked nothing, and its table is what it always was. + Exit codes: 0 dry run: nothing to retire · --yes: every act completed 3 dry run: something to retire (the `lane-end` gate's answer, #163) From 8e2227eaa4fdc0afc3a6404927e381821103264a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:52:00 +0000 Subject: [PATCH 04/42] =?UTF-8?q?WIP=20(untested):=20T018=20(a)=20tests=20?= =?UTF-8?q?=E2=80=94=20tests/test=5Ftriad=5Flane=5Ftooling.py=20and=20the?= =?UTF-8?q?=20single-repository=20golden=20captured=20from=20c45a452's=20t?= =?UTF-8?q?ools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkpoint for an account swap, on the coordinator's word. The module and its stored fixture, after the fix of the one failure the previous run found; the fixed form has NOT been run under tests/run.sh (the re-run, `tests/run.sh -k "t018 or repo_hygiene"`, was stopped while still waiting before the lock, exit 143, so it never held it). DONE: - The stored-fixture decision: the "before" of the single-repository invariant is tests/fixtures/t018/single-repository.golden, generated ONCE from c45a452's own tools (`git archive c45a452 | tar -x -C `, then `python3 tests/test_triad_lane_tooling.py --capture `), because CI checks out at depth 1 where c45a452 is not there to run. It covers add (dry run and act), the sweep's porcelain and table with --branches --include-scratch --include-caches, lane-reconcile, lane-end refusing and passing with the row and last log line, lane-handoff's output, top block and inventory, and the estate report, on a repository with a submodule that is no leg and on one whose project.yaml names no leg but itself. - Normalised for what no two runs share and nothing else: the bench's root and $HOME (both spellings), tree ids, operation ids, stamps, hashes, ages, byte sizes, the order of the inventory's sidecars (their file names are a checksum of the bench's path), and the sweep table's note about this account's unreadable /proc processes (a host fact). The golden test runs on Linux only, where it was captured. - The fix since the run: `$HOME` is normalised to `` before the root, so the fixture names no `/home//` path (the hygiene failure). Captured twice from c45a452 afterwards: byte-identical. The current tools' outputs on the same scenario equal it (compared by the module's own functions, not by pytest). REMAINS: run `tests/run.sh -k "t018 or repo_hygiene"` and record the counts; then (b) lane-handoff's paired-root poll and leg-checkout record with lane-reconcile's leg registrations, (c) the report's legs, (d) add's leg record (all three drafted in the session scratchpad, not applied), and the shell suite once at the end. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- tests/fixtures/t018/single-repository.golden | 297 +++++++++ tests/test_triad_lane_tooling.py | 635 +++++++++++++++++++ 2 files changed, 932 insertions(+) create mode 100644 tests/fixtures/t018/single-repository.golden create mode 100644 tests/test_triad_lane_tooling.py diff --git a/tests/fixtures/t018/single-repository.golden b/tests/fixtures/t018/single-repository.golden new file mode 100644 index 00000000..1d02e805 --- /dev/null +++ b/tests/fixtures/t018/single-repository.golden @@ -0,0 +1,297 @@ +=== add --dry-run === +exit 0 +--- stdout +/projects/.lane-worktrees/repoS-1/inv +--- stderr +lane-worktrees: DRY RUN, nothing was made: git -C /projects/repoS worktree add --no-track -b inv /projects/.lane-worktrees/repoS-1/inv ; then lanes-edit.sh set-lane-tree repoS-1 /projects/.lane-worktrees/repoS-1/inv --checkout /projects/repoS + +=== add === +exit 0 +--- stdout +/projects/.lane-worktrees/repoS-1/inv +--- stderr +lane-worktrees: made /projects/.lane-worktrees/repoS-1/inv on branch inv at in /projects/repoS; recorded in lane repoS-1's inventory as + +=== sweep porcelain === +exit 3 +--- stdout +lane repoS-1 RUNNING none - here ungraceful-stop +tree wip-rescue+remove retire /projects/.lane-worktrees/repoS-1/inv inv 1 dirty or untracked path(s): a WIP commit on a rescue branch, pushed, then removed +tree foreign - /projects/elsewhere elsewhere not in lane repoS-1's inventory (#97); registered in the lane's checkout +tree live retire /projects/repoS/.claude/worktrees/tr tr a live writer owns it: not in the inventory and active min ago (foreign trees quiet for 24 h only); not in #97's inventory - the lane's by its branch's Lane: trailer +branch list retire old-work · 1 ahead/0 behind origin/main · last commit ( d) · owner repoS-1 0 unmerged (not a GitHub origin); upstream origin/main is ahead: a person disposes of it (push it to rescue/repoS-1/..., or a word) - never deleted +scratch archive+remove retire /projects/.lane-worktrees/repoS-1/notes-scratch - lane scratch: tar and sha256 under the sweeps directory, then removed +cache remove - /projects/.lane-worktrees/repoS-1/inv/__pycache__ - a dependency cache or bytecode: it holds no work and is rebuilt +summary 4 3 1 1 +--- stderr + +=== sweep table === +exit 3 +--- stdout +lane-worktrees sweep repoS-1 - DRY RUN - nothing is changed; --yes performs it + lane: state RUNNING · holder none · binding here (eagle/none) · verdict ungraceful-stop + checkout: /projects/repoS · inventory 1 tree(s) · origin as of the last fetch + merged means: the register's LANDED line for the branch's PR, or gh's MERGED (gh: LANES_NO_GITHUB=1) + +TREES + wip-rescue+remove /projects/.lane-worktrees/repoS-1/inv + inv · 1 dirty or untracked path(s): a WIP commit on a rescue branch, pushed, then removed + foreign /projects/elsewhere + elsewhere · not in lane repoS-1's inventory (#97); registered in the lane's checkout + live /projects/repoS/.claude/worktrees/tr + tr · a live writer owns it: not in the inventory and active min ago (foreign trees quiet for 24 h only); not in #97's inventory - the lane's by its branch's Lane: trailer + +BRANCHES (--branches) + list old-work + · 1 ahead/0 behind origin/main · last commit ( d) · owner repoS-1 · unmerged (not a GitHub origin); upstream origin/main is ahead: a person disposes of it (push it to rescue/repoS-1/..., or a word) - never deleted + +SCRATCH (--include-scratch) + archive+remove /projects/.lane-worktrees/repoS-1/notes-scratch · + lane scratch: tar and sha256 under the sweeps directory, then removed + +CACHES (--include-caches) + remove /projects/.lane-worktrees/repoS-1/inv/__pycache__ · + a dependency cache or bytecode: it holds no work and is rebuilt + +SUMMARY: 3 tree(s): 4 to retire · 1 live · 1 foreign +--- stderr + +=== lane-reconcile === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoS-1 +STATERUNNINGgeneration 1operation op-seedowner -1111-4180-8180-updated +HOLDERnone +BINDINGhereeagle/none +TREEdirty/projects/.lane-worktrees/repoS-1/invbranch inv head upstream none 1 dirty 0 unpushed; observed 0/0 at ; head was and is +TREEunmanaged/projects/elsewheregit registers it in /projects/repoS and no sidecar of this lane names it; it is left exactly as it is +TREEunmanaged/projects/repoS/.claude/worktrees/trgit registers it in /projects/repoS and no sidecar of this lane names it; it is left exactly as it is +TREES1 inventoried1 dirty or unpushed0 require recovery2 unmanaged or stale +VERDICTungraceful-stopthe lane is recorded RUNNING and no verified holder is live: the session stopped before any handoff began, so nothing was polled, refreshed or recorded — inspect every tree below before relaunching a writer +--- stderr + +=== lane-end refused === +exit 2 +--- stdout +--- stderr +lane-end: in-flight read from lanes/log/repoS-1.md (exact), not from the state cell +lane-end: lane repoS-1 is NOT ended: the close-out gate (opensoft/openRepoTools#163) found what is still the lane's: 2 tree(s) and 1 branch(es) and 1 scratch dir(s) and 1 cache(s) + tree wip-rescue+remove /projects/.lane-worktrees/repoS-1/inv [inv ] + git status --ignored: 1 changed; 0 untracked; 2 ignored (.env __pycache__/) + 1 dirty or untracked path(s): a WIP commit on a rescue branch, pushed, then removed + tree live /projects/repoS/.claude/worktrees/tr [tr ] + git status --ignored: clean, nothing ignored in it either + a live writer owns it: not in the inventory and active min ago (foreign trees quiet for 24 h only); not in #97's inventory - the lane's by its branch's Lane: trailer + branch list old-work · 1 ahead/0 behind origin/main · last commit ( d) · owner repoS-1 + unmerged (not a GitHub origin); upstream origin/main is ahead: a person disposes of it (push it to rescue/repoS-1/..., or a word) - never deleted + scratch archive+remove /projects/.lane-worktrees/repoS-1/notes-scratch ( bytes) + lane scratch: tar and sha256 under the sweeps directory, then removed + cache remove /projects/.lane-worktrees/repoS-1/inv/__pycache__ ( bytes) +lane-end: (1 FOREIGN tree(s) beside these are not this lane's, and were not counted.) +lane-end: the table, which changes nothing: +lane-end: lane-worktrees sweep repoS-1 --branches --include-scratch --include-caches +lane-end: the one act that retires them (--yes removes only what is on origin or in a bundle, never a live writer's tree): +lane-end: lane-worktrees sweep repoS-1 --branches --include-scratch --include-caches --yes +lane-end: (2 row(s) above marked keep, live, load-bearing or list are left by --yes as well: each says why, and settling it is a person's) +lane-end: or end the lane and leave them where they are, in its inventory, with the reason written down: +lane-end: lane-end repoS-1 --inventory-only + +=== lane-end refused: row and log === +| `repoS-1` | harness `-1111-4180-8180-` | Eagle / test / brett | | none | handoffs/repoS-1.md | LIVE · · working | +STARTED — lane repoS-1, session -1111-4180-8180-@Eagle, , lane:repoS-1 → home opensoft/repoS; dir /projects/repoS; host eagle; container none; os linux +=== add (manifest, no leg) === +exit 0 +--- stdout +/projects/.lane-worktrees/repoP-1/w +--- stderr +lane-worktrees: made /projects/.lane-worktrees/repoP-1/w on branch w at in /projects/repoP; recorded in lane repoP-1's inventory as + +=== sweep porcelain (manifest, no leg) === +exit 3 +--- stdout +lane repoP-1 RUNNING none - here ungraceful-stop +tree push+remove retire /projects/.lane-worktrees/repoP-1/w w origin has no w: pushed as is, then removed +summary 1 1 0 0 +--- stderr + +=== lane-handoff === +exit 0 +--- stdout + +WRITER /projects/repoS/.claude/worktrees/tr — branch tr, last commit trailer work + git status --short: 0 dirty + git log @{u}..: 1 unpushed + trailer work + +WRITER /projects/.lane-worktrees/repoS-1/inv — branch inv, last commit inv work + git status --short: 1 dirty + M i.txt + git log @{u}..: 0 unpushed + +READY — restart with: pclaude --lane repoS-1 + record: PAUSED lane:repoS-1 → swap; dir /projects/repoS; workstation Eagle; agent claude; transcript none; kind unknown + agent claude, transcript none (lane-start --agent claude resumes it) + lifecycle: SWAPPED (generation 2, operation ) — lanes-edit.sh lane-reconcile repoS-1 + inventory: 2 worktree(s) recorded — lanes-edit.sh lane-trees repoS-1 +--- stderr +lane-handoff: lane=repoS-1 workstation=Eagle +lanes-edit: LANES_NO_FETCH=1 — not fetching; reading origin/main as the ref already stands here +lane-handoff: lane=repoS-1 workstation=Eagle +lane-handoff: lifecycle: RUNNING -> SWAPPING (generation 2, operation ) +lane-handoff: writers polled: 2 under /projects/repoS +lane-handoff: handoff refreshed: /projects/wip/handoffs/repoS-1.md (+15 lines at the head) +lane-handoff: handoff commit: handoff(repoS-1@Eagle): PAUSED +lane-handoff: handoff pushed to origin/main +lanes-edit: 1 line appended to /projects/wip/lanes/log/repoS-1.md +lanes-edit: committed: LOG(repoS-1@Eagle): PAUSED lane:repoS-1 +lanes-edit: pushed origin/main (attempt 1) +lane-handoff: object log: PAUSED lane:repoS-1 → swap; dir /projects/repoS; workstation Eagle; agent claude; transcript none; kind unknown +lane-handoff: lifecycle: SWAPPING -> SWAPPED (generation 2, operation ) + +=== lane-handoff: top block === +Lane: repoS-1 (profile unknown, session -1111-4180-8180-) — single-use resume prompt: stamp RESUMED-by before acting (lane-collision-protocol rule 3) + +## RESUME PROMPT — PAUSED (t018 handoff), agent claude, transcript none, kind unknown — the count decides: this handoff cannot know whether the process survived + +**FIRST ACTS, in order.** (1) You are lane `repoS-1`, checkout `/projects/repoS`. Stamp this block RESUMED before anything else (Rule 3, single use) — `lane-start --no-launch ` in this window writes it. (2) Read AGENTS.md, then this block. (3) COUNT THE LIVE WRITERS before relaunching anything — the WRITERS section below is a list to COUNT, not a list to relaunch (Amendment 17 Addendum 1 (i)). ONE WORKTREE, ONE WRITER (k): a worktree is one writer's for as long as that writer is live, a relaunch onto a live writer's worktree is the writers' form of Amendment 18(h)'s second binding, and where you find one made you retire the newer before either commits. THIS HANDOFF CANNOT KNOW WHICH KIND FOLLOWED IT: a plain handoff may be followed by a `/clear` in this process or by a relaunch, so nothing below assumes either — the count is the answer. Older sections below are history. + +**STATE at .** Handed off by `lane-handoff` on Eagle: t018 handoff. Agent `claude`, transcript `none`. Kind `unknown`: the act that follows this record was not this command's to know (`kind unknown`, Addendum 1 (h)) — the restart line below is one of the two, and a `/clear` typed here is the other. + +**WRITERS at ** (2 found under `/projects/repoS`) — FIRST count the live writers: `ListAgents` in Claude, the agent's equivalent elsewhere. A writer still live OWNS its worktree: do not relaunch it; one message telling it that its in-flight call died and who the coordinator now is, is enough. Relaunch only a writer that is NOT live, from where it stands — `git status` and `git log @{u}..` in its worktree, then its brief. WHERE THE COUNT AND THIS LIST DISAGREE, THE COUNT WINS: this list is what the paused session expected, the count is what is true. + +- `/projects/repoS/.claude/worktrees/tr` — branch `tr`, last commit ` trailer work`, 0 dirty, 1 unpushed; brief: (fill in: the brief this writer was given) +- `/projects/.lane-worktrees/repoS-1/inv` — branch `inv`, last commit ` inv work`, 1 dirty, 0 unpushed; brief: (fill in: the brief this writer was given) + +=== lane-handoff: inventory === +exit 0 +--- stdout +/projects/.lane-worktrees/repoS-1/invinvnone10-1111-4180-8180-/projects/repoS21 +/projects/repoS/.claude/worktrees/trtrorigin/main01-1111-4180-8180-/projects/repoS21 +--- stderr + +=== sweep porcelain after the handoff === +exit 3 +--- stdout +lane repoS-1 SWAPPED none - free resumable +tree wip-rescue+remove retire /projects/.lane-worktrees/repoS-1/inv inv 1 dirty or untracked path(s): a WIP commit on a rescue branch, pushed, then removed +tree push+remove retire /projects/repoS/.claude/worktrees/tr tr origin has no tr: pushed as is, then removed +tree foreign - /projects/elsewhere elsewhere not in lane repoS-1's inventory (#97); registered in the lane's checkout +branch list retire old-work · 1 ahead/0 behind origin/main · last commit ( d) · owner repoS-1 0 unmerged (not a GitHub origin); upstream origin/main is ahead: a person disposes of it (push it to rescue/repoS-1/..., or a word) - never deleted +scratch archive+remove retire /projects/.lane-worktrees/repoS-1/notes-scratch - lane scratch: tar and sha256 under the sweeps directory, then removed +cache remove - /projects/.lane-worktrees/repoS-1/inv/__pycache__ - a dependency cache or bytecode: it holds no work and is rebuilt +summary 4 3 0 1 +--- stderr + +=== lane-end passes === +exit 0 +--- stdout +## Done (RELEASED, CLAIM-LOST, CLOSED, LANDED) + +- nothing recorded as done in this lane's log + +## Open (CLAIMED, TAKEOVER, OPENED, LANDING, WITHDRAWN) + +- nothing open +--- stderr +lane-end: in-flight read from lanes/log/repoS-2.md (exact), not from the state cell +lane-end: close-out gate: nothing of lane repoS-2's is left on disk (lane-worktrees sweep repoS-2 --branches --include-scratch --include-caches --dry-run answered 0) +lanes-edit: line 6 rewritten in /projects/wip/lanes/LANES.md +lanes-edit: state cell REPLACED: 39 chars → 85 chars +lanes-edit: committed: LANES(repoS-2@Eagle): state · ENDED · lane-end on Eagle: window closing; NOTHING IN FLIGHT +lanes-edit: pushed origin/main (attempt 1) +lane-end: row closed: ENDED +lanes-edit: 1 line appended to /projects/wip/lanes/log/repoS-2.md +lanes-edit: committed: LOG(repoS-2@Eagle): ENDED lane:repoS-2 +lanes-edit: pushed origin/main (attempt 1) +lane-end: object log: ENDED + +=== lane-end passes: row and log === +| `repoS-2` | harness `-1111-4180-8180-` | Eagle / test / brett | | none | handoffs/repoS-2.md | ENDED · · lane-end on Eagle: window closing; NOTHING IN FLIGHT | +ENDED — lane repoS-2, session -1111-4180-8180-@Eagle, , lane:repoS-2 — window closing; NOTHING IN FLIGHT +=== report === +exit 0 +--- stdout +# Estate report - Eagle - + +`lane-worktrees sweep --all --dry-run --report` over `/projects`. It changed nothing. + +| finding | count | +|---|---| +| FOREIGN repositories | 0 | +| Orphaned worktrees of ENDED lanes | 0 | +| Unmerged branches with a missing, diverged or unpushed upstream | 5 | +| Root main divergence | 0 | +| Awaiting disposition (older than 14 days) | 0 | +| Caches and sandboxes ( in all) | 1 | +| Ignored directories over | 0 | +| Evidence-shaped paths inside the estate | 0 | +| Sweep archives past retention (90 days) | 0 | +| Workspace repository hygiene | 1 | +| `status --all` findings (pin drift and tracking are reported as what they are, never counted as dirt) | 1 | +| Records the report could not read | 0 | +| rescue branches (all ages) | 0 | +| the sweeps directory | | + +## FOREIGN repositories + +A lane creates worktrees, never clones. Each is a person's to dispose of. + +_none: every clone is the estate's own checkout_ + +## Orphaned worktrees of ENDED lanes + +_none_ + +## Unmerged branches with a missing, diverged or unpushed upstream + +Listed, never deleted: a person pushes each to rescue//... or gives a word. + +- /projects/repoP w · · no upstream · d · repoP-1 · held by a worktree +- /projects/repoS elsewhere · · unpushed [ahead 1] · d · repoS-9 · held by a worktree +- /projects/repoS inv · · no upstream · d · repoS-1 · held by a worktree +- /projects/repoS old-work · · unpushed [ahead 1] · d · repoS-1 +- /projects/repoS tr · · unpushed [ahead 1] · d · repoS-1 · held by a worktree + +## Root main divergence + +_none_ + +## Awaiting disposition (older than 14 days) + +_none_ + +## Caches and sandboxes ( in all) + +Removed without archiving by `--include-caches` / `--include-sandboxes`. + +- /projects/.lane-worktrees/repoS-1/inv/__pycache__ · cache · + +## Ignored directories over + +_none_ + +## Evidence-shaped paths inside the estate + +One evidence root per repository: ${XDG_STATE_HOME:-$HOME/.local/state}/openRepoTools/evidence//-/. Each is a finding to move. + +_none_ + +## Sweep archives past retention (90 days) + +_none_ + +## Workspace repository hygiene + +- /projects/wip/.gitignore · lacks __pycache__/ *.pyc *.pyo .pytest_cache/ .mypy_cache/ .ruff_cache/ node_modules/ .venv/ venv/ site-packages/ · printf '%s\n' '__pycache__/' '*.pyc' '*.pyo' '.pytest_cache/' '.mypy_cache/' '.ruff_cache/' 'node_modules/' '.venv/' 'venv/' 'site-packages/' >> /projects/wip/.gitignore + +## `status --all` findings (pin drift and tracking are reported as what they are, never counted as dirt) + +- repoS · a fixed finding + +## Records the report could not read + +UNKNOWN IS NEVER ABSENT: a lane snapshot, inventory or tree the report could not read is one whose findings above may be missing. + +_none_ +--- stderr + diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py new file mode 100644 index 00000000..4e6e418b --- /dev/null +++ b/tests/test_triad_lane_tooling.py @@ -0,0 +1,635 @@ +# SPDX-License-Identifier: Apache-2.0 +"""T018 (opensoft/openRepoTools#186): the lane tooling sees a TRIAD's trees. + +A lane's checkout is an ASSEMBLY when its own `project.yaml` declares a leg +other than itself - the standard's rule, read by `lanes-edit.sh project-legs` +with `lane-start`'s rung-5 grammar. Its legs (`spec/`, `code/`) are submodules +and its feature trees are worktrees of the LEGS' repositories, under +`/worktrees//{spec,code}` - so before this every lane tool +was blind to them (docs/triad-lane-tooling-analysis.md §4.4). + +TWO THINGS ARE HELD HERE, and the second is the condition of the first: + + * on an assembly built from openRepoShape's OWN template bytes at the + pinned commit (`upstream/openRepoShape/templates/assembly-root/`), with + every leg a real git repository behind a bare local origin and a fake `gh` + that exits 1, the sweep, `lane-end`'s gate (and, in later commits, the + handoff poll, the daily report and `add`) see the legs, their worktrees and + the paired feature trees; + * on a SINGLE REPOSITORY - no `project.yaml`, a submodule that is no leg, and + a `project.yaml` naming no leg but itself - every output of every touched + command is byte for byte what the tools at `c45a452` printed. The "before" + is STORED (`tests/fixtures/t018/single-repository.golden`), generated ONCE + from `c45a452`'s own tools by this module's `--capture` and committed: CI + checks out at depth 1, where `c45a452` is not there to run. It is + normalised for what no two runs share - the bench's path, UTC stamps, + hashes, ages and byte sizes - and for nothing else. + +NOTHING REAL IS TOUCHED: every repository, origin, register, `$HOME` and +state directory is under `tmp_path`, and the network is never reached. +""" + +from __future__ import annotations + +import difflib +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from conftest import REPO, UPSTREAM, WINDOWS_SKIP # noqa: E402 + +pytestmark = [WINDOWS_SKIP, pytest.mark.skipif( + shutil.which("git") is None or shutil.which("bash") is None, + reason="the lane tools are bash and Python over git")] + +GOLDEN = Path(__file__).resolve().parent / "fixtures" / "t018" / "single-repository.golden" +TEMPLATE = UPSTREAM / "templates" / "assembly-root" +NEEDS_TEMPLATE = pytest.mark.skipif( + not (TEMPLATE / "project.yaml").is_file(), + reason="the pinned openRepoShape is not checked out; " + "run `git submodule update --init upstream/openRepoShape`") + +UUID = "18018018-1111-4180-8180-180180180001" +#: Every command the bench installs beside each other, as `--install` does. +TOOLS = ("lanes-edit.sh", "lane-worktrees", "lane-end", "lane-handoff", "lane-start", + "repos.tsv") +GATE = ("--branches", "--include-scratch", "--include-caches") +US = "\x1f" + +FAKE_GH = "#!/bin/sh\nprintf 'fake gh: no GitHub here (%s)\\n' \"$*\" >&2\nexit 1\n" +FAKE_TMUX = "#!/bin/sh\nexit 1\n" +FAKE_STATUS = ("#!/bin/sh\nprintf 'status --all: reading every estate:\\n\\n" + "=== status repoS ===\\n - a fixed finding\\n'\nexit 1\n") + + +class Bench: + """One workstation under `root`: a workspace repository with a register, + `$HOME`, the projects directory, bare origins, fakes, and the tools under + test copied side by side into `root/bin`.""" + + def __init__(self, root: Path, tools: Path): + self.root = root + self.home = root / "home" + self.projects = self.home / "projects" + self.remotes = root / "remotes" + self.fake = root / "fakebin" + self.bin = root / "bin" + self.wip = self.projects / "wip" + for d in (self.home, self.projects, self.remotes, self.fake, self.bin, root / "tmp", + root / "sandboxes", self.home / ".agents", self.home / ".claude" / "sessions"): + d.mkdir(parents=True, exist_ok=True) + for name in TOOLS: + shutil.copy2(tools / name, self.bin / name) + (self.bin / name).chmod(0o755) + for name, text in (("gh", FAKE_GH), ("tmux", FAKE_TMUX), ("status", FAKE_STATUS)): + (self.fake / name).write_text(text) + (self.fake / name).chmod(0o755) + (self.home / ".gitconfig").write_text( + "[user]\n\tname = t018\n\temail = t018@example.invalid\n" + "[init]\n\tdefaultBranch = main\n[protocol \"file\"]\n\tallow = always\n" + "[advice]\n\tdetachedHead = false\n") + env = {k: v for k, v in os.environ.items() + if not k.startswith(("LANES_", "LANE_", "CLAUDE_", "WORKBENCHES_", "TMUX", "XDG_", + "PROJECTS_", "GH_", "GIT_", "FAKE_", "OPENREPOTOOLS_", + "PYTEST_")) + and k not in ("AGENT_PROTOCOL_ROOT",)} + system_path = os.pathsep.join(p for p in env.get("PATH", "/usr/bin:/bin").split(os.pathsep) + if p and not any(os.path.exists(os.path.join(p, n)) + for n in TOOLS + ("lanes-index",))) + env.update( + HOME=str(self.home), TMPDIR=str(root / "tmp"), + PATH=os.pathsep.join([str(self.fake), str(self.bin), system_path]), + AGENT_PROTOCOL_ROOT=str(self.home / ".agents"), + XDG_STATE_HOME=str(self.home / ".local" / "state"), + XDG_CONFIG_HOME=str(self.home / ".config"), XDG_CACHE_HOME=str(self.home / ".cache"), + PROJECTS_ROOT=str(self.projects), LANES_WORKSTATION="Eagle", LANES_HOST="eagle", + LANES_OS="linux", LANES_CONTAINER="none", LANES_IN_CONTAINER="0", + LANES_NO_GITHUB="1", LANES_INDEX="off", LANE_WORKTREES_REPORT="off", + LANE_WORKTREES_SANDBOX_ROOTS=str(root / "sandboxes"), + LANE_WORKTREES_STATUS=str(self.fake / "status"), + CLAUDE_CONFIG_DIR=str(self.home / ".claude"), LANES_SESSION=UUID, + GIT_CONFIG_NOSYSTEM="1", OPENREPOTOOLS_BIN_DIR=str(self.bin), LC_ALL="C") + self.env = env + self._seed_workspace() + + # ------------------------------------------------------------ plumbing + def run(self, *argv, cwd=None, env=None, timeout=300) -> subprocess.CompletedProcess: + child = dict(self.env) + child.update(env or {}) + return subprocess.run([str(a) for a in argv], cwd=str(cwd or self.root), env=child, + capture_output=True, text=True, timeout=timeout, + stdin=subprocess.DEVNULL) + + def tool(self, name, *args, cwd=None, env=None) -> subprocess.CompletedProcess: + return self.run(self.bin / name, *args, cwd=cwd, env=env) + + def git(self, *args, cwd=None) -> str: + proc = self.run("git", *args, cwd=cwd) + assert proc.returncode == 0, f"git {' '.join(map(str, args))}: {proc.stderr}" + return proc.stdout.strip() + + def commit(self, cwd: Path, message: str, files: dict | None = None, + lane: str | None = None) -> str: + for rel, text in (files or {}).items(): + path = Path(cwd) / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + self.git("add", "-A", cwd=cwd) + self.git("commit", "-q", "--allow-empty", "-m", + f"{message}\n\nLane: {lane}\n" if lane else message, cwd=cwd) + return self.git("rev-parse", "HEAD", cwd=cwd) + + def origin(self, name: str) -> Path: + bare = self.remotes / f"{name}.git" + self.git("init", "-q", "--bare", "-b", "main", bare) + return bare + + def seeded(self, name: str, files: dict) -> Path: + """A bare origin holding one commit of `files`.""" + bare = self.origin(name) + work = self.root / "seed" / name + self.git("clone", "-q", bare, work) + self.commit(work, f"{name}: seed", files) + self.git("push", "-q", "origin", "main", cwd=work) + return bare + + # ------------------------------------------------------------ register + def _seed_workspace(self) -> None: + bare = self.origin("wip") + self.git("clone", "-q", bare, self.wip) + (self.wip / "lanes" / "log").mkdir(parents=True) + (self.wip / "handoffs").mkdir() + (self.wip / "lanes" / "LANES.md").write_text( + "# LANES.md\n\n| lane | session id | workstation / env / user | started (UTC) " + "| objects owned | handoff path | state |\n|---|---|---|---|---|---|---|\n") + self.git("add", "-A", cwd=self.wip) + self.git("commit", "-q", "-m", "seed", cwd=self.wip) + self.git("push", "-q", "origin", "main", cwd=self.wip) + (self.home / ".agents" / "workspace.yaml").write_text( + f"repository: {bare}\npath: {self.wip}\n") + + def lanes(self, home: str, checkout: Path, *names: str) -> None: + """A register row, a handoff and a STARTED line naming `checkout` for + each lane, pushed in one commit; and a RUNNING #97 snapshot.""" + reg = self.wip / "lanes" / "LANES.md" + text = reg.read_text() + for lane in names: + text += (f"| `{lane}` | harness `{UUID}` | Eagle / test / brett | 2026-10-08T00:00Z " + f"| none | handoffs/{lane}.md | LIVE · 2026-10-08T00:00:00Z · working |\n") + (self.wip / "handoffs" / f"{lane}.md").write_text(f"# handoff {lane}\n\nwork.\n") + (self.wip / "lanes" / "log" / f"{lane}.md").write_text( + f"STARTED — lane {lane}, session {UUID}@Eagle, 2026-10-08T00:00:00Z, lane:{lane} " + f"→ home {home}; dir {checkout}; host eagle; container none; os linux\n") + reg.write_text(text) + self.git("add", "-A", cwd=self.wip) + self.git("commit", "-q", "-m", "lanes", cwd=self.wip) + self.git("push", "-q", "origin", "main", cwd=self.wip) + for lane in names: + proc = self.tool("lanes-edit.sh", "set-lane-state", lane, "RUNNING", "--expect", "none", + "--operation", "op-seed", "--owner", UUID) + assert proc.returncode == 0, proc.stderr + + def row(self, lane: str) -> str: + return next(line for line in (self.wip / "lanes" / "LANES.md").read_text().splitlines() + if line.startswith(f"| `{lane}`")) + + def last_log(self, lane: str) -> str: + return (self.wip / "lanes" / "log" / f"{lane}.md").read_text().splitlines()[-1] + + def sweep(self, lane: str, *args) -> subprocess.CompletedProcess: + return self.tool("lane-worktrees", "sweep", lane, *args) + + +# ================================================================ normalising + +_RULES = ( + (re.compile(r"\bc\d{3,}-[A-Za-z0-9._-]*"), ""), + (re.compile(r"op-\d{8}T\d{6}Z-\d+-\d+"), ""), + (re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}"), ""), + (re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?Z"), ""), + (re.compile(r"\d{8}T\d{6}Z"), ""), + (re.compile(r"\d{4}-\d{2}-\d{2}"), ""), + (re.compile(r"\b[0-9a-f]{7,40}\b"), ""), + (re.compile(r"\b\d+ d\b"), " d"), + (re.compile(r"active \d+ min ago"), "active min ago"), + (re.compile(r"\b\d+(\.\d+)? (B|KB|MB|GB)\b"), ""), + (re.compile(r"\(\d+ bytes\)"), "( bytes)"), +) + + +_PROC_NOTE = re.compile(r"^ note: \d+ process\(es\) of this account could not be read in /proc ") + + +def normalise(text: str, bench: Bench) -> str: + """What no two runs share, and nothing else: the bench's own path and its + `$HOME` (both spellings), tree ids (a checksum of that path), operation ids, stamps, + hashes, ages and byte sizes - and a porcelain row's byte count.""" + # `$HOME` FIRST, and as a word of its own: `tests/test_repo_hygiene.py` + # rightly refuses a committed `/home//` path, and the bench's home + # sits under its root. + for place, word in ((bench.home, ""), (bench.root, "")): + for spelling in sorted({str(place), os.path.realpath(place)}, key=len, reverse=True): + text = text.replace(spelling, word) + for rx, sub in _RULES: + text = rx.sub(sub, text) + out = [] + for line in text.split("\n"): + # A HOST FACT, NOT THE TOOL'S: how many of this account's processes + # `/proc` would not show the sweep is whatever else runs beside it. + if _PROC_NOTE.match(line): + continue + cols = line.split("\t") + if cols[0] in ("scratch", "cache", "sandbox") and len(cols) > 5: + cols[5] = "" + out.append("\t".join(cols)) + return "\n".join(out) + + +def shown(proc: subprocess.CompletedProcess, bench: Bench) -> str: + return normalise(f"exit {proc.returncode}\n--- stdout\n{proc.stdout}--- stderr\n{proc.stderr}", + bench) + + +# =================================================== the single repository + +def single_repository(bench: Bench) -> list: + """THE SCENARIO WHOSE OUTPUTS MAY NOT MOVE: one repository with a + submodule (as openRepoTools has `upstream/openRepoShape`) and a second with + a `project.yaml` naming no leg but itself, every kind of row the touched + commands print, and each command's output in the order it ran.""" + b = bench + out = [] + dep = b.seeded("dep", {"d.txt": "a dependency\n"}) + b.origin("repoS") + s = b.projects / "repoS" + b.git("clone", "-q", b.remotes / "repoS.git", s) + b.commit(s, "seed", {"README.md": "S\n", ".gitignore": ".env\n__pycache__/\n"}) + b.git("submodule", "add", "-q", dep, "vendor/dep", cwd=s) + b.commit(s, "a submodule that is no leg") + b.git("push", "-q", "origin", "main", cwd=s) + b.lanes("opensoft/repoS", s, "repoS-1", "repoS-2") + + # add: the dry run, then the act; work in the tree it made. + out.append(("add --dry-run", shown(b.tool("lane-worktrees", "add", "repoS-1", "inv", + "--dry-run"), b))) + out.append(("add", shown(b.tool("lane-worktrees", "add", "repoS-1", "inv"), b))) + inv = b.projects / ".lane-worktrees" / "repoS-1" / "inv" + b.commit(inv, "inv work", {"i.txt": "i\n"}, lane="repoS-1") + (inv / "i.txt").write_text("changed\n") + (inv / ".env").write_text("SECRET=1\n") + (inv / "__pycache__").mkdir() + (inv / "__pycache__" / "x.pyc").write_bytes(b"\0" * 64) + # a tree under .claude/worktrees carrying the lane's trailer, unrecorded + tr = s / ".claude" / "worktrees" / "tr" + b.git("worktree", "add", "-q", "-b", "tr", tr, "origin/main", cwd=s) + b.commit(tr, "trailer work", {"t.txt": "t\n"}, lane="repoS-1") + # a FOREIGN registration elsewhere, another lane's + b.git("worktree", "add", "-q", "-b", "elsewhere", b.projects / "elsewhere", "origin/main", cwd=s) + b.commit(b.projects / "elsewhere", "theirs", {"e.txt": "e\n"}, lane="repoS-9") + # scratch, and a branch of the lane's own with no tree + (b.projects / ".lane-worktrees" / "repoS-1" / "notes-scratch").mkdir() + (b.projects / ".lane-worktrees" / "repoS-1" / "notes-scratch" / "n.md").write_text("n\n") + tmp = b.root / "tmp-branch" + b.git("worktree", "add", "-q", "-b", "old-work", tmp, "origin/main", cwd=s) + b.commit(tmp, "old work", {"o.txt": "o\n"}, lane="repoS-1") + b.git("worktree", "remove", tmp, cwd=s) + + out.append(("sweep porcelain", shown(b.sweep("repoS-1", *GATE, "--dry-run", "--porcelain"), b))) + out.append(("sweep table", shown(b.sweep("repoS-1", *GATE, "--dry-run"), b))) + out.append(("lane-reconcile", shown(b.tool("lanes-edit.sh", "lane-reconcile", "repoS-1"), b))) + out.append(("lane-end refused", shown(b.tool("lane-end", "repoS-1"), b))) + out.append(("lane-end refused: row and log", normalise( + b.row("repoS-1") + "\n" + b.last_log("repoS-1"), b))) + + # a repository whose project.yaml names no leg but itself + p = b.projects / "repoP" + b.origin("repoP") + b.git("clone", "-q", b.remotes / "repoP.git", p) + b.commit(p, "seed", {"project.yaml": ( + "schema_version: 1\nkind: project-manifest\nid: repoP\nlegs:\n" + " - role: assembly\n repository: opensoft/repoP\n path: \".\"\n")}) + b.git("push", "-q", "origin", "main", cwd=p) + b.lanes("opensoft/repoP", p, "repoP-1") + pt = b.projects / ".lane-worktrees" / "repoP-1" / "w" + out.append(("add (manifest, no leg)", shown(b.tool("lane-worktrees", "add", "repoP-1", "w"), b))) + b.commit(pt, "p work", {"p.txt": "p\n"}, lane="repoP-1") + out.append(("sweep porcelain (manifest, no leg)", + shown(b.sweep("repoP-1", *GATE, "--dry-run", "--porcelain"), b))) + + out.append(("lane-handoff", shown(b.tool("lane-handoff", "--lane", "repoS-1", "--transcript", + "none", "--agent", "claude", "t018 handoff", + cwd=s), b))) + handoff = (b.wip / "handoffs" / "repoS-1.md").read_text() + out.append(("lane-handoff: top block", normalise(handoff.split("\n---\n", 1)[0], b))) + # ONE SIDECAR PER TREE, LISTED IN THE ORDER OF THEIR FILE NAMES - a + # checksum of each tree's path, so of the bench's own path: sorted here. + trees = b.tool("lanes-edit.sh", "lane-trees", "repoS-1") + trees.stdout = "".join(sorted(trees.stdout.splitlines(keepends=True), + key=lambda line: normalise(line, b))) + out.append(("lane-handoff: inventory", shown(trees, b))) + out.append(("sweep porcelain after the handoff", + shown(b.sweep("repoS-1", *GATE, "--dry-run", "--porcelain"), b))) + out.append(("lane-end passes", shown(b.tool("lane-end", "repoS-2"), b))) + out.append(("lane-end passes: row and log", normalise( + b.row("repoS-2") + "\n" + b.last_log("repoS-2"), b))) + out.append(("report", shown(b.tool("lane-worktrees", "sweep", "--all", "--dry-run", "--report", + "--estate", b.projects), b))) + return out + + +def render_golden(sections: list) -> str: + return "".join(f"=== {key} ===\n{text}\n" for key, text in sections) + + +def parse_golden(text: str) -> list: + out = [] + for chunk in re.split(r"(?m)^=== (.+) ===\n", text)[1:]: + out.append(chunk) + return [(out[i], out[i + 1][:-1] if out[i + 1].endswith("\n") else out[i + 1]) + for i in range(0, len(out), 2)] + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), + reason="the golden was captured on Linux, where liveness is read in /proc; " + "macOS reads it with lsof, a host fact this proof is not about") +def test_t018_single_repository_outputs_are_c45a452s_byte_for_byte(tmp_path): + """THE INVARIANT. Every output of every touched command on a single + repository - a submodule that is no leg, and a `project.yaml` naming no leg + but itself, included - equals what `c45a452`'s tools printed, stored in the + golden file this module's `--capture` wrote from them.""" + assert GOLDEN.is_file(), f"{GOLDEN} is missing; it is generated from c45a452's tools" + want = parse_golden(GOLDEN.read_text(encoding="utf-8")) + got = single_repository(Bench(tmp_path / "bench", REPO)) + assert [k for k, _ in got] == [k for k, _ in want] + for (key, have), (_k, gold) in zip(got, want): + if have != gold: + diff = "\n".join(difflib.unified_diff(gold.splitlines(), have.splitlines(), + f"c45a452: {key}", f"now: {key}", lineterm="")) + raise AssertionError(f"the single-repository output of '{key}' moved:\n{diff}") + + +# ================================================================ the assembly + +def build_assembly(b: Bench, name: str = "triad") -> dict: + """An assembly made of openRepoShape's own template bytes at the pinned + commit: every file of `templates/assembly-root/` copied, `project.yaml`'s + placeholders filled, the legs mounted as submodules from bare local + origins, and a dependency nested in the code leg (`upstream/dep`), as + openRepoTools' code leg will carry `upstream/openRepoShape`.""" + dep = b.seeded(f"{name}-dep", {"dep.txt": "a nested dependency\n"}) + spec = b.seeded(f"{name}-spec", {"spec.md": "the spec leg\n"}) + code_bare = b.origin(f"{name}-code") + seed = b.root / "seed" / f"{name}-code" + b.git("clone", "-q", code_bare, seed) + b.commit(seed, "code: seed", {"tool.sh": "#!/bin/sh\n"}) + b.git("submodule", "add", "-q", dep, "upstream/dep", cwd=seed) + b.commit(seed, "code: the nested dependency") + b.git("push", "-q", "origin", "main", cwd=seed) + b.origin(name) + a = b.projects / name + b.git("clone", "-q", b.remotes / f"{name}.git", a) + for src in TEMPLATE.rglob("*"): + rel = src.relative_to(TEMPLATE) + if src.is_dir(): + (a / rel).mkdir(parents=True, exist_ok=True) + else: + (a / rel).parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, a / rel) + manifest = (TEMPLATE / "project.yaml").read_text(encoding="utf-8") + fill = {"ASSEMBLY_REPOSITORY": f"opensoft/{name}", "SPEC_REPOSITORY": f"opensoft/{name}-spec", + "CODE_REPOSITORY": f"opensoft/{name}-code", "SPEC_PATH": "spec", "CODE_PATH": "code"} + lines = [] + for line in manifest.splitlines(): + if re.fullmatch(r"\{\{[A-Z_]+_NAMING\}\}", line.strip()): + continue + lines.append(re.sub(r"\{\{([A-Z_]+)\}\}", lambda m: fill.get(m.group(1), m.group(0)), line)) + (a / "project.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8") + b.commit(a, "the assembly, from the template") + b.git("submodule", "add", "-q", spec, "spec", cwd=a) + b.git("submodule", "add", "-q", code_bare, "code", cwd=a) + b.commit(a, "the legs") + b.git("push", "-q", "origin", "main", cwd=a) + b.git("submodule", "update", "-q", "--init", "--recursive", cwd=a) + return {"assembly": a, "code": a / "code", "spec": a / "spec", + "dep": a / "code" / "upstream" / "dep"} + + +def porcelain_rows(out: str, kind: str) -> dict: + rows = {} + for line in out.splitlines(): + cols = line.split("\t") + if cols[0] == kind and len(cols) > 6: + rows[cols[3]] = {"disposition": cols[1], "retire": cols[2], "branch": cols[4], + "head": cols[5], "why": cols[6]} + return rows + + +@pytest.fixture +def triad(tmp_path): + b = Bench(tmp_path / "bench", REPO) + t = build_assembly(b) + b.lanes("opensoft/triad", t["assembly"], "triad-1", "triad-2") + return b, t + + +def _lane_start_reader() -> list: + text = (REPO / "lane-start").read_text(encoding="utf-8") + start = text.index(" ls_cand=\"$(awk -v want=\"$ls_home\" '\n") + lines = text[start:].splitlines()[1:8] + return [ln.strip() for ln in lines] + + +def _shared_reader() -> list: + text = (REPO / "lanes-edit.sh").read_text(encoding="utf-8") + start = text.index("project_leg_paths_file() {") + lines = text[start:].splitlines()[3:10] + return [ln.strip() for ln in lines] + + +def test_t018_the_legs_reader_is_lane_starts_rung_5_line_for_line(): + """NEVER A SECOND GRAMMAR: the seven lines that decide what a leg is are + the ones `lane-start` resolves a lane's directory with.""" + theirs, ours = _lane_start_reader(), _shared_reader() + assert len(theirs) == 7 and theirs[0].startswith("/^[A-Za-z_]"), theirs + assert theirs == ours + + +@NEEDS_TEMPLATE +def test_t018_project_legs_reads_the_templates_manifest(tmp_path): + b = Bench(tmp_path / "bench", REPO) + t = build_assembly(b) + a = t["assembly"] + proc = b.tool("lanes-edit.sh", "project-legs", a, env={"AGENT_PROTOCOL_ROOT": str(tmp_path / "none")}) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.splitlines() == [f"opensoft/triad-spec\tspec\t{a}/spec", + f"opensoft/triad-code\tcode\t{a}/code"] + for where, rc in ((b.projects, 8), (b.projects / "wip", 8)): + proc = b.tool("lanes-edit.sh", "project-legs", where) + assert proc.returncode == rc, proc.stdout + proc.stderr + assert b.tool("lanes-edit.sh", "project-legs", "relative").returncode == 64 + os.chmod(a / "project.yaml", 0) + try: + if os.geteuid() != 0: + proc = b.tool("lanes-edit.sh", "project-legs", a) + assert proc.returncode == 1 and "could not be read" in proc.stderr + finally: + os.chmod(a / "project.yaml", 0o644) + + +@NEEDS_TEMPLATE +def test_t018_the_sweep_reads_every_leg_and_its_nested_dependency(triad): + b, t = triad + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 0, proc.stdout + proc.stderr + legs = porcelain_rows(proc.stdout, "leg") + assert list(legs) == [str(t["spec"]), str(t["code"]), str(t["dep"])] + assert all(r["disposition"] == "clean" and r["retire"] == "-" for r in legs.values()), legs + assert "the code leg of" in legs[str(t["code"])]["why"] + assert "nested in the code leg (code/upstream/dep)" in legs[str(t["dep"])]["why"] + table = b.sweep("triad-1", "--dry-run") + assert "LEGS (the assembly's own checkouts" in table.stdout + assert "3 leg checkout(s), 0 counted" in table.stdout + + +@NEEDS_TEMPLATE +def test_t018_a_paired_code_tree_with_unpushed_lane_work_holds_lane_end(triad): + """THE GAP §4.4 MEASURED: a paired `worktrees//code` tree, made + as Speckit makes one (a worktree of the code leg's repository), holding the + lane's unpushed commit, passed `lane-end`. Now the sweep claims it by its + HEAD's own `Lane:` trailer and the gate refuses, naming it.""" + b, t = triad + tree = t["assembly"] / "worktrees" / "005-x" / "code" + b.git("worktree", "add", "-q", "-b", "005-x", tree, "origin/main", cwd=t["code"]) + b.commit(tree, "the feature's code", {"f.txt": "f\n"}, lane="triad-1") + # the paired spec tree carries another lane's work: never this lane's + spec_tree = t["assembly"] / "worktrees" / "005-x" / "spec" + b.git("worktree", "add", "-q", "-b", "005-x", spec_tree, "origin/main", cwd=t["spec"]) + b.commit(spec_tree, "the feature's spec", {"s.md": "s\n"}, lane="triad-2") + proc = b.sweep("triad-1", *GATE, "--dry-run", "--porcelain") + assert proc.returncode == 3, proc.stdout + proc.stderr + trees = porcelain_rows(proc.stdout, "tree") + assert trees[str(tree)]["retire"] == "retire", trees + assert "Lane: trailer" in trees[str(tree)]["why"] + assert trees[str(spec_tree)]["disposition"] == "foreign", trees + assert "paired feature worktrees" in trees[str(spec_tree)]["why"] + assert "the spec leg of" in trees[str(spec_tree)]["why"] + end = b.tool("lane-end", "triad-1") + assert end.returncode == 2, end.stderr + assert "lane triad-1 is NOT ended" in end.stderr and str(tree) in end.stderr + assert "1 tree(s)" in end.stderr + # and the other lane's own sweep claims the spec tree, not the code one + other = porcelain_rows(b.sweep("triad-2", "--dry-run", "--porcelain").stdout, "tree") + assert other[str(spec_tree)]["retire"] == "retire" + assert other[str(tree)]["disposition"] == "foreign" + + +@NEEDS_TEMPLATE +def test_t018_a_leg_tree_made_elsewhere_is_registered_in_its_leg(triad): + b, t = triad + away = b.projects / "away-code" + b.git("worktree", "add", "-q", "-b", "away", away, "origin/main", cwd=t["code"]) + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + row = porcelain_rows(proc.stdout, "tree")[str(away)] + assert row["disposition"] == "foreign" and row["retire"] == "-", row + assert "registered in the code leg of" in row["why"] + # and a tree recorded with the leg as its checkout is the lane's + rec = b.tool("lanes-edit.sh", "set-lane-tree", "triad-1", away, "--checkout", t["code"]) + assert rec.returncode == 0, rec.stderr + row = porcelain_rows(b.sweep("triad-1", "--dry-run", "--porcelain").stdout, "tree")[str(away)] + assert row["retire"] == "retire" and row["disposition"] == "remove", row + + +@NEEDS_TEMPLATE +def test_t018_a_leg_holding_the_lanes_unpushed_commit_is_counted(triad): + """A lane working in the code leg itself: its HEAD's own trailer is this + lane's and origin lacks it, so the leg is COUNTED - the gate refuses, and + offers no `--yes`, which never acts on a leg.""" + b, t = triad + b.git("checkout", "-q", "-b", "in-place", cwd=t["code"]) + b.commit(t["code"], "worked in the leg", {"w.txt": "w\n"}, lane="triad-1") + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 3, proc.stdout + proc.stderr + leg = porcelain_rows(proc.stdout, "leg")[str(t["code"])] + assert leg["disposition"] == "unpushed" and leg["retire"] == "retire", leg + assert leg["branch"] == "in-place" + end = b.tool("lane-end", "triad-1") + assert end.returncode == 2 + assert "1 leg checkout(s)" in end.stderr and str(t["code"]) in end.stderr + assert "no sweep acts on one" in end.stderr + assert "--yes" not in end.stderr + # another lane's sweep lists that leg and does not count it + other = b.sweep("triad-2", "--dry-run", "--porcelain") + assert other.returncode == 0, other.stdout + assert porcelain_rows(other.stdout, "leg")[str(t["code"])]["retire"] == "-" + assert b.tool("lane-end", "triad-2").returncode == 0 + + +@NEEDS_TEMPLATE +def test_t018_a_dirty_leg_that_is_no_lanes_is_listed_never_counted(triad): + b, t = triad + (t["code"] / "scratch.txt").write_text("someone's edit\n") + (t["dep"] / "d2.txt").write_text("an edit in the dependency\n") + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 0, proc.stdout + proc.stderr + legs = porcelain_rows(proc.stdout, "leg") + assert legs[str(t["code"])]["disposition"] == "dirty" + assert legs[str(t["dep"])]["disposition"] == "dirty" + assert all(r["retire"] == "-" for r in legs.values()) + end = b.tool("lane-end", "triad-1") + assert end.returncode == 0, end.stderr + assert "close-out gate: nothing of lane triad-1's is left on disk" in end.stderr + assert "listed and NOT counted" in end.stderr and str(t["dep"]) in end.stderr + + +@NEEDS_TEMPLATE +def test_t018_the_legs_branches_are_read_under_branches(triad): + b, t = triad + tmp = b.root / "leg-branch" + b.git("worktree", "add", "-q", "-b", "leg-old", tmp, "origin/main", cwd=t["code"]) + b.commit(tmp, "leg work", {"l.txt": "l\n"}, lane="triad-1") + b.git("worktree", "remove", tmp, cwd=t["code"]) + proc = b.sweep("triad-1", "--branches", "--dry-run", "--porcelain") + assert proc.returncode == 3, proc.stdout + proc.stderr + rows = [ln.split("\t") for ln in proc.stdout.splitlines() if ln.startswith("branch\t")] + assert [r[3] for r in rows] == ["leg-old"], rows + assert rows[0][2] == "retire" and rows[0][4].startswith("in the code leg of"), rows + + +@NEEDS_TEMPLATE +def test_t018_a_manifest_or_leg_that_cannot_be_read_refuses(triad): + b, t = triad + if os.geteuid() == 0: + pytest.skip("root reads a mode-000 file") + os.chmod(t["assembly"] / "project.yaml", 0) + try: + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + finally: + os.chmod(t["assembly"] / "project.yaml", 0o644) + assert proc.returncode == 2, proc.stdout + assert "the legs of the assembly" in proc.stdout and "could not be read" in proc.stdout + + +# ===================================================================== capture + +def capture(tools: Path, golden: Path) -> None: + """Write the golden file from the tools in `tools` - run ONCE, against + `c45a452`'s (`git archive c45a452 | tar -x -C `), and committed.""" + with tempfile.TemporaryDirectory(prefix="t018-capture.") as tmp: + text = render_golden(single_repository(Bench(Path(tmp) / "bench", tools))) + golden.parent.mkdir(parents=True, exist_ok=True) + golden.write_text(text, encoding="utf-8") + + +if __name__ == "__main__": + if len(sys.argv) in (3, 4) and sys.argv[1] == "--capture": + capture(Path(sys.argv[2]).resolve(), Path(sys.argv[3]) if len(sys.argv) == 4 else GOLDEN) + sys.exit(0) + sys.stderr.write("usage: test_triad_lane_tooling.py --capture []\n") + sys.exit(64) From 96da9afc7a22dece1b2a121850955e09a17cd445 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:52:00 +0000 Subject: [PATCH 05/42] =?UTF-8?q?WIP=20(untested):=20T010=20docs=20?= =?UTF-8?q?=E2=80=94=20the=20=C2=A74.14=20CI=20sketch=20exits=204=20under?= =?UTF-8?q?=20#190's=20composed=20mode;=20three=20ways=20a=20composed=20jo?= =?UTF-8?q?b=20can=20pin=20the=20PR's=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan task T010 (opensoft/openRepoTools#186). Queued by the coordinator from the review of #190: the §4.14 sketch (code-PR CI with OPENREPOTOOLS_COMPOSED=1 against an assembly checked out at main) would exit 4, because #190 at dbc6837 refuses any code root the assembly does not pin — Roots.absent() (tests/conftest.py:283) runs _leg_at_its_pin (:259), and pytest_configure (:404-412) raises it as pytest.UsageError — while discover_assembly (:159-172) finds only an assembly that mounts the code root. Dated notes in §4.14 and §7 item 7: the composed job must check out an assembly whose code gitlink points at the PR's own commit, or a throwaway assembly built for the run that pins it, or use #190's standalone mode. Documentation only; no tool or test changes. Marked WIP (untested) because the hygiene suite has not been run over this text: the checkpoint came first. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/triad-lane-tooling-analysis.md | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index a39e0a41..828a0482 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -472,6 +472,31 @@ names at assembly `main`, or the paired feature branch of the same name. `tests- (:127-149) keeps proving that a fork's first run skips rather than fails. The assembly's own `validate.yml` from the shape checks manifest and pins. +**2026-10-09: the sketch above, as written, cannot pass #190's composed mode** (found by the +review of #190, read here at `dbc6837`). It checks out the PR's code at `code/` and the +assembly at its `main`, and names that assembly in `OPENREPOTOOLS_ASSEMBLY_ROOT` with +`OPENREPOTOOLS_COMPOSED=1`. #190's composed contract refuses any code root the assembly does +not pin: `Roots.absent()` (tests/conftest.py:283) runs `_leg_at_its_pin("code", …)` (:259), +which compares the code root's HEAD with the gitlink the assembly records at its code mount +and refuses a mismatch ("composed acceptance is for the pinned code leg and no other"), and +`pytest_configure` (:404-412) raises that refusal as a `pytest.UsageError`, **exit 4**, before +the first test. On a code PR the PR's commit is never the commit assembly `main` pins, so the +job would exit 4 on every code PR. Without `OPENREPOTOOLS_ASSEMBLY_ROOT`, `discover_assembly` +(tests/conftest.py:159-172) finds an assembly only above the code root that MOUNTS it at its +gitlink, which a sibling `code/` checkout is not. A composed job therefore has to do one of +three things: + +1. check out an assembly whose code gitlink already points at the PR's own commit (a paired + assembly branch that pins it); +2. build a throwaway assembly for the run that pins the PR's commit (the assembly at `main`, + its `code` gitlink and `contracts/code-pin.yaml` moved to the PR's commit, the code leg + checked out as its submodule), and run composed from that; or +3. run #190's **standalone** mode on code PRs (no `OPENREPOTOOLS_COMPOSED`), leaving composed + acceptance to the assembly's own pin-bump PRs. + +Which one is lane 1's decision under §7 item 7; the sketch above is item 2's starting point, +not a job that can run as it stands. + ### 4.15 The register, logs, inventory and handoffs in `brett-wip` (read only) `grep -rcE '/workspace/projects/openRepoTools|~/projects/openRepoTools|\$HOME/projects/openRepoTools'` @@ -560,6 +585,12 @@ kept only in this lane's scratch directory. for AGENTS.md (316) and README.md (486); the root guidance's `code/` prefixes (§4.12). 7. **T010 context:** which spec and assembly commits a code CI run pairs with, and whether the code leg's CI carries the two extra checkouts or the assembly's CI runs the composed suite. + **2026-10-09:** the §4.14 sketch exits 4 under #190's composed mode as it stands (#190 at + `dbc6837`: `_leg_at_its_pin`, tests/conftest.py:259, refuses a code root the assembly does + not pin, raised by `pytest_configure`, :404-412; `discover_assembly`, :159-172, finds only + an assembly that mounts the code root). The composed job must check out an assembly whose + code gitlink points at the PR's own commit, or a throwaway assembly built for the run that + pins it, or use #190's standalone mode (§4.14, 2026-10-09 note). 8. **Measure in T008:** what `gh api …/contents/` and `raw.githubusercontent.com` answer for a path under a gitlink. §4.10 derives a 404 and does not claim it. 9. **Standalone skip, or standalone fail (#190):** there are two readings of FR-008. #190 From 30f9dc089cd9706c6b6507cfb4058aa66e895fbe Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:18:01 +0000 Subject: [PATCH 06/42] T018 (c): the daily report counts each leg of an assembly as a checkout of its own, with a legs section only where an assembly's leg is checked out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan task T018 (opensoft/openRepoTools#186), proposal 3 of docs/triad-lane-tooling-analysis.md §4.4. A leg is a submodule, its `.git` a FILE, so `Report.build` never counted one as a canonical checkout and the branches all lane work makes after the split never reached the report. Now, for every canonical checkout whose own project.yaml declares legs (read by `lanes-edit.sh project-legs`), each checked-out leg is read as a checkout of its own: its branches ("Unmerged branches…"), its root `main` ("Root main divergence") and its rescue branches ("Awaiting disposition"), each leg row ending with `the leg of `; and a section "Assembly legs with uncommitted or unpublished work" lists the legs holding dirty or untracked paths or a HEAD no origin ref holds, or that could not be read. A manifest that cannot be read is a row of "Records the report could not read". Guidance in docs/README-lanes.md's report table and `lane-worktrees --help`. THE INVARIANT: a canonical checkout with no project.yaml is asked nothing; the legs section is added only where a leg was found, so a report of single repositories is byte for byte what it was. This changes the daily report for the estate's existing triads (IRRS, IRSS, MedxEHR, openDox), which is what proposal 3 is for. THE PROOF: * For 8e2227e and 96da9af, which were WIP: `tests/run.sh -k "t018 or repo_hygiene"` -> 162 passed, 1144 deselected in 134.24s (2026-10-09T13:25Z). * For this commit: run.sh's own in-lock command under the same lock, because the wrapper's workstation-wide census had waited 30 minutes on other projects' pytest runs (#184) - on the coordinator's word, the form lane 1 approved on 2026-10-07: `flock -w 7200 /tmp/openrepotools-pytest.lock env TMPDIR=/tmp python3 -m pytest tests -q -p no:cacheprovider --basetemp=/basetemp -k "t018 or repo_hygiene or test_lane_worktrees_report"` -> 184 passed, 1124 deselected in 457.65s. That includes the single-repository golden (the report section of c45a452's stored outputs, unchanged), every test of tests/test_lane_worktrees_report.py, the hygiene module, and two new cases: an assembly whose code leg holds an unpushed branch and whose spec leg is dirty, and an estate with no assembly, which has no legs section. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 3 +- lane-worktrees | 80 ++++++++++++++++++++++++++++---- tests/test_triad_lane_tooling.py | 41 ++++++++++++++++ 3 files changed, 114 insertions(+), 10 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 34339f40..befcdc2e 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3998,8 +3998,9 @@ lane-worktrees sweep --all --dry-run --report [--estate ] [--post | |---|---| | FOREIGN repositories | a CLONE inside a worktree container (`.lane-worktrees/`, `.claude/worktrees`, `worktrees`, `-worktrees`), and a second clone of an origin the estate already has a checkout of: path, size, last commit, owner (the last `Lane:` trailer), and LOAD-BEARING with its dependents where something leans on it | | Orphaned worktrees of ENDED lanes | trees an inventory records, and directories under `.lane-worktrees/`, of a lane whose snapshot is `CLOSED` or whose log's last lane line is `ENDED` or `RETIRED` — with the sweep that retires them. Empty beside a record it could not read — a lane snapshot, an inventory, the register's lines, a directory the walk could not list — it says "none found in what could be read", never "none" (#170 B8); a failure that is no lane record (an ignored-file listing, say) is still a row of the last section but leaves "none" as it is (#179); so does Awaiting disposition | -| Unmerged branches | local branches of every checkout with no upstream, a gone upstream, a diverged one, or unpushed commits: tip, state, age, owner, and whether a worktree holds it. Never deleted; `main`, `master` and `rescue/*` are not listed | +| Unmerged branches | local branches of every checkout — and of every leg of an assembly (T018) — with no upstream, a gone upstream, a diverged one, or unpushed commits: tip, state, age, owner, and whether a worktree holds it. Never deleted; `main`, `master` and `rescue/*` are not listed | | Root main divergence | a checkout whose local `main` is ahead of `origin/main`; where every changed path is under `handoffs/` or `lanes/`, the remedy is Amendment 4's — those live in the workspace repository and are pushed per write | +| Assembly legs with uncommitted or unpublished work | **only where the estate holds an assembly with a leg checked out** (T018): every leg of a canonical checkout whose `project.yaml` declares legs, holding dirty or untracked paths or a HEAD no origin ref holds, or that could not be read — each named with its assembly. A leg is a submodule (its `.git` a file), so it was never a canonical checkout; it is now read as a checkout of its own here, and in the three branch sections, whose leg rows end with `the leg of ` | | Awaiting disposition | a `rescue/*` branch (local or on origin) and a DIRTY inventory tree whose git directory has been still for `aging_days` (14), with owner and age | | Caches and sandboxes | `--include-caches`' and `--include-sandboxes`' rows across every lane, by size | | Ignored directories over `ignored_report_mb` | every ignored directory of every repository at or over 50 MB (`du -sk`) | diff --git a/lane-worktrees b/lane-worktrees index 509db03c..596452d3 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -114,8 +114,12 @@ dependency nested in one as a `leg` row (never acted on; counted only where its HEAD's own `Lane:` trailer is this lane's and it holds work, or where it could not be read), lists the worktrees git registers in every leg, and takes `/worktrees` - the paired `worktrees//{spec,code}` - as a -root whose trees are the lane's by that same trailer rule. A checkout with no -`project.yaml` is asked nothing, and its table is what it always was. +root whose trees are the lane's by that same trailer rule. The estate report +counts each leg of every canonical assembly as a checkout of its own - its +branches, root `main` and rescue branches, each row naming its assembly - and +adds a section of the legs holding uncommitted or unpublished work, only where +an assembly's leg is checked out. A checkout with no `project.yaml` is asked nothing, and its +table and its report are what they always were. Exit codes: 0 dry run: nothing to retire · --yes: every act completed @@ -2114,7 +2118,7 @@ def read_leg(leg: Leg, lane: str) -> None: if leg.upstream and leg.head: n = git_out(leg.path, "rev-list", "--count", "@{u}..HEAD") leg.ahead = int(n) if n and n.isdigit() else None - leg.claimed = bool(leg.head) and lane.lower() in own_trailers(leg.path) \ + leg.claimed = bool(lane) and bool(leg.head) and lane.lower() in own_trailers(leg.path) \ and head_lane_trailer(leg.path).lower() == lane.lower() @@ -5252,6 +5256,8 @@ class Report: clones = [r for r in repos if os.path.isdir(os.path.join(r, ".git"))] canonical = self._canonical(clones) self.canonical = canonical + # T018: EVERY CANONICAL ASSEMBLY'S LEGS, each a checkout of its own. + self.legs = self._assembly_legs(canonical) parents = _lane_parents(self.root, canonical) + self._recorded_parents() self.state_dirs = _lane_state_dirs(parents) self.tree_roots = _lane_tree_roots(parents) @@ -5260,6 +5266,8 @@ class Report: self._orphans(closed) self._branches(canonical) self._root_main(canonical) + if self.legs: + self._legs_section() self._aging(canonical) self._caches_and_sandboxes() self._ignored_and_evidence(repos, canonical) @@ -5336,6 +5344,57 @@ class Report: self.duplicates[g] = (keep, len(named) != 1) return sorted(out) + def _assembly_legs(self, canonical: list) -> list: + """T018 (opensoft/openRepoTools#186): THE LEGS OF EVERY CANONICAL + ASSEMBLY - a checkout whose own `project.yaml` declares a leg other than + itself, read by `lanes-edit.sh project-legs`. A leg is a submodule, its + `.git` a FILE, so no leg was ever canonical and the branches all lane + work makes after a split never reached this report. Each is read as a + checkout of its own, its rows naming its assembly. A checkout with no + `project.yaml` is asked nothing, so a report of single repositories is + what it always was; a manifest that could not be read is a row of the + last section.""" + legs = [] + for c in canonical: + rows, why = project_legs(self.helper, c) + if why: + self._unread(c, f"its legs could not be read: {why}", record=False) + continue + for repository, decl, path in rows: + leg = Leg(path, decl, repository, c) + probe_leg(leg) + if leg.checkout or leg.read_error: + legs.append(leg) + return legs + + def _leg_cell(self, path: str) -> str: + """'' for a canonical checkout; for a leg, which assembly it is under.""" + for g in getattr(self, "legs", []): + if g.path == path: + return f"the {g.decl} leg of {g.assembly}" + return "" + + def _legs_section(self) -> None: + """T018: every leg checkout holding edits no commit carries or commits + no origin ref holds - or that could not be read - under its assembly.""" + rows = [] + for g in self.legs: + read_leg(g, "") + decide_leg(g) + if g.disposition in ("clean", "absent"): + continue + state = (g.read_error if g.disposition == "unreadable" else + "; ".join(p for p in ( + f"{g.dirty} dirty or untracked path(s)" if g.dirty else "", + ("HEAD carries commit(s) no origin ref holds" + + (f" ({g.ahead} ahead of {g.upstream})" if g.upstream and g.ahead + else "")) if g.head and g.on_origin is False else "") if p)) + rows.append([g.path, f"the {g.decl} leg of {g.assembly}", g.label(), state]) + self.add("Assembly legs with uncommitted or unpublished work", rows, + "none: every leg checkout is clean and its HEAD is on origin", + "A leg is a checkout of its own under its assembly (T018): it is the " + "assembly's, shared by every lane it homes, and a person's to commit and push.") + def _foreign(self, clones: list, canonical: list) -> None: rows = [] for c in clones: @@ -5415,7 +5474,7 @@ class Report: rows = [] fmt = ("%(refname:short)%1f%(objectname:short=12)%1f%(upstream:short)%1f" "%(upstream:track)%1f%(committerdate:iso-strict)%1f%(contents:trailers)%00") - for c in canonical: + for c in canonical + [g.path for g in getattr(self, "legs", []) if g.checkout]: proc = git(c, "for-each-ref", f"--format={fmt}", "refs/heads/") if proc.returncode != 0: continue @@ -5443,7 +5502,8 @@ class Report: continue owner = (re.findall(r"^Lane:\s*(\S+)", trailers, re.MULTILINE) or ["-"])[-1] rows.append([f"{c} {name}", tip, state, f"{_age_days(date)} d", - owner, "held by a worktree" if name in held else ""]) + owner, "held by a worktree" if name in held else ""] + + ([self._leg_cell(c)] if self._leg_cell(c) else [])) rows.sort(key=lambda r: -int(r[3].split()[0])) self.add("Unmerged branches with a missing, diverged or unpushed upstream", rows, "none", "Listed, never deleted: a person pushes each to rescue//... " @@ -5451,7 +5511,7 @@ class Report: def _root_main(self, canonical: list) -> None: rows = [] - for c in canonical: + for c in canonical + [g.path for g in getattr(self, "legs", []) if g.checkout]: if git(c, "rev-parse", "-q", "--verify", "refs/heads/main").returncode != 0 or \ git(c, "rev-parse", "-q", "--verify", "refs/remotes/origin/main").returncode != 0: continue @@ -5465,7 +5525,8 @@ class Report: "only handoff/register paths" if only else f"{len(paths)} path(s)", "Amendment 4: handoffs and the register live in the workspace " "repository and are pushed per write" if only else - "push it through a pull request, or a person's word"]) + "push it through a pull request, or a person's word"] + + ([self._leg_cell(c)] if self._leg_cell(c) else [])) self.add("Root main divergence", rows, "none") def _aging(self, canonical: list) -> None: @@ -5473,7 +5534,7 @@ class Report: rows = [] rescue_count = 0 seen = set() - for c in canonical: + for c in canonical + [g.path for g in getattr(self, "legs", []) if g.checkout]: proc = git(c, "for-each-ref", "--format=%(refname)\t%(committerdate:iso-strict)", "refs/heads/rescue/", "refs/remotes/origin/rescue/") for line in proc.stdout.splitlines(): @@ -5488,7 +5549,8 @@ class Report: first = name.split("/", 1)[0] if "/" in name else "" owner = first if LANE_SHAPED_RE.match(first) else "-" rows.append([f"{c} rescue/{name}", f"rescue branch, {age} d", - owner, "awaiting disposition"]) + owner, "awaiting disposition"] + + ([self._leg_cell(c)] if self._leg_cell(c) else [])) for d in self.state_dirs: for lane in self._ls(d): for rec in self._tree_records(os.path.join(d, lane, "trees")): diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py index 4e6e418b..3c0e78d2 100644 --- a/tests/test_triad_lane_tooling.py +++ b/tests/test_triad_lane_tooling.py @@ -616,6 +616,47 @@ def test_t018_a_manifest_or_leg_that_cannot_be_read_refuses(triad): assert "the legs of the assembly" in proc.stdout and "could not be read" in proc.stdout +# ========================================================= (c) the daily report + +def _section(text: str, title: str) -> str: + start = text.index(f"## {title}") + end = text.find("\n## ", start + 3) + return text[start:end if end != -1 else len(text)] + + +@NEEDS_TEMPLATE +def test_t018_the_report_counts_each_leg_as_a_checkout_of_its_own(triad): + b, t = triad + a = t["assembly"] + tmp = b.root / "leg-branch" + b.git("worktree", "add", "-q", "-b", "leg-unpushed", tmp, "origin/main", cwd=t["code"]) + b.commit(tmp, "leg work", {"l.txt": "l\n"}, lane="triad-1") + b.git("worktree", "remove", tmp, cwd=t["code"]) + (t["spec"] / "edit.md").write_text("an edit in the spec leg\n") + proc = b.tool("lane-worktrees", "sweep", "--all", "--dry-run", "--report", "--estate", + b.projects) + assert proc.returncode == 0, proc.stderr + branches = _section(proc.stdout, "Unmerged branches with a missing, diverged or unpushed upstream") + assert f"- {t['code']} leg-unpushed ·" in branches, branches + assert f"the code leg of {a}" in branches + legs = _section(proc.stdout, "Assembly legs with uncommitted or unpublished work") + assert f"- {t['spec']} · the spec leg of {a} ·" in legs, legs + assert "1 dirty or untracked path(s)" in legs + assert str(t["code"]) + " ·" not in legs + assert "| Assembly legs with uncommitted or unpublished work | 1 |" in proc.stdout + + +@NEEDS_TEMPLATE +def test_t018_a_report_with_no_assembly_has_no_legs_section(tmp_path): + b = Bench(tmp_path / "bench", REPO) + b.seeded("plain", {"p.txt": "p\n"}) + b.git("clone", "-q", b.remotes / "plain.git", b.projects / "plain") + proc = b.tool("lane-worktrees", "sweep", "--all", "--dry-run", "--report", "--estate", + b.projects) + assert proc.returncode == 0, proc.stderr + assert "Assembly legs" not in proc.stdout + + # ===================================================================== capture def capture(tools: Path, golden: Path) -> None: From 13cb52d057a64690296172a7ec5e92a099914da3 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:46:10 +0000 Subject: [PATCH 07/42] T018 (d): lane-worktrees add records the leg it made a tree from, and says of a tree made from an assembly itself that its legs are empty there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan task T018 of opensoft/openRepoTools#186, slice (d2), proposal 1 of docs/triad-lane-tooling-analysis.md §4.4. Where the lane's checkout is an ASSEMBLY - its project.yaml declares a leg other than itself, read only through `lanes-edit.sh project-legs`, lane-start's rung-5 reading - `lane-worktrees add` matches the checkout it makes the tree from against each leg by their common git directory (the leg's own toplevel must be the leg's path, so an uninitialized leg, which git answers as the assembly, never matches). A tree made from the leg, or from any worktree of that leg's repository (a paired /worktrees//code included), is recorded in the #97 inventory with THE LEG as its checkout, which every inventory reader enumerates; the dry-run and success notes name the leg, and the success note says "with as its checkout" where the checkout given was a worktree of it. A tree made from the assembly itself is made as before, and a note says a worktree does not check the legs out and names the `--checkout /` that makes a leg's tree. A manifest that cannot be read refuses, exit 2, nothing made (Amendment 7(d)). THE INVARIANT: a lane whose checkout has no project.yaml is asked nothing (no helper call), and a manifest that declares no leg other than `.` answers 8 - so on a single repository `add`'s output, exit code, inventory row and take-back text are byte-for-byte what they were. PROOF: test_t018_single_repository_outputs_are_c45a452s_byte_for_byte compares the current tools against tests/fixtures/t018/single-repository.golden, stored from c45a452's tools, whose "add --dry-run", "add" and "add (manifest, no leg)" sections are add's; it passes unchanged. The shell suite's #163 add block (tests/test_lane_helpers.sh) runs once on the merged tree at the end of this slice. Help text that changes regardless of shape (listed in the §4.4 note for lane 1's ruling, item 2): `lane-worktrees --help`'s "A TRIAD" paragraph gains two sentences on add. Guidance: docs/README-lanes.md "In a triad (T018), a tree of a leg is made from the leg". The §4.4 note's first paragraph now names (a) through (d), with (b) a second writer's branch this one merges. Tests (tests/test_triad_lane_tooling.py, fixtures from openRepoShape's assembly-root template bytes, legs behind bare local remotes, gh exits 1): test_t018_add_records_the_leg_it_made_a_tree_from (dry run, an add from /code, an add from a paired worktree recorded under the leg, the sweep then reading that tree through its leg) and test_t018_add_from_the_assembly_itself_says_its_legs_are_empty. COUNTS: `tests/run.sh -k "t018 or repo_hygiene or test_lane_worktrees_report"` on this tree: 186 passed, 1124 deselected in 212.76s (0:03:32), exit 0 (30f9dc0's same selection: 184 passed; the two new (d) tests make 186). run.sh's own census and lock (it waited ~48 minutes behind other runs; no flock fallback was used). The selection holds no supervised-restart test. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 19 +++++++++ docs/triad-lane-tooling-analysis.md | 8 +++- lane-worktrees | 60 +++++++++++++++++++++++++---- tests/test_triad_lane_tooling.py | 51 ++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 8 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index befcdc2e..741f7156 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3974,6 +3974,25 @@ A tree made any other way — a brief's own `git worktree add`, the harness's `isolation: worktree` — is still legal git and is FOREIGN to every sweep until `lanes-edit.sh set-lane-tree ` records it. +**In a triad (T018), a tree of a leg is made from the leg** — the guidance of +docs/triad-lane-tooling-analysis.md §4.4, proposal 1: + +```sh +lane-worktrees add --checkout /code +``` + +Where the lane's checkout is an assembly (its `project.yaml` declares a leg other +than itself, read by `lanes-edit.sh project-legs`), a tree made from any checkout of +a leg's repository — the leg itself, or a worktree of it, a paired +`/worktrees//code` included — is recorded with **the leg** as its +checkout (`/`), which every reader of the inventory enumerates; the +note names the leg, and says so where the checkout given was a worktree of it. Made +from the assembly itself (no `--checkout`), the tree is a worktree of the assembly, +whose legs a worktree does not check out: `add` makes it as before and says that its +legs are empty there, naming the `--checkout /` that makes a leg's +tree. A lane whose checkout has no `project.yaml` is asked nothing, and its `add` is +what it always was. A manifest that cannot be read refuses, exit **2**, nothing made. + ### The estate report — `sweep --all --dry-run --report` The net under every actor that never runs `lane-end`: **creation outpacing diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index 828a0482..46a815c5 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -211,7 +211,13 @@ estate's existing triads, and the brief rules out behaviour changes. **Proposals **On the coordinator's brief for slice (d2)** (worktree visibility for triad legs, plan task T018), proposals 1–3 above are implemented as **shape-gated** changes, -one commit each with its tests (`tests/test_triad_lane_tooling.py`). The gate is +one commit each with its tests (`tests/test_triad_lane_tooling.py`): (a) the sweep and +`lane-end`'s gate (proposal 2); (b) `lane-handoff`'s poll of the paired root, each +tree recorded under its leg, and `lane-reconcile`'s leg registrations (proposal 2's +other two readers — a second writer's, on `feat/triad-lane-tooling-handoff`, which +this branch merges with its own tests); (c) the daily report (proposal 3); and (d) +`lane-worktrees add` recording the leg it made a tree from, with proposal 1's +guidance in `docs/README-lanes.md` and the usage text. The gate is the standard's own rule: the lane's checkout (or the checkout a tool polls) is an **assembly** when its own `project.yaml` declares a leg other than itself. One reader answers it for every tool, `lanes-edit.sh project-legs `, whose diff --git a/lane-worktrees b/lane-worktrees index 596452d3..53a7c42b 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -118,8 +118,11 @@ root whose trees are the lane's by that same trailer rule. The estate report counts each leg of every canonical assembly as a checkout of its own - its branches, root `main` and rescue branches, each row naming its assembly - and adds a section of the legs holding uncommitted or unpublished work, only where -an assembly's leg is checked out. A checkout with no `project.yaml` is asked nothing, and its -table and its report are what they always were. +an assembly's leg is checked out. `add` records a tree made from any checkout +of a leg's repository - the leg itself or a worktree of it - with the LEG as +its checkout, and says, of a tree made from the assembly itself, that its legs +are empty there. A checkout with no `project.yaml` is asked nothing, and its +table, its report and its `add` are what they always were. Exit codes: 0 dry run: nothing to retire · --yes: every act completed @@ -5805,6 +5808,18 @@ def report_all(opts) -> int: SLICE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +def git_common_dir_of(path: str) -> str: + """The physical path of `path`'s repository's common git directory, or ''. + A leg and every worktree of its repository share one, under the + assembly's `.git/modules/`.""" + common = git_out(path, "rev-parse", "--git-common-dir") + if not common: + return "" + if not os.path.isabs(common): + common = os.path.join(path, common) + return real(common) + + def add_tree(ns) -> int: """`add ` - the one act that MAKES a lane's worktree, and it records the tree in the lane's #97 inventory in the same act @@ -5876,6 +5891,32 @@ def add_tree(ns) -> int: if top is None or real(top) != real(checkout): return refuse(f"{checkout} is not the root of a git checkout" + (f" ({top} is)" if top else "")) + # T018 (opensoft/openRepoTools#186): THE LEG A TREE IS MADE FROM IS WHAT IS + # RECORDED. Where the lane's checkout is an ASSEMBLY, a tree made from one + # of its legs - the leg itself, or any worktree of that leg's repository - + # is recorded with the LEG as its checkout, which every reader of the + # inventory enumerates. A checkout with no `project.yaml` is asked nothing, + # and the record is the checkout given, as always. + record_co, leg_line, assembly_hint = checkout, "", "" + legs, why = project_legs(helper, lane_dir) + if why: + return refuse(f"the legs of the lane's assembly {lane_dir} could not be read, so which " + f"checkout a tree of it belongs to is unknown (Amendment 7(d)): {why}") + if legs: + made_from = git_common_dir_of(checkout) + for repository, decl, lpath in legs: + if made_from and os.path.isdir(lpath) and git_common_dir_of(lpath) == made_from \ + and real(git_out(lpath, "rev-parse", "--show-toplevel") or "") == real(lpath): + record_co = lpath + leg_line = (f" - the {decl} leg of {os.path.join(lane_dir, 'project.yaml')}" + + (f" ({repository})" if repository else "")) + break + if not leg_line and real(checkout) == real(lane_dir): + assembly_hint = (f"lane {lane}'s checkout {lane_dir} is an ASSEMBLY, and this tree is " + "of the assembly itself: a worktree does not check its legs out (" + + ", ".join(f"{d}/" for _r, d, _p in legs) + " are empty in it). A " + "tree of a leg is made from the leg: --checkout " + + os.path.join(lane_dir, legs[-1][1])) # THE RECORD MUST HAVE SOMEWHERE TO GO BEFORE THE TREE IS MADE: a tree # this act could not record is the very thing it exists to stop. tproc = helper.call("lane-trees", lane) @@ -5909,7 +5950,9 @@ def add_tree(ns) -> int: ["worktree", "add", "--no-track", "-b", branch, path, start_oid]) if ns.dry_run: note(f"DRY RUN, nothing was made: git -C {checkout} {' '.join(argv)}; then " - f"lanes-edit.sh set-lane-tree {lane} {path} --checkout {checkout}") + f"lanes-edit.sh set-lane-tree {lane} {path} --checkout {record_co}{leg_line}") + if assembly_hint: + note(assembly_hint) say(path) return 0 try: @@ -5954,7 +5997,7 @@ def add_tree(ns) -> int: return 2 note(f"{what}, and this is left of what this act made: {'; '.join(left)}. Record a " f"tree that is wanted with: lanes-edit.sh set-lane-tree {lane} {path} " - f"--checkout {checkout}") + f"--checkout {record_co}") return 1 made = git(checkout, *argv) @@ -5965,13 +6008,16 @@ def add_tree(ns) -> int: return take_back(f"git worktree add failed: {_first_line(made.stderr)}") head = git_out(path, "rev-parse", "HEAD") or "" caller = os.environ.get("LANES_SESSION") or os.environ.get("CLAUDE_CODE_SESSION_ID") or "" - record = ["set-lane-tree", lane, path, "--checkout", checkout] + record = ["set-lane-tree", lane, path, "--checkout", record_co] if UUID_RE.match(caller): record += ["--writer", caller] rproc = helper.call(*record) if rproc.returncode == 0: - note(f"made {path} on branch {branch} at {short(head)} in {checkout}; recorded in lane " - f"{lane}'s inventory as {rproc.stdout.strip() or '?'}") + note(f"made {path} on branch {branch} at {short(head)} in {checkout}{leg_line}; recorded " + f"in lane {lane}'s inventory as {rproc.stdout.strip() or '?'}" + + (f" with {record_co} as its checkout" if record_co != checkout else "")) + if assembly_hint: + note(assembly_hint) _ACTIVE.pop("add", None) say(path) return 0 diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py index 3c0e78d2..b19e15d9 100644 --- a/tests/test_triad_lane_tooling.py +++ b/tests/test_triad_lane_tooling.py @@ -657,6 +657,57 @@ def test_t018_a_report_with_no_assembly_has_no_legs_section(tmp_path): assert "Assembly legs" not in proc.stdout +# ================================================================ (d) add + +def _inventory(b: Bench, lane: str) -> dict: + """{path: checkout} of the lane's #97 inventory.""" + proc = b.tool("lanes-edit.sh", "lane-trees", lane) + assert proc.returncode in (0, 8), proc.stderr + rows = {} + for line in proc.stdout.splitlines(): + cols = line.split(US) + if len(cols) > 9: + rows[cols[1]] = cols[9] + return rows + + +@NEEDS_TEMPLATE +def test_t018_add_records_the_leg_it_made_a_tree_from(triad): + b, t = triad + a = t["assembly"] + dry = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", t["code"], "--dry-run") + assert dry.returncode == 0, dry.stderr + assert f"--checkout {t['code']} - the code leg of {a}/project.yaml (opensoft/triad-code)" \ + in dry.stderr + made = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", t["code"]) + assert made.returncode == 0, made.stderr + tree = Path(made.stdout.strip()) + assert f"in {t['code']} - the code leg of {a}/project.yaml" in made.stderr + assert _inventory(b, "triad-1")[str(tree)] == str(t["code"]) + # FROM A WORKTREE OF THE LEG'S REPOSITORY, the leg is what is recorded + paired = a / "worktrees" / "010-v" / "code" + b.git("worktree", "add", "-q", "-b", "010-v", paired, "origin/main", cwd=t["code"]) + made = b.tool("lane-worktrees", "add", "triad-1", "c2", "--checkout", paired) + assert made.returncode == 0, made.stderr + assert f"with {t['code']} as its checkout" in made.stderr + assert _inventory(b, "triad-1")[made.stdout.strip()] == str(t["code"]) + # the sweep then reads it through its leg + row = porcelain_rows(b.sweep("triad-1", "--dry-run", "--porcelain").stdout, + "tree")[made.stdout.strip()] + assert row["retire"] == "retire", row + + +@NEEDS_TEMPLATE +def test_t018_add_from_the_assembly_itself_says_its_legs_are_empty(triad): + b, t = triad + a = t["assembly"] + made = b.tool("lane-worktrees", "add", "triad-1", "root-work") + assert made.returncode == 0, made.stderr + assert "is an ASSEMBLY, and this tree is of the assembly itself" in made.stderr + assert f"--checkout {a}/code" in made.stderr + assert _inventory(b, "triad-1")[made.stdout.strip()] == str(a) + + # ===================================================================== capture def capture(tools: Path, golden: Path) -> None: From 4e39854b8e7f3570ab07931e6b8fa911b8b033c1 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:52:56 +0000 Subject: [PATCH 08/42] =?UTF-8?q?T018=20(b):=20lane-handoff=20sees=20a=20t?= =?UTF-8?q?riad's=20legs=20=E2=80=94=20the=20paired=20trees=20polled,=20ea?= =?UTF-8?q?ch=20tree=20recorded=20under=20its=20leg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan task T018 (opensoft/openRepoTools#186), slice (d2), part (b), first half. Two gaps docs/triad-lane-tooling-analysis.md §4.4 and §4.7 measured in the handoff's writer poll, closed SHAPE-GATED through 2aa8a59's shared reader (`lanes-edit.sh project-legs`, called as it is): (1) THE PAIRED ROOT IS POLLED. Where the lane's checkout is an assembly (its own project.yaml declares a leg other than itself), the handoff polls `/worktrees/` and `/` as it polls `.claude/worktrees`: every directory there with a `.git` of its own (anything else under it is the assembly's working tree, where git answers too). A lane with unpushed work in a paired tree used to be absent from its WRITERS block. (2) EACH TREE IS RECORDED UNDER ITS LEG. A polled tree whose repository's common git directory is a checked-out leg's is filed with that leg (project-legs' third column, the path `add --checkout` records) as its checkout. Anything else keeps the lane's dir, as before. The first handoff used to rewrite the leg that `lane-worktrees add --checkout /code` recorded to the assembly. A leg that is NOT checked out is passed over: git answers the assembly there, and the drafts' leg map, which lacked that guard, would have filed every tree of the assembly under a deinitialized leg (measured: with the guard deleted, the deinit case fails). AN UNREADABLE project.yaml is never "no legs" (Amendment 7(d)), and the swap still completes (R-A11-11). The paired root is not polled, the top block's WRITERS section carries a `- NOT POLLED:` line, no sidecar is filed for a tree that is not of the lane's own repository (the checkout `add` recorded is kept), and the lane stays SWAPPING. THE GATE: a checkout with no project.yaml is asked nothing. There is no helper call and no git read, and set-lane-tree gets the same argv. One whose project.yaml names no leg but itself costs one `project-legs` call, which answers 8, and nothing else. `--supervise` and `--restart-status` exit before any changed line. No usage or --help text moves. THE PROOF, under tests/run.sh, lanes-edit.sh at base (lane-reconcile is the next commit): tests/run.sh -k "t018 or supervised_restart or repo_hygiene or openrepotools_command" -rs -> 7 failed, 429 passed, 874 deselected in 3149.31s (load 60-70 on 32 cores) The 429 include tests/test_triad_lane_tooling.py, whose single-repository golden from c45a452 passes UNREGENERATED (tests/fixtures/t018/ untouched), and the new tests/test_triad_lane_tooling_handoff.py: three assembly cases built from openRepoShape's template bytes at the pin (legs as real repositories behind bare local origins, fake gh exiting 1), and a second stored golden, tests/fixtures/t018-handoff/single-repository-decoys.golden. That golden was captured twice from c45a452's own tools, byte-identical, on a single repository laid out to look like a triad: a submodule's worktree under worktrees//code, and a tree recorded with the submodule as its checkout. Its scenario has no project.yaml, and then one naming no leg but itself. The handoff output, top block, inventory and lane-reconcile before and after all match it. The 7 failures are --restart/--supervise timing cases of tests/test_supervised_restart_regressions.py (TimeoutExpired, "observer never confirmed readiness"). A/B at load ~40, one run.sh run each: this tree: -k "" -> 7 passed, 1303 deselected in 143.69s 96da9af (archive copy, no change): -> 7 passed, 1299 deselected in 150.59s so they are load, not this change. Against 96da9af's lane-handoff the three new assembly cases fail (measured); the base handoff turns the `add`-recorded `/code` into ``. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lane-handoff | 140 +++++++- .../single-repository-decoys.golden | 148 ++++++++ tests/test_triad_lane_tooling_handoff.py | 339 ++++++++++++++++++ 3 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/t018-handoff/single-repository-decoys.golden create mode 100644 tests/test_triad_lane_tooling_handoff.py diff --git a/lane-handoff b/lane-handoff index e854cb7a..cbb62892 100755 --- a/lane-handoff +++ b/lane-handoff @@ -1892,6 +1892,8 @@ lifecycle_begin # TWO PLACES, because the estate uses both: `/.claude/worktrees/` # for a writer working inside the lane's own repository, and # `/.lane-worktrees//` for the ones a lane keeps beside it. +# AND A THIRD WHERE THE CHECKOUT IS AN ASSEMBLY (T018, below): the paired +# feature trees `/worktrees//{spec,code}`. # # THE SNAPSHOT FILE IS A PRECONDITION, NOT A CONVENIENCE (Copilot round 1 on # openRepoTools#47). With no temporary file the count below still rises and @@ -1972,9 +1974,25 @@ poll_writer() { # # handoff's generation and operation, so a tree recorded by a superseded # operation is visible as one. It never fails the poll and never fails the # swap: a lane with no control root simply keeps no inventory yet. + # + # THE CHECKOUT IT IS RECORDED UNDER IS THE LANE'S `dir`, EXCEPT IN AN + # ASSEMBLY (T018): there a tree of a leg's repository is recorded under its + # LEG, the checkout `lane-worktrees add --checkout /code` recorded + # and every reader of the inventory enumerates (`writer_checkout`, below). + # Where no checkout can be vouched for — the assembly's legs could not be + # read and the tree is not of the lane's own repository — NOTHING is filed: + # a guessed checkout would replace the one `add` recorded, so the tree is + # named and the lane stays SWAPPING for it, as for a refused write. if [ -n "$lc_on" ] && [ "$pw_orc" = 0 ]; then + pw_co="${dir:-unknown}" + [ -z "$legs_state" ] || pw_co="$(writer_checkout "$pw_d")" + if [ -z "$pw_co" ]; then + lc_inventory_missed="${lc_inventory_missed:+$lc_inventory_missed }$pw_d" + note "the worktree inventory entry for $pw_d was NOT written: $dir/project.yaml could not be read, so whether this tree is of one of the assembly's legs — and so which checkout it is recorded under — is not known, and a guessed one would replace the checkout its record names now. This handoff's WRITERS section still names the tree, the lane stays SWAPPING for it, and \`$LANES_EDIT lane-reconcile $lane\` says what the inventory holds." + return 0 + fi LANES_NO_FETCH=1 "$LANES_EDIT" set-lane-tree "$lane" "$pw_d" \ - --checkout "${dir:-unknown}" --branch "$pw_branch" --head "$pw_head" \ + --checkout "$pw_co" --branch "$pw_branch" --head "$pw_head" \ --upstream "$pw_up" --dirty "$pw_dirty" --unpushed "$pw_ahead" \ --writer "${uuid:-none}" --generation "$lc_gen" --operation "$lc_op" \ >/dev/null 2>&1 || { @@ -1989,11 +2007,123 @@ poll_writer() { # return 0 } +# ------------------------------------------------ 3a. A TRIAD'S LEGS (T018) +# +# opensoft/openRepoTools#186, plan task T018. THE LANE'S CHECKOUT IS AN +# ASSEMBLY when its own `project.yaml` declares a leg other than itself — the +# standard's rule, read by `lanes-edit.sh project-legs`, the one reader every +# lane tool asks, never a second grammar here. Its legs (`spec/`, `code/`) are +# submodules, and a feature's trees are worktrees of the LEGS' repositories, so +# two things follow, and neither touches a checkout with no `project.yaml`, +# which is asked nothing and polled and recorded exactly as before: +# +# * THE PAIRED ROOT IS POLLED — `/worktrees/` and +# `/`, the standard's three-leg feature +# trees — exactly as `.claude/worktrees` is: every checkout there, because +# the paired root, like that one, is per feature and not per lane, and a +# writer with nothing committed yet has no trailer to be found by. Only a +# directory with a `.git` of its own is a checkout there: anything else +# under it is the assembly's own working tree, where git answers too. +# * A TREE IS RECORDED UNDER THE CHECKOUT IT BELONGS TO. A tree of a leg's +# repository — `lane-worktrees add --checkout /code` makes one +# under the lane's own root, and every paired tree is one — was re-recorded +# here with the ASSEMBLY as its checkout, and the leg `add` had recorded +# was lost at the first handoff. Its repository's common git directory, +# which a leg shares with every worktree of it, names its leg now. +# +# A MANIFEST THAT COULD NOT BE READ IS SAID, AND THE SWAP GOES ON +# (`R-A11-11`): the paired root is then NOT polled, the top block's WRITERS +# section says so, no tree is recorded under a checkout nobody could establish, +# and the lane stays SWAPPING — a poll that could not look everywhere is not +# the record a recovery reads as a finished swap. +legs_state=""; legs_unread=""; leg_rows=""; leg_commons=""; dir_common="" +tab="$(printf '\t')" +# THE PHYSICAL PATH OF A CHECKOUT'S COMMON GIT DIRECTORY, or nothing: a leg +# and every worktree of its repository share one (`/.git/modules/ +# `), and git spells it relative where it is the checkout's own `.git`. +common_dir_of() { # + cdo_c="$(git -C "$1" rev-parse --git-common-dir 2>/dev/null)" || return 0 + [ -n "$cdo_c" ] || return 0 + case "$cdo_c" in /*) : ;; *) cdo_c="$1/$cdo_c" ;; esac + ( CDPATH=''; cd -P -- "$cdo_c" 2>/dev/null && pwd -P ) || : + return 0 +} +physical_of() { # + ( CDPATH=''; cd -P -- "$1" 2>/dev/null && pwd -P ) || : + return 0 +} +if [ -n "$dir" ] && { [ -e "$dir/project.yaml" ] || [ -L "$dir/project.yaml" ]; }; then + legs_rc=0 + leg_rows="$(LANES_NO_FETCH=1 "$LANES_EDIT" project-legs "$dir" 2>/dev/null)" || legs_rc=$? + case "$legs_rc" in + 0) [ -z "$leg_rows" ] || legs_state=known ;; + 8) leg_rows="" ;; + *) + leg_rows=""; legs_state=unread + legs_unread="$dir/project.yaml is there and its legs could not be read (\`$LANES_EDIT project-legs $dir\` exited $legs_rc)" + dir_common="$(common_dir_of "$dir")" + note "$legs_unread — which is never 'no legs' (Amendment 7(d)). So the assembly's paired feature trees under $dir/worktrees were NOT polled, and a tree that is not of $dir's own repository is NOT recorded: read them by hand before a writer is relaunched there. The handoff goes on (\`R-A11-11\`), and where it records a lifecycle the lane stays SWAPPING for it." ;; + esac +fi +# EACH LEG THAT IS CHECKED OUT HERE, BY ITS COMMON GIT DIRECTORY. A leg that is +# NOT — a submodule never initialized, or deinitialized — is an empty +# directory of the assembly's, where git answers the ASSEMBLY: its common +# directory would then be the assembly's own, and every tree of the assembly +# would be recorded under that leg. So a leg counts only where git's own +# toplevel there is the leg itself. +if [ "$legs_state" = known ]; then + while IFS="$tab" read -r lg_repo lg_decl lg_abs; do + [ -n "$lg_abs" ] && [ -d "$lg_abs" ] || continue + lg_top="$(git -C "$lg_abs" rev-parse --show-toplevel 2>/dev/null + wc_c="$(common_dir_of "$1")" + if [ -n "$wc_c" ] && [ -n "$leg_commons" ]; then + while IFS="$tab" read -r wc_l wc_p; do + if [ -n "$wc_l" ] && [ "$wc_l" = "$wc_c" ]; then printf '%s' "$wc_p"; return 0; fi + done < "$writers_tmp" if [ -n "$dir" ]; then for w in "$dir"/.claude/worktrees/*; do poll_writer "$w"; done parent="${dir%/*}" for w in "$parent"/.lane-worktrees/"$lane"/*; do poll_writer "$w"; done + # THE PAIRED ROOT, where the checkout is an assembly whose legs were read. + # `}" @@ -2055,6 +2185,12 @@ top_block() { else cat -- "$writers_tmp" fi + # T018: A POLL THAT COULD NOT LOOK EVERYWHERE SAYS SO HERE, where the next + # session reads it — never only on a stderr nobody kept. + if [ -n "$legs_unread" ]; then + printf -- '- NOT POLLED: %s, so the assembly'"'"'s paired feature trees under `%s/worktrees` are not in this list. Read them by hand before relaunching a writer there.\n' \ + "$legs_unread" "$dir" + fi printf '\n' } @@ -2215,6 +2351,7 @@ if [ "$do_late" = 1 ]; then lc_unfinished="" [ "$handoff_written" = 1 ] || lc_unfinished="the handoff file was not refreshed" [ -z "$lc_inventory_missed" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the worktree inventory was not recorded for $lc_inventory_missed" + [ -z "$legs_unread" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the assembly's legs could not be read, so its paired feature trees were not polled" lifecycle_finish "$lc_unfinished" else lifecycle_finish "the late PAUSED record was refused by the helper" @@ -2337,6 +2474,7 @@ lc_unfinished="" [ -z "$row_write_refused" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the row's state cell was not flipped" [ "$handoff_written" = 1 ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the handoff file was not refreshed" [ -z "$lc_inventory_missed" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the worktree inventory was not recorded for $lc_inventory_missed" +[ -z "$legs_unread" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the assembly's legs could not be read, so its paired feature trees were not polled" lifecycle_finish "$lc_unfinished" # ------------------------------------------- 5. the restart line — one command diff --git a/tests/fixtures/t018-handoff/single-repository-decoys.golden b/tests/fixtures/t018-handoff/single-repository-decoys.golden new file mode 100644 index 00000000..e547835c --- /dev/null +++ b/tests/fixtures/t018-handoff/single-repository-decoys.golden @@ -0,0 +1,148 @@ +=== repoD: lane-reconcile before === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoD-1 +STATERUNNINGgeneration 1operation op-seedowner -1111-4180-8180-updated +HOLDERnone +BINDINGhereeagle/none +TREEunmanaged/projects/repoD/worktrees/001-d/specgit registers it in /projects/repoD and no sidecar of this lane names it; it is left exactly as it is +TREEunpushed/projects/.lane-worktrees/repoD-1/depbranch dep head upstream origin/main 0 dirty 1 unpushed; observed 0/1 at +TREES1 inventoried1 dirty or unpushed0 require recovery1 unmanaged or stale +VERDICTungraceful-stopthe lane is recorded RUNNING and no verified holder is live: the session stopped before any handoff began, so nothing was polled, refreshed or recorded — inspect every tree below before relaunching a writer +--- stderr + +=== repoD: lane-handoff === +exit 0 +--- stdout + +WRITER /projects/.lane-worktrees/repoD-1/dep — branch dep, last commit lane work in the dependency + git status --short: 0 dirty + git log @{u}..: 1 unpushed + lane work in the dependency + +READY — restart with: pclaude --lane repoD-1 + record: PAUSED lane:repoD-1 → swap; dir /projects/repoD; workstation Eagle; agent claude; transcript none; kind unknown + agent claude, transcript none (lane-start --agent claude resumes it) + lifecycle: SWAPPED (generation 2, operation ) — lanes-edit.sh lane-reconcile repoD-1 + inventory: 1 worktree(s) recorded — lanes-edit.sh lane-trees repoD-1 +--- stderr +lane-handoff: lane=repoD-1 workstation=Eagle +lanes-edit: LANES_NO_FETCH=1 — not fetching; reading origin/main as the ref already stands here +lane-handoff: lane=repoD-1 workstation=Eagle +lane-handoff: lifecycle: RUNNING -> SWAPPING (generation 2, operation ) +lane-handoff: writers polled: 1 under /projects/repoD +lane-handoff: handoff refreshed: /projects/wip/handoffs/repoD-1.md (+14 lines at the head) +lane-handoff: handoff commit: handoff(repoD-1@Eagle): PAUSED +lane-handoff: handoff pushed to origin/main +lanes-edit: 1 line appended to /projects/wip/lanes/log/repoD-1.md +lanes-edit: committed: LOG(repoD-1@Eagle): PAUSED lane:repoD-1 +lanes-edit: pushed origin/main (attempt 1) +lane-handoff: object log: PAUSED lane:repoD-1 → swap; dir /projects/repoD; workstation Eagle; agent claude; transcript none; kind unknown +lane-handoff: lifecycle: SWAPPING -> SWAPPED (generation 2, operation ) + +=== repoD: lane-handoff: top block === +Lane: repoD-1 (profile unknown, session -1111-4180-8180-) — single-use resume prompt: stamp RESUMED-by before acting (lane-collision-protocol rule 3) + +## RESUME PROMPT — PAUSED (t018 handoff), agent claude, transcript none, kind unknown — the count decides: this handoff cannot know whether the process survived + +**FIRST ACTS, in order.** (1) You are lane `repoD-1`, checkout `/projects/repoD`. Stamp this block RESUMED before anything else (Rule 3, single use) — `lane-start --no-launch ` in this window writes it. (2) Read AGENTS.md, then this block. (3) COUNT THE LIVE WRITERS before relaunching anything — the WRITERS section below is a list to COUNT, not a list to relaunch (Amendment 17 Addendum 1 (i)). ONE WORKTREE, ONE WRITER (k): a worktree is one writer's for as long as that writer is live, a relaunch onto a live writer's worktree is the writers' form of Amendment 18(h)'s second binding, and where you find one made you retire the newer before either commits. THIS HANDOFF CANNOT KNOW WHICH KIND FOLLOWED IT: a plain handoff may be followed by a `/clear` in this process or by a relaunch, so nothing below assumes either — the count is the answer. Older sections below are history. + +**STATE at .** Handed off by `lane-handoff` on Eagle: t018 handoff. Agent `claude`, transcript `none`. Kind `unknown`: the act that follows this record was not this command's to know (`kind unknown`, Addendum 1 (h)) — the restart line below is one of the two, and a `/clear` typed here is the other. + +**WRITERS at ** (1 found under `/projects/repoD`) — FIRST count the live writers: `ListAgents` in Claude, the agent's equivalent elsewhere. A writer still live OWNS its worktree: do not relaunch it; one message telling it that its in-flight call died and who the coordinator now is, is enough. Relaunch only a writer that is NOT live, from where it stands — `git status` and `git log @{u}..` in its worktree, then its brief. WHERE THE COUNT AND THIS LIST DISAGREE, THE COUNT WINS: this list is what the paused session expected, the count is what is true. + +- `/projects/.lane-worktrees/repoD-1/dep` — branch `dep`, last commit ` lane work in the dependency`, 0 dirty, 1 unpushed; brief: (fill in: the brief this writer was given) + +=== repoD: lane-handoff: inventory === +exit 0 +--- stdout +/projects/.lane-worktrees/repoD-1/depdeporigin/main01-1111-4180-8180-/projects/repoD21 +--- stderr + +=== repoD: lane-reconcile after === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoD-1 +STATESWAPPEDgeneration 2operation owner -1111-4180-8180-updated +HOLDERnone +BINDINGfreenone +TREEunmanaged/projects/repoD/worktrees/001-d/specgit registers it in /projects/repoD and no sidecar of this lane names it; it is left exactly as it is +TREEunpushed/projects/.lane-worktrees/repoD-1/depbranch dep head upstream origin/main 0 dirty 1 unpushed; observed 0/1 at +TREES1 inventoried1 dirty or unpushed0 require recovery1 unmanaged or stale +VERDICTresumablethe swap completed and no holder is live: the lane may be resumed once the trees below are read +--- stderr + +=== repoQ: lane-reconcile before === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoQ-1 +STATERUNNINGgeneration 1operation op-seedowner -1111-4180-8180-updated +HOLDERnone +BINDINGhereeagle/none +TREEunmanaged/projects/repoQ/worktrees/001-d/specgit registers it in /projects/repoQ and no sidecar of this lane names it; it is left exactly as it is +TREEunpushed/projects/.lane-worktrees/repoQ-1/depbranch dep head upstream origin/main 0 dirty 1 unpushed; observed 0/1 at +TREES1 inventoried1 dirty or unpushed0 require recovery1 unmanaged or stale +VERDICTungraceful-stopthe lane is recorded RUNNING and no verified holder is live: the session stopped before any handoff began, so nothing was polled, refreshed or recorded — inspect every tree below before relaunching a writer +--- stderr + +=== repoQ: lane-handoff === +exit 0 +--- stdout + +WRITER /projects/.lane-worktrees/repoQ-1/dep — branch dep, last commit lane work in the dependency + git status --short: 0 dirty + git log @{u}..: 1 unpushed + lane work in the dependency + +READY — restart with: pclaude --lane repoQ-1 + record: PAUSED lane:repoQ-1 → swap; dir /projects/repoQ; workstation Eagle; agent claude; transcript none; kind unknown + agent claude, transcript none (lane-start --agent claude resumes it) + lifecycle: SWAPPED (generation 2, operation ) — lanes-edit.sh lane-reconcile repoQ-1 + inventory: 1 worktree(s) recorded — lanes-edit.sh lane-trees repoQ-1 +--- stderr +lane-handoff: lane=repoQ-1 workstation=Eagle +lanes-edit: LANES_NO_FETCH=1 — not fetching; reading origin/main as the ref already stands here +lane-handoff: lane=repoQ-1 workstation=Eagle +lane-handoff: lifecycle: RUNNING -> SWAPPING (generation 2, operation ) +lane-handoff: writers polled: 1 under /projects/repoQ +lane-handoff: handoff refreshed: /projects/wip/handoffs/repoQ-1.md (+14 lines at the head) +lane-handoff: handoff commit: handoff(repoQ-1@Eagle): PAUSED +lane-handoff: handoff pushed to origin/main +lanes-edit: 1 line appended to /projects/wip/lanes/log/repoQ-1.md +lanes-edit: committed: LOG(repoQ-1@Eagle): PAUSED lane:repoQ-1 +lanes-edit: pushed origin/main (attempt 1) +lane-handoff: object log: PAUSED lane:repoQ-1 → swap; dir /projects/repoQ; workstation Eagle; agent claude; transcript none; kind unknown +lane-handoff: lifecycle: SWAPPING -> SWAPPED (generation 2, operation ) + +=== repoQ: lane-handoff: top block === +Lane: repoQ-1 (profile unknown, session -1111-4180-8180-) — single-use resume prompt: stamp RESUMED-by before acting (lane-collision-protocol rule 3) + +## RESUME PROMPT — PAUSED (t018 handoff), agent claude, transcript none, kind unknown — the count decides: this handoff cannot know whether the process survived + +**FIRST ACTS, in order.** (1) You are lane `repoQ-1`, checkout `/projects/repoQ`. Stamp this block RESUMED before anything else (Rule 3, single use) — `lane-start --no-launch ` in this window writes it. (2) Read AGENTS.md, then this block. (3) COUNT THE LIVE WRITERS before relaunching anything — the WRITERS section below is a list to COUNT, not a list to relaunch (Amendment 17 Addendum 1 (i)). ONE WORKTREE, ONE WRITER (k): a worktree is one writer's for as long as that writer is live, a relaunch onto a live writer's worktree is the writers' form of Amendment 18(h)'s second binding, and where you find one made you retire the newer before either commits. THIS HANDOFF CANNOT KNOW WHICH KIND FOLLOWED IT: a plain handoff may be followed by a `/clear` in this process or by a relaunch, so nothing below assumes either — the count is the answer. Older sections below are history. + +**STATE at .** Handed off by `lane-handoff` on Eagle: t018 handoff. Agent `claude`, transcript `none`. Kind `unknown`: the act that follows this record was not this command's to know (`kind unknown`, Addendum 1 (h)) — the restart line below is one of the two, and a `/clear` typed here is the other. + +**WRITERS at ** (1 found under `/projects/repoQ`) — FIRST count the live writers: `ListAgents` in Claude, the agent's equivalent elsewhere. A writer still live OWNS its worktree: do not relaunch it; one message telling it that its in-flight call died and who the coordinator now is, is enough. Relaunch only a writer that is NOT live, from where it stands — `git status` and `git log @{u}..` in its worktree, then its brief. WHERE THE COUNT AND THIS LIST DISAGREE, THE COUNT WINS: this list is what the paused session expected, the count is what is true. + +- `/projects/.lane-worktrees/repoQ-1/dep` — branch `dep`, last commit ` lane work in the dependency`, 0 dirty, 1 unpushed; brief: (fill in: the brief this writer was given) + +=== repoQ: lane-handoff: inventory === +exit 0 +--- stdout +/projects/.lane-worktrees/repoQ-1/depdeporigin/main01-1111-4180-8180-/projects/repoQ21 +--- stderr + +=== repoQ: lane-reconcile after === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoQ-1 +STATESWAPPEDgeneration 2operation owner -1111-4180-8180-updated +HOLDERnone +BINDINGfreenone +TREEunmanaged/projects/repoQ/worktrees/001-d/specgit registers it in /projects/repoQ and no sidecar of this lane names it; it is left exactly as it is +TREEunpushed/projects/.lane-worktrees/repoQ-1/depbranch dep head upstream origin/main 0 dirty 1 unpushed; observed 0/1 at +TREES1 inventoried1 dirty or unpushed0 require recovery1 unmanaged or stale +VERDICTresumablethe swap completed and no holder is live: the lane may be resumed once the trees below are read +--- stderr + diff --git a/tests/test_triad_lane_tooling_handoff.py b/tests/test_triad_lane_tooling_handoff.py new file mode 100644 index 00000000..2eeefb86 --- /dev/null +++ b/tests/test_triad_lane_tooling_handoff.py @@ -0,0 +1,339 @@ +# SPDX-License-Identifier: Apache-2.0 +"""T018 (b) (opensoft/openRepoTools#186): `lane-handoff` and `lane-reconcile` +see a TRIAD's trees. + +A lane's checkout is an ASSEMBLY when its own `project.yaml` declares a leg +other than itself - read by `lanes-edit.sh project-legs`, the one reader every +lane tool asks. Its legs are submodules, and a feature's trees are worktrees of +the LEGS' repositories, so before this (docs/triad-lane-tooling-analysis.md +§4.4 and §4.7): + + 1. `lane-handoff`'s writer poll never looked in the paired feature trees + `/worktrees//{spec,code}`, so a lane with unpushed work + there was absent from its WRITERS block; + 2. a handoff re-recorded a tree made by `lane-worktrees add --checkout + /code` with the ASSEMBLY as its checkout, so the leg `add` had + recorded was lost from the inventory at the first handoff; + 3. `lane-reconcile` recomputed from the checkout's own worktree registrations + and never read the legs', nor the paired root. + +Each is held here on an assembly built from openRepoShape's own template bytes +at the pinned commit, with every leg a real git repository behind a bare local +origin and a fake `gh` that exits 1 (the bench of `tests/test_triad_lane_tooling.py`, +imported, so the two modules build one assembly one way). AND THE CONDITION OF +ALL THREE: on a single repository - no `project.yaml`, or one naming no leg but +itself - the handoff's output, its top block, the inventory it writes and the +reconciliation are byte for byte what `c45a452`'s tools printed, even where the +repository is laid out to look like a triad (a submodule with a worktree under +`worktrees//code`, and a tree recorded with the submodule as its +checkout). That "before" is STORED (`tests/fixtures/t018-handoff/`), generated +ONCE from `c45a452`'s own tools by this module's `--capture`, because CI checks +out at depth 1; `tests/test_triad_lane_tooling.py`'s own golden covers the +ordinary single repository and keeps passing unregenerated. + +NOTHING REAL IS TOUCHED: every repository, origin, register, `$HOME` and state +directory is under `tmp_path`, and the network is never reached. +""" + +from __future__ import annotations + +import difflib +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from conftest import REPO, WINDOWS_SKIP # noqa: E402 +from test_triad_lane_tooling import ( # noqa: E402 + NEEDS_TEMPLATE, US, Bench, build_assembly, normalise, parse_golden, render_golden, shown) + +pytestmark = [WINDOWS_SKIP, pytest.mark.skipif( + shutil.which("git") is None or shutil.which("bash") is None, + reason="the lane tools are bash and Python over git")] + +GOLDEN = (Path(__file__).resolve().parent / "fixtures" / "t018-handoff" + / "single-repository-decoys.golden") + + +# ================================================================== helpers + +def inventory(b: Bench, lane: str) -> dict: + """{path: checkout} of the lane's #97 inventory, `lane-trees`' tenth field.""" + proc = b.tool("lanes-edit.sh", "lane-trees", lane) + assert proc.returncode in (0, 8), proc.stderr + rows = {} + for line in proc.stdout.splitlines(): + cols = line.split(US) + if len(cols) > 9: + rows[cols[1]] = cols[9] + return rows + + +def reconcile_rows(out: str) -> dict: + """{physical path: (class, why)} of `lane-reconcile`'s TREE rows. Keyed by + the PHYSICAL path, because git's registrations are spelled physically and + a recorded tree as it was polled (macOS keeps `$TMPDIR` under `/var`, a + symlink); look a path up with `real()`.""" + rows = {} + for line in out.splitlines(): + cols = line.split(US) + if cols[0] == "TREE" and len(cols) > 4: + rows[real(cols[3])] = (cols[2], cols[4]) + return rows + + +def real(path) -> str: + return os.path.realpath(str(path)) + + +def field(out: str, key: str) -> list: + return next((ln.split(US)[1:] for ln in out.splitlines() if ln.split(US)[0] == key), []) + + +def handoff(b: Bench, lane: str, cwd: Path, why: str = "t018 handoff") -> subprocess.CompletedProcess: + return b.tool("lane-handoff", "--lane", lane, "--transcript", "none", "--agent", "claude", + why, cwd=cwd) + + +def top_block(b: Bench, lane: str) -> str: + return (b.wip / "handoffs" / f"{lane}.md").read_text().split("\n---\n", 1)[0] + + +def leg_tree(b: Bench, leg: Path, path: Path, branch: str) -> Path: + """A worktree of `leg`'s repository at `path`, on a new branch from origin/main.""" + b.git("worktree", "add", "-q", "-b", branch, path, "origin/main", cwd=leg) + return path + + +@pytest.fixture +def triad(tmp_path): + b = Bench(tmp_path / "bench", REPO) + t = build_assembly(b) + b.lanes("opensoft/triad", t["assembly"], "triad-1", "triad-2") + return b, t + + +# ============================================================= (1) and (2) + +@NEEDS_TEMPLATE +def test_t018_the_handoff_polls_the_paired_trees_and_records_each_tree_under_its_leg(triad): + """(1) THE PAIRED TREES ARE POLLED: a paired code tree holding the lane's + unpushed commit and a paired spec tree holding a writer's first edit are in + the WRITERS block, and what under the paired root is not a checkout of its + own is not. (2) EACH TREE IS RECORDED UNDER ITS LEG: the code tree `add` + made with `--checkout /code` keeps the leg as its checkout through + the handoff, the paired trees are recorded under theirs, and a tree of the + assembly itself is still recorded under the assembly. The reconciliation + then reads every one of them as inventoried, never as unmanaged.""" + b, t = triad + a, code, spec = t["assembly"], t["code"], t["spec"] + made = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", code) + assert made.returncode == 0, made.stderr + lane_code = Path(made.stdout.strip()) + assert inventory(b, "triad-1")[str(lane_code)] == str(code) + b.commit(lane_code, "the slice's code", {"c1.txt": "c1\n"}, lane="triad-1") + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + paired_code = leg_tree(b, code, a / "worktrees" / "007-y" / "code", "007-y") + b.commit(paired_code, "the feature's code", {"f.txt": "f\n"}, lane="triad-1") + paired_spec = leg_tree(b, spec, a / "worktrees" / "007-y" / "spec", "007-y") + (paired_spec / "draft.md").write_text("a writer's first edit\n") + # under the paired root and NOT a checkout of its own: the assembly's tree + (a / "worktrees" / "007-y" / "notes").mkdir() + (a / "worktrees" / "007-y" / "notes" / "n.md").write_text("n\n") + + proc = handoff(b, "triad-1", a) + assert proc.returncode == 0, proc.stdout + proc.stderr + for tree, branch in ((lane_code, "c1"), (lane_root, "root"), (paired_code, "007-y"), + (paired_spec, "007-y")): + assert f"WRITER {tree} — branch {branch}" in proc.stdout, proc.stdout + for not_one in (a / "worktrees" / "007-y" / "notes", a / "worktrees" / "007-y"): + assert f"WRITER {not_one} —" not in proc.stdout, proc.stdout + assert f"writers polled: 4 under {a}" in proc.stderr, proc.stderr + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + assert "NOT POLLED" not in proc.stdout + proc.stderr + + block = top_block(b, "triad-1") + assert f"(4 found under `{a}`)" in block, block + assert (f"- `{paired_code}` — branch `007-y`, last commit" in block + and "0 dirty, 1 unpushed" in block.split(f"`{paired_code}`", 1)[1].split("\n", 1)[0]), block + assert "1 dirty, 0 unpushed" in block.split(f"`{paired_spec}`", 1)[1].split("\n", 1)[0], block + + inv = inventory(b, "triad-1") + assert inv == {str(lane_code): str(code), str(lane_root): str(a), + str(paired_code): str(code), str(paired_spec): str(spec)}, inv + + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + assert rec.returncode == 0, rec.stderr + rows = reconcile_rows(rec.stdout) + assert rows[real(paired_code)][0] == "unpushed", rows + assert rows[real(paired_spec)][0] == "dirty", rows + assert not [p for p, (cls, _w) in rows.items() if cls in ("unmanaged", "stale-registration")], rows + assert field(rec.stdout, "TREES")[0] == "4 inventoried", rec.stdout + + +@NEEDS_TEMPLATE +def test_t018_a_leg_that_is_not_checked_out_claims_no_tree_of_the_assembly(triad): + """A LEG THAT IS NOT CHECKED OUT is an empty directory of the assembly's, + where git answers the ASSEMBLY - so read naively, its repository would be + the assembly's and every tree of the assembly would be recorded under it. + Deinitialized, the spec leg names no tree; the code leg still names its own.""" + b, t = triad + a, code = t["assembly"], t["code"] + b.git("submodule", "deinit", "-q", "spec", cwd=a) + assert not any((a / "spec").iterdir()) + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + paired_code = leg_tree(b, code, a / "worktrees" / "008-z" / "code", "008-z") + proc = handoff(b, "triad-1", a) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"writers polled: 2 under {a}" in proc.stderr, proc.stderr + assert inventory(b, "triad-1") == {str(lane_root): str(a), str(paired_code): str(code)} + + +@NEEDS_TEMPLATE +def test_t018_a_manifest_the_handoff_cannot_read_is_said_and_the_handoff_goes_on(triad): + """A `project.yaml` that is there and cannot be read is never "no legs" + (Amendment 7(d)), and a swap is never left unwritten (`R-A11-11`): the + handoff exits 0, the paired root is NOT polled and the top block says so, + a tree of the assembly's own repository is recorded as always, a tree of a + leg keeps the checkout `add` recorded rather than one nobody could + establish, and the lane stays SWAPPING.""" + b, t = triad + if os.geteuid() == 0: + pytest.skip("root reads a mode-000 file") + a, code = t["assembly"], t["code"] + made = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", code) + assert made.returncode == 0, made.stderr + lane_code = Path(made.stdout.strip()) + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + paired_code = leg_tree(b, code, a / "worktrees" / "009-w" / "code", "009-w") + b.commit(paired_code, "the feature's code", {"w.txt": "w\n"}, lane="triad-1") + os.chmod(a / "project.yaml", 0) + try: + proc = handoff(b, "triad-1", a, "unreadable manifest") + finally: + os.chmod(a / "project.yaml", 0o644) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"{a}/project.yaml is there and its legs could not be read" in proc.stderr, proc.stderr + assert "paired feature trees" in proc.stderr and "were NOT polled" in proc.stderr + assert f"WRITER {paired_code} —" not in proc.stdout, proc.stdout + assert f"WRITER {lane_code} —" in proc.stdout and f"WRITER {lane_root} —" in proc.stdout + assert f"the worktree inventory entry for {lane_code} was NOT written" in proc.stderr + assert "lifecycle: SWAPPING" in proc.stdout, proc.stdout + assert "the assembly's legs could not be read" in proc.stderr + block = top_block(b, "triad-1") + assert "- NOT POLLED: " in block and f"`{a}/worktrees`" in block, block + assert inventory(b, "triad-1") == {str(lane_code): str(code), str(lane_root): str(a)} + + +# ===================================================== the single repository + +def single_repository_decoys(bench: Bench) -> list: + """THE SCENARIO WHOSE OUTPUTS MAY NOT MOVE, laid out to look like a triad: + a repository with a submodule that is NO leg, a worktree of that submodule + under `worktrees//code` and one of the repository itself under + `worktrees//spec`, and a tree under the lane's own root recorded + with the SUBMODULE as its checkout - first with no `project.yaml`, then with + one naming no leg but the checkout itself. The handoff's output, its top + block and inventory, and the reconciliation before and after it.""" + b = bench + out = [] + dep = b.seeded("dep", {"d.txt": "a dependency\n"}) + + def scenario(name: str, manifest: str | None) -> None: + lane = f"{name}-1" + b.origin(name) + s = b.projects / name + b.git("clone", "-q", b.remotes / f"{name}.git", s) + files = {"README.md": f"{name}\n", ".gitignore": "worktrees/\n"} + if manifest is not None: + files["project.yaml"] = manifest + b.commit(s, "seed", files) + b.git("submodule", "add", "-q", dep, "vendor/dep", cwd=s) + b.commit(s, "a submodule that is no leg") + b.git("push", "-q", "origin", "main", cwd=s) + b.lanes(f"opensoft/{name}", s, lane) + vendor = s / "vendor" / "dep" + decoy_code = leg_tree(b, vendor, s / "worktrees" / "001-d" / "code", "001-d") + b.commit(decoy_code, "decoy code", {"x.txt": "x\n"}, lane=lane) + decoy_spec = leg_tree(b, s, s / "worktrees" / "001-d" / "spec", "001-d") + (decoy_spec / "y.md").write_text("y\n") + mine = leg_tree(b, vendor, b.projects / ".lane-worktrees" / lane / "dep", "dep") + b.commit(mine, "lane work in the dependency", {"m.txt": "m\n"}, lane=lane) + rec = b.tool("lanes-edit.sh", "set-lane-tree", lane, mine, "--checkout", vendor) + assert rec.returncode == 0, rec.stderr + out.append((f"{name}: lane-reconcile before", reconciled(b, lane))) + out.append((f"{name}: lane-handoff", shown(handoff(b, lane, s), b))) + out.append((f"{name}: lane-handoff: top block", normalise(top_block(b, lane), b))) + trees = b.tool("lanes-edit.sh", "lane-trees", lane) + trees.stdout = "".join(sorted(trees.stdout.splitlines(keepends=True), + key=lambda line: normalise(line, b))) + out.append((f"{name}: lane-handoff: inventory", shown(trees, b))) + out.append((f"{name}: lane-reconcile after", reconciled(b, lane))) + + scenario("repoD", None) + scenario("repoQ", ("schema_version: 1\nkind: project-manifest\nid: repoQ\nlegs:\n" + " - role: assembly\n repository: opensoft/repoQ\n path: \".\"\n")) + return out + + +def reconciled(b: Bench, lane: str) -> str: + """`lane-reconcile`, its TREE rows in a stable order: the inventoried ones + come in the order of their sidecars' file names, a checksum of the bench's + own path, so every row is sorted by its normalised text, in place.""" + proc = b.tool("lanes-edit.sh", "lane-reconcile", lane) + lines = proc.stdout.splitlines(keepends=True) + idx = [i for i, ln in enumerate(lines) if ln.startswith("TREE" + US)] + for i, ln in zip(idx, sorted((lines[i] for i in idx), key=lambda ln: normalise(ln, b))): + lines[i] = ln + proc.stdout = "".join(lines) + return shown(proc, b) + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), + reason="the golden was captured on Linux, as tests/test_triad_lane_tooling.py's") +def test_t018_single_repository_handoff_and_reconcile_are_c45a452s_byte_for_byte(tmp_path): + """THE INVARIANT, ON A REPOSITORY LAID OUT TO LOOK LIKE A TRIAD: with no + `project.yaml` naming a leg, a `worktrees//code` holding a + submodule's worktree is not polled or reported as a paired tree, and a tree + of a submodule is recorded under the lane's checkout, exactly as + `c45a452`'s tools did - every byte of every output equal to the stored + golden this module's `--capture` wrote from them.""" + assert GOLDEN.is_file(), f"{GOLDEN} is missing; it is generated from c45a452's tools" + want = parse_golden(GOLDEN.read_text(encoding="utf-8")) + got = single_repository_decoys(Bench(tmp_path / "bench", REPO)) + assert [k for k, _ in got] == [k for k, _ in want] + for (key, have), (_k, gold) in zip(got, want): + if have != gold: + diff = "\n".join(difflib.unified_diff(gold.splitlines(), have.splitlines(), + f"c45a452: {key}", f"now: {key}", lineterm="")) + raise AssertionError(f"the single-repository output of '{key}' moved:\n{diff}") + + +# ===================================================================== capture + +def capture(tools: Path, golden: Path) -> None: + """Write the golden file from the tools in `tools` - run ONCE, against + `c45a452`'s (`git archive c45a452 | tar -x -C `), and committed.""" + with tempfile.TemporaryDirectory(prefix="t018-handoff-capture.") as tmp: + text = render_golden(single_repository_decoys(Bench(Path(tmp) / "bench", tools))) + golden.parent.mkdir(parents=True, exist_ok=True) + golden.write_text(text, encoding="utf-8") + + +if __name__ == "__main__": + if len(sys.argv) in (3, 4) and sys.argv[1] == "--capture": + capture(Path(sys.argv[2]).resolve(), Path(sys.argv[3]) if len(sys.argv) == 4 else GOLDEN) + sys.exit(0) + sys.stderr.write("usage: test_triad_lane_tooling_handoff.py --capture []\n") + sys.exit(64) From 9a5bbe24d02f26a942c07b21748f5a2375faca21 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:15:36 +0000 Subject: [PATCH 09/42] =?UTF-8?q?T018=20(b):=20lane-reconcile=20reads=20a?= =?UTF-8?q?=20triad's=20legs=20=E2=80=94=20each=20leg's=20worktree=20regis?= =?UTF-8?q?trations=20and=20the=20paired=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan task T018 (opensoft/openRepoTools#186), slice (d2), part (b), second half: the third gap docs/triad-lane-tooling-analysis.md §4.4 measured. `lanes-edit.sh lane-reconcile` read only the checkout's own worktree registrations and the two lane roots, so a tree of a leg's repository that no sidecar names was invisible to it. SHAPE-GATED, through `assembly_legs` (2aa8a59's shared reader, called as it is): - EVERY CHECKED-OUT LEG'S REGISTRATIONS, read beside the checkout's own. Each worktree git registers in a leg that no sidecar names is a TREE row: `unmanaged`, or `stale-registration` where its directory is gone. The row names the leg ("git registers it in , the code leg of /project.yaml ()"). The leg's main entry, which git spells as its git directory under `.git/modules/`, is skipped by place. A leg that is not checked out (git answers the assembly there) registers nothing of its own and is passed over. - THE PAIRED ROOT, after the lane roots: `/worktrees/` and `/`, each holding a `.git` of its own that nothing above named (a lost registration, or a clone where a worktree belongs), is `unmanaged`. - PATHS NOBODY READ ARE NOT CLEARED: a project.yaml that cannot be read, a leg whose `git worktree list` fails, or a paired root or feature directory that cannot be listed makes the verdict `indeterminate`. The unreadable manifest is said in the words `lane-worktrees`' sweep refuses with ("the legs of the assembly could not be read"), because the sweep reads this verdict first and refuses on it. The previous run's one failure, tests/test_triad_lane_tooling.py:616, was exactly that: same exit 2, the reconciliation's wording instead of the sweep's. One fact now has one spelling, and that test passes unchanged. Nothing is adopted, deleted or overwritten, as before. THE GATE: a checkout with no project.yaml is asked nothing. One whose project.yaml names no leg but itself gets `assembly_legs`' 8 and nothing else. Every row, count and verdict on a single repository is what it was. THE PROOF, under tests/run.sh, this exact tree (lanes-edit.sh and the test module last written 2026-10-10T02:43:15Z, the run started 02:43:34Z): tests/run.sh -k "t018 or supervised_restart or lanes_index or repo_hygiene or lane_start_claude_current or openrepotools_command" -rs -> 544 passed, 768 deselected in 1318.62s (0:21:58), on a quiet box The 544 include both T018 modules. Two goldens stored from c45a452's tools pass UNREGENERATED: tests/fixtures/t018/single-repository.golden (byte identical) and tests/fixtures/t018-handoff/single-repository-decoys.golden. The latter covers lane-reconcile before and after a handoff on a repository laid out to look like a triad. Also included are the supervised-restart module, the lanes-index module (the real lane-reconcile), lane-start's module (it calls lane-reconcile), hygiene and the installer module. Two new cases are in tests/test_triad_lane_tooling_handoff.py, on openRepoShape's template bytes at the pin: the legs' registrations and the paired root are reported (a spec tree made away from every root, a paired code worktree, a clone in the paired root, a stale registration; neither leg nor its `.git/modules` entry reported), and the indeterminate verdicts (unreadable manifest, unlistable paired root). Against 96da9af's lanes-edit.sh both fail (measured). The earlier run of this half, before the wording fix: 1 failed, 446 passed, 865 deselected in 2718.97s (the :616 case above). Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 110 ++++++++++++++++++++++- tests/test_triad_lane_tooling_handoff.py | 77 ++++++++++++++++ 2 files changed, 186 insertions(+), 1 deletion(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index b7b34305..a2523d31 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -12760,7 +12760,10 @@ lane_real_path() { # # THE LANE'S TWO WORKTREE ROOTS — the same two `lane-handoff` polls, and they # are here rather than there so the poll and the reconciliation cannot come to -# disagree about where a lane keeps its writers. +# disagree about where a lane keeps its writers. Where the lane's checkout is an +# ASSEMBLY (T018) both also read its paired root, `/worktrees/ +# /`, which is two levels deep and per feature, so it +# is read beside these two (`lane_reconcile`, step 4) rather than as a third. lane_worktree_roots() { # lwr_lane="${1-}"; lwr_dir="${2-}" [ -n "$lwr_dir" ] || return 0 @@ -13070,6 +13073,22 @@ EOF # from the two lane roots on disk. It is REPORTED and never adopted, deleted # or overwritten: which lane a tree belongs to is a person's to say. lrc_unmanaged=0 + # A TRIAD (opensoft/openRepoTools#186, plan task T018). WHERE THE LANE'S + # CHECKOUT IS AN ASSEMBLY — its own `project.yaml` declares a leg other than + # itself, read by `assembly_legs`, the one reader every lane tool asks — what + # its LEGS hold is read too: the worktrees git registers in each leg's + # repository, which `git worktree list` in the assembly never names, and the + # paired feature trees `/worktrees//`. + # Both are reported exactly as the checkout's own registrations and the lane + # roots are, and adopted, deleted or overwritten no more. A checkout with no + # `project.yaml` is asked nothing, so its report is what it always was; a + # manifest, a leg's registrations or the paired root that could not be read + # leaves paths nobody read, and the verdict below says so. + lrc_legs=""; lrc_lgrc=8; lrc_legwhy=""; lrc_tab="$(printf '\t')" + if [ -n "$lrc_dir" ] && [ -d "$lrc_dir" ] && { [ -e "$lrc_dir/project.yaml" ] || [ -L "$lrc_dir/project.yaml" ]; }; then + lrc_lgrc=0 + lrc_legs="$(assembly_legs "$lrc_dir")" || lrc_lgrc=$? + fi if [ -n "$lrc_dir" ] && [ -d "$lrc_dir" ]; then # THE CHECKOUT'S OWN ROW IS NOT AN UNMANAGED TREE, AND git ANSWERS WITH THE # PHYSICAL PATH. A recorded `dir` reached through a symlink — which is how @@ -13104,6 +13123,46 @@ EOF done </dev/null || :) EOF + fi + # T018: EVERY LEG'S REGISTRATIONS, beside the checkout's own. A leg that is + # not checked out here is an empty directory of the assembly's, where git + # answers the ASSEMBLY — it registers nothing of its own, so it is passed + # over. A leg's main worktree is listed FIRST and spelled as its git + # directory under the assembly's `.git/modules/`, so it is skipped by place, + # not by name. + if [ "$lrc_lgrc" = 0 ]; then + while IFS="$lrc_tab" read -r lrc_lrepo lrc_ldecl lrc_labs; do + [ -n "$lrc_labs" ] && [ -d "$lrc_labs" ] || continue + lrc_ltop="$(git -C "$lrc_labs" rev-parse --show-toplevel 2>/dev/null /dev/null could + # not be read`), because the sweep reads this verdict FIRST and refuses on + # it: one fact, one spelling, whichever reader a person meets. + if [ "$lrc_lgrc" != 0 ] && [ "$lrc_lgrc" != 8 ] && [ "$lrc_v" != indeterminate ]; then + lrc_v=indeterminate + lrc_why="the legs of the assembly $lrc_dir could not be read (its project.yaml is there and unreadable), so which legs it declares — and the worktrees they register and the paired feature trees under $lrc_dir/worktrees — is NOT established, and no clearance is pronounced over paths nobody read (T018, Amendment 7(d))" + fi + if [ -n "$lrc_legwhy" ] && [ "$lrc_v" != indeterminate ]; then + lrc_v=indeterminate + lrc_why="lane $lrc_lane's checkout $lrc_dir is an assembly and not all of what its legs hold could be read ($lrc_legwhy), so no clearance is pronounced over paths nobody read (T018, Amendment 7(d))" + fi printf 'TREES%s%s inventoried%s%s dirty or unpushed%s%s require recovery%s%s unmanaged or stale\n' \ "$US" "$lrc_n" "$US" "$lrc_dirty" "$US" "$lrc_recover" "$US" "$lrc_unmanaged" printf 'VERDICT%s%s%s%s\n' "$US" "$lrc_v" "$US" "$lrc_why" diff --git a/tests/test_triad_lane_tooling_handoff.py b/tests/test_triad_lane_tooling_handoff.py index 2eeefb86..43cf4e59 100644 --- a/tests/test_triad_lane_tooling_handoff.py +++ b/tests/test_triad_lane_tooling_handoff.py @@ -236,6 +236,83 @@ def test_t018_a_manifest_the_handoff_cannot_read_is_said_and_the_handoff_goes_on assert inventory(b, "triad-1") == {str(lane_code): str(code), str(lane_root): str(a)} +# ===================================================================== (3) + +@NEEDS_TEMPLATE +def test_t018_reconcile_reports_what_the_legs_hold_and_no_sidecar_names(triad): + """(3) THE LEGS' REGISTRATIONS AND THE PAIRED ROOT ARE READ: a spec tree + made away from every root, a paired code tree, a clone where a paired + worktree belongs and a registration whose directory is gone are each + reported - unmanaged or stale - naming the leg, while a tree the inventory + records is not, and neither leg's own checkout is.""" + b, t = triad + a, code, spec = t["assembly"], t["code"], t["spec"] + away = leg_tree(b, spec, b.projects / "away-spec", "away") + paired = leg_tree(b, code, a / "worktrees" / "010-v" / "code", "010-v") + clone = a / "worktrees" / "010-v" / "spec" + b.git("clone", "-q", b.remotes / "triad-spec.git", clone) + gone = leg_tree(b, code, b.projects / "gone-code", "gone") + shutil.rmtree(gone) + made = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", code) + assert made.returncode == 0, made.stderr + recorded = made.stdout.strip() + + proc = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + assert proc.returncode == 0, proc.stderr + rows = reconcile_rows(proc.stdout) + assert rows[real(away)][0] == "unmanaged", rows + assert f"git registers it in {spec}, the spec leg of {a}/project.yaml (opensoft/triad-spec)" \ + in rows[real(away)][1], rows + assert rows[real(paired)][0] == "unmanaged", rows + assert f"the code leg of {a}/project.yaml" in rows[real(paired)][1], rows + assert rows[real(clone)][0] == "unmanaged", rows + assert f"paired feature trees ({a}/worktrees)" in rows[real(clone)][1], rows + assert rows[real(gone)][0] == "stale-registration", rows + assert f"git -C {code} worktree prune" in rows[real(gone)][1], rows + assert rows[real(recorded)][0] == "ok", rows + for not_a_tree in (a, code, spec, a / ".git" / "modules" / "code", a / ".git" / "modules" / "spec", + a / "worktrees" / "010-v"): + assert real(not_a_tree) not in rows, rows + assert field(proc.stdout, "TREES") == ["1 inventoried", "0 dirty or unpushed", + "0 require recovery", "4 unmanaged or stale"], proc.stdout + assert field(proc.stdout, "VERDICT")[0] == "ungraceful-stop", proc.stdout + + +@NEEDS_TEMPLATE +def test_t018_reconcile_pronounces_no_clearance_over_legs_nobody_read(triad): + """A manifest, or the paired root, that could not be read leaves paths + nobody inspected: the verdict is `indeterminate`, never a clearance.""" + b, t = triad + if os.geteuid() == 0: + pytest.skip("root reads a mode-000 file") + a, code = t["assembly"], t["code"] + paired = leg_tree(b, code, a / "worktrees" / "011-u" / "code", "011-u") + os.chmod(a / "project.yaml", 0) + try: + proc = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + finally: + os.chmod(a / "project.yaml", 0o644) + assert proc.returncode == 0, proc.stderr + verdict = field(proc.stdout, "VERDICT") + assert verdict[0] == "indeterminate", proc.stdout + assert (f"the legs of the assembly {a} could not be read (its project.yaml is there and " + "unreadable)") in verdict[1], verdict + assert real(paired) not in reconcile_rows(proc.stdout) + # the paired root that cannot be listed: a clone stands in it, which only + # the listing could find (a registered tree is found through its leg) + b.git("worktree", "remove", paired, cwd=code) + b.git("clone", "-q", b.remotes / "triad-code.git", a / "worktrees" / "012-t" / "code") + os.chmod(a / "worktrees", 0) + try: + proc = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + finally: + os.chmod(a / "worktrees", 0o755) + assert proc.returncode == 0, proc.stderr + verdict = field(proc.stdout, "VERDICT") + assert verdict[0] == "indeterminate", proc.stdout + assert f"the assembly's paired root {a}/worktrees could not be listed" in verdict[1], verdict + + # ===================================================== the single repository def single_repository_decoys(bench: Bench) -> list: From b71e547d0f16693e9500fb5065cbfe0293a66c94 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:48:46 +0000 Subject: [PATCH 10/42] =?UTF-8?q?WIP=20(untested):=20T018=20(b)=20guidance?= =?UTF-8?q?=20=E2=80=94=20README-lanes=20says=20what=20lane-handoff=20and?= =?UTF-8?q?=20lane-reconcile=20read=20in=20a=20triad,=20writer=202's=20dra?= =?UTF-8?q?ft=20checked=20against=209a5bbe2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writer 2's paragraph (briefs/t018-b-guidance.md, 2026-10-09T21:53Z) after "`lane-end`'s gate" in the T018 section, each sentence checked against the merged lane-handoff (4e39854) and lane_reconcile (9a5bbe2). Two corrections: the unreadable-manifest verdict is spelled in the sweep's words, which the sweep reads first and refuses on (exit 2); and a project.yaml naming no leg but itself is asked one project-legs call (answering 8), not nothing. Untested: committed for an account swap while the merged tree's scoped run was in flight; that run's counts follow in the next commit. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 741f7156..5e0cbfbe 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3922,6 +3922,43 @@ names, with the remedy a person's (commit and push from inside it; no `--yes` is offered when legs are all it counted), and lists a leg that holds work and is not this lane's below the table as NOT counted — whether the gate passes or not. +**`lane-handoff` and `lane-reconcile` (T018 (b)).** Where the lane's checkout is an +assembly, `lane-handoff` also polls the **paired root**: `/worktrees/` +and `/`. It polls every directory there with a `.git` +of its own, exactly as it polls `.claude/worktrees`, because the paired root is per +feature, not per lane, and a writer with nothing committed yet has no trailer to be +found by. So a writer in a paired tree is in the WRITERS block. Every tree it polls is +**recorded under the checkout it belongs to**. A worktree of a leg's repository (a +paired tree, or one `lane-worktrees add --checkout /code` made under the +lane's root) is recorded under that leg, the path `project-legs` prints and `add` +records. Any other tree keeps the lane's `dir`, as before. A leg that is not checked +out here (never initialized, or deinitialized) names no tree. In an assembly several +lanes share, each lane's handoff records every paired tree, as it records every tree in +`.claude/worktrees`. A tree two lanes' inventories both name is `keep` in both sweeps +until a person says whose it is. A worktree a leg registers anywhere else is not polled +as a writer, just as the checkout's own registrations elsewhere are not; +`lanes-edit.sh lane-reconcile` reports it. The reconciliation reads what the legs hold +beside what the checkout holds. Every worktree git registers in a checked-out leg that +no sidecar names is a `TREE` row: `unmanaged`, or `stale-registration` where its +directory is gone, naming the leg. A checkout in the paired root that nothing else named +is `unmanaged`. Nothing is adopted, deleted or overwritten. + +**A `project.yaml` that cannot be read is never "no legs".** `lane-handoff` still +completes (`R-A11-11`), but it does not poll the paired root. It says so in the top +block's WRITERS section (`- NOT POLLED: …`), files no sidecar for a tree it cannot +place (a tree of the lane's own repository is still recorded), and leaves the lane +`SWAPPING`. The reconciliation's verdict is `indeterminate`, said in the words the +sweep refuses with ("the legs of the assembly `` could not be read"): the sweep +reads that verdict first and refuses on it, exit **2**. The verdict is `indeterminate` +too where a leg's registrations, the paired root or a feature directory under it +cannot be listed. **A checkout with no `project.yaml` is asked nothing**, and one whose +`project.yaml` names no leg but itself is asked only `project-legs`, which answers 8. +The handoff's output, top block and inventory, and the reconciliation, are byte for +byte what they were. `tests/test_triad_lane_tooling_handoff.py` holds them to the +output `c45a452`'s tools printed, on a repository laid out to look like a triad: a +submodule's worktree under `worktrees//code`, and a tree recorded with the +submodule as its checkout. + ### Two protocol lines this act assumes (proposed for the amendment that ratifies it) 1. **A lane creates worktrees, never clones.** Every tree a lane works in is a From 9991d697849b5aee070ffe1ea3c451ef0314ef05 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:48:46 +0000 Subject: [PATCH 11/42] =?UTF-8?q?WIP=20(untested):=20T018=20(d)=20?= =?UTF-8?q?=E2=80=94=20add's=20refusal=20on=20an=20unreadable=20manifest?= =?UTF-8?q?=20is=20tested,=20and=20spelled=20as=20the=20sweep=20and=20lane?= =?UTF-8?q?-reconcile=20spell=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_t018_add_refuses_where_the_manifest_cannot_be_read holds 13cb52d's README claim (mode-000 project.yaml: exit 2, no tree, no inventory row). The refusal reads "the legs of the assembly could not be read", the sweep's and 9a5bbe2's words, in place of "the legs of the lane's assembly ". Reached only where /project.yaml is there and project-legs fails on it; a checkout with no project.yaml is asked nothing. Untested: committed for an account swap while the merged tree's scoped run was in flight; that run's counts follow in the next commit. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lane-worktrees | 2 +- tests/test_triad_lane_tooling.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lane-worktrees b/lane-worktrees index 53a7c42b..ab9bd286 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -5900,7 +5900,7 @@ def add_tree(ns) -> int: record_co, leg_line, assembly_hint = checkout, "", "" legs, why = project_legs(helper, lane_dir) if why: - return refuse(f"the legs of the lane's assembly {lane_dir} could not be read, so which " + return refuse(f"the legs of the assembly {lane_dir} could not be read, so which " f"checkout a tree of it belongs to is unknown (Amendment 7(d)): {why}") if legs: made_from = git_common_dir_of(checkout) diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py index b19e15d9..42c36529 100644 --- a/tests/test_triad_lane_tooling.py +++ b/tests/test_triad_lane_tooling.py @@ -708,6 +708,26 @@ def test_t018_add_from_the_assembly_itself_says_its_legs_are_empty(triad): assert _inventory(b, "triad-1")[made.stdout.strip()] == str(a) + +@NEEDS_TEMPLATE +def test_t018_add_refuses_where_the_manifest_cannot_be_read(triad): + """Never "no legs" (Amendment 7(d)): which checkout the tree would be + recorded under is unknown, so nothing is made, in the words the sweep + and the reconciliation use for the same fact.""" + b, t = triad + if os.geteuid() == 0: + pytest.skip("root reads a mode-000 file") + os.chmod(t["assembly"] / "project.yaml", 0) + try: + proc = b.tool("lane-worktrees", "add", "triad-1", "c9", "--checkout", t["code"]) + finally: + os.chmod(t["assembly"] / "project.yaml", 0o644) + assert proc.returncode == 2, proc.stderr + assert (f"REFUSED, nothing was made: the legs of the assembly {t['assembly']} could not " + "be read") in proc.stderr + assert not (t["assembly"].parent / ".lane-worktrees" / "triad-1" / "c9").exists() + assert not any(p.endswith("/c9") for p in _inventory(b, "triad-1")) + # ===================================================================== capture def capture(tools: Path, golden: Path) -> None: From c9ffdce04615a196ddea7ce8b154efa52c97adf7 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:00:30 +0000 Subject: [PATCH 12/42] WIP: checkpoint before account swap; done: #194 proving test written, not yet run; left: run it, the commit_push fix, both RECOVERY texts, the reset warning, manual lines, PR done: tests/test_commit_push_published_edit_194.py, the proving test for opensoft/openRepoTools#194, written first and not yet run (the run queued behind another lane's full suite on the shared pytest lock and was stopped for the swap). Six cases: the issue's own sequence end to end (exit 3, the peer commits its file, the printed re-run must publish); a clean checkout with nothing ahead stays a no-op with no network call; a checkout behind origin stays a no-op; a publish past a peer's dirty file keeps the dirty-elsewhere guard; the conflict RECOVERY warns that reset --hard drops a peer's unpushed commit; refuse_dirty_checkout names the act to re-run. left: run the test on unmodified lanes-edit.sh to see it fail; the fix in commit_push (when nothing is staged and origin/..HEAD is non-empty, go on to the push loop); the two RECOVERY texts (commit_push's dirty-elsewhere exit 3, refuse_dirty_checkout); the reset --hard warning in the conflict RECOVERY; the matching manual lines in docs/README-lanes.md (about :95-97 and :198-211); two test nits (the pull net-call check should not depend on the -C path spelling; the warning/drops line order is provisional until the text is final); then green, push, PR, CI. Co-Authored-By: Claude Opus 5.5 --- tests/test_commit_push_published_edit_194.py | 316 +++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 tests/test_commit_push_published_edit_194.py diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py new file mode 100644 index 00000000..757e6c1c --- /dev/null +++ b/tests/test_commit_push_published_edit_194.py @@ -0,0 +1,316 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A register edit that is already a local commit is PUBLISHED by the re-run +its own RECOVERY prints (opensoft/openRepoTools#194). + +`commit_push` returned at *"nothing staged … no commit made"* BEFORE its push +loop. So when a peer's uncommitted file had blocked the rebase and the helper +had exited 3 with the edit as a local commit, the RECOVERY it printed — the +peer commits its file, then `LANES_LANE= lanes-edit.sh commit ""` — staged nothing, exited 0 and pushed nothing. The commit stayed local +until some later write from that checkout happened to carry it. + +Every run here is the real `lanes-edit.sh` in a sandbox: a bare origin, the +workstation's shared workspace checkout `wip` that `workspace.yaml` names, and +a second clone `peer` standing in for another workstation. `LANES_GIT` is a +wrapper that logs every NETWORK call the helper makes and runs the real git, +so a case can say whether the push loop was reached at all. Nothing reaches a +real remote. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from conftest import REPO, WINDOWS_SKIP + +pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, + reason="lanes-edit.sh is a bash script"), + pytest.mark.skipif(shutil.which("git") is None, + reason="the register is a git repository"), + WINDOWS_SKIP] + +LANE = "repoP-1" +PEER_LANE = "repoQ-1" +REGISTER = "lanes/LANES.md" +PEER_HANDOFF = f"handoffs/{PEER_LANE}.md" +SUBJECT = "row rewritten by hand" + +#: The environment a case must not inherit from the host that runs it. +SCRUBBED_PREFIXES = ("LANES_", "LANE_START_", "CLAUDE_", "OPENREPOTOOLS_", + "FAKE_", "GIT_", "TMUX") +SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", + "XDG_CACHE_HOME"} + +NET_GIT = """#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$FAKE_NET_LOG" +exec git "$@" +""" + +FAKE_GH = """#!/usr/bin/env bash +printf 'FAKE gh REFUSED: %s -- this suite reaches no GitHub surface\\n' "$*" >&2 +exit 90 +""" + + +def _row(lane: str, state: str) -> str: + return (f"| `{lane}` | `{lane}-uuid` / window `{lane}` | Eagle / WSL2 / tester " + f"| 2026-10-11T00:00Z | — | handoffs/{lane}.md | {state} |") + + +class Box: + """One workstation's shared workspace checkout, its origin and a peer.""" + + def __init__(self, root: Path) -> None: + self.root = root + self.home = root / "home" + self.origin = root / "origin.git" + self.wip = self.home / "projects" / "wip" + self.peer = root / "peer" + self.net_log = root / "net.log" + fakebin = root / "fakebin" + for d in (self.home / ".agents", self.home / "projects", root / "tmp", fakebin): + d.mkdir(parents=True, exist_ok=True) + self.net_log.write_text("", encoding="utf-8") + for name, text in (("net-git", NET_GIT), ("gh", FAKE_GH)): + (fakebin / name).write_text(text, encoding="utf-8") + (fakebin / name).chmod(0o755) + + env = {k: v for k, v in os.environ.items() + if k not in SCRUBBED and not k.startswith(SCRUBBED_PREFIXES)} + env.update({ + "HOME": str(self.home), + "TMPDIR": str(root / "tmp"), + "XDG_CONFIG_HOME": str(self.home / ".config"), + "XDG_CACHE_HOME": str(self.home / ".cache"), + "PATH": f"{fakebin}{os.pathsep}{env.get('PATH', '')}", + "AGENT_PROTOCOL_ROOT": str(self.home / ".agents"), + "LANES_WORKSTATION": "Eagle", + "LANES_NO_GITHUB": "1", + "LANES_INDEX": "off", + "LANES_GIT": str(fakebin / "net-git"), + "FAKE_NET_LOG": str(self.net_log), + "GIT_AUTHOR_NAME": "commit_push tests", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "commit_push tests", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + }) + self.env = env + + self.git("init", "-q", "--bare", "-b", "main", str(self.origin)) + self.git("clone", "-q", str(self.origin), str(self.wip)) + self.write(self.wip, REGISTER, + "# LANES.md -- the sandbox register\n\n" + "| lane | session id | workstation / env / user | started (UTC) " + "| objects owned | handoff path | state |\n" + "|---|---|---|---|---|---|---|\n" + f"{_row(LANE, 'LIVE · seeded')}\n" + f"{_row(PEER_LANE, 'LIVE · seeded')}\n") + self.write(self.wip, PEER_HANDOFF, f"# {PEER_LANE}\n") + self.git("-C", str(self.wip), "add", "--", REGISTER, PEER_HANDOFF) + self.git("-C", str(self.wip), "commit", "-q", "-m", "seed") + self.git("-C", str(self.wip), "push", "-q", "origin", "main") + self.git("clone", "-q", str(self.origin), str(self.peer)) + (self.home / ".agents" / "workspace.yaml").write_text( + f"repository: {self.origin}\npath: {self.wip}\n", encoding="utf-8") + + def git(self, *args: str) -> str: + return subprocess.run(["git", *args], env=self.env, check=True, + capture_output=True, text=True).stdout + + def wgit(self, *args: str) -> str: + return self.git("-C", str(self.wip), *args) + + def ogit(self, *args: str) -> str: + return self.git("--git-dir", str(self.origin), *args) + + @staticmethod + def write(checkout: Path, rel: str, text: str) -> None: + path = checkout / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def set_state(self, checkout: Path, lane: str, state: str) -> None: + """A hand edit of one row's state cell, the edit `commit` wraps.""" + path = checkout / REGISTER + text = path.read_text(encoding="utf-8") + old = _row(lane, "LIVE · seeded") + assert old in text, text + path.write_text(text.replace(old, _row(lane, state)), encoding="utf-8") + + def peer_pushes(self, rel: str, text: str, message: str) -> None: + """Origin moves on: a commit from another workstation.""" + self.write(self.peer, rel, text) + self.git("-C", str(self.peer), "add", "--", rel) + self.git("-C", str(self.peer), "commit", "-q", "-m", message) + self.git("-C", str(self.peer), "push", "-q", "origin", "main") + + def peer_edit_in_wip(self, line: str) -> None: + """A peer lane's handoff, mid-write and uncommitted in THIS checkout.""" + with (self.wip / PEER_HANDOFF).open("a", encoding="utf-8") as fh: + fh.write(line + "\n") + + def helper(self, *args: str, lane: str = LANE) -> subprocess.CompletedProcess: + self.net_log.write_text("", encoding="utf-8") + env = dict(self.env, LANES_LANE=lane) + return subprocess.run(["bash", str(REPO / "lanes-edit.sh"), *args], + env=env, cwd=str(self.wip), capture_output=True, + text=True, timeout=180, check=False) + + def net_calls(self) -> list[str]: + return self.net_log.read_text(encoding="utf-8").splitlines() + + def unpublished(self) -> list[str]: + return self.wgit("rev-list", "origin/main..HEAD").split() + + def origin_file(self, rel: str) -> str: + return self.ogit("show", f"main:{rel}") + + def origin_subjects(self) -> list[str]: + return self.ogit("log", "--format=%s", "main").splitlines() + + +@pytest.fixture +def box(tmp_path: Path) -> Box: + return Box(tmp_path) + + +def _line_of(stderr: str, needle: str) -> int: + for number, line in enumerate(stderr.splitlines()): + if needle in line: + return number + raise AssertionError(f"{needle!r} is on no line of:\n{stderr}") + + +def test_the_printed_recovery_publishes_the_edit_it_stranded(box): + """THE ISSUE'S OWN SEQUENCE, END TO END. Origin moves on; a peer's handoff + is uncommitted in the shared checkout; a register write commits, cannot + pull, has its push rejected six times and exits 3 with the RECOVERY. The + peer then commits its file exactly as that RECOVERY says, and the re-run it + names must PUBLISH: one copy of the edit on origin, the peer's commit with + it, and the commit origin already had kept — not exit 0 with nothing sent. + """ + box.peer_pushes("handoffs/repoR-1.md", "# repoR-1\n", + "handoff(repoR-1@Raven): another workstation's write") + box.peer_edit_in_wip("a peer's line, mid-write") + box.set_state(box.wip, LANE, "LIVE · edited by hand") + + stranded = box.helper("commit", SUBJECT) + assert stranded.returncode == 3, stranded.stderr + assert "a peer's uncommitted file blocks the rebase" in stranded.stderr + assert len(box.unpublished()) == 1, "the edit is a local commit and only that" + # The RECOVERY names the re-run and says what it does once nothing is staged. + assert f'LANES_LANE= lanes-edit.sh commit ""' in stranded.stderr + assert "pushes the local commits this lists" in stranded.stderr, stranded.stderr + + box.wgit("commit", "-q", "-m", f"handoff({PEER_LANE}@Eagle): the mid-write line", + "--", PEER_HANDOFF) + rerun = box.helper("commit", SUBJECT) + assert rerun.returncode == 0, rerun.stderr + assert "pushed origin/main" in rerun.stderr, rerun.stderr + assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") + assert _row(LANE, "LIVE · edited by hand") in box.origin_file(REGISTER) + assert "a peer's line, mid-write" in box.origin_file(PEER_HANDOFF) + assert box.origin_file("handoffs/repoR-1.md") == "# repoR-1\n" + subjects = box.origin_subjects() + assert sum(s.endswith(f"): {SUBJECT}") for s in subjects) == 1, subjects + assert len(subjects) == 4, ("seed, the other workstation's write, the edit and " + f"the peer's handoff — no commit of the re-run's own: {subjects}") + + +def test_a_clean_checkout_with_nothing_ahead_is_still_a_no_op(box): + """TODAY'S BEHAVIOUR, KEPT. Nothing staged and nothing on HEAD that origin + lacks: the same note, exit 0, no commit and no network call at all.""" + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + result = box.helper("commit", SUBJECT) + assert result.returncode == 0, result.stderr + assert f"nothing staged for {REGISTER} — no commit made" in result.stderr + assert "push" not in result.stderr, result.stderr + assert box.net_calls() == [] + assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) + + +def test_a_checkout_behind_origin_with_nothing_of_its_own_is_still_a_no_op(box): + """Behind is not ahead: origin's newer commits are no local commit to + publish, so this is the no-op above and not a pull.""" + box.peer_pushes("handoffs/repoR-1.md", "# repoR-1\n", "a newer commit elsewhere") + box.wgit("fetch", "-q", "origin") + head = box.wgit("rev-parse", "HEAD") + result = box.helper("commit", SUBJECT) + assert result.returncode == 0, result.stderr + assert f"nothing staged for {REGISTER} — no commit made" in result.stderr + assert box.net_calls() == [] + assert box.wgit("rev-parse", "HEAD") == head + + +def test_publishing_past_a_peers_dirty_file_keeps_the_dirty_elsewhere_guard(box): + """The publish takes the SAME push loop a commit does, guard included: with + a peer's file still uncommitted it warns, names the file, skips the pull, + pushes straight out, and leaves that file exactly as it found it.""" + box.set_state(box.wip, LANE, "LIVE · edited by hand") + box.wgit("commit", "-q", "-m", f"LANES({LANE}@Eagle): {SUBJECT}", "--", REGISTER) + box.peer_edit_in_wip("a peer's line, still mid-write") + + result = box.helper("commit", SUBJECT) + assert result.returncode == 0, result.stderr + assert f"nothing staged for {REGISTER} — no commit made" in result.stderr + assert f"WARNING: this checkout has uncommitted changes to files OTHER than the register" in result.stderr + assert PEER_HANDOFF in result.stderr + assert "pushed origin/main (attempt 1, no pull" in result.stderr, result.stderr + assert not any(c.startswith(f"-C {box.wip} pull") for c in box.net_calls()), box.net_calls() + assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") + assert _row(LANE, "LIVE · edited by hand") in box.origin_file(REGISTER) + assert box.wgit("diff", "--name-only").split() == [PEER_HANDOFF] + assert "still mid-write" not in box.origin_file(PEER_HANDOFF) + + +def test_the_conflict_recovery_warns_that_a_reset_drops_a_peers_commits(box): + """The issue's near-loss of 2026-10-09: the conflict RECOVERY ended in + `reset --hard origin/main`, which in a shared checkout also drops a PEER's + unpushed commit. The RECOVERY lists every unpublished commit by content and + says, before the reset, that it must not run while any of them is not the + reader's own — and the helper itself leaves the peer's commit in place.""" + box.peer_edit_in_wip("a peer's handoff, committed and not yet pushed") + box.wgit("commit", "-q", "-m", f"handoff({PEER_LANE}@Eagle): unpushed", + "--", PEER_HANDOFF) + peer_commit = box.wgit("rev-parse", "HEAD").strip() + box.set_state(box.peer, LANE, "LIVE · the peer rewrote this row") + box.git("-C", str(box.peer), "commit", "-q", "-am", "the same row, elsewhere") + box.git("-C", str(box.peer), "push", "-q", "origin", "main") + box.set_state(box.wip, LANE, "LIVE · edited by hand") + + result = box.helper("commit", SUBJECT) + err = result.stderr + assert result.returncode == 3, err + assert "REBASE CONFLICT on origin/main" in err + assert box.wgit("merge-base", "--is-ancestor", peer_commit, "HEAD") == "" + + count = _line_of(err, "cherry origin/main") + listing = _line_of(err, "cherry -v origin/main") + warning = _line_of(err, "never reset, stash or overwrite a peer's work") + drops = _line_of(err, "drops EVERY '+' commit in this checkout") + reset = _line_of(err, "reset --hard origin/main") + assert count < listing < warning <= drops < reset, err + + # The listing the RECOVERY prints is a command that runs, and it names + # the peer's commit as one origin does not have. + command = err.splitlines()[listing].split("#", 1)[0].split() + listed = subprocess.run(command, env=box.env, capture_output=True, + text=True, check=True).stdout + assert f"+ {peer_commit}" in listed, listed + + +def test_the_dirty_checkout_refusal_names_the_act_to_re_run(box): + """`refuse_dirty_checkout`'s RECOVERY used to end in *"then re-run:"* + followed by two COMMIT commands, which read as if `lanes-edit.sh commit` + were the re-run. The authors commit their files; what is re-run is the + act that was refused, whose own push publishes their commits.""" + box.peer_edit_in_wip("a peer's line, mid-write") + result = box.helper("log", "NOTED", f"lane:{LANE}", "a note") + assert result.returncode == 2, result.stderr + assert "RECOVERY — whoever wrote those files commits them:" in result.stderr + assert "then re-run the refused write NOTED" in result.stderr, result.stderr From 9a29bbba1d636bb0e1a5dd1f439e65735e7a58fd Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 18:22:54 +0000 Subject: [PATCH 13/42] commit_push publishes the local commits a write with nothing staged finds ahead of origin, so the RECOVERY's re-run of `commit` pushes the edit it stranded (#194) `commit_push` returned at "nothing staged ... no commit made" before its push loop. An edit the loop could not get out (a peer's uncommitted file blocking the rebase, six rejected pushes) is already a local commit, so the RECOVERY's re-run of `lanes-edit.sh commit` staged nothing, exited 0 and pushed nothing. Now, with nothing staged, it reads origin/..HEAD: empty is today's no-op with no network call; non-empty takes the same push loop (the caller's lock, the dirty-elsewhere guard, the retries) without a commit of its own. The RECOVERY texts say what works: the dirty-elsewhere exit 3 says the re-run pushes the local commits; refuse_dirty_checkout separates what the authors commit from the refused act that is re-run; the conflict RECOVERY lists every unpublished commit by content (`cherry -v`) and warns that `reset --hard` drops a peer's unpushed commit along with the reader's own. docs/README-lanes.md says the same in both places. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 13 +++++-- lanes-edit.sh | 38 ++++++++++++++++---- tests/test_commit_push_published_edit_194.py | 11 +++--- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 7c0d16ca..cc0511ce 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -94,7 +94,9 @@ files — `pull --rebase` refuses outright on any unstaged tracked change — an pushes straight out instead, warning and naming the files. If the push is also rejected (the remote moved), it stops with exit 3, leaves your edit as a local commit, and prints the recovery: the owner of that file commits it, then you -re-run `lanes-edit.sh commit`. +re-run `lanes-edit.sh commit`. With nothing left to stage, that re-run makes no +commit of its own and pushes the local commits origin does not have yet, through +the same loop, guard and retries (#194); with none, it is the no-op it always was. ## THE ONE HAZARD — `sed -i` DESTROYS THE SYMLINK @@ -205,7 +207,14 @@ dangling commit by rebasing it onto a newer base, which changes its hash, so actually published. `git -C cherry origin/main | grep -c '^+'` compares by patch instead and still finds it; 0 means it is already there, in which case STOP. Only once fetch succeeded AND this -prints something other than 0 do you read your edit back with +prints something other than 0 do you list WHOSE they are, with +`git -C cherry -v origin/main `: one `+` +line per local commit origin lacks, and in a shared checkout not every one is +yours. If any `+` line is not your own write, STOP and take it to the lane its +subject names. Never reset, stash or overwrite a peer's work: `reset --hard` +drops EVERY `+` commit in the checkout, theirs with yours, and origin holds no +copy of any of them (#194). Only when every `+` line is yours do you read your +edit back with `git -C diff origin/main..HEAD -- lanes/LANES.md`, then `git -C reset --hard origin/main` and redo it on top of the peer's version. diff --git a/lanes-edit.sh b/lanes-edit.sh index aadd2db1..b4acc853 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1797,9 +1797,10 @@ refuse_dirty_checkout() { *) note "an object-log write is ordered against other lanes by landing on main, and 'pull --rebase'" note "refuses on any unstaged tracked change — so this write cannot be ordered safely here." ;; esac - note "RECOVERY — whoever wrote those files commits them, then re-run:" + note "RECOVERY — whoever wrote those files commits them:" note " lanes/LANES.md LANES_LANE= lanes-edit.sh commit \"\"" note " anything else git -C $LANES_REPO commit -m \"handoff(@): \" -- " + note "then re-run the refused $rd_what: its own push publishes their commits with it (#194)." die "refusing to $rd_what on a checkout that cannot be rebased" 2 } @@ -2019,11 +2020,29 @@ commit_push() { [ -z "$cp_byte" ] || die "$(bytecode_refusal_text "$cp_byte")" 2 git -C "$LANES_REPO" -c "$CP_EXACT" add -- ${cp_add[@]+"${cp_add[@]}"} || die "git add failed" 6 fi + # #194 — NOTHING STAGED IS NOT NOTHING TO PUBLISH. A write the push loop + # below could not get out (a peer's uncommitted file blocking the rebase, six + # rejected pushes) leaves its edit as a LOCAL commit, and the RECOVERY printed + # for it is to re-run `commit` once the way is clear. Returning here sent that + # re-run home with exit 0 and the commit still local. So a write with nothing + # of its own to commit still takes the push loop — under the caller's lock, + # with the dirty-elsewhere guard and the retries — whenever HEAD holds commits + # `origin/$LANES_BRANCH` does not, as of the last fetch (no such ref reads as + # none). With none it is the no-op it always was, and reaches no network. + cp_publish=0 if git -C "$LANES_REPO" -c "$CP_EXACT" diff --cached --quiet -- "${CP_PATHS[@]}"; then - note "nothing staged for ${CP_PATHS[*]} — no commit made" - return 0 + cp_ahead="$(git -C "$LANES_REPO" --no-pager log --oneline "origin/$LANES_BRANCH..HEAD" 2>/dev/null || :)" + if [ -z "$cp_ahead" ]; then + note "nothing staged for ${CP_PATHS[*]} — no commit made" + return 0 + fi + note "nothing staged for ${CP_PATHS[*]} — no commit made; pushing the local commits origin/$LANES_BRANCH does not have:" + printf '%s\n' "$cp_ahead" | head -n 10 >&2 || : + cp_publish=1 fi - if [ -n "$cp_ren" ]; then + if [ "$cp_publish" = 1 ]; then + : # nothing of this write's own to commit: straight on to the push + elif [ -n "$cp_ren" ]; then cp_staged="$(git -C "$LANES_REPO" -c "$CP_EXACT" diff --cached --name-only)" cp_extra="" while IFS= read -r cp_s; do @@ -2047,7 +2066,7 @@ EOF else git -C "$LANES_REPO" -c "$CP_EXACT" commit -q -m "$msg" -- "${CP_PATHS[@]}" || die "git commit failed" 6 fi - note "committed: $msg" + [ "$cp_publish" = 1 ] || note "committed: $msg" if ! remote_has_branch; then if ! git_net -C "$LANES_REPO" push -q -u origin "$LANES_BRANCH"; then [ "$GIT_TIMED_OUT" = 1 ] && git_timeout_die "git push -u origin $LANES_BRANCH" @@ -2086,7 +2105,8 @@ EOF if [ "$attempt" -ge 6 ]; then note "RECOVERY: their author commits those files (a handoff: handoff(@): )," note " then re-run: LANES_LANE= lanes-edit.sh commit \"\" — your edit is already" - note " a local commit here: git -C $LANES_REPO log --oneline origin/$LANES_BRANCH..HEAD" + note " a local commit here, so that re-run stages nothing, makes no commit of its own, and" + note " pushes the local commits this lists (#194): git -C $LANES_REPO log --oneline origin/$LANES_BRANCH..HEAD" die "could not push origin/$LANES_BRANCH: behind the remote, and a peer's uncommitted file blocks the rebase. Nothing was lost — your commit is local." 3 fi elif git_net -C "$LANES_REPO" pull --rebase -q origin "$LANES_BRANCH"; then @@ -2150,8 +2170,12 @@ EOF note " git -C $LANES_REPO fetch origin $LANES_BRANCH || echo FETCH-FAILED # if that printed FETCH-FAILED, STOP and retry — a stale or unreachable origin proves nothing either way" note " git -C $LANES_REPO cherry origin/$LANES_BRANCH $cp_sha | grep -c '^+' # BY CONTENT, not by sha: a peer's rebase changes the hash but git cherry still finds the same patch" note " if that printed 0, STOP — reset or redo now would write it a second time." + note " git -C $LANES_REPO cherry -v origin/$LANES_BRANCH $cp_sha # WHOSE: one '+' line per local commit origin lacks, and this checkout is shared" + note " if any '+' line is not your own write, STOP and take it to the lane its subject names:" + note " never reset, stash or overwrite a peer's work — the reset below drops EVERY '+' commit in this checkout," + note " theirs with yours, and origin holds no copy of any of them (#194)." note " git -C $LANES_REPO diff origin/$LANES_BRANCH..HEAD -- ${CP_PATHS[*]} # only if it is not there: read back exactly what you wrote" - note " git -C $LANES_REPO reset --hard origin/$LANES_BRANCH # then drop the local commits (NOTE: also drops any" + note " git -C $LANES_REPO reset --hard origin/$LANES_BRANCH # only if every '+' line is yours: drop the local commits (NOTE: also drops any" note " # uncommitted peer edit in this checkout)" note " then re-read LANES.md and redo the edit with lanes-edit.sh, on top of the peer's version." die "rebase conflict on origin/$LANES_BRANCH — not pushed by this attempt; a later write from this checkout may already carry it, so look before you retry." 3 diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 757e6c1c..0c4970d5 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -204,7 +204,7 @@ def test_the_printed_recovery_publishes_the_edit_it_stranded(box): assert "a peer's uncommitted file blocks the rebase" in stranded.stderr assert len(box.unpublished()) == 1, "the edit is a local commit and only that" # The RECOVERY names the re-run and says what it does once nothing is staged. - assert f'LANES_LANE= lanes-edit.sh commit ""' in stranded.stderr + assert 'LANES_LANE= lanes-edit.sh commit ""' in stranded.stderr assert "pushes the local commits this lists" in stranded.stderr, stranded.stderr box.wgit("commit", "-q", "-m", f"handoff({PEER_LANE}@Eagle): the mid-write line", @@ -258,10 +258,13 @@ def test_publishing_past_a_peers_dirty_file_keeps_the_dirty_elsewhere_guard(box) result = box.helper("commit", SUBJECT) assert result.returncode == 0, result.stderr assert f"nothing staged for {REGISTER} — no commit made" in result.stderr - assert f"WARNING: this checkout has uncommitted changes to files OTHER than the register" in result.stderr + assert ("WARNING: this checkout has uncommitted changes to files OTHER than the register" + in result.stderr) assert PEER_HANDOFF in result.stderr assert "pushed origin/main (attempt 1, no pull" in result.stderr, result.stderr - assert not any(c.startswith(f"-C {box.wip} pull") for c in box.net_calls()), box.net_calls() + calls = [f" {c} " for c in box.net_calls()] + assert not any(" pull " in c for c in calls), calls + assert any(" push " in c for c in calls), calls assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") assert _row(LANE, "LIVE · edited by hand") in box.origin_file(REGISTER) assert box.wgit("diff", "--name-only").split() == [PEER_HANDOFF] @@ -298,7 +301,7 @@ def test_the_conflict_recovery_warns_that_a_reset_drops_a_peers_commits(box): # The listing the RECOVERY prints is a command that runs, and it names # the peer's commit as one origin does not have. - command = err.splitlines()[listing].split("#", 1)[0].split() + command = err.splitlines()[listing].split("lanes-edit: ", 1)[1].split("#", 1)[0].split() listed = subprocess.run(command, env=box.env, capture_output=True, text=True, check=True).stdout assert f"+ {peer_commit}" in listed, listed From 941330ad73af6cce16f8204873b7a25d1d362104 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 18:23:12 +0000 Subject: [PATCH 14/42] =?UTF-8?q?T018=20docs:=20a=20parked=20paired=20tree?= =?UTF-8?q?=20is=20not=20the=20lane's=20to=20the=20sweep=20(=C2=A77=20item?= =?UTF-8?q?=2011,=20for=20lane=201's=20ruling),=20and=20=C2=A76=20says=20w?= =?UTF-8?q?hat=20the=20branch=20now=20ships?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/README-lanes.md, T018 section: a parked or resumed paired tree carries no Lane: trailer (park.sh puts the lane in the WIP subject only; resume soft-resets those commits), so the sweep and lane-end's gate count it for nobody unless the lane's inventory names it. The paragraph says what to read before lane-end in an assembly, and how to record such a tree with set-lane-tree so the gate counts it. docs/triad-lane-tooling-analysis.md: a dated §6 note on what the branch ships beyond its first commit, and §7 item 11, the choice between reading park's WIP subject and giving park's commit a trailer, plus whether resume records the tree it rebuilds. Written by the previous writer on 2026-10-11 and left uncommitted; checked here against HEAD 8cb1368, whose lane-worktrees, lane-end and lane-handoff are byte-identical to 9991d69's. Every line reference holds: lane-worktrees :2424-2431, :4473-4494 and :2124, lane-end :2122-2127, the FOREIGN reason text (lane-worktrees:3037-3039), and workBenches ffd5516 park.sh :226 and :860 and resume.sh :221-231 and :746. Three corrections in item 11: the source is review 5481327496 on #196 (a pull-request review, not an issue comment); its finding 2 was derived from code and not run, so "measured" is now "derived", the document's own word for that; and the inventory names a paired tree through lanes-edit.sh set-lane-tree, not through lane-worktrees add, which makes its trees under the lane's own root only. Docs only. No test run for this commit; the branch's one verification run follows the M1(a) and should-fix commits. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 15 +++++++++++ docs/triad-lane-tooling-analysis.md | 40 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 5e0cbfbe..b36316ad 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3959,6 +3959,21 @@ output `c45a452`'s tools printed, on a repository laid out to look like a triad: submodule's worktree under `worktrees//code`, and a tree recorded with the submodule as its checkout. +**A parked paired tree is not the lane's to the sweep — a gap, for lane 1's ruling +(docs/triad-lane-tooling-analysis.md §7, item 11).** The sweep and `lane-end`'s gate +give a tree under `/worktrees` to a lane by its HEAD's own `Lane:` trailer +and by nothing else, unless the lane's inventory names it. `make park` commits a dirty +leg tree as `wip: park — lane `, with no trailer, and `resume` +soft-resets those commits, leaving the work uncommitted on the commit below them. So a +parked or resumed paired tree is a FOREIGN row of `lane-worktrees sweep +--dry-run` ("… its HEAD carries no Lane: trailer of this lane's as its own"), counted +for nobody, and a gate that passes says nothing of it. **Before `lane-end` in an +assembly**, run that dry run and read every FOREIGN row under `/worktrees`: +one whose HEAD, or whose branch's upstream, is a `wip: park … — lane ` +commit, or that holds uncommitted edits, may be this lane's parked work. Land it or +hand it over first, or record it with `lanes-edit.sh set-lane-tree +--checkout /` so the gate counts it. + ### Two protocol lines this act assumes (proposed for the amendment that ratifies it) 1. **A lane creates worktrees, never clones.** Every tree a lane works in is a diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index 46a815c5..633415f2 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -557,6 +557,19 @@ Every behaviour change proposed in §4 changes what a command prints or does **t this repository or for the estate's existing triads. Each is written above as a proposal with a sketch, for a ruling. +**2026-10-11: what the branch now ships.** That paragraph described the branch's first +commit. The branch now also carries T018's shape-gated changes (§4.4, 2026-10-09 note): +(a) `lanes-edit.sh project-legs`, the one reader of an assembly's legs, with +`lane-worktrees sweep` and `lane-end`'s gate enumerating the legs, the worktrees they +register and the paired root; (b) `lane-handoff` polling the paired root and recording +each tree under its leg, and `lane-reconcile` reading the legs' registrations and the +paired root (merged from writer 2's `feat/triad-lane-tooling-handoff`); (c) the daily +report counting each leg as a checkout of its own; (d) `lane-worktrees add` recording +the leg it made a tree from. On a single repository every output is byte for byte what +`c45a452` printed, which two stored goldens hold: `tests/fixtures/t018/` and +`tests/fixtures/t018-handoff/`. Usage text moves for every shape (§4.4). The branch merges +main at `f87fd5a` (#190) and changes none of #190's test-root files. + **Overlap with #190, and why this branch carries no test change.** An earlier draft of this branch made the suite's roots shape-aware. Lane 1's PR #190 does the same work, more completely, under other names. The draft was dropped before it was committed; its diff is @@ -609,3 +622,30 @@ kept only in this lane's scratch directory. `/worktrees//code`, with the spec root at `../spec` (§4.13), and move the third manual read (#190's test_lane_helpers.sh:6595-6596) inside its `if`, or a standalone code-leg run aborts on `ln_doc: unbound variable` (§4.13, measured). +11. **Parked paired trees (T018).** **2026-10-11**, from lane 3's review of #196's T018 + record (review 5481327496 on #196, finding 2), **derived** from the code of this + branch and of workBenches `ffd5516`, not run. The sweep, and so `lane-end`'s + close-out gate, gives a tree under `/worktrees` to a lane only by its + HEAD's own `Lane:` trailer, among the commits origin's default branch lacks: + `lane-worktrees` at 9991d69, :2424-2431 (the paired-root claim) and :4473-4494 + (`own_trailers`, `head_lane_trailer`), and a leg checkout by the same rule, :2124. + workBenches' `park.sh` + (`devBenches/base-image/files/speckit-worktree/templates/specify/extensions/git/scripts/bash/park.sh`) + puts the lane only in the WIP commit's subject, `wip: park — lane ` + (:226), committed with `-m "$WIP_SUBJECT"` and no trailer (:860). `resume.sh` knows + those commits by that subject (:221-231) and `reset --soft HEAD~`s them + (:746), so HEAD is back on the commit below them and the work is uncommitted. A + parked or resumed paired tree is therefore FOREIGN, counted for nobody, and a gate + that passes prints nothing of it: `lane-end`'s pass path (:2122-2127) names only the + unclaimed leg checkouts. It is counted only where the lane's #97 inventory names + it, which happens through `lanes-edit.sh set-lane-tree`, or a `lane-handoff` that + ran while it stood (T018 (b)); `lane-worktrees add` makes its trees under the + lane's own root, never in the paired root. Lane 3 changed no attribution code; `docs/README-lanes.md` + (T018 section) says what to check before `lane-end`. **Choose:** (i) the sweep also + reads park's WIP subject as the lane's mark, which couples `lane-worktrees` to a + workBenches subject format; or (ii) park's WIP commit carries a `Lane: ` + trailer, a workBenches change that resume's subject match does not disturb. Either + way a RESUMED tree has no trailer of its own until its writer next commits, because + its HEAD is back below the WIP commits. So decide too whether `resume` records the + tree it rebuilds in the lane's inventory (`lanes-edit.sh set-lane-tree + --checkout /`), which covers both states. From 4d4957db6e093cb17faa5a61298d4e4d1c4ccf92 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 18:36:51 +0000 Subject: [PATCH 15/42] T018 (b), pre-PR review M1 option (a): lane-handoff polls the paired root by the sweep's rule, so one lane's handoff never takes another lane's feature trees; and S1, S5, N1-N5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 (a). In an assembly, lane-handoff polled and recorded every .git-bearing tree under /worktrees as the running lane's writer. The paired root is per feature, not per lane, so after one handoff another lane's paired work was this lane's inventory: lane-end refused on it, sweep --yes could rescue and remove it, and the next session was told to relaunch writers into it. Now a paired tree is polled and recorded only where this lane's inventory names it (lanes-edit.sh lane-trees, read once), its HEAD's own last Lane: trailer is this lane's on a commit origin's default branch lacks (a bash mirror of lane-worktrees' own_trailers/head_lane_trailer; a clone is never claimed by trailer, as the sweep never adopts one), or the --writers-file names it. Every other checkout there is one "- PAIRED ROOT, not this lane's: — branch …" line under its own heading in the top block, outside WRITERS, not counted and not recorded. Pending lane 1's ruling on #186 between (a) and (b). S1. A paired root, feature directory or leg directory that cannot be listed or searched was skipped in silence by the handoff. It is now a "- NOT POLLED" line, a stderr note and a lane left SWAPPING, as is an inventory that cannot be read. lane-reconcile (indeterminate) and the sweep's _paired (exit 2) gain the one case they also passed over: a leg's directory under a feature that cannot be searched, and in the sweep a root or feature directory that can be listed but not searched. N1. lane-reconcile passes the feature directory to awk through ENVIRON, not -v. N3. The handoff's unreadable-manifest words are the sweep's and lane-reconcile's ("the legs of the assembly could not be read"). N4. add's hint names every leg's --checkout, not legs[-1]. N5. The handoff keeps a recorded checkout of the tree's own repository (a dependency nested in a leg) instead of re-recording the tree under the assembly. Tests. N2: the assembly cases wear conftest's NEEDS_UPSTREAM (composed mode fails rather than skips), and the no-assembly report test needs no marker. New: the two-lane M1 regression; S1's three unlistable directories (handoff and reconcile); N5; S5(b) the sweep's absent row and add's toplevel guard on a deinitialized leg; S5(c) an unreadable leg (counted, lane-end refuses), the submodule-status refusal (unshadowed by reconcile) and _paired's own refusals called directly; and the coordinator's quoted-manifest case (openRepoShape v1.0.0, #395 writes path: "{{SPEC_PATH}}"): project-legs already strips one pair of " or ' quotes, and the test holds it for both quote styles. Two existing handoff tests change for M1 (a): the no-commit paired spec tree is named by a writers file, and the paired code tree of the deinitialized-leg case carries the lane's trailer. A checkout with no project.yaml is untouched: every new path runs only where project-legs answered legs (lane-handoff, lane-reconcile, the sweep) or the manifest declares them (add). Untested in this commit: the branch's one verification run follows on the final tree, and its counts are in the commit that ends this step. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lane-handoff | 204 ++++++++++++++++++++--- lane-worktrees | 35 +++- lanes-edit.sh | 14 +- tests/test_triad_lane_tooling.py | 202 +++++++++++++++++++--- tests/test_triad_lane_tooling_handoff.py | 150 +++++++++++++++-- 5 files changed, 541 insertions(+), 64 deletions(-) diff --git a/lane-handoff b/lane-handoff index cbb62892..40a5501b 100755 --- a/lane-handoff +++ b/lane-handoff @@ -2017,13 +2017,25 @@ poll_writer() { # # two things follow, and neither touches a checkout with no `project.yaml`, # which is asked nothing and polled and recorded exactly as before: # -# * THE PAIRED ROOT IS POLLED — `/worktrees/` and -# `/`, the standard's three-leg feature -# trees — exactly as `.claude/worktrees` is: every checkout there, because -# the paired root, like that one, is per feature and not per lane, and a -# writer with nothing committed yet has no trailer to be found by. Only a -# directory with a `.git` of its own is a checkout there: anything else -# under it is the assembly's own working tree, where git answers too. +# * THE PAIRED ROOT IS POLLED BY THE SWEEP'S RULE — `/worktrees/ +# ` and `/`, the standard's +# three-leg feature trees. The paired root is per feature and NOT per +# lane: every lane the assembly homes keeps its feature trees there. So a +# tree there is polled and recorded as THIS lane's writer only where one +# of three things holds: this lane's inventory already names it; its +# HEAD's own last `Lane:` trailer is this lane's on a commit origin's +# default branch lacks (`lane-worktrees`' `own_trailers` and +# `head_lane_trailer`, the rule its sweep claims the same root by); or +# the writers file names it, because a writer with nothing committed yet +# has no trailer to be found by. Every other checkout there is ANOTHER +# LANE'S OR NOBODY'S: one informational line in the top block, outside +# WRITERS, never counted and never recorded. A handoff that recorded them +# all made other lanes' feature trees this lane's inventory, so its +# `lane-end` refused on their work, its `sweep --yes` could act on it, and +# the next session was told to relaunch writers into them (the pre-PR +# review of T018, M1, option (a)). Only a directory with a `.git` of its +# own is a checkout there: anything else under it is the assembly's own +# working tree, where git answers too. # * A TREE IS RECORDED UNDER THE CHECKOUT IT BELONGS TO. A tree of a leg's # repository — `lane-worktrees add --checkout /code` makes one # under the lane's own root, and every paired tree is one — was re-recorded @@ -2035,8 +2047,13 @@ poll_writer() { # # (`R-A11-11`): the paired root is then NOT polled, the top block's WRITERS # section says so, no tree is recorded under a checkout nobody could establish, # and the lane stays SWAPPING — a poll that could not look everywhere is not -# the record a recovery reads as a finished swap. +# the record a recovery reads as a finished swap. SO IS A PAIRED ROOT, A +# FEATURE DIRECTORY OR A LEG'S DIRECTORY UNDER IT THAT COULD NOT BE LISTED, and +# an inventory that could not be read (`paired_unread`): each is a place a +# tree of this lane's may stand unseen, exactly as `lane-reconcile` says +# `indeterminate` and the sweep refuses for the same directories. legs_state=""; legs_unread=""; leg_rows=""; leg_commons=""; dir_common="" +paired_unread=""; paired_others=""; paired_other_count=0; paired_inv="" tab="$(printf '\t')" # THE PHYSICAL PATH OF A CHECKOUT'S COMMON GIT DIRECTORY, or nothing: a leg # and every worktree of its repository share one (`/.git/modules/ @@ -2060,7 +2077,7 @@ if [ -n "$dir" ] && { [ -e "$dir/project.yaml" ] || [ -L "$dir/project.yaml" ]; 8) leg_rows="" ;; *) leg_rows=""; legs_state=unread - legs_unread="$dir/project.yaml is there and its legs could not be read (\`$LANES_EDIT project-legs $dir\` exited $legs_rc)" + legs_unread="the legs of the assembly $dir could not be read (its project.yaml is there, and \`$LANES_EDIT project-legs $dir\` exited $legs_rc)" dir_common="$(common_dir_of "$dir")" note "$legs_unread — which is never 'no legs' (Amendment 7(d)). So the assembly's paired feature trees under $dir/worktrees were NOT polled, and a tree that is not of $dir's own repository is NOT recorded: read them by hand before a writer is relaunched there. The handoff goes on (\`R-A11-11\`), and where it records a lifecycle the lane stays SWAPPING for it." ;; esac @@ -2084,10 +2101,49 @@ if [ "$legs_state" = known ]; then $leg_rows LEG_ROWS fi +# A PLACE THE POLL COULD NOT LOOK, said where it is found and kept for the top +# block's `- NOT POLLED` line and the lifecycle. +paired_note_unread() { # + paired_unread="${paired_unread:+$paired_unread; }$1" + note "$1 — a read that failed is never an empty one (Amendment 7(d)). So a tree of this lane's there may be missing from the WRITERS section: read it by hand before a writer is relaunched there. The handoff goes on (\`R-A11-11\`), and where it records a lifecycle the lane stays SWAPPING for it." +} +# THIS LANE'S INVENTORY, READ ONCE, where the checkout is an assembly whose legs +# were read: which paired trees are already this lane's, and the checkout each +# recorded tree was filed under (`writer_checkout`). ` TAB `, one +# a line. A checkout with no `project.yaml` never asks it. +if [ "$legs_state" = known ]; then + pi_out=""; pi_rc=0 + pi_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-trees "$lane" 2>/dev/null = 2 && $2 != "" { print $2 "\t" $10 }')" + pi_bad="$(printf '%s\n' "$pi_out" | awk -F'\037' 'NF >= 2 && $2 == "" { printf "%s ", $1 }')" + [ -z "$pi_bad" ] || + paired_note_unread "this lane's worktree inventory holds record(s) that could not be read (${pi_bad% }), so a paired tree only they name could not be told from another lane's" ;; + 8) : ;; + *) paired_note_unread "this lane's worktree inventory could not be read (\`$LANES_EDIT lane-trees $lane\` exited $pi_rc), so a paired tree only it names could not be told from another lane's" ;; + esac +fi +# THE CHECKOUT THIS LANE'S INVENTORY RECORDS FOR A TREE, under either spelling +# of the tree's path, or nothing. +inventory_checkout() { # + [ -n "$paired_inv" ] || return 1 + ic_r="$(physical_of "$1")" + IC_P="$1" IC_R="$ic_r" awk -F'\t' ' + $1 == ENVIRON["IC_P"] || (ENVIRON["IC_R"] != "" && $1 == ENVIRON["IC_R"]) { print $2; f = 1; exit } + END { exit f ? 0 : 1 }' </code/upstream/`) is no declared leg, and re-recording its +# tree under the assembly would lose the checkout `add` recorded; else the +# lane's own `dir`, as always. Where the legs could not be read, only a tree +# of `dir`'s own repository can be vouched for, and anything else answers +# NOTHING (the caller files nothing). writer_checkout() { # wc_c="$(common_dir_of "$1")" if [ -n "$wc_c" ] && [ -n "$leg_commons" ]; then @@ -2097,11 +2153,78 @@ writer_checkout() { # $leg_commons WC_ROWS fi - if [ "$legs_state" = known ]; then printf '%s' "${dir:-unknown}"; return 0; fi + if [ "$legs_state" = known ]; then + wc_rec="$(inventory_checkout "$1" + [ -n "$writers_file" ] && [ -r "$writers_file" ] || return 1 + awk -F'\t' -v p="$1" '$1 == p { f = 1; exit } END { exit f ? 0 : 1 }' "$writers_file" +} +# EVERY `Lane:` TRAILER of the messages on stdin, one a line, as +# `lane-worktrees` reads them (`^Lane:\s*(\S+)\s*$`, multi-line). +lane_trailers() { + awk '/^Lane:[[:space:]]*[^[:space:]]+[[:space:]]*$/ { + v = $0; sub(/^Lane:[[:space:]]*/, "", v); sub(/[[:space:]]+$/, "", v); print v }' +} +# THE SWEEP'S OWN CLAIM ON THE PAIRED ROOT (`lane-worktrees`' `_lane_claims`, +# with `own_trailers` and `head_lane_trailer`): HEAD's own last `Lane:` +# trailer is this lane's, and this lane's trailer is among the commits HEAD has +# that origin's default branch lacks — `origin/HEAD`, else `origin/main`; with +# neither there is no base to tell them by, and no claim. Compared without +# case, as the sweep compares. A standalone CLONE is never claimed by its +# trailer, as the sweep never adopts one. +head_trailer_is_lane() { # + [ -d "$1/.git" ] && return 1 + ht_base="$(git -C "$1" symbolic-ref -q refs/remotes/origin/HEAD 2>/dev/null || :)" + if [ -z "$ht_base" ] && git -C "$1" rev-parse -q --verify refs/remotes/origin/main >/dev/null 2>&1; then + ht_base=refs/remotes/origin/main + fi + [ -n "$ht_base" ] || return 1 + ht_want="$(printf '%s' "$lane" | tr '[:upper:]' '[:lower:]')" + ht_head="$(git -C "$1" log -1 --format=%B HEAD 2>/dev/null | lane_trailers | tail -n 1 | tr '[:upper:]' '[:lower:]')" + [ -n "$ht_head" ] && [ "$ht_head" = "$ht_want" ] || return 1 + git -C "$1" log --format=%B "$ht_base..HEAD" 2>/dev/null | lane_trailers | tr '[:upper:]' '[:lower:]' | + HT_W="$ht_want" awk '$0 == ENVIRON["HT_W"] { f = 1 } END { exit f ? 0 : 1 }' +} +# ONE CHECKOUT OF THE PAIRED ROOT: polled and recorded as this lane's writer, +# or named once as not this lane's — its branch, its last commit and its HEAD's +# own trailer, which is what a person needs to find its lane — and never +# counted, recorded or offered for a relaunch. +paired_tree() { # + if inventory_checkout "$1" >/dev/null || writers_name "$1" || head_trailer_is_lane "$1"; then + poll_writer "$1" + return 0 + fi + pt_branch="$(git -C "$1" symbolic-ref -q --short HEAD 2>/dev/null)"; pt_brc=$? + case "$pt_brc" in 0) : ;; 1) pt_branch='(detached)' ;; *) pt_branch='?' ;; esac + pt_last="$(git -C "$1" log -1 --format='%h %s' 2>/dev/null || :)" + pt_trailer="$(git -C "$1" log -1 --format=%B HEAD 2>/dev/null | lane_trailers | tail -n 1)" + pt_shown=none + [ -z "$pt_trailer" ] || pt_shown="\`$pt_trailer\`" + paired_other_count=$((paired_other_count + 1)) + say "" + say "PAIRED ROOT, not this lane's: $1 — branch $pt_branch, last commit ${pt_last:-}" + say " HEAD's own Lane: trailer ${pt_trailer:-none}; neither lane $lane's inventory nor its writers file names it, so it is not polled, counted or recorded for $lane" + paired_others="$paired_others$(printf -- '- PAIRED ROOT, not this lane'"'"'s: `%s` — branch `%s`, last commit `%s`, HEAD'"'"'s own `Lane:` trailer %s' \ + "$1" "$pt_branch" "${pt_last:-}" "$pt_shown") +" + return 0 +} + : > "$writers_tmp" if [ -n "$dir" ]; then for w in "$dir"/.claude/worktrees/*; do poll_writer "$w"; done @@ -2109,23 +2232,40 @@ if [ -n "$dir" ]; then for w in "$parent"/.lane-worktrees/"$lane"/*; do poll_writer "$w"; done # THE PAIRED ROOT, where the checkout is an assembly whose legs were read. # `}" +[ "$paired_other_count" = 0 ] || + step "paired root: $paired_other_count tree(s) under $dir/worktrees are not lane $lane's — listed in the top block, never counted or recorded" # ------------------------------------------------------ 2. refresh the handoff # @@ -2191,7 +2331,21 @@ top_block() { printf -- '- NOT POLLED: %s, so the assembly'"'"'s paired feature trees under `%s/worktrees` are not in this list. Read them by hand before relaunching a writer there.\n' \ "$legs_unread" "$dir" fi + if [ -n "$paired_unread" ]; then + printf -- '- NOT POLLED: %s, so a tree of this lane'"'"'s under `%s/worktrees` may be missing from this list. Read it by hand before relaunching a writer there.\n' \ + "$paired_unread" "$dir" + fi printf '\n' + # T018, M1 (a): THE PAIRED ROOT'S OTHER TREES, OUTSIDE WRITERS. They are + # other lanes' feature trees, or nobody's: named so the next session knows + # they are there and whose to ask, never counted above, never recorded, and + # never a writer to relaunch. Nothing is printed where there are none, so + # a checkout with no `project.yaml` gets the block it always got. + if [ -n "$paired_others" ]; then + printf '**PAIRED ROOT at %s: %s tree(s) under `%s/worktrees` that are NOT this lane'"'"'s.** None is in this lane'"'"'s inventory, carries its `Lane:` trailer on a commit origin'"'"'s default branch lacks, or is named by its writers file, so none is counted above or recorded. Each is another lane'"'"'s feature tree, or nobody'"'"'s: NEVER relaunch a writer into one of them.\n\n' \ + "$UTC" "$paired_other_count" "$dir" + printf '%s\n' "$paired_others" + fi } handoff_written=0 @@ -2352,6 +2506,7 @@ if [ "$do_late" = 1 ]; then [ "$handoff_written" = 1 ] || lc_unfinished="the handoff file was not refreshed" [ -z "$lc_inventory_missed" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the worktree inventory was not recorded for $lc_inventory_missed" [ -z "$legs_unread" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the assembly's legs could not be read, so its paired feature trees were not polled" + [ -z "$paired_unread" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }a directory of the assembly's paired root, or this lane's inventory, could not be read, so a paired tree of this lane's may not have been polled" lifecycle_finish "$lc_unfinished" else lifecycle_finish "the late PAUSED record was refused by the helper" @@ -2475,6 +2630,7 @@ lc_unfinished="" [ "$handoff_written" = 1 ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the handoff file was not refreshed" [ -z "$lc_inventory_missed" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the worktree inventory was not recorded for $lc_inventory_missed" [ -z "$legs_unread" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the assembly's legs could not be read, so its paired feature trees were not polled" +[ -z "$paired_unread" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }a directory of the assembly's paired root, or this lane's inventory, could not be read, so a paired tree of this lane's may not have been polled" lifecycle_finish "$lc_unfinished" # ------------------------------------------- 5. the restart line — one command diff --git a/lane-worktrees b/lane-worktrees index ab9bd286..f5c7c830 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -1964,9 +1964,12 @@ class Leg: a dependency NESTED in one - an initialized submodule of the leg's own (`code/upstream/openRepoShape`). It is the ASSEMBLY'S, shared by every lane the assembly homes, as the assembly's own checkout is, so a sweep - NEVER acts on one: it is read - for the worktrees git registers in it, for - its branches, and for its own dirty and unpushed state - and counted for - this lane only where its HEAD's own `Lane:` trailer is this lane's.""" + NEVER acts on the CHECKOUT: it is read - for the worktrees git registers + in it and for its own dirty and unpushed state - and counted for this lane + only where its HEAD's own `Lane:` trailer is this lane's. Its BRANCHES are + another matter: under `--branches` they are rows like the checkout's own + (`_branch_checkouts`), and `--yes` deletes a merged lane branch there + exactly as it does in the checkout, bundling first.""" def __init__(self, path: str, decl: str, repository: str, assembly: str, nested_in: str = ""): @@ -2544,10 +2547,14 @@ class Sweep: /` (and `` itself), that no registration above named - a registration lost, or a clone where a worktree belongs. A directory that cannot be listed refuses, as a lane - root does (#170 G1): an unread root is never an empty one.""" + root does (#170 G1): an unread root is never an empty one. One that can + be listed and not SEARCHED is unread too - what stands in it cannot be + told - and so is a leg's directory under a feature that cannot be + searched: `lane-reconcile` and `lane-handoff` say the same of each.""" root = self.paired_root try: names = sorted(os.listdir(root)) + unsearchable = not os.access(root, os.X_OK) except (FileNotFoundError, NotADirectoryError): return except OSError as exc: @@ -2555,6 +2562,11 @@ class Sweep: f"the assembly's worktree root {root} could not be listed " f"({exc.strerror or exc}), so which feature trees stand in it is unknown") return + if unsearchable: + self.refusal = self.refusal or ( + f"the assembly's worktree root {root} could not be listed (it cannot be " + "searched), so which feature trees stand in it is unknown") + return decls = [g.decl for g in self.legs if not g.nested_in] for name in names: fdir = os.path.join(root, name) @@ -2562,14 +2574,22 @@ class Sweep: continue try: os.listdir(fdir) + why = "" if os.access(fdir, os.X_OK) else "it cannot be searched" except OSError as exc: + why = exc.strerror or str(exc) + if why: self.refusal = self.refusal or ( f"the assembly's feature directory {fdir} could not be listed " - f"({exc.strerror or exc}), so which trees stand in it is unknown") + f"({why}), so which trees stand in it is unknown") continue for full in [fdir] + [os.path.join(fdir, d) for d in decls]: if not os.path.isdir(full) or os.path.islink(full) or real(full) in seen: continue + if not os.access(full, os.X_OK): + self.refusal = self.refusal or ( + f"the assembly's paired tree directory {full} could not be searched, " + "so whether a tree stands in it is unknown") + continue if not os.path.lexists(os.path.join(full, ".git")): continue kind = ("paired-clone" if os.path.isdir(os.path.join(full, ".git")) @@ -5915,8 +5935,9 @@ def add_tree(ns) -> int: assembly_hint = (f"lane {lane}'s checkout {lane_dir} is an ASSEMBLY, and this tree is " "of the assembly itself: a worktree does not check its legs out (" + ", ".join(f"{d}/" for _r, d, _p in legs) + " are empty in it). A " - "tree of a leg is made from the leg: --checkout " - + os.path.join(lane_dir, legs[-1][1])) + "tree of a leg is made from that leg: " + + " or ".join(f"--checkout {os.path.join(lane_dir, d)}" + for _r, d, _p in legs)) # THE RECORD MUST HAVE SOMEWHERE TO GO BEFORE THE TREE IS MADE: a tree # this act could not record is the very thing it exists to stop. tproc = helper.call("lane-trees", lane) diff --git a/lanes-edit.sh b/lanes-edit.sh index a2523d31..c6b37427 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -13197,14 +13197,22 @@ EOF continue fi # THE FEATURE DIRECTORY ITSELF, then each declared leg's path under - # it — a relative one, as the standard writes them. + # it — a relative one, as the standard writes them. The directory is + # passed through the ENVIRONMENT: `awk -v` reads escapes in a value, + # so a name holding `\` would come out as another path. lrc_cands="$lrc_f -$(printf '%s\n' "$lrc_legs" | awk -F'\t' -v f="$lrc_f" '$2 != "" && $2 !~ /^\// { p = $2; sub(/^\.\//, "", p); print f "/" p }')" +$(printf '%s\n' "$lrc_legs" | LRC_F="$lrc_f" awk -F'\t' '$2 != "" && $2 !~ /^\// { p = $2; sub(/^\.\//, "", p); print ENVIRON["LRC_F"] "/" p }')" while IFS= read -r lrc_c; do [ -n "$lrc_c" ] && [ -d "$lrc_c" ] && [ ! -L "$lrc_c" ] || continue - { [ -e "$lrc_c/.git" ] || [ -L "$lrc_c/.git" ]; } || continue lrc_cr="$(lane_real_path "$lrc_c")" case "$lrc_seen" in *" $lrc_c "*|*" $lrc_cr "*) continue ;; esac + # A LEG'S DIRECTORY THAT CANNOT BE SEARCHED hides whether a `.git` + # stands in it: a read nobody made, never an empty directory. + if [ ! -x "$lrc_c" ]; then + lrc_legwhy="${lrc_legwhy:+$lrc_legwhy; }the assembly's paired tree directory $lrc_c could not be searched" + continue + fi + { [ -e "$lrc_c/.git" ] || [ -L "$lrc_c/.git" ]; } || continue printf 'TREE%s%s%sunmanaged%s%s%sit sits in the assembly'\''s paired feature trees (%s/worktrees) and no sidecar of this lane names it; it is left exactly as it is\n' \ "$US" "$(tree_id_for "$lrc_c")" "$US" "$US" "$lrc_c" "$US" "$lrc_dir" lrc_seen="$lrc_seen $lrc_c $lrc_cr " diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py index 42c36529..def4b1c2 100644 --- a/tests/test_triad_lane_tooling.py +++ b/tests/test_triad_lane_tooling.py @@ -43,18 +43,17 @@ import pytest sys.path.insert(0, str(Path(__file__).resolve().parent)) -from conftest import REPO, UPSTREAM, WINDOWS_SKIP # noqa: E402 +from conftest import NEEDS_UPSTREAM, REPO, UPSTREAM, WINDOWS_SKIP # noqa: E402 pytestmark = [WINDOWS_SKIP, pytest.mark.skipif( shutil.which("git") is None or shutil.which("bash") is None, reason="the lane tools are bash and Python over git")] GOLDEN = Path(__file__).resolve().parent / "fixtures" / "t018" / "single-repository.golden" +#: The assembly is built from the pinned openRepoShape's own template bytes, +#: so every assembly case wears conftest's `NEEDS_UPSTREAM`: standalone it +#: skips where the submodule is not checked out, and composed it FAILS. TEMPLATE = UPSTREAM / "templates" / "assembly-root" -NEEDS_TEMPLATE = pytest.mark.skipif( - not (TEMPLATE / "project.yaml").is_file(), - reason="the pinned openRepoShape is not checked out; " - "run `git submodule update --init upstream/openRepoShape`") UUID = "18018018-1111-4180-8180-180180180001" #: Every command the bench installs beside each other, as `--install` does. @@ -461,7 +460,7 @@ def test_t018_the_legs_reader_is_lane_starts_rung_5_line_for_line(): assert theirs == ours -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_project_legs_reads_the_templates_manifest(tmp_path): b = Bench(tmp_path / "bench", REPO) t = build_assembly(b) @@ -483,7 +482,7 @@ def test_t018_project_legs_reads_the_templates_manifest(tmp_path): os.chmod(a / "project.yaml", 0o644) -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_the_sweep_reads_every_leg_and_its_nested_dependency(triad): b, t = triad proc = b.sweep("triad-1", "--dry-run", "--porcelain") @@ -498,7 +497,7 @@ def test_t018_the_sweep_reads_every_leg_and_its_nested_dependency(triad): assert "3 leg checkout(s), 0 counted" in table.stdout -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_paired_code_tree_with_unpushed_lane_work_holds_lane_end(triad): """THE GAP §4.4 MEASURED: a paired `worktrees//code` tree, made as Speckit makes one (a worktree of the code leg's repository), holding the @@ -530,7 +529,7 @@ def test_t018_a_paired_code_tree_with_unpushed_lane_work_holds_lane_end(triad): assert other[str(tree)]["disposition"] == "foreign" -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_leg_tree_made_elsewhere_is_registered_in_its_leg(triad): b, t = triad away = b.projects / "away-code" @@ -546,7 +545,7 @@ def test_t018_a_leg_tree_made_elsewhere_is_registered_in_its_leg(triad): assert row["retire"] == "retire" and row["disposition"] == "remove", row -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_leg_holding_the_lanes_unpushed_commit_is_counted(triad): """A lane working in the code leg itself: its HEAD's own trailer is this lane's and origin lacks it, so the leg is COUNTED - the gate refuses, and @@ -571,7 +570,7 @@ def test_t018_a_leg_holding_the_lanes_unpushed_commit_is_counted(triad): assert b.tool("lane-end", "triad-2").returncode == 0 -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_dirty_leg_that_is_no_lanes_is_listed_never_counted(triad): b, t = triad (t["code"] / "scratch.txt").write_text("someone's edit\n") @@ -588,7 +587,7 @@ def test_t018_a_dirty_leg_that_is_no_lanes_is_listed_never_counted(triad): assert "listed and NOT counted" in end.stderr and str(t["dep"]) in end.stderr -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_the_legs_branches_are_read_under_branches(triad): b, t = triad tmp = b.root / "leg-branch" @@ -602,7 +601,7 @@ def test_t018_the_legs_branches_are_read_under_branches(triad): assert rows[0][2] == "retire" and rows[0][4].startswith("in the code leg of"), rows -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_manifest_or_leg_that_cannot_be_read_refuses(triad): b, t = triad if os.geteuid() == 0: @@ -616,6 +615,171 @@ def test_t018_a_manifest_or_leg_that_cannot_be_read_refuses(triad): assert "the legs of the assembly" in proc.stdout and "could not be read" in proc.stdout +@NEEDS_UPSTREAM +def test_t018_a_leg_git_cannot_read_is_counted_and_holds_lane_end(triad): + """A LEG WITH A `.git` OF ITS OWN THAT GIT CANNOT READ is a read that + failed, never an absent or a clean leg (Amendment 7(d)): its row is + `unreadable` and counted, the dry run exits 3, and `lane-end` refuses, + naming it.""" + b, t = triad + (t["spec"] / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 3, proc.stdout + proc.stderr + leg = porcelain_rows(proc.stdout, "leg")[str(t["spec"])] + assert leg["disposition"] == "unreadable" and leg["retire"] == "retire", leg + assert "git could not be read through it" in leg["why"], leg + end = b.tool("lane-end", "triad-1") + assert end.returncode == 2, end.stderr + assert "lane triad-1 is NOT ended" in end.stderr and str(t["spec"]) in end.stderr + + +@NEEDS_UPSTREAM +def test_t018_dependencies_a_leg_nests_that_cannot_be_listed_refuse_the_sweep(triad): + """THE SWEEP'S OWN REFUSAL, UNSHADOWED: `git submodule status --recursive` + failing in a checked-out leg - here a nested dependency whose `.git` names + no repository - leaves the assembly's checkouts unknown, so the sweep + refuses, exit 2. `lane-reconcile` does not read nested dependencies, so its + verdict is no `indeterminate` that refuses first: this is `_assembly`'s.""" + b, t = triad + a = t["assembly"] + (t["dep"] / ".git").write_text("gitdir: /nonexistent/t018-dep\n") + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + verdict = next((ln.split(US)[1] for ln in rec.stdout.splitlines() + if ln.split(US)[0] == "VERDICT"), "") + assert verdict and verdict != "indeterminate", rec.stdout + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 2, proc.stdout + proc.stderr + assert f"the legs of the assembly {a} could not be read" in proc.stdout, proc.stdout + assert "the dependencies nested in the code leg" in proc.stdout + assert "could not be listed (git submodule status failed)" in proc.stdout + + +@NEEDS_UPSTREAM +def test_t018_a_leg_that_is_not_checked_out_is_absent_and_claims_no_tree_of_the_assembly(triad): + """A DEINITIALIZED LEG is an empty directory of the assembly's, where git + answers the ASSEMBLY. The sweep lists it `absent`, counted for nobody; the + assembly's own trees and registrations stay the assembly's rather than + becoming that leg's (`probe_leg`'s toplevel guard); and `add` from the + assembly records the assembly, never the empty leg (`add`'s guard).""" + b, t = triad + a = t["assembly"] + b.git("submodule", "deinit", "-q", "spec", cwd=a) + assert not any((a / "spec").iterdir()) + made = b.tool("lane-worktrees", "add", "triad-1", "root-work") + assert made.returncode == 0, made.stderr + tree = made.stdout.strip() + assert "is an ASSEMBLY, and this tree is of the assembly itself" in made.stderr + assert "the spec leg of" not in made.stderr, made.stderr + assert _inventory(b, "triad-1")[tree] == str(a) + away = b.projects / "away-root" + b.git("worktree", "add", "-q", "-b", "away-root", away, "origin/main", cwd=a) + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + legs = porcelain_rows(proc.stdout, "leg") + assert legs[str(t["spec"])]["disposition"] == "absent", legs + assert legs[str(t["spec"])]["retire"] == "-", legs + assert "not checked out here" in legs[str(t["spec"])]["why"], legs + assert legs[str(t["code"])]["disposition"] == "clean", legs + trees = porcelain_rows(proc.stdout, "tree") + assert trees[tree]["retire"] == "retire", trees + for path in (tree, str(away)): + assert "spec leg" not in trees[path]["why"], trees[path] + assert trees[str(away)]["disposition"] == "foreign", trees + + +def _lane_worktrees_module(): + """`lane-worktrees` as a module, its bytecode written nowhere (as + tests/test_lane_worktrees.py loads it).""" + import importlib.util + from importlib.machinery import SourceFileLoader + loader = SourceFileLoader("lane_worktrees_t018", str(REPO / "lane-worktrees")) + spec = importlib.util.spec_from_loader("lane_worktrees_t018", loader) + module = importlib.util.module_from_spec(spec) + was = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + loader.exec_module(module) + finally: + sys.dont_write_bytecode = was + return module + + +def test_t018_the_sweeps_paired_root_refuses_what_it_cannot_list_or_search(tmp_path): + """THE SWEEP'S OWN PAIRED-ROOT REFUSAL (`Sweep._paired`), which no run + shows: `lane-reconcile` reads the same directories first and says + `indeterminate`, and the sweep refuses on that verdict before it looks. + Called directly: the root, a feature directory, or a leg's directory under + it that cannot be listed or searched each refuse, naming it; readable, + the tree in it is found.""" + if os.geteuid() == 0: + pytest.skip("root lists and searches a mode-000 directory") + import types + lw = _lane_worktrees_module() + root = tmp_path / "worktrees" + feature = root / "001-a" + tree = feature / "code" + tree.mkdir(parents=True) + (tree / ".git").write_text("gitdir: /nonexistent\n") + legs = [types.SimpleNamespace(decl="spec", nested_in=""), + types.SimpleNamespace(decl="code", nested_in="")] + + def paired(): + sweep = types.SimpleNamespace(paired_root=str(root), legs=legs, refusal="") + seen: dict = {} + lw.Sweep._paired(sweep, seen) + return sweep.refusal, seen + + refusal, seen = paired() + assert refusal == "" and list(seen) == [lw.real(str(tree))], (refusal, seen) + for target, mode, words in ((root, 0, "worktree root"), (root, 0o600, "worktree root"), + (feature, 0, "feature directory"), + (feature, 0o600, "feature directory"), + (tree, 0o600, "paired tree directory")): + os.chmod(target, mode) + try: + refusal, seen = paired() + finally: + os.chmod(target, 0o755) + assert f"{words} {target} could not be" in refusal, (target, oct(mode), refusal) + assert not seen, (target, oct(mode), seen) + + +@NEEDS_UPSTREAM +def test_t018_project_legs_reads_quoted_scalars_as_openreposhape_v1_writes_them(triad): + """openRepoShape v1.0.0 (opensoft/openRepoShape#395) writes the assembly + template's scalars QUOTED: `path: "{{SPEC_PATH}}"` under `legs:`, and `id:`, + `elected_on:`, `visibility:` and `tracking_branch:` alike. The one reader + every lane tool asks reads `path: "code"` and `path: 'code'` - and a quoted + `repository:` - exactly as `path: code`, so the sweep's leg rows are the + same three.""" + b, t = triad + a = t["assembly"] + want = [f"opensoft/triad-spec\tspec\t{a}/spec", f"opensoft/triad-code\tcode\t{a}/code"] + proc = b.tool("lanes-edit.sh", "project-legs", a) + assert proc.returncode == 0 and proc.stdout.splitlines() == want, proc.stdout + proc.stderr + plain = (a / "project.yaml").read_text(encoding="utf-8") + for q in ('"', "'"): + lines, inlegs, quoted = [], False, 0 + for line in plain.splitlines(): + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*:", line): + inlegs = line.startswith("legs:") + top = re.match(r"^(id|elected_on|visibility|tracking_branch):\s*(\S.*?)\s*$", line) + leg = re.match(r"^(\s+(?:repository|path):\s*)(\S.*?)\s*$", line) if inlegs else None + m = top or leg + if m: + head = f"{m.group(1)}: " if top else m.group(1) + line = f"{head}{q}{m.group(2).strip(chr(34) + chr(39))}{q}" + quoted += 1 + lines.append(line) + assert quoted >= 10, quoted + (a / "project.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8") + assert f"path: {q}spec{q}" in (a / "project.yaml").read_text() + proc = b.tool("lanes-edit.sh", "project-legs", a) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.splitlines() == want, (q, proc.stdout) + legs = porcelain_rows(b.sweep("triad-1", "--dry-run", "--porcelain").stdout, "leg") + assert list(legs) == [str(t["spec"]), str(t["code"]), str(t["dep"])], (q, legs) + + # ========================================================= (c) the daily report def _section(text: str, title: str) -> str: @@ -624,7 +788,7 @@ def _section(text: str, title: str) -> str: return text[start:end if end != -1 else len(text)] -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_the_report_counts_each_leg_as_a_checkout_of_its_own(triad): b, t = triad a = t["assembly"] @@ -646,7 +810,6 @@ def test_t018_the_report_counts_each_leg_as_a_checkout_of_its_own(triad): assert "| Assembly legs with uncommitted or unpublished work | 1 |" in proc.stdout -@NEEDS_TEMPLATE def test_t018_a_report_with_no_assembly_has_no_legs_section(tmp_path): b = Bench(tmp_path / "bench", REPO) b.seeded("plain", {"p.txt": "p\n"}) @@ -671,7 +834,7 @@ def _inventory(b: Bench, lane: str) -> dict: return rows -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_add_records_the_leg_it_made_a_tree_from(triad): b, t = triad a = t["assembly"] @@ -697,19 +860,20 @@ def test_t018_add_records_the_leg_it_made_a_tree_from(triad): assert row["retire"] == "retire", row -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_add_from_the_assembly_itself_says_its_legs_are_empty(triad): b, t = triad a = t["assembly"] made = b.tool("lane-worktrees", "add", "triad-1", "root-work") assert made.returncode == 0, made.stderr assert "is an ASSEMBLY, and this tree is of the assembly itself" in made.stderr - assert f"--checkout {a}/code" in made.stderr + # every leg is named, never only the last one the manifest declares + assert f"--checkout {a}/spec or --checkout {a}/code" in made.stderr, made.stderr assert _inventory(b, "triad-1")[made.stdout.strip()] == str(a) -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_add_refuses_where_the_manifest_cannot_be_read(triad): """Never "no legs" (Amendment 7(d)): which checkout the tree would be recorded under is unknown, so nothing is made, in the words the sweep diff --git a/tests/test_triad_lane_tooling_handoff.py b/tests/test_triad_lane_tooling_handoff.py index 43cf4e59..52106a4f 100644 --- a/tests/test_triad_lane_tooling_handoff.py +++ b/tests/test_triad_lane_tooling_handoff.py @@ -48,9 +48,9 @@ import pytest sys.path.insert(0, str(Path(__file__).resolve().parent)) -from conftest import REPO, WINDOWS_SKIP # noqa: E402 +from conftest import NEEDS_UPSTREAM, REPO, WINDOWS_SKIP # noqa: E402 from test_triad_lane_tooling import ( # noqa: E402 - NEEDS_TEMPLATE, US, Bench, build_assembly, normalise, parse_golden, render_golden, shown) + US, Bench, build_assembly, normalise, parse_golden, render_golden, shown) pytestmark = [WINDOWS_SKIP, pytest.mark.skipif( shutil.which("git") is None or shutil.which("bash") is None, @@ -95,9 +95,17 @@ def field(out: str, key: str) -> list: return next((ln.split(US)[1:] for ln in out.splitlines() if ln.split(US)[0] == key), []) -def handoff(b: Bench, lane: str, cwd: Path, why: str = "t018 handoff") -> subprocess.CompletedProcess: +def handoff(b: Bench, lane: str, cwd: Path, why: str = "t018 handoff", + *flags: str) -> subprocess.CompletedProcess: return b.tool("lane-handoff", "--lane", lane, "--transcript", "none", "--agent", "claude", - why, cwd=cwd) + *flags, why, cwd=cwd) + + +def writers_file(b: Bench, lane: str, rows: dict) -> Path: + """A `--writers-file`: ` TAB `, one a line.""" + path = b.root / f"writers-{lane}.tsv" + path.write_text("".join(f"{tree}\t{brief}\n" for tree, brief in rows.items())) + return path def top_block(b: Bench, lane: str) -> str: @@ -120,7 +128,7 @@ def triad(tmp_path): # ============================================================= (1) and (2) -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_the_handoff_polls_the_paired_trees_and_records_each_tree_under_its_leg(triad): """(1) THE PAIRED TREES ARE POLLED: a paired code tree holding the lane's unpushed commit and a paired spec tree holding a writer's first edit are in @@ -147,8 +155,11 @@ def test_t018_the_handoff_polls_the_paired_trees_and_records_each_tree_under_its # under the paired root and NOT a checkout of its own: the assembly's tree (a / "worktrees" / "007-y" / "notes").mkdir() (a / "worktrees" / "007-y" / "notes" / "n.md").write_text("n\n") + # the spec writer has committed nothing, so no trailer finds it: the + # writers file names it, and that makes it this lane's (M1 (a)) + wf = writers_file(b, "triad-1", {paired_spec: "the 007-y spec writer"}) - proc = handoff(b, "triad-1", a) + proc = handoff(b, "triad-1", a, "t018 handoff", "--writers-file", str(wf)) assert proc.returncode == 0, proc.stdout + proc.stderr for tree, branch in ((lane_code, "c1"), (lane_root, "root"), (paired_code, "007-y"), (paired_spec, "007-y")): @@ -164,6 +175,8 @@ def test_t018_the_handoff_polls_the_paired_trees_and_records_each_tree_under_its assert (f"- `{paired_code}` — branch `007-y`, last commit" in block and "0 dirty, 1 unpushed" in block.split(f"`{paired_code}`", 1)[1].split("\n", 1)[0]), block assert "1 dirty, 0 unpushed" in block.split(f"`{paired_spec}`", 1)[1].split("\n", 1)[0], block + assert "brief: the 007-y spec writer" in block, block + assert "PAIRED ROOT" not in block and "PAIRED ROOT" not in proc.stdout, block inv = inventory(b, "triad-1") assert inv == {str(lane_code): str(code), str(lane_root): str(a), @@ -178,7 +191,7 @@ def test_t018_the_handoff_polls_the_paired_trees_and_records_each_tree_under_its assert field(rec.stdout, "TREES")[0] == "4 inventoried", rec.stdout -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_leg_that_is_not_checked_out_claims_no_tree_of_the_assembly(triad): """A LEG THAT IS NOT CHECKED OUT is an empty directory of the assembly's, where git answers the ASSEMBLY - so read naively, its repository would be @@ -192,13 +205,14 @@ def test_t018_a_leg_that_is_not_checked_out_claims_no_tree_of_the_assembly(triad assert made.returncode == 0, made.stderr lane_root = Path(made.stdout.strip()) paired_code = leg_tree(b, code, a / "worktrees" / "008-z" / "code", "008-z") + b.commit(paired_code, "the feature's code", {"z.txt": "z\n"}, lane="triad-1") proc = handoff(b, "triad-1", a) assert proc.returncode == 0, proc.stdout + proc.stderr assert f"writers polled: 2 under {a}" in proc.stderr, proc.stderr assert inventory(b, "triad-1") == {str(lane_root): str(a), str(paired_code): str(code)} -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_manifest_the_handoff_cannot_read_is_said_and_the_handoff_goes_on(triad): """A `project.yaml` that is there and cannot be read is never "no legs" (Amendment 7(d)), and a swap is never left unwritten (`R-A11-11`): the @@ -224,7 +238,8 @@ def test_t018_a_manifest_the_handoff_cannot_read_is_said_and_the_handoff_goes_on finally: os.chmod(a / "project.yaml", 0o644) assert proc.returncode == 0, proc.stdout + proc.stderr - assert f"{a}/project.yaml is there and its legs could not be read" in proc.stderr, proc.stderr + assert (f"the legs of the assembly {a} could not be read (its project.yaml is there, " + "and ") in proc.stderr, proc.stderr assert "paired feature trees" in proc.stderr and "were NOT polled" in proc.stderr assert f"WRITER {paired_code} —" not in proc.stdout, proc.stdout assert f"WRITER {lane_code} —" in proc.stdout and f"WRITER {lane_root} —" in proc.stdout @@ -236,9 +251,122 @@ def test_t018_a_manifest_the_handoff_cannot_read_is_said_and_the_handoff_goes_on assert inventory(b, "triad-1") == {str(lane_code): str(code), str(lane_root): str(a)} +@NEEDS_UPSTREAM +def test_t018_a_handoff_takes_no_other_lanes_paired_tree(triad): + """M1 OF THE PRE-PR REVIEW, OPTION (a): THE PAIRED ROOT IS POLLED BY THE + SWEEP'S RULE. Two lanes share one assembly, and its paired root holds lane + 2's trees: one whose HEAD's own trailer is lane 2's, unpushed, and one with + nothing committed that only lane 2's writers file names. Lane 1's handoff + neither polls, counts nor records either - each is one `PAIRED ROOT, not + this lane's` line outside WRITERS - so lane 1's `lane-end` passes; and lane + 2's handoff polls and records both, each under its leg.""" + b, t = triad + a, code, spec = t["assembly"], t["code"], t["spec"] + theirs = leg_tree(b, code, a / "worktrees" / "020-b" / "code", "020-b") + b.commit(theirs, "lane 2's feature code", {"b.txt": "b\n"}, lane="triad-2") + fresh = leg_tree(b, spec, a / "worktrees" / "021-c" / "spec", "021-c") + (fresh / "draft.md").write_text("lane 2's writer's first edit\n") + wf2 = writers_file(b, "triad-2", {fresh: "lane 2's 021-c spec writer"}) + + one = handoff(b, "triad-1", a) + assert one.returncode == 0, one.stdout + one.stderr + for tree in (theirs, fresh): + assert f"WRITER {tree} —" not in one.stdout, one.stdout + assert f"PAIRED ROOT, not this lane's: {tree} — branch" in one.stdout, one.stdout + assert f"writers polled: 0 under {a}" in one.stderr, one.stderr + assert "paired root: 2 tree(s)" in one.stderr, one.stderr + assert "lifecycle: SWAPPED" in one.stdout, one.stdout + assert "NOT POLLED" not in one.stdout + one.stderr + block = top_block(b, "triad-1") + writers, _sep, rest = block.partition("**PAIRED ROOT at ") + assert "(0 found under" in writers and "- none running at the handoff." in writers, block + assert str(theirs) not in writers and str(fresh) not in writers, block + assert f"2 tree(s) under `{a}/worktrees` that are NOT this lane's" in rest, block + assert (f"- PAIRED ROOT, not this lane's: `{theirs}` — branch `020-b`, last commit " + in rest), block + assert "HEAD's own `Lane:` trailer `triad-2`" in rest, block + assert f"- PAIRED ROOT, not this lane's: `{fresh}` — branch `021-c`" in rest, block + assert "HEAD's own `Lane:` trailer none" in rest, block + assert inventory(b, "triad-1") == {} + end = b.tool("lane-end", "triad-1") + assert end.returncode == 0, end.stdout + end.stderr + + two = handoff(b, "triad-2", a, "t018 handoff", "--writers-file", str(wf2)) + assert two.returncode == 0, two.stdout + two.stderr + for tree in (theirs, fresh): + assert f"WRITER {tree} —" in two.stdout, two.stdout + assert f"writers polled: 2 under {a}" in two.stderr, two.stderr + assert "PAIRED ROOT" not in two.stdout and "PAIRED ROOT" not in top_block(b, "triad-2") + assert inventory(b, "triad-2") == {str(theirs): str(code), str(fresh): str(spec)} + + +@NEEDS_UPSTREAM +def test_t018_a_paired_directory_the_handoff_cannot_list_is_said_and_the_lane_stays_swapping(triad): + """S1 OF THE PRE-PR REVIEW: a paired root, a feature directory, or a leg's + directory under one that could not be listed or searched hid what stood in + it from the poll IN SILENCE, while `lane-reconcile` said `indeterminate` + and the sweep refused. Now each is a `- NOT POLLED` line in WRITERS and a + note on stderr, the handoff still exits 0 (`R-A11-11`), and the lane stays + SWAPPING; the reconciliation is `indeterminate` for every one of them.""" + b, t = triad + if os.geteuid() == 0: + pytest.skip("root lists and searches a mode-000 directory") + a, code = t["assembly"], t["code"] + mine = leg_tree(b, code, a / "worktrees" / "030-m" / "code", "030-m") + b.commit(mine, "the lane's feature code", {"m.txt": "m\n"}, lane="triad-1") + clone = a / "worktrees" / "031-n" / "spec" + b.git("clone", "-q", b.remotes / "triad-spec.git", clone) + cases = ( + (a / "worktrees", f"the assembly's paired root {a}/worktrees could not be listed", False), + (a / "worktrees" / "030-m", + f"the assembly's feature directory {a}/worktrees/030-m could not be listed", False), + (clone, f"the assembly's paired tree directory {clone} could not be searched", True), + ) + for target, words, mine_seen in cases: + os.chmod(target, 0) + try: + proc = handoff(b, "triad-1", a, "unlistable") + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + finally: + os.chmod(target, 0o755) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert (f"WRITER {mine} —" in proc.stdout) is mine_seen, (target, proc.stdout) + assert words in proc.stderr, (target, proc.stderr) + assert "may not have been polled" in proc.stderr, (target, proc.stderr) + assert "lifecycle: SWAPPING" in proc.stdout, (target, proc.stdout) + assert f"- NOT POLLED: {words}" in top_block(b, "triad-1"), target + verdict = field(rec.stdout, "VERDICT") + assert verdict and verdict[0] == "indeterminate", (target, rec.stdout) + assert words in verdict[1], (target, verdict) + proc = handoff(b, "triad-1", a, "readable again") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"WRITER {mine} — branch 030-m" in proc.stdout, proc.stdout + assert f"PAIRED ROOT, not this lane's: {clone} —" in proc.stdout, proc.stdout + assert "NOT POLLED" not in proc.stdout + proc.stderr + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + + +@NEEDS_UPSTREAM +def test_t018_a_tree_of_a_nested_dependency_keeps_the_checkout_add_recorded(triad): + """N5 OF THE PRE-PR REVIEW: a tree `add` made from a dependency nested in a + leg (`--checkout /code/upstream/dep`) is no declared leg's, and + the next handoff re-recorded it under the assembly - the loss (b) fixed for + the legs. A recorded checkout of the tree's own repository is now kept.""" + b, t = triad + dep = t["dep"] + made = b.tool("lane-worktrees", "add", "triad-1", "d1", "--checkout", dep) + assert made.returncode == 0, made.stderr + tree = made.stdout.strip() + assert inventory(b, "triad-1")[tree] == str(dep) + proc = handoff(b, "triad-1", t["assembly"]) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"WRITER {tree} —" in proc.stdout, proc.stdout + assert inventory(b, "triad-1")[tree] == str(dep) + + # ===================================================================== (3) -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_reconcile_reports_what_the_legs_hold_and_no_sidecar_names(triad): """(3) THE LEGS' REGISTRATIONS AND THE PAIRED ROOT ARE READ: a spec tree made away from every root, a paired code tree, a clone where a paired @@ -278,7 +406,7 @@ def test_t018_reconcile_reports_what_the_legs_hold_and_no_sidecar_names(triad): assert field(proc.stdout, "VERDICT")[0] == "ungraceful-stop", proc.stdout -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_reconcile_pronounces_no_clearance_over_legs_nobody_read(triad): """A manifest, or the paired root, that could not be read leaves paths nobody inspected: the verdict is `indeterminate`, never a clearance.""" From 352c1b6f094e1c441cf2fd2ec0798e1f74bf5548 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 19:03:11 +0000 Subject: [PATCH 16/42] test_commit_push_published_edit_194: the refusal case names a transcript, so `log` reaches refuse_dirty_checkout instead of the Amendment 7(b) session refusal The first locked run showed case 6 failing on both trees for that reason, before the checkout was ever read. Co-Authored-By: Claude Opus 5.5 --- tests/test_commit_push_published_edit_194.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 0c4970d5..96be311a 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -154,9 +154,9 @@ def peer_edit_in_wip(self, line: str) -> None: with (self.wip / PEER_HANDOFF).open("a", encoding="utf-8") as fh: fh.write(line + "\n") - def helper(self, *args: str, lane: str = LANE) -> subprocess.CompletedProcess: + def helper(self, *args: str, lane: str = LANE, **extra: str) -> subprocess.CompletedProcess: self.net_log.write_text("", encoding="utf-8") - env = dict(self.env, LANES_LANE=lane) + env = dict(self.env, LANES_LANE=lane, **extra) return subprocess.run(["bash", str(REPO / "lanes-edit.sh"), *args], env=env, cwd=str(self.wip), capture_output=True, text=True, timeout=180, check=False) @@ -313,7 +313,10 @@ def test_the_dirty_checkout_refusal_names_the_act_to_re_run(box): were the re-run. The authors commit their files; what is re-run is the act that was refused, whose own push publishes their commits.""" box.peer_edit_in_wip("a peer's line, mid-write") - result = box.helper("log", "NOTED", f"lane:{LANE}", "a note") + # An event names the transcript taking the act (Amendment 7(b)); without + # one the write refuses before it ever reads the checkout. + result = box.helper("log", "NOTED", f"lane:{LANE}", "a note", + LANES_SESSION="0e3c9a52-6d1b-4b8e-9f3a-2a7c5d4e6f81") assert result.returncode == 2, result.stderr assert "RECOVERY — whoever wrote those files commits them:" in result.stderr assert "then re-run the refused write NOTED" in result.stderr, result.stderr From 6658396e45ddeb125f21e48dbf2ce33126388224 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 19:42:46 +0000 Subject: [PATCH 17/42] T018 docs: README-lanes and the analysis say what M1 (a), S1 and S2 changed; the analysis's false statements about the branch are dated and corrected (S4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/README-lanes.md, T018 section: - M1 (a): "each lane's handoff records every paired tree" is gone. The handoff polls and records a paired tree only where the lane's inventory names it, its HEAD's own Lane: trailer is the lane's on a commit origin's default branch lacks, or the writers file names it; every other one is a "PAIRED ROOT, not this lane's" line outside WRITERS, not counted or recorded. The sweep's bullet says the handoff no longer widens its paired-root rule, and the parked-tree paragraph says a handoff does not record a parked tree for the lane. - S1: the handoff's NOT POLLED, stderr note and SWAPPING for an unlistable paired root, feature directory or leg directory, or an unreadable inventory; reconcile's indeterminate and the sweep's refusal now also cover a leg directory that cannot be searched. - S2: "no --yes acts on a leg" is now "on a leg checkout": under --branches a leg's branches are rows like the checkout's own, and --yes deletes a merged lane branch there as it does in the checkout, bundling first. - N5: a tree recorded under a checkout of its own repository (a dependency nested in a leg) keeps that checkout through a handoff. docs/triad-lane-tooling-analysis.md (S4): dated 2026-10-13 notes at §0 item 1 and §6. "This branch carries no test change of its own" was true of its first commit only; it now carries two test modules and two goldens and still changes none of the suite's roots. §6 records M1 (a) and S1, and §7 item 11 says the handoff no longer closes the parked-tree gap by itself. Verification, one run through tests/run.sh on this commit's tree, 1511f94 (4d4957d plus these docs; main f87fd5a merged at 8cb1368), 2026-10-13 19:10-19:40Z. Every module but the four below was --ignore'd, with -k "triad_lane_tooling or repo_hygiene or test_the_lane_helper_suite_passes": 181 passed, 1 failed, 1 deselected in 1818.96s. - tests/test_triad_lane_tooling.py, tests/test_triad_lane_tooling_handoff.py (both goldens unregenerated) and tests/test_repo_hygiene.py: all passed. - The shell suite, tests/test_lane_helpers_suite.py:: test_the_lane_helper_suite_passes: 4598 passed, 2 failed. Both are lane-start row-stamp assertions, "…with clause (c)'s directory and window in the tail" and "the row's stamp states clause (c)'s two facts … in its TAIL". lane-start's cut_to_line caps the stamp at 230 characters (lane-start:3502-3510). Under run.sh the suite's $HOME is ~/.local/state/openRepoTools/tmp/-/tmp/tmp.XXXXXXXXXX/home, and the stamp then reaches 231 characters before its window value, so the tail is cut ("…/home/projects/repoA; ..."). With a /tmp-length home it is 165. This branch changes none of lane-start, tests/test_lane_helpers.sh, tests/test_lane_helpers_suite.py or tests/run.sh (git diff f87fd5a is empty for all four), so the two failures do not come from it. An earlier attempt (run1) ran nothing: zsh passed the --ignore list as one argument and pytest exited 4. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 78 +++++++++++++++++++++-------- docs/triad-lane-tooling-analysis.md | 23 ++++++++- 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index b36316ad..93f5cd85 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3896,7 +3896,8 @@ one passed `lane-end`. the leg's own, as `code/upstream/openRepoShape` will be), as a **`leg` row** — `clean`, `dirty`, `unpushed`, `dirty+unpushed`, `absent` (not checked out here) or `unreadable`. A leg is the assembly's, shared by every lane the assembly - homes, as the assembly's own checkout is: **no `--yes` acts on one**. It is + homes, as the assembly's own checkout is: **no `--yes` acts on a leg + checkout** (its branches are another matter: see `--branches` below). It is COUNTED (exit 3, and `lane-end`'s gate) only where its HEAD's own `Lane:` trailer is this lane's and it holds edits or commits origin lacks — the rule the sweep already applies to `.claude/worktrees` — or where it could not be read; one @@ -3904,12 +3905,20 @@ one passed `lane-end`. * lists the worktrees git registers in **every leg** — a FOREIGN row names the leg it is registered in — so a tree recorded with the leg as its checkout is the lane's as any recorded tree is; -* takes **`/worktrees`** as a root: a tree there is the lane's by its - HEAD's own `Lane:` trailer and by nothing else, because the paired root, like - `.claude/worktrees`, is per feature and not per lane; a standalone clone there is - FOREIGN; a directory there that cannot be listed refuses, exit **2**; -* with `--branches`, reads every leg's branches too, each row naming its leg; with - `--fetch` (and under `--yes`), asks each leg's origin. +* takes **`/worktrees`** as a root: a tree there that the lane's + inventory does not record is the lane's by its HEAD's own `Lane:` trailer and by + nothing else, because the paired root, like `.claude/worktrees`, is per feature + and not per lane — and `lane-handoff` records a tree there only on that rule, or + where the lane's own writers file names it (below), so the inventory does not + take in other lanes' trees; a standalone clone there + is FOREIGN; the root, a feature directory or a leg's directory under it that + cannot be listed or searched refuses, exit **2**; +* with `--branches`, makes each leg's branches rows like the checkout's own, each + naming its leg, and **`--yes` deletes a merged lane branch there exactly as it + does in the checkout** — bundling first any commits only its reflog names, and + deleting its origin branch too where the row says `delete+remote`. No `--yes` + acts on a leg *checkout*. With `--fetch` (and under `--yes`), it asks each leg's + origin. A manifest, or a leg's own submodule list, that cannot be read refuses, exit **2**. **A checkout with no `project.yaml` is asked nothing** — no helper call, no @@ -3923,19 +3932,38 @@ offered when legs are all it counted), and lists a leg that holds work and is no this lane's below the table as NOT counted — whether the gate passes or not. **`lane-handoff` and `lane-reconcile` (T018 (b)).** Where the lane's checkout is an -assembly, `lane-handoff` also polls the **paired root**: `/worktrees/` -and `/`. It polls every directory there with a `.git` -of its own, exactly as it polls `.claude/worktrees`, because the paired root is per -feature, not per lane, and a writer with nothing committed yet has no trailer to be -found by. So a writer in a paired tree is in the WRITERS block. Every tree it polls is -**recorded under the checkout it belongs to**. A worktree of a leg's repository (a -paired tree, or one `lane-worktrees add --checkout /code` made under the -lane's root) is recorded under that leg, the path `project-legs` prints and `add` -records. Any other tree keeps the lane's `dir`, as before. A leg that is not checked -out here (never initialized, or deinitialized) names no tree. In an assembly several -lanes share, each lane's handoff records every paired tree, as it records every tree in -`.claude/worktrees`. A tree two lanes' inventories both name is `keep` in both sweeps -until a person says whose it is. A worktree a leg registers anywhere else is not polled +assembly, `lane-handoff` also looks in the **paired root**: +`/worktrees/` and `/`, at every +directory there with a `.git` of its own. The paired root is per feature and not per +lane — every lane the assembly homes keeps its feature trees there — so the handoff +takes a tree there as this lane's on the sweep's terms and on no wider ones. It +**polls and records a paired tree only where one of three things holds**: + +* this lane's inventory already names it; +* its HEAD's own last `Lane:` trailer is this lane's, on a commit origin's default + branch lacks — the sweep's own rule for that root; +* the `--writers-file` names it, because a writer with nothing committed yet has no + trailer to be found by. + +Those trees are in the WRITERS block and its count. **Every other checkout there is +another lane's, or nobody's**: one line in the top block under a heading of its own, +outside WRITERS — `- PAIRED ROOT, not this lane's: — branch …`, with its last +commit and its HEAD's own trailer, which is what finds its lane. It is not counted, not +recorded, and never a writer for the next session to relaunch; its own lane's handoff +records it. So one lane's `lane-end` never refuses on another lane's paired work, and +its `sweep --yes` never acts on it. This is option (a) of the pre-PR review's M1, +pending lane 1's ruling on #186: a handoff that recorded every paired tree made other +lanes' trees the running lane's inventory, and a tree two lanes' inventories both name +is `keep`, counted, in both sweeps. + +Every tree it polls is **recorded under the checkout it belongs to**. A worktree of a +leg's repository (a paired tree, or one `lane-worktrees add --checkout +/code` made under the lane's root) is recorded under that leg, the path +`project-legs` prints and `add` records. A tree the inventory already records under a +checkout of the tree's own repository keeps it: a dependency nested in a leg +(`--checkout /code/upstream/`) is no declared leg. Any other tree keeps +the lane's `dir`, as before. A leg that is not checked out here (never initialized, or +deinitialized) names no tree. A worktree a leg registers anywhere else is not polled as a writer, just as the checkout's own registrations elsewhere are not; `lanes-edit.sh lane-reconcile` reports it. The reconciliation reads what the legs hold beside what the checkout holds. Every worktree git registers in a checked-out leg that @@ -3951,7 +3979,11 @@ place (a tree of the lane's own repository is still recorded), and leaves the la sweep refuses with ("the legs of the assembly `` could not be read"): the sweep reads that verdict first and refuses on it, exit **2**. The verdict is `indeterminate` too where a leg's registrations, the paired root or a feature directory under it -cannot be listed. **A checkout with no `project.yaml` is asked nothing**, and one whose +cannot be listed, or a leg's directory under a feature cannot be searched. For the +same directories, and where the lane's inventory cannot be read (which paired trees it +names would then be unknown), `lane-handoff` gives the same three signs: a +`- NOT POLLED: …` line in WRITERS, a note on stderr, and the lane left `SWAPPING`. +**A checkout with no `project.yaml` is asked nothing**, and one whose `project.yaml` names no leg but itself is asked only `project-legs`, which answers 8. The handoff's output, top block and inventory, and the reconciliation, are byte for byte what they were. `tests/test_triad_lane_tooling_handoff.py` holds them to the @@ -3972,7 +4004,9 @@ assembly**, run that dry run and read every FOREIGN row under `/worktr one whose HEAD, or whose branch's upstream, is a `wip: park … — lane ` commit, or that holds uncommitted edits, may be this lane's parked work. Land it or hand it over first, or record it with `lanes-edit.sh set-lane-tree ---checkout /` so the gate counts it. +--checkout /` so the gate counts it. `lane-handoff` does not record it +for you: on the sweep's terms it records a paired tree with no trailer of the lane's +only where the inventory or the writers file already names it. ### Two protocol lines this act assumes (proposed for the amendment that ratifies it) diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index 633415f2..f409cf90 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -33,7 +33,11 @@ checks) and T018 (feature selection, park/resume and bindings). `$selfdir` or `__file__`. All of those helpers move to `code/` together, so that lookup works unchanged. The only code that really reads across legs is in the **tests**. Lane 1's PR #190 (`feat/test-roots-for-the-triad` at `c139e88`) gives the suite those roots, so this - branch carries no test change of its own (§6). + branch carries no test change of its own (§6). **2026-10-13:** that was true of this + branch's first commit only. It now carries T018's shape-gated changes to five tools, + with two test modules of its own (`tests/test_triad_lane_tooling.py`, + `tests/test_triad_lane_tooling_handoff.py`) and their two goldens; it still changes none + of the suite's roots. The §4.4 note of 2026-10-09 and the §6 notes say what it carries. 2. **The installer is the most fragile surface.** The README one-liners and every already-installed copy's own `openRepoTools --install` fetch files by **repository-root path** from `opensoft/openRepoTools@main` (openRepoTools:70-71, :258-275, :1370-1394). Once @@ -570,6 +574,18 @@ the leg it made a tree from. On a single repository every output is byte for byt `tests/fixtures/t018-handoff/`. Usage text moves for every shape (§4.4). The branch merges main at `f87fd5a` (#190) and changes none of #190's test-root files. +**2026-10-13: the paired root in `lane-handoff`, after the pre-PR review.** (b) above is +narrower now (the review's M1, option (a), pending lane 1's ruling on #186). The handoff +polls and records a paired tree only where the lane's inventory names it, its HEAD's own +`Lane:` trailer is the lane's on a commit origin's default branch lacks (the sweep's +rule), or the writers file names it. Every other paired tree is a `PAIRED ROOT, not this +lane's` line in the top block, outside WRITERS, not counted and not recorded. A paired +root, feature directory or leg directory it cannot list or search, and an inventory it +cannot read, are `NOT POLLED` and leave the lane `SWAPPING`, as `lane-reconcile` is +`indeterminate` and the sweep refuses for the same directories. The heading below, +"why this branch carries no test change", means no change to the suite's **roots**: the +branch's own tests are the two modules and goldens named above. + **Overlap with #190, and why this branch carries no test change.** An earlier draft of this branch made the suite's roots shape-aware. Lane 1's PR #190 does the same work, more completely, under other names. The draft was dropped before it was committed; its diff is @@ -640,7 +656,10 @@ kept only in this lane's scratch directory. unclaimed leg checkouts. It is counted only where the lane's #97 inventory names it, which happens through `lanes-edit.sh set-lane-tree`, or a `lane-handoff` that ran while it stood (T018 (b)); `lane-worktrees add` makes its trees under the - lane's own root, never in the paired root. Lane 3 changed no attribution code; `docs/README-lanes.md` + lane's own root, never in the paired root. **2026-10-13:** since the handoff polls the + paired root by the sweep's rule (§6, note of that date), it records a parked or + resumed tree only where the inventory or the writers file already names it, so the + handoff no longer closes this gap by itself. Lane 3 changed no attribution code; `docs/README-lanes.md` (T018 section) says what to check before `lane-end`. **Choose:** (i) the sweep also reads park's WIP subject as the lane's mark, which couples `lane-worktrees` to a workBenches subject format; or (ii) park's WIP commit carries a `Lane: ` From 836e1e1289341fa52678a822817b0faaf08fd1c9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 20:46:19 +0000 Subject: [PATCH 18/42] #214 review: only `commit` publishes the commits a write with nothing staged finds ahead, and only lane tooling's own; the conflict RECOVERY lists HEAD and stops on a moved checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review measured the blocker: with another lane's unpushed commit in the shared checkout deleting `scripts/link-estates`, a no-op `replace-in-row` published it and origin lost the file. Every write with nothing staged took the push loop; now only `commit` does (CP_PUBLISH_AHEAD, set in its arm alone and never read from the environment), and every other no-op write is today's exit 0 with no network call. `commit`'s publish reads every queued commit first and refuses with 2, naming the commit and the path, when one deletes a file or touches a path outside lanes/ and handoffs/. A failed read refuses the same way. Nothing is pushed. The conflict RECOVERY lists `cherry -v origin/ HEAD`, which is what the reset acts on, and adds a STOP for a checkout that moved after the abort. It also says a LANES(concurrent@…) or LANES(pre-existing@…) capture names no lane and is not the reader's. The dirty-elsewhere RECOVERY's claim is qualified ("if lanes/LANES.md is clean") and names the refusal. The publish list counts what it leaves out. replace_line's contract comment and the manual say the same. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 29 +++--- lanes-edit.sh | 69 ++++++++++---- tests/test_commit_push_published_edit_194.py | 94 ++++++++++++++++++-- 3 files changed, 157 insertions(+), 35 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index cc0511ce..eebd1e8e 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -94,9 +94,13 @@ files — `pull --rebase` refuses outright on any unstaged tracked change — an pushes straight out instead, warning and naming the files. If the push is also rejected (the remote moved), it stops with exit 3, leaves your edit as a local commit, and prints the recovery: the owner of that file commits it, then you -re-run `lanes-edit.sh commit`. With nothing left to stage, that re-run makes no -commit of its own and pushes the local commits origin does not have yet, through -the same loop, guard and retries (#194); with none, it is the no-op it always was. +re-run `lanes-edit.sh commit`. If `lanes/LANES.md` is clean, that re-run makes +no commit of its own and pushes the local commits origin does not have yet, +through the same loop, guard and retries (#194). It refuses with 2 and pushes +nothing if any of those commits deletes a file or touches a path outside +`lanes/` and `handoffs/`. Only `commit` publishes that queue: any other write with +nothing to stage leaves it local, and with nothing ahead `commit` is the no-op +it always was. ## THE ONE HAZARD — `sed -i` DESTROYS THE SYMLINK @@ -207,14 +211,17 @@ dangling commit by rebasing it onto a newer base, which changes its hash, so actually published. `git -C cherry origin/main | grep -c '^+'` compares by patch instead and still finds it; 0 means it is already there, in which case STOP. Only once fetch succeeded AND this -prints something other than 0 do you list WHOSE they are, with -`git -C cherry -v origin/main `: one `+` -line per local commit origin lacks, and in a shared checkout not every one is -yours. If any `+` line is not your own write, STOP and take it to the lane its -subject names. Never reset, stash or overwrite a peer's work: `reset --hard` -drops EVERY `+` commit in the checkout, theirs with yours, and origin holds no -copy of any of them (#194). Only when every `+` line is yours do you read your -edit back with +prints something other than 0 do you check that `git -C rev-parse HEAD` is still the commit the recovery named; if the checkout +has moved since the abort, STOP. Then list WHOSE the commits are, with +`git -C cherry -v origin/main HEAD`: one `+` line per +local commit origin lacks, which is exactly what a reset drops, and in a shared +checkout not every one is yours. If any `+` line is not your own write, STOP and +take it to the lane its subject names. A `LANES(concurrent@…)` or +`LANES(pre-existing@…)` capture names no lane, so it is not yours either. Never +reset, stash or overwrite a peer's work: `reset --hard` drops EVERY `+` commit +in the checkout, theirs with yours, and origin holds no copy of any of them +(#194). Only when every `+` line is yours do you read your edit back with `git -C diff origin/main..HEAD -- lanes/LANES.md`, then `git -C reset --hard origin/main` and redo it on top of the peer's version. diff --git a/lanes-edit.sh b/lanes-edit.sh index b4acc853..20f2ba45 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1098,6 +1098,10 @@ LANES_PATH="${LANES_PATH:-$(git -C "${LANES_DIR:-.}" rev-parse --show-prefix 2>/ # lane's claim landed first. CP_PATHS=("$LANES_PATH") CP_AFTER_REBASE="" +# Set by `commit` alone, and never taken from the environment: with nothing +# staged, commit_push publishes the local commits ahead of origin for that one +# write and for no other (#194). +CP_PUBLISH_AHEAD=0 # die "" [] — the MESSAGE is $1 alone. It used to be "$*", # which joined the exit code on to the end of every refusal that passed one: @@ -1592,7 +1596,9 @@ replace_line() { # nothing staged and says so on its own line, and every one of the four # callers (`set-row-state`, `append-session-id`, `replace-in-row` and # `migrate-state-cells`) wanted the file to SAY something rather than to - # differ from what it said. + # differ from what it said. Nor does it push: a write with nothing staged + # publishes nothing, whatever this shared checkout has queued ahead of + # origin; only `commit` does that, and only lane tooling's own commits (#194). if cmp -s -- "$pre" "$out"; then REPLACE_LINE_NOOP=1 note "line $n of $LANES_FILE is already exactly what this write asked for — nothing rewritten, and nothing to commit" @@ -2020,15 +2026,23 @@ commit_push() { [ -z "$cp_byte" ] || die "$(bytecode_refusal_text "$cp_byte")" 2 git -C "$LANES_REPO" -c "$CP_EXACT" add -- ${cp_add[@]+"${cp_add[@]}"} || die "git add failed" 6 fi - # #194 — NOTHING STAGED IS NOT NOTHING TO PUBLISH. A write the push loop - # below could not get out (a peer's uncommitted file blocking the rebase, six - # rejected pushes) leaves its edit as a LOCAL commit, and the RECOVERY printed - # for it is to re-run `commit` once the way is clear. Returning here sent that - # re-run home with exit 0 and the commit still local. So a write with nothing - # of its own to commit still takes the push loop — under the caller's lock, - # with the dirty-elsewhere guard and the retries — whenever HEAD holds commits - # `origin/$LANES_BRANCH` does not, as of the last fetch (no such ref reads as - # none). With none it is the no-op it always was, and reaches no network. + # #194 — NOTHING STAGED IS NOT NOTHING TO PUBLISH, FOR `commit` ALONE. A + # write the push loop below could not get out (a peer's uncommitted file + # blocking the rebase, six rejected pushes) leaves its edit as a LOCAL commit, + # and the RECOVERY printed for it is to re-run `commit` once the way is clear. + # Returning here sent that re-run home with exit 0 and the commit still local. + # So `commit`, the one caller that sets CP_PUBLISH_AHEAD, still takes the push + # loop (its lock, the dirty-elsewhere guard, the retries) whenever HEAD holds + # commits `origin/$LANES_BRANCH` does not, as of the last fetch; no such ref + # reads as none. Every other write with nothing to stage stays the no-op it + # always was and reaches no network: a repeated `set-row-state` must not turn + # into a push of whatever this SHARED checkout has queued (the #214 review + # measured exactly that deleting `scripts/link-estates` on origin). + # + # AND `commit` PUBLISHES ONLY WHAT LANE TOOLING STRANDS. Every queued commit is + # read first; one that DELETES a file, or touches a path outside `lanes/` and + # `handoffs/`, refuses the whole publish (2) and nothing is pushed. A read + # that fails refuses the same way, because it proves nothing either way. cp_publish=0 if git -C "$LANES_REPO" -c "$CP_EXACT" diff --cached --quiet -- "${CP_PATHS[@]}"; then cp_ahead="$(git -C "$LANES_REPO" --no-pager log --oneline "origin/$LANES_BRANCH..HEAD" 2>/dev/null || :)" @@ -2036,8 +2050,24 @@ commit_push() { note "nothing staged for ${CP_PATHS[*]} — no commit made" return 0 fi - note "nothing staged for ${CP_PATHS[*]} — no commit made; pushing the local commits origin/$LANES_BRANCH does not have:" + cp_n="$(printf '%s\n' "$cp_ahead" | awk 'END { print NR }')" + if [ "$CP_PUBLISH_AHEAD" != 1 ]; then + note "nothing staged for ${CP_PATHS[*]} — no commit made; the $cp_n local commit(s) ahead of origin/$LANES_BRANCH stay local, and only \`lanes-edit.sh commit\` publishes them" + return 0 + fi + cp_files=""; cp_frc=0 + cp_files="$(git -C "$LANES_REPO" -c core.quotePath=false --no-pager log --no-renames --name-status --format='commit %h' "origin/$LANES_BRANCH..HEAD")" || cp_frc=$? + [ "$cp_frc" = 0 ] || die "git could not list what the $cp_n local commit(s) ahead of origin/$LANES_BRANCH change, so whether they are \`commit\`'s to publish is unknown; nothing was pushed (#194)" 2 + cp_bad="$(printf '%s\n' "$cp_files" | awk -F '\t' '/^commit / { c = substr($0, 8); next } + NF >= 2 && ($1 == "D" || ($2 !~ /^lanes\// && $2 !~ /^handoffs\//)) { printf " %s %s %s\n", c, $1, $2 }')" + if [ -n "$cp_bad" ]; then + note "nothing staged for ${CP_PATHS[*]}, and of the $cp_n local commit(s) ahead of origin/$LANES_BRANCH these delete a file or reach outside lanes/ and handoffs/:" + printf '%s\n' "$cp_bad" >&2 + die "refusing to publish: \`commit\` pushes only the register and handoff commits lane tooling strands (#194), so nothing was pushed. Whoever queued those commits publishes them; never reset them away. Read them: git -C $LANES_REPO log --stat origin/$LANES_BRANCH..HEAD" 2 + fi + note "nothing staged for ${CP_PATHS[*]} — no commit made; pushing the $cp_n local commit(s) origin/$LANES_BRANCH does not have:" printf '%s\n' "$cp_ahead" | head -n 10 >&2 || : + [ "$cp_n" -le 10 ] || note " … and $((cp_n - 10)) more" cp_publish=1 fi if [ "$cp_publish" = 1 ]; then @@ -2105,8 +2135,9 @@ EOF if [ "$attempt" -ge 6 ]; then note "RECOVERY: their author commits those files (a handoff: handoff(@): )," note " then re-run: LANES_LANE= lanes-edit.sh commit \"\" — your edit is already" - note " a local commit here, so that re-run stages nothing, makes no commit of its own, and" - note " pushes the local commits this lists (#194): git -C $LANES_REPO log --oneline origin/$LANES_BRANCH..HEAD" + note " a local commit here. If $LANES_PATH is clean, that re-run makes no commit of its own and" + note " pushes the local commits this lists (#194), refusing (2) and pushing nothing if any of them" + note " deletes a file or reaches outside lanes/ and handoffs/: git -C $LANES_REPO log --oneline origin/$LANES_BRANCH..HEAD" die "could not push origin/$LANES_BRANCH: behind the remote, and a peer's uncommitted file blocks the rebase. Nothing was lost — your commit is local." 3 fi elif git_net -C "$LANES_REPO" pull --rebase -q origin "$LANES_BRANCH"; then @@ -2170,10 +2201,11 @@ EOF note " git -C $LANES_REPO fetch origin $LANES_BRANCH || echo FETCH-FAILED # if that printed FETCH-FAILED, STOP and retry — a stale or unreachable origin proves nothing either way" note " git -C $LANES_REPO cherry origin/$LANES_BRANCH $cp_sha | grep -c '^+' # BY CONTENT, not by sha: a peer's rebase changes the hash but git cherry still finds the same patch" note " if that printed 0, STOP — reset or redo now would write it a second time." - note " git -C $LANES_REPO cherry -v origin/$LANES_BRANCH $cp_sha # WHOSE: one '+' line per local commit origin lacks, and this checkout is shared" - note " if any '+' line is not your own write, STOP and take it to the lane its subject names:" - note " never reset, stash or overwrite a peer's work — the reset below drops EVERY '+' commit in this checkout," - note " theirs with yours, and origin holds no copy of any of them (#194)." + note " git -C $LANES_REPO rev-parse HEAD # if this is not $cp_sha, STOP: the checkout moved after this abort, and the lines below no longer describe it" + note " git -C $LANES_REPO cherry -v origin/$LANES_BRANCH HEAD # WHOSE: one '+' line per local commit origin lacks, which is what the reset below drops" + note " if any '+' line is not your own write, STOP and take it to the lane its subject names; a LANES(concurrent@…) or" + note " LANES(pre-existing@…) capture names no lane, so it is NOT yours. Never reset, stash or overwrite a peer's work:" + note " the reset below drops EVERY '+' commit in this checkout, theirs with yours, and origin holds no copy of any of them (#194)." note " git -C $LANES_REPO diff origin/$LANES_BRANCH..HEAD -- ${CP_PATHS[*]} # only if it is not there: read back exactly what you wrote" note " git -C $LANES_REPO reset --hard origin/$LANES_BRANCH # only if every '+' line is yours: drop the local commits (NOTE: also drops any" note " # uncommitted peer edit in this checkout)" @@ -13722,6 +13754,9 @@ Nothing was written." 2 msg="$msg + sweeps uncommitted edit to row $other" fi fi + # #194 — the one write that, with nothing to stage, publishes the local + # commits ahead of origin: it is the RECOVERY's re-run. See commit_push. + CP_PUBLISH_AHEAD=1 commit_push "LANES(${LANES_LANE:-unknown}@$WS): $msg" ;; diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 96be311a..588a6096 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -38,6 +38,9 @@ PEER_LANE = "repoQ-1" REGISTER = "lanes/LANES.md" PEER_HANDOFF = f"handoffs/{PEER_LANE}.md" +#: A tracked file outside `lanes/` and `handoffs/`, the kind the #214 review +#: saw a queued commit delete from origin. +TOOL = "scripts/link-estates" SUBJECT = "row rewritten by hand" #: The environment a case must not inherit from the host that runs it. @@ -111,7 +114,8 @@ def __init__(self, root: Path) -> None: f"{_row(LANE, 'LIVE · seeded')}\n" f"{_row(PEER_LANE, 'LIVE · seeded')}\n") self.write(self.wip, PEER_HANDOFF, f"# {PEER_LANE}\n") - self.git("-C", str(self.wip), "add", "--", REGISTER, PEER_HANDOFF) + self.write(self.wip, TOOL, "#!/usr/bin/env bash\n") + self.git("-C", str(self.wip), "add", "--", REGISTER, PEER_HANDOFF, TOOL) self.git("-C", str(self.wip), "commit", "-q", "-m", "seed") self.git("-C", str(self.wip), "push", "-q", "origin", "main") self.git("clone", "-q", str(self.origin), str(self.peer)) @@ -154,6 +158,17 @@ def peer_edit_in_wip(self, line: str) -> None: with (self.wip / PEER_HANDOFF).open("a", encoding="utf-8") as fh: fh.write(line + "\n") + def queue(self, message: str, *, write: str = "", delete: str = "") -> str: + """A commit made in THIS checkout and not pushed: someone's queue.""" + if write: + with (self.wip / write).open("a", encoding="utf-8") as fh: + fh.write(f"{message}\n") + self.wgit("add", "--", write) + if delete: + self.wgit("rm", "-q", "--", delete) + self.wgit("commit", "-q", "-m", message) + return self.wgit("rev-parse", "--short", "HEAD").strip() + def helper(self, *args: str, lane: str = LANE, **extra: str) -> subprocess.CompletedProcess: self.net_log.write_text("", encoding="utf-8") env = dict(self.env, LANES_LANE=lane, **extra) @@ -205,6 +220,7 @@ def test_the_printed_recovery_publishes_the_edit_it_stranded(box): assert len(box.unpublished()) == 1, "the edit is a local commit and only that" # The RECOVERY names the re-run and says what it does once nothing is staged. assert 'LANES_LANE= lanes-edit.sh commit ""' in stranded.stderr + assert "If lanes/LANES.md is clean, that re-run makes no commit of its own" in stranded.stderr assert "pushes the local commits this lists" in stranded.stderr, stranded.stderr box.wgit("commit", "-q", "-m", f"handoff({PEER_LANE}@Eagle): the mid-write line", @@ -292,19 +308,28 @@ def test_the_conflict_recovery_warns_that_a_reset_drops_a_peers_commits(box): assert "REBASE CONFLICT on origin/main" in err assert box.wgit("merge-base", "--is-ancestor", peer_commit, "HEAD") == "" + aborted_at = box.wgit("rev-parse", "HEAD").strip() count = _line_of(err, "cherry origin/main") - listing = _line_of(err, "cherry -v origin/main") - warning = _line_of(err, "never reset, stash or overwrite a peer's work") + moved = _line_of(err, f"rev-parse HEAD # if this is not {aborted_at}, STOP") + listing = _line_of(err, "cherry -v origin/main HEAD") + warning = _line_of(err, "Never reset, stash or overwrite a peer's work") drops = _line_of(err, "drops EVERY '+' commit in this checkout") reset = _line_of(err, "reset --hard origin/main") - assert count < listing < warning <= drops < reset, err - - # The listing the RECOVERY prints is a command that runs, and it names - # the peer's commit as one origin does not have. + assert count < moved < listing < warning < drops < reset, err + assert f"cherry origin/main {aborted_at}" in err.splitlines()[count] + captures = "\n".join(err.splitlines()[listing:drops + 1]) + assert "LANES(concurrent@" in captures and "LANES(pre-existing@" in captures, captures + assert "names no lane, so it is NOT yours" in captures, captures + + # The listing reads HEAD, which is what the reset acts on: a peer's commit + # made AFTER the abort is in it too, and the moved-checkout STOP fires. + later = box.queue(f"handoff({PEER_LANE}@Eagle): written after the abort", write=PEER_HANDOFF) + assert box.wgit("rev-parse", "HEAD").strip() != aborted_at command = err.splitlines()[listing].split("lanes-edit: ", 1)[1].split("#", 1)[0].split() listed = subprocess.run(command, env=box.env, capture_output=True, text=True, check=True).stdout assert f"+ {peer_commit}" in listed, listed + assert any(line.startswith("+ ") and later in line.split()[1] for line in listed.splitlines()), listed def test_the_dirty_checkout_refusal_names_the_act_to_re_run(box): @@ -320,3 +345,58 @@ def test_the_dirty_checkout_refusal_names_the_act_to_re_run(box): assert result.returncode == 2, result.stderr assert "RECOVERY — whoever wrote those files commits them:" in result.stderr assert "then re-run the refused write NOTED" in result.stderr, result.stderr + + +def test_a_no_op_write_never_publishes_what_the_checkout_queued(box): + """THE #214 REVIEW'S BLOCKER. This shared checkout holds another lane's + unpushed commit that deletes a file outside lane tooling's trees. A write + whose result is the line already there must stay what it is on `main`: + exit 0, no network call, origin untouched. Only `commit` publishes.""" + queued = box.queue("another lane's commit: retire the tool", delete=TOOL) + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + result = box.helper("replace-in-row", LANE, "seeded", "seeded") + assert result.returncode == 0, result.stderr + assert f"nothing staged for {REGISTER} — no commit made" in result.stderr + assert "stay local, and only `lanes-edit.sh commit` publishes them" in result.stderr + assert box.net_calls() == [] + assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) + assert box.origin_file(TOOL) == "#!/usr/bin/env bash\n" + assert box.unpublished()[0].startswith(queued) + + +@pytest.mark.parametrize("queued", [ + {"delete": TOOL, "status": "D", "path": TOOL}, + {"write": "workspaces/repoP.yaml", "status": "A", "path": "workspaces/repoP.yaml"}, + {"delete": PEER_HANDOFF, "status": "D", "path": PEER_HANDOFF}, +], ids=["deletes-a-tool", "outside-lane-tooling", "deletes-a-handoff"]) +def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, queued): + """`commit` publishes the register and handoff commits lane tooling strands + and nothing else: one queued commit that deletes a file, or touches a path + outside `lanes/` and `handoffs/`, refuses the WHOLE publish with 2, names + the commit and the path, and pushes nothing, not even the stranded edit.""" + box.set_state(box.wip, LANE, "LIVE · edited by hand") + box.wgit("commit", "-q", "-m", f"LANES({LANE}@Eagle): {SUBJECT}", "--", REGISTER) + if "write" in queued: + (box.wip / queued["write"]).parent.mkdir(parents=True, exist_ok=True) + sha = box.queue("a queued commit", write=queued.get("write", ""), + delete=queued.get("delete", "")) + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + assert result.returncode == 2, result.stderr + assert f" {sha} {queued['status']} {queued['path']}" in result.stderr, result.stderr + assert "nothing was pushed" in result.stderr + assert "pushing the" not in result.stderr + assert box.net_calls() == [] + assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) + + +def test_the_publish_counts_what_its_list_leaves_out(box): + """The list of commits being pushed shows ten and counts the rest.""" + for n in range(12): + box.queue(f"handoff({PEER_LANE}@Eagle): stamp {n}", write=PEER_HANDOFF) + result = box.helper("commit", SUBJECT) + assert result.returncode == 0, result.stderr + assert "pushing the 12 local commit(s) origin/main does not have:" in result.stderr + assert " … and 2 more" in result.stderr, result.stderr + assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") From fab2cbc57274522e174d9af079f3ed550bbe27a9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 20:47:30 +0000 Subject: [PATCH 19/42] test_commit_push_published_edit_194: the no-op case asserts its behaviour before its wording, so a regression to publishing fails on the push, not on a note Co-Authored-By: Claude Opus 5.5 --- tests/test_commit_push_published_edit_194.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 588a6096..c5805a10 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -356,12 +356,12 @@ def test_a_no_op_write_never_publishes_what_the_checkout_queued(box): head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") result = box.helper("replace-in-row", LANE, "seeded", "seeded") assert result.returncode == 0, result.stderr - assert f"nothing staged for {REGISTER} — no commit made" in result.stderr - assert "stay local, and only `lanes-edit.sh commit` publishes them" in result.stderr - assert box.net_calls() == [] + assert box.net_calls() == [], result.stderr assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) assert box.origin_file(TOOL) == "#!/usr/bin/env bash\n" assert box.unpublished()[0].startswith(queued) + assert f"nothing staged for {REGISTER} — no commit made" in result.stderr + assert "stay local, and only `lanes-edit.sh commit` publishes them" in result.stderr @pytest.mark.parametrize("queued", [ From 9f23a6b098212efa2c839624f98ec68100d6e96f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 20:49:41 +0000 Subject: [PATCH 20/42] WIP (untested): T018: lane-reconcile reports a leg whose .git git cannot read as the sweep does - a TREE row `unreadable` that requires recovery - instead of passing over it in silence lanes-edit.sh, lane_reconcile, the legs' registrations: a declared leg whose git toplevel is not the leg itself was skipped whether or not a .git stood in it, so a leg with a .git of its own that git cannot read (a gitfile naming no repository, or a .git where git answers the assembly) said nothing, while the sweep's `leg` row for it is `unreadable` and counted (exit 3, lane-end refuses). It now takes the sweep's rule (probe_leg: toplevel not the leg, and a .git stands there) and the sweep's word: one TREE row `unreadable`, in the sweep's words ("git could not be read through it (it has a .git of its own and git could not read it: )"), counted under "require recovery", so lane-start's recovery read counts it too. A leg with no .git (not checked out) is passed over as before, and a path a sidecar already names is not reported twice. The verdict is not changed: the sweep counts that leg and goes on, and an `indeterminate` here is what the sweep refuses on (exit 2) before it reads the legs, which would hide its own count and break test_t018_a_leg_git_cannot_read_is_counted_and_holds_lane_end. tests/test_triad_lane_tooling_handoff.py: a triad whose spec leg's .git is a gitfile naming no repository, then an empty .git directory: one `unreadable` row naming the leg, 1 require recovery, the verdict ungraceful-stop, and the sweep still exit 3 with the leg `unreadable` and counted; readable again, and deinitialized, the leg names nothing. docs/README-lanes.md (T018 section): one sentence for the row. A checkout with no project.yaml is untouched: the block runs only where project-legs answered legs (lrc_lgrc 0). lane-handoff's own leg loop (lane-handoff:2093-2095) skips such a leg in the same way; it is not changed here. Untested: the one verification run (run3, on this tree 2f23887) waited in the shared pytest lock queue from 20:50Z and was stopped at 21:21Z, before it took the lock, for the account swap; no count was taken. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 5 ++- lanes-edit.sh | 27 +++++++++++- tests/test_triad_lane_tooling_handoff.py | 55 +++++++++++++++++++++++- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 93f5cd85..cb75fe3f 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3969,7 +3969,10 @@ as a writer, just as the checkout's own registrations elsewhere are not; beside what the checkout holds. Every worktree git registers in a checked-out leg that no sidecar names is a `TREE` row: `unmanaged`, or `stale-registration` where its directory is gone, naming the leg. A checkout in the paired root that nothing else named -is `unmanaged`. Nothing is adopted, deleted or overwritten. +is `unmanaged`. A leg with a `.git` of its own that git cannot read is a `TREE` row +`unreadable` that requires recovery: the sweep's rule and word for its `leg` row, which +it counts. The verdict is not `indeterminate` for it, so the sweep still lists and +counts that leg. Nothing is adopted, deleted or overwritten. **A `project.yaml` that cannot be read is never "no legs".** `lane-handoff` still completes (`R-A11-11`), but it does not poll the paired root. It says so in the top diff --git a/lanes-edit.sh b/lanes-edit.sh index c6b37427..acd4a004 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -13130,11 +13130,36 @@ EOF # over. A leg's main worktree is listed FIRST and spelled as its git # directory under the assembly's `.git/modules/`, so it is skipped by place, # not by name. + # A LEG WITH A `.git` OF ITS OWN THAT GIT CANNOT READ is no absent leg: it is + # a read that failed, never an absent or a clean one (Amendment 7(d)), and + # passed over it was a leg this reconciliation said nothing of while the + # sweep counted it. So it is the sweep's rule (`probe_leg`: git's toplevel + # there is not the leg, and a `.git` stands in it) and the sweep's word: a + # TREE row `unreadable`, which requires recovery, as the sweep's `leg` row is + # `unreadable` and counted. It is NO `indeterminate`: the sweep counts that + # leg and goes on (exit 3), and a verdict it refuses on would hide the count. if [ "$lrc_lgrc" = 0 ]; then while IFS="$lrc_tab" read -r lrc_lrepo lrc_ldecl lrc_labs; do [ -n "$lrc_labs" ] && [ -d "$lrc_labs" ] || continue lrc_ltop="$(git -C "$lrc_labs" rev-parse --show-toplevel 2>/dev/null &1 >/dev/null /dev/null tuple: + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + assert rec.returncode == 0, rec.stderr + return rec, reconcile_rows(rec.stdout) + + def check(answer: str) -> None: + rec, rows = reconciled_rows() + assert list(rows) == [real(spec)], rows + cls, why = rows[real(spec)] + assert cls == "unreadable", rows + assert why.startswith(f"the spec leg of {a}/project.yaml (opensoft/triad-spec): git could " + "not be read through it (it has a .git of its own and git could not " + "read it: "), why + assert answer in why and f"(git -C {spec} status)" in why, (answer, why) + assert field(rec.stdout, "TREES") == ["0 inventoried", "0 dirty or unpushed", + "1 require recovery", "0 unmanaged or stale"], rec.stdout + assert field(rec.stdout, "VERDICT")[0] == "ungraceful-stop", rec.stdout + sweep = b.sweep("triad-1", "--dry-run", "--porcelain") + assert sweep.returncode == 3, sweep.stdout + sweep.stderr + leg = porcelain_rows(sweep.stdout, "leg")[str(spec)] + assert leg["disposition"] == "unreadable" and leg["retire"] == "retire", leg + assert "git could not be read through it" in leg["why"] and answer in leg["why"], leg + + (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + check("/nonexistent/t018-spec") + (spec / ".git").unlink() + (spec / ".git").mkdir() + check(f"git answers {real(a)} there") + shutil.rmtree(spec / ".git") + (spec / ".git").write_text(gitfile) + for state in ("readable again", "deinitialized"): + if state == "deinitialized": + b.git("submodule", "deinit", "-q", "spec", cwd=a) + assert not any(spec.iterdir()) + rec, rows = reconciled_rows() + assert real(spec) not in rows, (state, rows) + assert field(rec.stdout, "TREES")[2] == "0 require recovery", (state, rec.stdout) + + # ===================================================== the single repository def single_repository_decoys(bench: Bench) -> list: From bf2a220ae804aa963c301a5d0a8791f7f742a438 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 22:27:17 +0000 Subject: [PATCH 21/42] WIP (untested): T018: lane-handoff counts a leg git cannot read as unreadable, as the sweep and lane-reconcile do; verification not run lane-handoff, the legs' common directories (was lines 2093-2095): a declared leg whose git toplevel is not the leg itself was skipped whether or not a .git stood in it. So a leg with a .git of its own that git cannot read (a gitfile naming no repository, or a .git where git answers the assembly) said nothing, while the sweep's `leg` row for it is `unreadable` and counted and lane-reconcile's TREE row (9f23a6b) is `unreadable` under "require recovery". Worse, a tree `lane-worktrees add --checkout ` made from that leg was re-recorded under the ASSEMBLY at the handoff, over the checkout `add` recorded. It now takes the sweep's rule (probe_leg: toplevel not the leg, and a .git stands there) and the sweep's words: one `- UNREADABLE LEG:` line in the top block's WRITERS section ("`` is unreadable, the leg of /project.yaml (): git could not be read through it (it has a .git of its own and git could not read it: )"), a note on stderr, and a count (`legs: N of the assembly's legs under unreadable`). As lane-reconcile keeps its verdict for it, the lifecycle is not held SWAPPING for the leg itself: the poll looked everywhere it looks. writer_checkout files no guessed checkout while a leg is unreadable: a tree of no readable leg's repository, not of the assembly's own and recorded under no checkout of its own repository is named and NOT recorded, so the leg `add` recorded stays, and the lane stays SWAPPING for that tree (the existing "no checkout can be vouched for" rule). A leg with no .git (not checked out) is passed over as before. A checkout with no project.yaml, or one naming no leg but itself, never runs the leg loop: single-repository output is unchanged. docs/README-lanes.md: the handoff paragraph says so. tests/test_triad_lane_tooling_handoff.py: test_t018_the_handoff_reports_a_leg_git_cannot_read_as_the_sweep_counts_it, beside the reconcile one: the spec leg's .git a gitfile naming no repository, then an empty .git directory: one UNREADABLE LEG line, the note and the count, the lane SWAPPED, the reconciliation `unreadable`, 1 require recovery and `resumable`; a tree `add` made from the spec leg keeps the spec leg as its checkout, named and not recorded, the lane SWAPPING; readable again, nothing named and SWAPPED. The deinitialized leg test now also holds that a leg with no .git is never `unreadable`. VERIFICATION NOT RUN. The one run of the T018 selection (verify.sh, run4.log, head 9f23a6b, index tree 0b691c8) waited for the shared pytest lock behind other lanes' runs from 21:59:32Z and ended exit=143 (a SIGTERM this writer did not send) at 22:26:46Z before any test ran; an account swap was called at 22:20:05Z. So this commit and 9f23a6b are both UNTESTED. Before this commit: bash -n lane-handoff is clean, the test file parses. The next run on this tree verifies both. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 8 ++- lane-handoff | 64 +++++++++++++++++-- tests/test_triad_lane_tooling_handoff.py | 81 ++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 8 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index cb75fe3f..0a045f6f 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3963,7 +3963,13 @@ leg's repository (a paired tree, or one `lane-worktrees add --checkout checkout of the tree's own repository keeps it: a dependency nested in a leg (`--checkout /code/upstream/`) is no declared leg. Any other tree keeps the lane's `dir`, as before. A leg that is not checked out here (never initialized, or -deinitialized) names no tree. A worktree a leg registers anywhere else is not polled +deinitialized) names no tree. A leg with a `.git` of its own that git cannot read is +`unreadable`, the sweep's word: one `- UNREADABLE LEG: …` line in WRITERS and a note on +stderr, counted, and the lane is not held `SWAPPING` for the leg itself. A polled tree +it then cannot place (of no readable leg's repository, not of the assembly's own, and +recorded under no checkout of its own repository) is named and not recorded, so the +checkout `add` recorded stays, and the lane stays `SWAPPING` for that tree. A worktree +a leg registers anywhere else is not polled as a writer, just as the checkout's own registrations elsewhere are not; `lanes-edit.sh lane-reconcile` reports it. The reconciliation reads what the legs hold beside what the checkout holds. Every worktree git registers in a checked-out leg that diff --git a/lane-handoff b/lane-handoff index 40a5501b..1403ce56 100755 --- a/lane-handoff +++ b/lane-handoff @@ -1980,15 +1980,21 @@ poll_writer() { # # LEG, the checkout `lane-worktrees add --checkout /code` recorded # and every reader of the inventory enumerates (`writer_checkout`, below). # Where no checkout can be vouched for — the assembly's legs could not be - # read and the tree is not of the lane's own repository — NOTHING is filed: - # a guessed checkout would replace the one `add` recorded, so the tree is - # named and the lane stays SWAPPING for it, as for a refused write. + # read, or one of them is unreadable, and the tree is not of the lane's own + # repository nor of a leg git could read — NOTHING is filed: a guessed + # checkout would replace the one `add` recorded, so the tree is named and + # the lane stays SWAPPING for it, as for a refused write. if [ -n "$lc_on" ] && [ "$pw_orc" = 0 ]; then pw_co="${dir:-unknown}" [ -z "$legs_state" ] || pw_co="$(writer_checkout "$pw_d")" if [ -z "$pw_co" ]; then lc_inventory_missed="${lc_inventory_missed:+$lc_inventory_missed }$pw_d" - note "the worktree inventory entry for $pw_d was NOT written: $dir/project.yaml could not be read, so whether this tree is of one of the assembly's legs — and so which checkout it is recorded under — is not known, and a guessed one would replace the checkout its record names now. This handoff's WRITERS section still names the tree, the lane stays SWAPPING for it, and \`$LANES_EDIT lane-reconcile $lane\` says what the inventory holds." + if [ "$legs_state" = known ]; then + pw_cant="the assembly has $legs_unreadable_count unreadable leg(s), each named by an UNREADABLE LEG line in the top block, so whether this tree is of such a leg's repository" + else + pw_cant="$dir/project.yaml could not be read, so whether this tree is of one of the assembly's legs" + fi + note "the worktree inventory entry for $pw_d was NOT written: $pw_cant — and so which checkout it is recorded under — is not known, and a guessed one would replace the checkout its record names now. This handoff's WRITERS section still names the tree, the lane stays SWAPPING for it, and \`$LANES_EDIT lane-reconcile $lane\` says what the inventory holds." return 0 fi LANES_NO_FETCH=1 "$LANES_EDIT" set-lane-tree "$lane" "$pw_d" \ @@ -2088,11 +2094,44 @@ fi # directory would then be the assembly's own, and every tree of the assembly # would be recorded under that leg. So a leg counts only where git's own # toplevel there is the leg itself. +# +# A LEG WITH A `.git` OF ITS OWN THAT GIT CANNOT READ is no absent leg: it is a +# read that failed, never an absent or a clean one (Amendment 7(d)). Passed +# over, it was a leg this handoff said nothing of while the sweep counted it, +# and every tree of its repository was re-recorded under the assembly, over +# the leg `add` had recorded. So it is the sweep's rule (`probe_leg`: git's +# toplevel there is not the leg, and a `.git` stands in it), the sweep's word +# and `lane-reconcile`'s: `unreadable`, one `- UNREADABLE LEG` line a leg in the +# top block's WRITERS section and a note on stderr, and COUNTED +# (`legs_unreadable_count`). The lifecycle is NOT left SWAPPING for the leg +# itself, as the reconciliation's verdict is not `indeterminate` for it: the +# poll looked everywhere it looks. A tree this handoff then cannot place is +# another matter (`writer_checkout`): it is named, not recorded, and the lane +# stays SWAPPING for it, as for any tree no checkout can be vouched for. +legs_unreadable=""; legs_unreadable_count=0 if [ "$legs_state" = known ]; then while IFS="$tab" read -r lg_repo lg_decl lg_abs; do [ -n "$lg_abs" ] && [ -d "$lg_abs" ] || continue - lg_top="$(git -C "$lg_abs" rev-parse --show-toplevel 2>/dev/null /dev/null &1 >/dev/null }" +[ "$legs_unreadable_count" = 0 ] || + step "legs: $legs_unreadable_count of the assembly's legs under $dir unreadable — named in the top block's WRITERS section, never a clean leg" [ "$paired_other_count" = 0 ] || step "paired root: $paired_other_count tree(s) under $dir/worktrees are not lane $lane's — listed in the top block, never counted or recorded" @@ -2335,6 +2383,8 @@ top_block() { printf -- '- NOT POLLED: %s, so a tree of this lane'"'"'s under `%s/worktrees` may be missing from this list. Read it by hand before relaunching a writer there.\n' \ "$paired_unread" "$dir" fi + # AND A LEG GIT COULD NOT READ, one line a leg, in the sweep's words. + [ -z "$legs_unreadable" ] || printf '%s' "$legs_unreadable" printf '\n' # T018, M1 (a): THE PAIRED ROOT'S OTHER TREES, OUTSIDE WRITERS. They are # other lanes' feature trees, or nobody's: named so the next session knows diff --git a/tests/test_triad_lane_tooling_handoff.py b/tests/test_triad_lane_tooling_handoff.py index 72477905..609a20a0 100644 --- a/tests/test_triad_lane_tooling_handoff.py +++ b/tests/test_triad_lane_tooling_handoff.py @@ -209,6 +209,9 @@ def test_t018_a_leg_that_is_not_checked_out_claims_no_tree_of_the_assembly(triad proc = handoff(b, "triad-1", a) assert proc.returncode == 0, proc.stdout + proc.stderr assert f"writers polled: 2 under {a}" in proc.stderr, proc.stderr + # not checked out is no unreadable leg: it has no `.git` of its own + assert f"the leg {a / 'spec'} is unreadable" not in proc.stderr, proc.stderr + assert "UNREADABLE LEG" not in top_block(b, "triad-1") assert inventory(b, "triad-1") == {str(lane_root): str(a), str(paired_code): str(code)} @@ -494,6 +497,84 @@ def check(answer: str) -> None: assert field(rec.stdout, "TREES")[2] == "0 require recovery", (state, rec.stdout) +@NEEDS_UPSTREAM +def test_t018_the_handoff_reports_a_leg_git_cannot_read_as_the_sweep_counts_it(triad): + """THE SAME SILENT SKIP IN `lane-handoff`: a leg with a `.git` of its own + that git cannot read was passed over as a leg that is not checked out is, + so the handoff said nothing of it, and a tree `add` made from that leg was + re-recorded under the ASSEMBLY - the loss (2) fixed. Now it is the sweep's + rule and word: one `- UNREADABLE LEG` line in WRITERS, a note on stderr and + a count, for a `.git` naming no repository and for one where git answers + the assembly. The lifecycle is not held for the leg itself (the + reconciliation's verdict is not `indeterminate` for it either): with no + tree of that leg the lane is SWAPPED. A tree of that leg keeps the checkout + `add` recorded - named, NOT recorded, and the lane stays SWAPPING for it - + while a tree of the assembly is recorded as always. Readable again, the + leg names nothing and its tree is recorded under it.""" + b, t = triad + a, spec = t["assembly"], t["spec"] + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + gitfile = (spec / ".git").read_text() + words = (f"the spec leg of {a}/project.yaml (opensoft/triad-spec): git could not be read " + "through it (it has a .git of its own and git could not read it: ") + + def check(answer: str, trees: tuple, unplaced: tuple, state: str) -> None: + proc = handoff(b, "triad-1", a, "unreadable leg") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable: {words}" in proc.stderr, proc.stderr + assert answer in proc.stderr.split(f"the leg {spec} is unreadable: ", 1)[1], proc.stderr + assert f"legs: 1 of the assembly's legs under {a} unreadable" in proc.stderr, proc.stderr + assert f"writers polled: {len(trees)} under {a}" in proc.stderr, proc.stderr + for tree in trees: + assert f"WRITER {tree} —" in proc.stdout, proc.stdout + missed = f"the worktree inventory entry for {tree} was NOT written: " + if tree in unplaced: + assert (missed + "the assembly has 1 unreadable leg(s), each named by an " + "UNREADABLE LEG line in the top block") in proc.stderr, proc.stderr + else: + assert missed not in proc.stderr, proc.stderr + assert f"lifecycle: {state}" in proc.stdout, proc.stdout + assert "NOT POLLED" not in proc.stdout + proc.stderr + writers = top_block(b, "triad-1").split("**WRITERS at ", 1)[1] + line = next((ln for ln in writers.splitlines() if ln.startswith("- UNREADABLE LEG: ")), "") + assert line.startswith(f"- UNREADABLE LEG: `{spec}` is unreadable, {words}"), writers + assert answer in line and f"(`git -C {spec} status`)" in line, line + assert writers.count("- UNREADABLE LEG: ") == 1, writers + + (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + check("/nonexistent/t018-spec", (lane_root,), (), "SWAPPED") + assert inventory(b, "triad-1") == {str(lane_root): str(a)} + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + assert reconcile_rows(rec.stdout)[real(spec)][0] == "unreadable", rec.stdout + assert field(rec.stdout, "TREES")[2] == "1 require recovery", rec.stdout + assert field(rec.stdout, "VERDICT")[0] == "resumable", rec.stdout + (spec / ".git").unlink() + (spec / ".git").mkdir() + check(f"git answers {real(a)} there", (lane_root,), (), "SWAPPED") + shutil.rmtree(spec / ".git") + (spec / ".git").write_text(gitfile) + + made = b.tool("lane-worktrees", "add", "triad-1", "s1", "--checkout", spec) + assert made.returncode == 0, made.stderr + lane_spec = Path(made.stdout.strip()) + want = {str(lane_spec): str(spec), str(lane_root): str(a)} + assert inventory(b, "triad-1") == want + (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + check("/nonexistent/t018-spec", (lane_spec, lane_root), (lane_spec,), "SWAPPING") + assert inventory(b, "triad-1") == want + + (spec / ".git").write_text(gitfile) + proc = handoff(b, "triad-1", a, "readable again") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable" not in proc.stderr, proc.stderr + assert "of the assembly's legs under" not in proc.stderr, proc.stderr + assert "UNREADABLE LEG" not in top_block(b, "triad-1") + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + assert inventory(b, "triad-1") == want + + # ===================================================== the single repository def single_repository_decoys(bench: Bench) -> list: From f34c993e317ccb0c7e5af9a5b3296d8635c8cacb Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 22:59:24 +0000 Subject: [PATCH 22/42] #214 adversarial check: `commit` reads the queue on the branch it pushes, refuses merges, type changes and HEAD off the branch, fails closed on every read, and pushes exactly the commit it read The check found three ordinary checkout states where `commit` still published a deletion of scripts/link-estates, and a race: - a merge's own changes (`log --name-status` prints none); - HEAD on another branch (the fence read HEAD, the push sent the branch); - a local branch named origin/main shadowing the tracking ref; - a commit made between the fence and the push. cp_publish_fence now reads refs/remotes/origin/..refs/heads/, spelled in full, and requires HEAD to be refs/heads/. Its reads are plumbing (rev-list --parents, diff-tree --stdin --always --raw) under --no-replace-objects. It refuses merge and root commits, and passes only an added or modified regular file under the register's derived directory (LANES_PREFIX), its log and alias paths, or handoffs/. Every read and every classification is status-checked, and diff-tree must return one header per commit it was given, because it skips an unreadable commit with exit 0. The push sends :refs/heads/. After each pull --rebase, CP_AFTER_REBASE re-reads the queue and re-pins the push. Ordinary writes push the branch as before. Lane renames (a log deleted and re-added) stay refused, by design, and the message says what carries them. Both exit-code tables list the new refusal; the manual describes the rule. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 16 +- lanes-edit.sh | 124 ++++++++++--- tests/test_commit_push_published_edit_194.py | 177 ++++++++++++++++++- 3 files changed, 281 insertions(+), 36 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index eebd1e8e..69c087f3 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -96,11 +96,15 @@ rejected (the remote moved), it stops with exit 3, leaves your edit as a local commit, and prints the recovery: the owner of that file commits it, then you re-run `lanes-edit.sh commit`. If `lanes/LANES.md` is clean, that re-run makes no commit of its own and pushes the local commits origin does not have yet, -through the same loop, guard and retries (#194). It refuses with 2 and pushes -nothing if any of those commits deletes a file or touches a path outside -`lanes/` and `handoffs/`. Only `commit` publishes that queue: any other write with -nothing to stage leaves it local, and with nothing ahead `commit` is the no-op -it always was. +through the same loop, guard and retries (#194). It reads that queue on +`refs/heads/main`, the branch the push sends, with HEAD required to be on it, +and pushes exactly the commit it read. It refuses with 2 and pushes nothing +unless every queued commit is a non-merge commit whose every change is an added +or modified regular file under `lanes/` or `handoffs/`. That refuses a merge, a +deletion (a lane rename's included), a type change, a symlink, a gitlink and +any other path. Only `commit` publishes that queue: any other write with nothing +to stage leaves it local, and with nothing ahead `commit` is the no-op it always +was. ## THE ONE HAZARD — `sed -i` DESTROYS THE SYMLINK @@ -1178,7 +1182,7 @@ else. |---|---| | 0 | done | | 1 | environment — no register, no writer | -| 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased | +| 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased, or `commit` refusing to publish local commits that are not register or handoff writes, or from a checkout whose HEAD is off the branch (#194; nothing pushed) | | 3 | the edit is never a permanent loss, but this attempt cannot confirm whether it also reached origin — a rebase conflict (not pushed by this attempt; a later write from this checkout may already carry it to origin, so look by content before you retry), a network timeout on a push or a pull (a hung one often already landed), or six attempts exhausted because a peer's uncommitted file blocks every rebase | | 4 | the mutex could not be taken within 60s | | 5 | an edit moved more than one line and was refused | diff --git a/lanes-edit.sh b/lanes-edit.sh index 20f2ba45..d41d6fab 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -431,7 +431,10 @@ # `canon-lane` refuse until 15(d)'s merge, or AMENDMENT 12's BLOCKED # PROMPT, or — opensoft/openRepoTools#162 — a commit whose pathspec # would stage bytecode, a cache or a virtual environment -# (`commit_push`, `pathspec-check`). SEVEN MEANINGS ON ONE NUMBER, and they +# (`commit_push`, `pathspec-check`), or — #194 — `commit` refusing to +# publish local commits that are not register or handoff writes, or a +# checkout whose HEAD is off the branch (`cp_publish_fence`), nothing +# pushed. EIGHT MEANINGS ON ONE NUMBER, and they # stay readable only because of where each can occur: bad arguments to a # subcommand that predates Amendment 11; `register-row`'s "not lane-shaped"; # AN UNKNOWN SUBCOMMAND (the `*)` arm below), which is how a caller detects @@ -1102,6 +1105,10 @@ CP_AFTER_REBASE="" # staged, commit_push publishes the local commits ahead of origin for that one # write and for no other (#194). CP_PUBLISH_AHEAD=0 +# A publish's push sends the commit its fence read, `:refs/heads/`; +# empty, every push sends the branch as it stands, as it always has (#194). +CP_PUSH_SPEC="" +CP_FENCE_TIP="" # die "" [] — the MESSAGE is $1 alone. It used to be "$*", # which joined the exit code on to the end of every refusal that passed one: @@ -1953,6 +1960,81 @@ BRT_EOF printf 'Nothing was staged or committed. Unstage or delete those paths, then re-run.' } +# #194, AS #214's ADVERSARIAL CHECK HARDENED IT — WHAT `commit` MAY PUBLISH +# WITH NOTHING STAGED, read from the objects the push will send and nothing +# else. Three ordinary checkout states got a deletion past the first fence, +# which read `git log --name-status origin/..HEAD`: +# +# * A MERGE's own changes: `log` prints no diff for a merge, so a deletion +# made while resolving one passed unread. A merge or root commit refuses. +# * HEAD ON ANOTHER BRANCH: the fence read HEAD and the push sent +# `refs/heads/`. The range is the branch, and HEAD must be it, because +# `pull --rebase` moves HEAD and the push moves the branch. +# * A LOCAL BRANCH NAMED `origin/`, which shadows the tracking ref. Both +# ends of the range are spelled in full. +# +# The reads are plumbing (`rev-list`, `diff-tree --raw`), which no `color.*` or +# `diff.ignoreSubmodules` reshapes, under `--no-replace-objects`, so a replace +# ref cannot show the fence one commit while the push sends another. A change +# passes only as an ADDED or MODIFIED REGULAR FILE under the register's own +# directory (`$LANES_PREFIX`, derived, as every path of the register's is) or +# `handoffs/`. A deletion, a type change, a symlink or a gitlink refuses, and so +# does a path anywhere else. That includes lane tooling's own lane rename, +# which deletes a log to re-add it: a deletion is the one change origin cannot +# get back, and the fence keeps one rule for it. A read or a classification +# that fails refuses too (2), because it proves nothing. +# +# It leaves CP_FENCE_TIP at the commit it read, and the publish pushes exactly +# that commit (`:refs/heads/`). A commit another lane makes in this +# shared checkout between the read and the push stays local. +cp_publish_fence() { + cpf_ref="refs/heads/$LANES_BRANCH" + cpf_head="$(git -C "$LANES_REPO" symbolic-ref -q HEAD 2>/dev/null || :)" + [ "$cpf_head" = "$cpf_ref" ] || die "refusing to publish: HEAD is ${cpf_head:-detached}, not $cpf_ref, and the push sends $cpf_ref while \`pull --rebase\` moves HEAD, so what would be published is not what HEAD shows. Nothing was pushed (#194). Whoever moved this shared checkout off $LANES_BRANCH puts it back; never reset or check out over their work." 2 + cpf_rc=0 + cpf_base="$(git --no-replace-objects -C "$LANES_REPO" rev-parse -q --verify "refs/remotes/origin/$LANES_BRANCH^{commit}")" || cpf_rc=$? + cpf_tip="$(git --no-replace-objects -C "$LANES_REPO" rev-parse -q --verify "$cpf_ref^{commit}")" || cpf_rc=$? + cpf_revs="$(git --no-replace-objects -C "$LANES_REPO" rev-list --parents "$cpf_base..$cpf_tip")" || cpf_rc=$? + cpf_odd="$(printf '%s\n' "$cpf_revs" | awk 'NF > 0 && NF != 2 { printf " %s %s\n", substr($1, 1, 12), (NF > 2 ? "is a merge commit" : "is a root commit") }')" || cpf_rc=$? + cpf_pairs="$(printf '%s\n' "$cpf_revs" | awk 'NF == 2')" || cpf_rc=$? + # `diff-tree --stdin` SKIPS A COMMIT IT CANNOT READ and still exits 0, and + # prints no header for an empty diff unless told `--always`: so every commit + # handed to it must come back as a header, or the read refuses. + cpf_raw=""; cpf_n=0 + if [ -n "$cpf_pairs" ]; then + cpf_n="$(printf '%s\n' "$cpf_pairs" | awk 'END { print NR }')" || cpf_rc=$? + cpf_raw="$(printf '%s\n' "$cpf_pairs" | git --no-replace-objects -C "$LANES_REPO" -c core.quotePath=false diff-tree --stdin --always -r --no-renames --raw --ignore-submodules=none)" || cpf_rc=$? + fi + cpf_bad="$(printf '%s\n' "$cpf_raw" | awk -F '\t' -v want="$cpf_n" -v reg="$LANES_PREFIX" -v logp="$LANES_LOG_PREFIX" \ + -v reg_path="$LANES_PATH" -v alias_path="$LANES_ALIASES_PATH" ' + function inside(p) { + return (reg != "" && index(p, reg) == 1) || (logp != "" && index(p, logp) == 1) || + index(p, "handoffs/") == 1 || p == reg_path || p == alias_path + } + NF == 0 { next } + NF == 1 && $1 ~ /^[0-9a-f]+$/ { c = substr($1, 1, 12); seen++; next } + { split($1, f, " "); st = f[5]; mode = f[2] + if (!(NF == 2 && (st == "A" || st == "M") && (mode == "100644" || mode == "100755") && inside($2))) + printf " %s %s %s %s\n", c, st, mode, $2 } + END { if (seen != want) exit 3 }')" || cpf_rc=$? + [ "$cpf_rc" = 0 ] || die "git could not read, or this helper could not classify, the local commits on $cpf_ref ahead of refs/remotes/origin/$LANES_BRANCH, so whether they are \`commit\`'s to publish is unknown; nothing was pushed (#194)" 2 + if [ -n "$cpf_odd$cpf_bad" ]; then + note "of the local commits on $cpf_ref ahead of origin/$LANES_BRANCH, these are not register or handoff writes (a merge or root commit, or a change that is not an added or modified regular file under ${LANES_PREFIX:-the register directory} or handoffs/):" + [ -z "$cpf_odd" ] || printf '%s\n' "$cpf_odd" >&2 + [ -z "$cpf_bad" ] || printf '%s\n' "$cpf_bad" >&2 + die "refusing to publish: \`commit\` pushes only the register and handoff commits lane tooling strands (#194), so nothing was pushed. A lane rename (Amendment 16) deletes and re-adds its log and is refused here too; the next register write from this checkout carries it. Anything else, whoever queued it publishes it; never reset it away. Read them: git -C $LANES_REPO log --stat refs/remotes/origin/$LANES_BRANCH..$cpf_ref" 2 + fi + CP_FENCE_TIP="$cpf_tip" +} + +# The publish's CP_AFTER_REBASE: `pull --rebase` has rewritten the queue onto +# what origin holds now, and may have taken in a commit made since the first +# read, so the fence reads it again and the push is pinned to what it read. +cp_publish_refence() { + cp_publish_fence + CP_PUSH_SPEC="$CP_FENCE_TIP:refs/heads/$LANES_BRANCH" +} + # commit_push "" [...] — the pathspecs default to the # register alone, which is every caller that predates Amendment 7. A LANDING # or LANDED writes TWO (the register and the lane's log) so that both halves @@ -2032,20 +2114,21 @@ commit_push() { # and the RECOVERY printed for it is to re-run `commit` once the way is clear. # Returning here sent that re-run home with exit 0 and the commit still local. # So `commit`, the one caller that sets CP_PUBLISH_AHEAD, still takes the push - # loop (its lock, the dirty-elsewhere guard, the retries) whenever HEAD holds - # commits `origin/$LANES_BRANCH` does not, as of the last fetch; no such ref - # reads as none. Every other write with nothing to stage stays the no-op it + # loop (its lock, the dirty-elsewhere guard, the retries) whenever the branch + # holds commits `origin/$LANES_BRANCH` does not, as of the last fetch; no such + # ref reads as none. Every other write with nothing to stage stays the no-op it # always was and reaches no network: a repeated `set-row-state` must not turn # into a push of whatever this SHARED checkout has queued (the #214 review # measured exactly that deleting `scripts/link-estates` on origin). # - # AND `commit` PUBLISHES ONLY WHAT LANE TOOLING STRANDS. Every queued commit is - # read first; one that DELETES a file, or touches a path outside `lanes/` and - # `handoffs/`, refuses the whole publish (2) and nothing is pushed. A read - # that fails refuses the same way, because it proves nothing either way. + # AND `commit` PUBLISHES ONLY WHAT LANE TOOLING STRANDS, and only what it read: + # `cp_publish_fence` (above) reads the queue on the branch the push sends and + # refuses the whole publish (2) for anything that is not a register or + # handoff write. The push is pinned to the commit the fence read, and every + # `pull --rebase` reads it again (CP_AFTER_REBASE) and re-pins it. cp_publish=0 if git -C "$LANES_REPO" -c "$CP_EXACT" diff --cached --quiet -- "${CP_PATHS[@]}"; then - cp_ahead="$(git -C "$LANES_REPO" --no-pager log --oneline "origin/$LANES_BRANCH..HEAD" 2>/dev/null || :)" + cp_ahead="$(git --no-replace-objects -C "$LANES_REPO" --no-pager log --oneline "refs/remotes/origin/$LANES_BRANCH..refs/heads/$LANES_BRANCH" 2>/dev/null || :)" if [ -z "$cp_ahead" ]; then note "nothing staged for ${CP_PATHS[*]} — no commit made" return 0 @@ -2055,19 +2138,12 @@ commit_push() { note "nothing staged for ${CP_PATHS[*]} — no commit made; the $cp_n local commit(s) ahead of origin/$LANES_BRANCH stay local, and only \`lanes-edit.sh commit\` publishes them" return 0 fi - cp_files=""; cp_frc=0 - cp_files="$(git -C "$LANES_REPO" -c core.quotePath=false --no-pager log --no-renames --name-status --format='commit %h' "origin/$LANES_BRANCH..HEAD")" || cp_frc=$? - [ "$cp_frc" = 0 ] || die "git could not list what the $cp_n local commit(s) ahead of origin/$LANES_BRANCH change, so whether they are \`commit\`'s to publish is unknown; nothing was pushed (#194)" 2 - cp_bad="$(printf '%s\n' "$cp_files" | awk -F '\t' '/^commit / { c = substr($0, 8); next } - NF >= 2 && ($1 == "D" || ($2 !~ /^lanes\// && $2 !~ /^handoffs\//)) { printf " %s %s %s\n", c, $1, $2 }')" - if [ -n "$cp_bad" ]; then - note "nothing staged for ${CP_PATHS[*]}, and of the $cp_n local commit(s) ahead of origin/$LANES_BRANCH these delete a file or reach outside lanes/ and handoffs/:" - printf '%s\n' "$cp_bad" >&2 - die "refusing to publish: \`commit\` pushes only the register and handoff commits lane tooling strands (#194), so nothing was pushed. Whoever queued those commits publishes them; never reset them away. Read them: git -C $LANES_REPO log --stat origin/$LANES_BRANCH..HEAD" 2 - fi - note "nothing staged for ${CP_PATHS[*]} — no commit made; pushing the $cp_n local commit(s) origin/$LANES_BRANCH does not have:" + cp_publish_fence + note "nothing staged for ${CP_PATHS[*]} — no commit made; pushing the $cp_n local commit(s) origin/$LANES_BRANCH does not have, up to ${CP_FENCE_TIP:0:12}:" printf '%s\n' "$cp_ahead" | head -n 10 >&2 || : [ "$cp_n" -le 10 ] || note " … and $((cp_n - 10)) more" + CP_PUSH_SPEC="$CP_FENCE_TIP:refs/heads/$LANES_BRANCH" + CP_AFTER_REBASE=cp_publish_refence cp_publish=1 fi if [ "$cp_publish" = 1 ]; then @@ -2125,7 +2201,7 @@ EOF note "WARNING: this checkout has uncommitted changes to files OTHER than the register — not this invocation's:" printf ' %s\n' $others >&2 note "skipping 'pull --rebase' (it refuses on any unstaged tracked change) and pushing straight out" - if git_net -C "$LANES_REPO" push -q origin "$LANES_BRANCH"; then + if git_net -C "$LANES_REPO" push -q origin "${CP_PUSH_SPEC:-$LANES_BRANCH}"; then LANES_INDEX_NUDGE=1 note "pushed origin/$LANES_BRANCH (attempt $attempt, no pull — see the warning above)" return 0 @@ -2136,8 +2212,8 @@ EOF note "RECOVERY: their author commits those files (a handoff: handoff(@): )," note " then re-run: LANES_LANE= lanes-edit.sh commit \"\" — your edit is already" note " a local commit here. If $LANES_PATH is clean, that re-run makes no commit of its own and" - note " pushes the local commits this lists (#194), refusing (2) and pushing nothing if any of them" - note " deletes a file or reaches outside lanes/ and handoffs/: git -C $LANES_REPO log --oneline origin/$LANES_BRANCH..HEAD" + note " pushes the local commits this lists (#194), refusing (2) and pushing nothing unless every one" + note " is a register or handoff write: git -C $LANES_REPO log --oneline origin/$LANES_BRANCH..HEAD" die "could not push origin/$LANES_BRANCH: behind the remote, and a peer's uncommitted file blocks the rebase. Nothing was lost — your commit is local." 3 fi elif git_net -C "$LANES_REPO" pull --rebase -q origin "$LANES_BRANCH"; then @@ -2147,7 +2223,7 @@ EOF if [ -n "${CP_AFTER_REBASE:-}" ]; then "$CP_AFTER_REBASE" || return $? fi - if git_net -C "$LANES_REPO" push -q origin "$LANES_BRANCH"; then + if git_net -C "$LANES_REPO" push -q origin "${CP_PUSH_SPEC:-$LANES_BRANCH}"; then LANES_INDEX_NUDGE=1 note "pushed origin/$LANES_BRANCH (attempt $attempt)" return 0 diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index c5805a10..aa8944e7 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -49,8 +49,15 @@ SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"} +#: With FAKE_NET_RACE naming a tracked file, the FIRST push is raced: another +#: lane commits that file's deletion into the shared checkout after the +#: helper's fence has read the queue and before the push runs. NET_GIT = """#!/usr/bin/env bash printf '%s\\n' "$*" >> "$FAKE_NET_LOG" +if [ -n "${FAKE_NET_RACE:-}" ] && [ "${3:-}" = push ] && [ ! -e "$FAKE_NET_LOG.raced" ]; then + : > "$FAKE_NET_LOG.raced" + git -C "$2" rm -q -- "$FAKE_NET_RACE" && git -C "$2" commit -q -m "another lane, racing: retire the tool" +fi exec git "$@" """ @@ -167,7 +174,7 @@ def queue(self, message: str, *, write: str = "", delete: str = "") -> str: if delete: self.wgit("rm", "-q", "--", delete) self.wgit("commit", "-q", "-m", message) - return self.wgit("rev-parse", "--short", "HEAD").strip() + return self.wgit("rev-parse", "HEAD").strip() def helper(self, *args: str, lane: str = LANE, **extra: str) -> subprocess.CompletedProcess: self.net_log.write_text("", encoding="utf-8") @@ -365,9 +372,9 @@ def test_a_no_op_write_never_publishes_what_the_checkout_queued(box): @pytest.mark.parametrize("queued", [ - {"delete": TOOL, "status": "D", "path": TOOL}, - {"write": "workspaces/repoP.yaml", "status": "A", "path": "workspaces/repoP.yaml"}, - {"delete": PEER_HANDOFF, "status": "D", "path": PEER_HANDOFF}, + {"delete": TOOL, "status": "D 000000", "path": TOOL}, + {"write": "workspaces/repoP.yaml", "status": "A 100644", "path": "workspaces/repoP.yaml"}, + {"delete": PEER_HANDOFF, "status": "D 000000", "path": PEER_HANDOFF}, ], ids=["deletes-a-tool", "outside-lane-tooling", "deletes-a-handoff"]) def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, queued): """`commit` publishes the register and handoff commits lane tooling strands @@ -384,7 +391,7 @@ def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, queued result = box.helper("commit", SUBJECT) assert result.returncode == 2, result.stderr - assert f" {sha} {queued['status']} {queued['path']}" in result.stderr, result.stderr + assert f" {sha[:12]} {queued['status']} {queued['path']}" in result.stderr, result.stderr assert "nothing was pushed" in result.stderr assert "pushing the" not in result.stderr assert box.net_calls() == [] @@ -397,6 +404,164 @@ def test_the_publish_counts_what_its_list_leaves_out(box): box.queue(f"handoff({PEER_LANE}@Eagle): stamp {n}", write=PEER_HANDOFF) result = box.helper("commit", SUBJECT) assert result.returncode == 0, result.stderr - assert "pushing the 12 local commit(s) origin/main does not have:" in result.stderr + assert "pushing the 12 local commit(s) origin/main does not have, up to" in result.stderr assert " … and 2 more" in result.stderr, result.stderr assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") + + +# ---- #214's adversarial check: three ordinary checkout states got a deletion +# past the first fence, and a fourth was a race. Every case below exits 0 and +# deletes `scripts/link-estates` on origin against the first fence (`fab2cbc`). + +def _stranded_edit(box: Box) -> None: + box.set_state(box.wip, LANE, "LIVE · edited by hand") + box.wgit("commit", "-q", "-m", f"LANES({LANE}@Eagle): {SUBJECT}", "--", REGISTER) + + +def _refused_untouched(box: Box, result, head: str, origin: str) -> None: + assert result.returncode == 2, result.stderr + assert "nothing was pushed" in result.stderr, result.stderr + assert box.net_calls() == [], result.stderr + assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) + assert box.origin_file(TOOL) == "#!/usr/bin/env bash\n" + + +def test_a_merge_commits_own_deletion_is_refused(box): + """BYPASS 1. `git log --name-status` prints no diff for a merge, so a + deletion made while resolving one went out unread, by the straight push a + peer's dirty file takes. A merge in the queue refuses.""" + _stranded_edit(box) + box.wgit("checkout", "-q", "-b", "side", "origin/main") + box.queue(f"handoff({PEER_LANE}@Eagle): a side line", write=PEER_HANDOFF) + box.wgit("checkout", "-q", "main") + box.wgit("merge", "-q", "--no-ff", "--no-commit", "side") + box.wgit("rm", "-q", "--", TOOL) + box.wgit("commit", "-q", "-m", "Merge branch side") + merge = box.wgit("rev-parse", "HEAD").strip() + box.peer_edit_in_wip("a peer mid-write") + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + _refused_untouched(box, result, head, origin) + assert f" {merge[:12]} is a merge commit" in result.stderr, result.stderr + + +def test_a_checkout_left_on_another_branch_is_refused(box): + """BYPASS 2. The fence read HEAD while the push sent `refs/heads/main`: + with a deletion queued on local main and HEAD on a scratch branch holding a + register edit, the fence saw only the edit. HEAD must be the branch.""" + box.queue("another lane: retire the tool", delete=TOOL) + box.wgit("checkout", "-q", "-b", "scratch", "origin/main") + _stranded_edit(box) + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + _refused_untouched(box, result, head, origin) + assert "HEAD is refs/heads/scratch, not refs/heads/main" in result.stderr, result.stderr + + +def test_a_local_branch_named_origin_main_does_not_shadow_the_tracking_ref(box): + """BYPASS 3. A local branch called `origin/main` made `origin/main..HEAD` + an empty-looking range for the commits beneath it. The range is spelled in + full, so the queued deletion is read and refused.""" + sha = box.queue("peer: retire the tool", delete=TOOL) + box.wgit("branch", "origin/main", "HEAD") + _stranded_edit(box) + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + _refused_untouched(box, result, head, origin) + assert f" {sha[:12]} D 000000 {TOOL}" in result.stderr, result.stderr + + +def test_a_type_change_is_refused(box): + """A handoff turned into a symlink is a `T`, not a modification: the fence + passes only regular files, added or modified.""" + _stranded_edit(box) + (box.wip / PEER_HANDOFF).unlink() + (box.wip / PEER_HANDOFF).symlink_to(f"../{TOOL}") + box.wgit("add", "--", PEER_HANDOFF) + box.wgit("commit", "-q", "-m", "peer: the handoff becomes a symlink to the tool") + sha = box.wgit("rev-parse", "HEAD").strip() + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + _refused_untouched(box, result, head, origin) + assert f" {sha[:12]} T 120000 {PEER_HANDOFF}" in result.stderr, result.stderr + + +@pytest.mark.parametrize("path", ["rebase", "straight"]) +def test_the_push_sends_the_commit_the_fence_read(box, path): + """THE RACE. Another lane commits a deletion into this shared checkout + after the fence read the queue and before the push: the push sends the + commit the fence read, `:refs/heads/main`, so the late commit stays + local, on both roads out (the pull-and-rebase and the straight push a + peer's dirty file forces).""" + _stranded_edit(box) + if path == "straight": + box.peer_edit_in_wip("a peer mid-write") + result = box.helper("commit", SUBJECT, FAKE_NET_RACE=TOOL) + assert result.returncode == 0, result.stderr + assert box.origin_file(TOOL) == "#!/usr/bin/env bash\n", result.stderr + assert _row(LANE, "LIVE · edited by hand") in box.origin_file(REGISTER) + late = box.wgit("log", "-1", "--format=%s", "HEAD").strip() + assert late == "another lane, racing: retire the tool", late + assert box.wgit("rev-parse", "HEAD~1") == box.ogit("rev-parse", "main") + + +def test_a_lane_rename_is_refused_and_told_what_carries_it(box): + """A DELIBERATE OVER-REFUSAL. A lane rename deletes its log to re-add it, + and a deletion is the one change the fence never lets through: the refusal + says so, and names what does carry it.""" + box.write(box.wip, "lanes/log/repocase-1.md", "# log\n") + box.wgit("add", "--", "lanes/log/repocase-1.md") + box.wgit("commit", "-q", "-m", "seed a log") + box.wgit("push", "-q", "origin", "main") + box.wgit("mv", "lanes/log/repocase-1.md", "lanes/log/repoCase-1.md") + box.wgit("commit", "-q", "-m", "LANES(repoCase-1@Eagle): RENAMED lane repocase-1 → repoCase-1") + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + assert result.returncode == 2, result.stderr + assert " D 000000 lanes/log/repocase-1.md" in result.stderr, result.stderr + assert "A lane rename (Amendment 16) deletes and re-adds its log" in result.stderr + assert "the next register write from this checkout carries it" in result.stderr + assert box.net_calls() == [] + assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) + + +def test_the_fence_follows_the_registers_own_directory(box): + """The register's directory is derived (`LANES_PREFIX`), never spelled + `lanes/`: a register kept in `registry/` publishes its own stranded edit.""" + text = (box.wip / REGISTER).read_text(encoding="utf-8") + box.write(box.wip, "registry/LANES.md", text) + box.wgit("add", "--", "registry/LANES.md") + box.wgit("commit", "-q", "-m", "a register kept elsewhere") + box.wgit("push", "-q", "origin", "main") + path = box.wip / "registry/LANES.md" + path.write_text(text.replace(_row(LANE, "LIVE · seeded"), _row(LANE, "LIVE · edited by hand")), + encoding="utf-8") + box.wgit("commit", "-q", "-m", f"LANES({LANE}@Eagle): {SUBJECT}", "--", "registry/LANES.md") + + result = box.helper("commit", SUBJECT, LANES_DIR=str(box.wip / "registry")) + assert result.returncode == 0, result.stderr + assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") + assert _row(LANE, "LIVE · edited by hand") in box.origin_file("registry/LANES.md") + + +def test_a_queued_commit_git_cannot_read_refuses(box): + """FAIL CLOSED. `diff-tree --stdin` passes over a commit it cannot read and + still exits 0, so a queued commit whose tree is missing must refuse, not + read as a commit with nothing in it.""" + _stranded_edit(box) + tree = box.wgit("rev-parse", "HEAD^{tree}").strip() + box.queue(f"handoff({PEER_LANE}@Eagle): a line", write=PEER_HANDOFF) + # The EARLIER queued commit's tree, so HEAD's own stays readable and the + # refusal is the fence's, not the index check's. + (box.wip / ".git" / "objects" / tree[:2] / tree[2:]).unlink() + origin = box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + assert result.returncode == 2, result.stderr + assert "nothing was pushed" in result.stderr, result.stderr + assert box.ogit("rev-parse", "main") == origin From 5d2c1c8dc040664ee8af5aaffac656ecec860ab6 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 23:19:13 +0000 Subject: [PATCH 23/42] T018: lane-handoff withholds only a tree that could be an unreadable leg's, names a tree git cannot read, and says what it withheld (review of bf2a220, S1, S2, N1-N4) S1. lane-handoff, writer_checkout: while a leg was unreadable, every tree not of the assembly's own repository was withheld, a worktree of an unrelated repository in the lane's own root included, so the lane stayed SWAPPING on every handoff and those records went stale. Now a tree is withheld only where it could be of that leg's repository: its common git directory cannot be read, or lies physically inside the assembly or its git directory (.git/modules/, a leg's own .git) without being the assembly's own, or the inventory records it under an unreadable leg. Any other tree keeps the lane's dir, as when every leg reads. Test: test_t018_an_unreadable_leg_withholds_no_tree_of_another_repository (a worktree of a third repository, recorded before and made after the leg broke; and one a record names under the unreadable leg). S2. lane-handoff, poll_writer: `git rev-parse --git-dir || return 0` dropped any tree whose .git git cannot read, so a damaged .git/modules/ took every tree of that leg out of WRITERS and the count while the lane went SWAPPED. In an assembly (legs_state set) such a tree is now listed and counted, UNREADABLE and (NOT recorded), added to lc_inventory_missed, and named on stderr. A checkout with no project.yaml naming a leg passes over it as before, so the goldens do not move. Test: test_t018_a_tree_git_cannot_read_in_an_assembly_is_named_and_holds_the_lane_swapping (modules/spec deleted after add --checkout spec, with a paired spec tree the inventory names; and a single repository's tree, passed over). N1. The UNREADABLE LEG line's clause now depends on whether any tree was withheld ("the N tree(s) this handoff could not place are named above, each marked (NOT recorded)", or "every tree this handoff polled could still be placed, so none is withheld for it"), and a withheld tree's WRITERS line is marked (NOT recorded). The leg rows are kept as TAB and written by top_block after the poll. N2. lane-handoff's UNREADABLE LEG line and note, and lanes-edit.sh's lane-reconcile TREE row for an unreadable leg, say "before relaunching a writer onto a tree of its repository": a leg is the assembly's. N3. docs/README-lanes.md says the leg count is stderr's `legs: N ...` line, never WRITERS' (N found), and describes S1, S2 and N1. N4. Test: test_t018_a_leg_tree_add_made_is_withheld_where_the_legs_git_is_an_empty_directory (the empty .git directory, where git answers the assembly in the leg, with a tree add made from it). The existing handoff and reconcile unreadable-leg tests now also assert N1 and N2. bash -n passes on lane-handoff and lanes-edit.sh; the added lines use no declare -A, ${x,,}, mapfile, readlink -f, <<< or [[. Authoring smoke check only (the one verification follows on the final head): the three new tests, the two changed ones and the handoff golden test, each called once outside pytest with LANES_* and TMUX* unset, all passed; the three new tests each failed against cf7db4a's tools, on the withheld third-repository tree, the writer count and the (NOT recorded) marker respectively. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 19 ++- lane-handoff | 138 +++++++++++++--- lanes-edit.sh | 2 +- tests/test_triad_lane_tooling_handoff.py | 197 +++++++++++++++++++++++ 4 files changed, 332 insertions(+), 24 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 0a045f6f..259a70cc 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3965,10 +3965,21 @@ checkout of the tree's own repository keeps it: a dependency nested in a leg the lane's `dir`, as before. A leg that is not checked out here (never initialized, or deinitialized) names no tree. A leg with a `.git` of its own that git cannot read is `unreadable`, the sweep's word: one `- UNREADABLE LEG: …` line in WRITERS and a note on -stderr, counted, and the lane is not held `SWAPPING` for the leg itself. A polled tree -it then cannot place (of no readable leg's repository, not of the assembly's own, and -recorded under no checkout of its own repository) is named and not recorded, so the -checkout `add` recorded stays, and the lane stays `SWAPPING` for that tree. A worktree +stderr, and counted on stderr's `legs: N of the assembly's legs under unreadable` +line, never in WRITERS' `(N found)`. The lane is not held `SWAPPING` for the leg +itself. A polled tree that could then be of that leg's repository is one it cannot +place: its common git directory lies inside the assembly or its git directory (a +leg's `.git/modules/`) and is no readable leg's and not the assembly's own, or +the inventory records it under the unreadable leg. Such a tree is named, marked +`(NOT recorded)` in its WRITERS line, and not recorded, so the checkout `add` recorded +stays, and the lane stays `SWAPPING` for that tree. The `UNREADABLE LEG` line says how +many trees were withheld, or that none was. A tree of any other repository, such as a +worktree of an unrelated repository in the lane's own root, is recorded under the +lane's `dir`, as when every leg reads. A tree with a `.git` of its own that git cannot +read at all (a damaged `.git/modules/` takes every worktree of that leg with it) +is listed in WRITERS and counted, marked `(NOT recorded)` and `UNREADABLE`, never +recorded, and the lane stays `SWAPPING` for it. A checkout with no `project.yaml` +naming a leg passes over such a tree, as it always did. A worktree a leg registers anywhere else is not polled as a writer, just as the checkout's own registrations elsewhere are not; `lanes-edit.sh lane-reconcile` reports it. The reconciliation reads what the legs hold diff --git a/lane-handoff b/lane-handoff index 1403ce56..75b52a37 100755 --- a/lane-handoff +++ b/lane-handoff @@ -1916,10 +1916,49 @@ brief_for() { # } writer_count=0 +pw_withheld_count=0 +# A TREE WITH A `.git` OF ITS OWN THAT GIT CANNOT READ, IN AN ASSEMBLY (T018, +# S2 of the review of bf2a220), is NAMED, never passed over. A leg whose +# module directory (`/.git/modules/`) is damaged takes every +# worktree of its repository with it: the trees `lane-worktrees add --checkout +# /` made, and the paired trees this lane's inventory names. +# Passed over, they vanished from the WRITERS section and from its count while +# the lane went SWAPPED, as if the poll had looked everywhere it looks. So such +# a tree is listed and counted as a writer whose state nobody could read, it is +# NOT recorded (a guessed reading would replace the one its record names), and +# the lane stays SWAPPING for it. Only `poll_writer` calls this, and only where +# the checkout is an assembly: a checkout with no `project.yaml` naming a leg +# passes over such a tree as it always did, which its golden holds. +unreadable_writer() { # + uw_d="$1" + uw_err="$(git -C "$uw_d" rev-parse --git-dir 2>&1 >/dev/null > "$writers_tmp" + if [ -n "$lc_on" ]; then + lc_inventory_missed="${lc_inventory_missed:+$lc_inventory_missed }$uw_d" + note "the worktree inventory entry for $uw_d was NOT written: it has a .git of its own and git could not read it ($uw_err), so neither what it holds nor which checkout it is recorded under is known, and a guessed reading would replace the one its record names now. This handoff's WRITERS section names it, the lane stays SWAPPING for it, and \`$LANES_EDIT lane-reconcile $lane\` says what the inventory holds." + else + note "the tree $uw_d has a .git of its own and git could not read it ($uw_err), so nothing is assumed about it, and this handoff's WRITERS section names it. Read it by hand (git -C $uw_d status) before relaunching a writer onto it." + fi + return 0 +} poll_writer() { # pw_d="$1" [ -d "$pw_d" ] || return 0 - git -C "$pw_d" rev-parse --git-dir >/dev/null 2>&1 || return 0 + if ! git -C "$pw_d" rev-parse --git-dir >/dev/null 2>&1; then + [ -n "$legs_state" ] || return 0 + { [ -e "$pw_d/.git" ] || [ -L "$pw_d/.git" ]; } || return 0 + unreadable_writer "$pw_d" + return 0 + fi # THE OBSERVATION IS THE HELPER'S, AND THIS COMMAND MAKES NONE OF ITS OWN # (Copilot round 5 on openRepoTools#97). `lanes-edit.sh lane-tree-now` is the # one implementation of *what git says about this tree right now* — the same @@ -1964,8 +2003,19 @@ poll_writer() { # [ -n "$pw_status" ] && printf '%s\n' "$pw_status" | sed 's/^/ /' say " git log @{u}..: ${pw_ahead} unpushed" [ -n "$pw_unpushed" ] && printf '%s\n' "$pw_unpushed" | sed 's/^/ /' - printf -- '- `%s` — branch `%s`, last commit `%s`, %s dirty, %s unpushed; brief: %s\n' \ - "$pw_d" "$pw_branch" "${pw_last:-}" "$pw_dirty" "$pw_ahead" \ + # WHERE IT IS RECORDED, decided before its line is written (below), so that a + # tree this handoff withholds says so in its own line — `(NOT recorded)` — + # rather than reading exactly as a recorded one (N1 of the review of + # bf2a220). A checkout with no `project.yaml` naming a leg withholds nothing, + # and its line is the one it always was. + pw_co=""; pw_withheld="" + if [ -n "$lc_on" ] && [ "$pw_orc" = 0 ]; then + pw_co="${dir:-unknown}" + [ -z "$legs_state" ] || pw_co="$(writer_checkout "$pw_d")" + [ -n "$pw_co" ] || pw_withheld=" (NOT recorded)" + fi + printf -- '- `%s`%s — branch `%s`, last commit `%s`, %s dirty, %s unpushed; brief: %s\n' \ + "$pw_d" "$pw_withheld" "$pw_branch" "${pw_last:-}" "$pw_dirty" "$pw_ahead" \ "${pw_brief:-(fill in: the brief this writer was given)}" >> "$writers_tmp" # AND THE SAME OBSERVATION, MACHINE-READABLE, BESIDE THE LANE AND NOT INSIDE # IT (openRepoTools#91). The WRITERS section above is what a person and the @@ -1985,9 +2035,8 @@ poll_writer() { # # checkout would replace the one `add` recorded, so the tree is named and # the lane stays SWAPPING for it, as for a refused write. if [ -n "$lc_on" ] && [ "$pw_orc" = 0 ]; then - pw_co="${dir:-unknown}" - [ -z "$legs_state" ] || pw_co="$(writer_checkout "$pw_d")" - if [ -z "$pw_co" ]; then + if [ -n "$pw_withheld" ]; then + pw_withheld_count=$((pw_withheld_count + 1)) lc_inventory_missed="${lc_inventory_missed:+$lc_inventory_missed }$pw_d" if [ "$legs_state" = known ]; then pw_cant="the assembly has $legs_unreadable_count unreadable leg(s), each named by an UNREADABLE LEG line in the top block, so whether this tree is of such a leg's repository" @@ -2058,7 +2107,7 @@ poll_writer() { # # an inventory that could not be read (`paired_unread`): each is a place a # tree of this lane's may stand unseen, exactly as `lane-reconcile` says # `indeterminate` and the sweep refuses for the same directories. -legs_state=""; legs_unread=""; leg_rows=""; leg_commons=""; dir_common="" +legs_state=""; legs_unread=""; leg_rows=""; leg_commons=""; dir_common=""; dir_phys="" paired_unread=""; paired_others=""; paired_other_count=0; paired_inv="" tab="$(printf '\t')" # THE PHYSICAL PATH OF A CHECKOUT'S COMMON GIT DIRECTORY, or nothing: a leg @@ -2124,12 +2173,15 @@ if [ "$legs_state" = known ]; then fi lg_why="the $lg_decl leg of $dir/project.yaml${lg_repo:+ ($lg_repo)}: git could not be read through it (it has a .git of its own and git could not read it: $lg_err)" legs_unreadable_count=$((legs_unreadable_count + 1)) - legs_unreadable="$legs_unreadable$(printf -- '- UNREADABLE LEG: `%s` is unreadable, %s. So NOTHING is assumed about it — not its own state, nor which tree is of its repository: a tree this handoff could not place is named above and NOT recorded. A read that failed is never a clean leg (Amendment 7(d)) — read it by hand (`git -C %s status`) before relaunching a writer onto it.' \ - "$lg_abs" "$lg_why" "$lg_abs") + # ` TAB `, one a line: the top block writes each leg's line + # once the poll has said whether any tree was withheld (N1), and + # `writer_checkout` asks whether a record names one of these legs. + legs_unreadable="$legs_unreadable$lg_abs$tab$lg_why " - # ONLY A TREE OF THE ASSEMBLY'S OWN REPOSITORY IS STILL VOUCHED FOR. + # WHAT IS STILL VOUCHED FOR, AND WHAT COULD BE A LEG'S (`writer_checkout`). [ -n "$dir_common" ] || dir_common="$(common_dir_of "$dir" /.git/modules/`, a leg's own `.git`, or a linked worktree's +# `modules/` under the common directory); or one this lane's inventory records +# under an unreadable leg, or under a checkout inside one. A tree of ANY OTHER +# repository (a worktree of an unrelated repository in the lane's own root) +# is of no leg, so it keeps the lane's `dir`, exactly as where every leg reads +# (S1 of the review of bf2a220): withheld, it left the lane SWAPPING on every +# handoff and its record going stale for as long as the leg stayed broken. +recorded_under_unreadable_leg() { # + ru_p="$(physical_of "$1")" + while IFS="$tab" read -r ru_abs ru_why; do + [ -n "$ru_abs" ] || continue + case "$1/" in "$ru_abs"/*) return 0 ;; esac + ru_lp="$(physical_of "$ru_abs" wc_c="$(common_dir_of "$1")" if [ -n "$wc_c" ] && [ -n "$leg_commons" ]; then @@ -2202,8 +2279,15 @@ WC_ROWS printf '%s' "$wc_rec"; return 0 fi if [ "$legs_unreadable_count" -gt 0 ]; then - if [ -n "$wc_c" ] && [ -n "$dir_common" ] && [ "$wc_c" = "$dir_common" ]; then printf '%s' "$dir"; fi - return 0 + if [ -n "$wc_c" ] && [ -n "$dir_common" ] && [ "$wc_c" = "$dir_common" ]; then + printf '%s' "$dir"; return 0 + fi + [ -n "$wc_c" ] && [ -n "$dir_phys" ] || return 0 + case "$wc_c/" in "$dir_phys"/*) return 0 ;; esac + if [ -n "$dir_common" ]; then + case "$wc_c/" in "$dir_common"/*) return 0 ;; esac + fi + if [ -n "$wc_rec" ] && recorded_under_unreadable_leg "$wc_rec" None: "not be read through it (it has a .git of its own and git could not " "read it: "), why assert answer in why and f"(git -C {spec} status)" in why, (answer, why) + # N2 of the review of bf2a220: a leg is the assembly's, not a writer's tree + assert why.endswith("before relaunching a writer onto a tree of its repository"), why assert field(rec.stdout, "TREES") == ["0 inventoried", "0 dirty or unpushed", "1 require recovery", "0 unmanaged or stale"], rec.stdout assert field(rec.stdout, "VERDICT")[0] == "ungraceful-stop", rec.stdout @@ -542,6 +544,22 @@ def check(answer: str, trees: tuple, unplaced: tuple, state: str) -> None: assert line.startswith(f"- UNREADABLE LEG: `{spec}` is unreadable, {words}"), writers assert answer in line and f"(`git -C {spec} status`)" in line, line assert writers.count("- UNREADABLE LEG: ") == 1, writers + # N2 of the review of bf2a220: a leg is the assembly's, not a writer's tree + assert line.endswith("before relaunching a writer onto a tree of its repository."), line + assert proc.stderr.split(f"the leg {spec} is unreadable: ", 1)[1].split("\n", 1)[0] \ + .endswith("before relaunching a writer onto a tree of its repository."), proc.stderr + # N1: the leg's line says what was withheld, and a withheld tree's own line + # says it is NOT recorded, where a recorded tree's line does not + if unplaced: + assert (f"the {len(unplaced)} tree(s) this handoff could not place are named " + "above, each marked (NOT recorded)") in line, line + else: + assert ("every tree this handoff polled could still be placed, so none is " + "withheld for it") in line, line + for tree in trees: + assert (f"- `{tree}` (NOT recorded) — branch `" in writers) is (tree in unplaced), \ + (tree, writers) + assert (f"- `{tree}` — branch `" in writers) is (tree not in unplaced), (tree, writers) (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") check("/nonexistent/t018-spec", (lane_root,), (), "SWAPPED") @@ -575,6 +593,185 @@ def check(answer: str, trees: tuple, unplaced: tuple, state: str) -> None: assert inventory(b, "triad-1") == want +@NEEDS_UPSTREAM +def test_t018_an_unreadable_leg_withholds_no_tree_of_another_repository(triad): + """S1 OF THE REVIEW OF bf2a220: while a leg was unreadable, the handoff + withheld EVERY tree that was not of the assembly's own repository - a + worktree of an unrelated repository in the lane's own root included, as + lane openRepoTools-3's root holds today - so the lane stayed SWAPPING on + every handoff and those trees' records went stale for as long as the leg + stayed broken. Now only a tree that could be of the leg's repository is + withheld: one whose common git directory lies inside the assembly, or one + the inventory records under the unreadable leg. A worktree of a third + repository, recorded before or new, is recorded under the lane's `dir` as + when every leg reads, and with nothing else withheld the lane is SWAPPED.""" + b, t = triad + a, spec = t["assembly"], t["spec"] + third = b.projects / "third" + b.git("clone", "-q", b.seeded("third", {"t.txt": "a third repository\n"}), third) + root = b.projects / ".lane-worktrees" / "triad-1" + before = leg_tree(b, third, root / "nw-before", "nw-before") + proc = handoff(b, "triad-1", a, "every leg reads") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert inventory(b, "triad-1") == {str(before): str(a)} + + (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + after = leg_tree(b, third, root / "nw-after", "nw-after") + proc = handoff(b, "triad-1", a, "unreadable leg") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable: " in proc.stderr, proc.stderr + assert f"writers polled: 2 under {a}" in proc.stderr, proc.stderr + for tree in (before, after): + assert f"WRITER {tree} — branch {tree.name}" in proc.stdout, proc.stdout + assert f"the worktree inventory entry for {tree} was NOT written" not in proc.stderr, \ + proc.stderr + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + writers = top_block(b, "triad-1").split("**WRITERS at ", 1)[1] + assert "(NOT recorded)" not in writers, writers + assert ("every tree this handoff polled could still be placed, so none is withheld " + "for it") in writers, writers + assert inventory(b, "triad-1") == {str(before): str(a), str(after): str(a)} + + # A RECORD NAMING THE UNREADABLE LEG still withholds its tree, whatever + # repository the tree's own common directory says it is of. + named = leg_tree(b, third, root / "nw-named", "nw-named") + rec = b.tool("lanes-edit.sh", "set-lane-tree", "triad-1", named, "--checkout", spec) + assert rec.returncode == 0, rec.stderr + proc = handoff(b, "triad-1", a, "unreadable leg, and a record naming it") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert (f"the worktree inventory entry for {named} was NOT written: the assembly has 1 " + "unreadable leg(s)") in proc.stderr, proc.stderr + for tree in (before, after): + assert f"the worktree inventory entry for {tree} was NOT written" not in proc.stderr, \ + proc.stderr + assert "lifecycle: SWAPPING" in proc.stdout, proc.stdout + writers = top_block(b, "triad-1").split("**WRITERS at ", 1)[1] + assert f"- `{named}` (NOT recorded) — branch `nw-named`" in writers, writers + for tree in (before, after): + assert f"- `{tree}` — branch `{tree.name}`" in writers, writers + assert ("the 1 tree(s) this handoff could not place are named above, each marked " + "(NOT recorded)") in writers, writers + assert inventory(b, "triad-1") == {str(before): str(a), str(after): str(a), + str(named): str(spec)} + + +@NEEDS_UPSTREAM +def test_t018_a_tree_git_cannot_read_in_an_assembly_is_named_and_holds_the_lane_swapping(triad): + """S2 OF THE REVIEW OF bf2a220: the poll passed over IN SILENCE any tree + with a `.git` git cannot read. With the spec leg's module directory + deleted after `add --checkout spec`, every tree of that leg - the one + `add` made, and a paired spec tree the inventory names - vanished from + WRITERS and from its count, and the lane went SWAPPED. Now each is listed + and counted, `UNREADABLE` and `(NOT recorded)`, its record is kept, and + the lane stays SWAPPING. Scoped to assemblies: a single repository's tree + that git cannot read is passed over, as it always was.""" + b, t = triad + a, spec = t["assembly"], t["spec"] + made = b.tool("lane-worktrees", "add", "triad-1", "s1", "--checkout", spec) + assert made.returncode == 0, made.stderr + lane_spec = Path(made.stdout.strip()) + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + paired_spec = leg_tree(b, spec, a / "worktrees" / "040-p" / "spec", "040-p") + b.commit(paired_spec, "the feature's spec", {"p.md": "p\n"}, lane="triad-1") + proc = handoff(b, "triad-1", a, "every leg reads") + assert proc.returncode == 0, proc.stdout + proc.stderr + want = {str(lane_spec): str(spec), str(lane_root): str(a), str(paired_spec): str(spec)} + assert inventory(b, "triad-1") == want + + shutil.rmtree(a / ".git" / "modules" / "spec") + proc = handoff(b, "triad-1", a, "the spec leg's module directory is gone") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable: " in proc.stderr, proc.stderr + assert f"writers polled: 3 under {a}" in proc.stderr, proc.stderr + assert f"WRITER {lane_root} — branch root" in proc.stdout, proc.stdout + block = top_block(b, "triad-1") + assert f"(3 found under `{a}`)" in block, block + writers = block.split("**WRITERS at ", 1)[1] + for tree in (lane_spec, paired_spec): + assert (f"WRITER {tree} — UNREADABLE: it has a .git of its own and git could not " + "read it: ") in proc.stdout, proc.stdout + assert (f"the worktree inventory entry for {tree} was NOT written: it has a .git of " + "its own and git could not read it") in proc.stderr, proc.stderr + assert (f"- `{tree}` (NOT recorded) — UNREADABLE: it has a .git of its own and git " + "could not read it (") in writers, writers + assert f"(`git -C {tree} status`)" in writers, writers + assert f"- `{lane_root}` — branch `root`" in writers, writers + assert ("the 2 tree(s) this handoff could not place are named above, each marked " + "(NOT recorded)") in writers, writers + assert "lifecycle: SWAPPING" in proc.stdout, proc.stdout + held = proc.stderr.split("the worktree inventory was not recorded for ", 1)[1].split("\n", 1)[0] + assert str(lane_spec) in held and str(paired_spec) in held and str(lane_root) not in held, held + assert inventory(b, "triad-1") == want + + # A SINGLE REPOSITORY PASSES OVER SUCH A TREE, AS IT ALWAYS DID. + b.origin("plain") + s = b.projects / "plain" + b.git("clone", "-q", b.remotes / "plain.git", s) + b.commit(s, "seed", {"README.md": "plain\n"}) + b.git("push", "-q", "origin", "main", cwd=s) + b.lanes("opensoft/plain", s, "plain-1") + made = b.tool("lane-worktrees", "add", "plain-1", "w") + assert made.returncode == 0, made.stderr + w = Path(made.stdout.strip()) + admin = (w / ".git").read_text().split("gitdir:", 1)[1].strip() + shutil.rmtree(admin) + proc = handoff(b, "plain-1", s, "a tree git cannot read") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"writers polled: 0 under {s}" in proc.stderr, proc.stderr + assert "UNREADABLE" not in proc.stdout + proc.stderr + assert "NOT recorded" not in proc.stdout + proc.stderr + top_block(b, "plain-1") + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + + +@NEEDS_UPSTREAM +def test_t018_a_leg_tree_add_made_is_withheld_where_the_legs_git_is_an_empty_directory(triad): + """N4 OF THE REVIEW OF bf2a220: the empty-`.git`-directory case was run + only with no tree of that leg, and its path differs from the gitfile + case's: git answers the ASSEMBLY in the leg, so the common directory of + the checkout the tree's record names is the assembly's own `.git`, not + nothing. A tree `add --checkout spec` made is still withheld - named, + marked `(NOT recorded)`, its record kept under the leg and never moved to + the assembly - and the lane stays SWAPPING; readable again, the tree is + recorded under its leg and the lane is SWAPPED.""" + b, t = triad + a, spec = t["assembly"], t["spec"] + made = b.tool("lane-worktrees", "add", "triad-1", "s1", "--checkout", spec) + assert made.returncode == 0, made.stderr + lane_spec = Path(made.stdout.strip()) + want = {str(lane_spec): str(spec)} + assert inventory(b, "triad-1") == want + gitfile = (spec / ".git").read_text() + (spec / ".git").unlink() + (spec / ".git").mkdir() + # the path this case takes: in the leg, git answers the assembly + assert real(b.git("rev-parse", "--show-toplevel", cwd=spec)) == real(a) + assert real(spec / b.git("rev-parse", "--git-common-dir", cwd=spec)) == real(a / ".git") + proc = handoff(b, "triad-1", a, "the spec leg's .git is an empty directory") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable: " in proc.stderr, proc.stderr + assert f"git answers {real(a)} there" in proc.stderr, proc.stderr + assert f"WRITER {lane_spec} — branch s1" in proc.stdout, proc.stdout + assert (f"the worktree inventory entry for {lane_spec} was NOT written: the assembly has 1 " + "unreadable leg(s)") in proc.stderr, proc.stderr + assert "lifecycle: SWAPPING" in proc.stdout, proc.stdout + writers = top_block(b, "triad-1").split("**WRITERS at ", 1)[1] + assert f"- `{lane_spec}` (NOT recorded) — branch `s1`" in writers, writers + assert ("the 1 tree(s) this handoff could not place are named above, each marked " + "(NOT recorded)") in writers, writers + assert inventory(b, "triad-1") == want + + shutil.rmtree(spec / ".git") + (spec / ".git").write_text(gitfile) + proc = handoff(b, "triad-1", a, "readable again") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "UNREADABLE LEG" not in top_block(b, "triad-1") + assert "(NOT recorded)" not in top_block(b, "triad-1") + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + assert inventory(b, "triad-1") == want + + # ===================================================== the single repository def single_repository_decoys(bench: Bench) -> list: From 9a8f17549b4982d90951203b8a9d78ac0b147029 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 23:19:52 +0000 Subject: [PATCH 24/42] =?UTF-8?q?T018=20docs:=20record=20lane=201's=20ruli?= =?UTF-8?q?ngs=20of=20comment=206240800023=20in=20the=20analysis's=20?= =?UTF-8?q?=C2=A77=20(items=203,=2011=20and=20a=20new=2012=20for=20M1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/triad-lane-tooling-analysis.md §7: a dated head note naming comment 6240800023 on #186 (posted 2026-10-17T20:48Z), and each ruling at its item: - item 3: proposal 1 (the guidance) goes live now; proposals 2 and 3 go live with the T018 PR, on the condition of the read-only before/after measurement over the triads (IRRS, IRSS, MedxEHR, MedxGlass, openDox, openXdox, openWallet and the codexFactory copies), main's tools against T018's, and any verdict that flips to a refusal told to its owning lane before the landing; - item 11: (ii), park's WIP commit carries a `Lane: ` trailer, and resume records the tree it rebuilds with `lanes-edit.sh set-lane-tree --checkout /`; both workBenches changes, one workBenches issue linked from the T018 PR ("workBenches issue: to be linked"); the PR does not wait for it; - item 12, new: the pre-PR review's M1, (a) as implemented in 4d4957d, with the two-lane regression test (test_t018_a_handoff_takes_no_other_lanes_paired_tree). §6's 2026-10-13 note now says the M1 ruling was made, and where. docs/README-lanes.md: the two T018 sentences that called these rulings pending now say they were made, and the parked-tree check before lane-end stands until the workBenches changes land. Docs only. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 13 ++++++--- docs/triad-lane-tooling-analysis.md | 43 +++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 259a70cc..fc642b57 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3951,8 +3951,9 @@ outside WRITERS — `- PAIRED ROOT, not this lane's: — branch …`, wit commit and its HEAD's own trailer, which is what finds its lane. It is not counted, not recorded, and never a writer for the next session to relaunch; its own lane's handoff records it. So one lane's `lane-end` never refuses on another lane's paired work, and -its `sweep --yes` never acts on it. This is option (a) of the pre-PR review's M1, -pending lane 1's ruling on #186: a handoff that recorded every paired tree made other +its `sweep --yes` never acts on it. This is option (a) of the pre-PR review's M1, as +lane 1 ruled on 2026-10-17 (#186, comment 6240800023, item 1; the analysis's §7 item +12): a handoff that recorded every paired tree made other lanes' trees the running lane's inventory, and a tree two lanes' inventories both name is `keep`, counted, in both sweeps. @@ -4011,8 +4012,12 @@ output `c45a452`'s tools printed, on a repository laid out to look like a triad: submodule's worktree under `worktrees//code`, and a tree recorded with the submodule as its checkout. -**A parked paired tree is not the lane's to the sweep — a gap, for lane 1's ruling -(docs/triad-lane-tooling-analysis.md §7, item 11).** The sweep and `lane-end`'s gate +**A parked paired tree is not the lane's to the sweep — a gap lane 1 has ruled on, and +not yet closed (docs/triad-lane-tooling-analysis.md §7, item 11).** On 2026-10-17 (#186, +comment 6240800023, item 2) lane 1 ruled that park's WIP commit carries a `Lane: ` +trailer and that `resume` records the tree it rebuilds with `lanes-edit.sh +set-lane-tree`. Both are workBenches changes and neither has landed, so what follows +holds until they do. The sweep and `lane-end`'s gate give a tree under `/worktrees` to a lane by its HEAD's own `Lane:` trailer and by nothing else, unless the lane's inventory names it. `make park` commits a dirty leg tree as `wip: park — lane `, with no trailer, and `resume` diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index f409cf90..45d13bc1 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -575,8 +575,9 @@ the leg it made a tree from. On a single repository every output is byte for byt main at `f87fd5a` (#190) and changes none of #190's test-root files. **2026-10-13: the paired root in `lane-handoff`, after the pre-PR review.** (b) above is -narrower now (the review's M1, option (a), pending lane 1's ruling on #186). The handoff -polls and records a paired tree only where the lane's inventory names it, its HEAD's own +narrower now (the review's M1, option (a), then pending lane 1's ruling on #186; ruled +2026-10-17, §7 item 12). The handoff polls and records a paired tree only where the +lane's inventory names it, its HEAD's own `Lane:` trailer is the lane's on a commit origin's default branch lacks (the sweep's rule), or the writers file names it. Every other paired tree is a `PAIRED ROOT, not this lane's` line in the top block, outside WRITERS, not counted and not recorded. A paired @@ -603,6 +604,14 @@ kept only in this lane's scratch directory. ## 7. Decisions for lane 1 (the migration owner) +**2026-10-17: lane 1's rulings on T018.** Lane openRepoTools-1 ruled in comment +**6240800023** on opensoft/openRepoTools#186 ("the four T018 rulings", posted +2026-10-17T20:48Z), answering lane 3's 6240688657, first asked in 6111266988. Each +ruling is quoted at its item below: item 3 (what goes live), item 11 (parked paired +trees), and item 12, added here, for the pre-PR review's M1. The same comment gives +lane 1's word to open the T018 PR once five conditions are met, and lane 1 owns the +landing. + 1. **Bindings (T018):** keep every lane's `dir` at the assembly root and its home at `opensoft/openRepoTools`, and spell leg objects in full. The alternative is lanes homed on a leg, which changes the Claude project folder, and with it memory and `--resume`. @@ -610,6 +619,16 @@ kept only in this lane's scratch directory. `opensoft/openRepoTools-spec:…` at cutover, by release and claim. 3. **Worktree visibility (T018):** adopt §4.4 proposal 1 (guidance) now. Rule on proposals 2 and 3, which change the sweep and the daily report for existing triads too. + **Ruled 2026-10-17 (6240800023, item 3).** Proposal 1, the guidance, goes live now. + Proposals 2 and 3 go live with the T018 PR, on one condition. They change the sweep, + `lane-end`'s gate, the handoff poll, `lane-reconcile`'s TREE rows and the daily report + for every triad on this workstation, and those triads belong to other lanes. So the PR + body must carry a read-only before/after measurement over each of them (IRRS, IRSS, + MedxEHR, MedxGlass, openDox, openXdox, openWallet and the codexFactory copies), + comparing main's tools with T018's. For each triad it lists the sweep rows that + change, every `lane-end` verdict that changes with the lane that owns it, and the + report lines that appear. Any verdict that flips to a refusal is told to its owning + lane, on #186 or in the register, before the landing. 4. **Checkout-copy callers:** opsXfactory-1's handoff and its `land-444.sh` call `~/projects/openRepoTools/lanes-edit.sh`. Re-point them before the live checkout pulls the split. @@ -668,3 +687,23 @@ kept only in this lane's scratch directory. its HEAD is back below the WIP commits. So decide too whether `resume` records the tree it rebuilds in the lane's inventory (`lanes-edit.sh set-lane-tree --checkout /`), which covers both states. + **Ruled 2026-10-17 (6240800023, item 2): (ii), and `resume` records the tree.** + Park's WIP commit carries a `Lane: ` trailer, so attribution stays on one + contract, the trailer, and `lane-worktrees` is not coupled to a workBenches subject + format. `resume` records the tree it rebuilds with `lanes-edit.sh set-lane-tree + --checkout /`, which covers both the parked and the + resumed state. Both are workBenches changes, to be filed as one workBenches issue + that cites this item and is linked from the T018 PR. **workBenches issue: to be + linked.** The PR does not wait for it. Until it lands, `docs/README-lanes.md` (T018 + section) says what to check before `lane-end`. +12. **The paired root in `lane-handoff` (T018, the pre-PR review's M1).** **Ruled + 2026-10-17 (6240800023, item 1): (a), as implemented in `4d4957d`, with the two-lane + regression test** (`test_t018_a_handoff_takes_no_other_lanes_paired_tree`). The + handoff polls and records a paired tree only when one of these holds: this lane's + inventory names it, its HEAD's last `Lane:` trailer is this lane's on a commit + origin's default branch lacks, or the lane's writers file names it. Every other + paired tree is printed as "PAIRED ROOT, not this lane's", and is not counted or + recorded. Lane 1's reason: recording everything is the misattribution that + `.claude/worktrees` already causes in this checkout's directory, where on 2026-10-11 + six other lanes' trees went into lane 1's inventory that way. Option (b), recording + every paired tree as the running lane's, is not taken. From 5b68127fdde730fb49290a28f525f04e47dcd0ac Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 23:38:17 +0000 Subject: [PATCH 25/42] Exit-code tables: say the #194 refusal pushes none of them, not "nothing pushed", which test_the_exit_3_documentation_agrees_with_the_die_message_it_describes reserves Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 2 +- lanes-edit.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 69c087f3..549fb8c8 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1182,7 +1182,7 @@ else. |---|---| | 0 | done | | 1 | environment — no register, no writer | -| 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased, or `commit` refusing to publish local commits that are not register or handoff writes, or from a checkout whose HEAD is off the branch (#194; nothing pushed) | +| 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased, or `commit` refusing to publish local commits that are not register or handoff writes, or from a checkout whose HEAD is off the branch (#194; it pushes none of them) | | 3 | the edit is never a permanent loss, but this attempt cannot confirm whether it also reached origin — a rebase conflict (not pushed by this attempt; a later write from this checkout may already carry it to origin, so look by content before you retry), a network timeout on a push or a pull (a hung one often already landed), or six attempts exhausted because a peer's uncommitted file blocks every rebase | | 4 | the mutex could not be taken within 60s | | 5 | an edit moved more than one line and was refused | diff --git a/lanes-edit.sh b/lanes-edit.sh index d41d6fab..4df8c5e8 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -433,8 +433,8 @@ # would stage bytecode, a cache or a virtual environment # (`commit_push`, `pathspec-check`), or — #194 — `commit` refusing to # publish local commits that are not register or handoff writes, or a -# checkout whose HEAD is off the branch (`cp_publish_fence`), nothing -# pushed. EIGHT MEANINGS ON ONE NUMBER, and they +# checkout whose HEAD is off the branch (`cp_publish_fence`), which +# pushes none of them. EIGHT MEANINGS ON ONE NUMBER, and they # stay readable only because of where each can occur: bad arguments to a # subcommand that predates Amendment 11; `register-row`'s "not lane-shaped"; # AN UNKNOWN SUBCOMMAND (the `*)` arm below), which is how a caller detects From 9f8aea86f61360dfe5320e4c11308bcaedbdf046 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 01:20:11 +0000 Subject: [PATCH 26/42] #214 reviews (openRepoShape-2 6242165339, openRepoTools-3 6242725522): the refusals as variants of one test, the flag pinned against the environment, bytecode refused, the fence called a path filter - The HEAD refusal says "nothing was pushed" like every other refusal of the publish. CI at 5b68127 failed on the capital N, and nothing else. - HEAD off the branch, a merge in the queue, a branch shadowing origin/main, a type change, and bytecode beside a handoff are variants of test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish, as lane 3 asked. The separate bypass tests are folded in. - The no-op case also runs with CP_PUBLISH_AHEAD=1 exported, which kills lane 2's mutant M01. - The composite assert is split (Sonar S9073). - The fence refuses bytecode, a cache or an environment (#162's list). Its comment and the manual call it a path and kind filter, never an ownership check. - The no-op note says `commit` would publish every queued commit, so read `git cherry -v` first (lane 2's SHOULD 3). - The `commit` usage line and both exit tables name the publish, its refusals and the failed read. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 14 +- lanes-edit.sh | 33 ++-- tests/test_commit_push_published_edit_194.py | 152 ++++++++++--------- 3 files changed, 116 insertions(+), 83 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 549fb8c8..5a630481 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -100,11 +100,13 @@ through the same loop, guard and retries (#194). It reads that queue on `refs/heads/main`, the branch the push sends, with HEAD required to be on it, and pushes exactly the commit it read. It refuses with 2 and pushes nothing unless every queued commit is a non-merge commit whose every change is an added -or modified regular file under `lanes/` or `handoffs/`. That refuses a merge, a -deletion (a lane rename's included), a type change, a symlink, a gitlink and -any other path. Only `commit` publishes that queue: any other write with nothing -to stage leaves it local, and with nothing ahead `commit` is the no-op it always -was. +or modified regular file under `lanes/` or `handoffs/`, and never bytecode, a +cache or an environment. That refuses a merge, a deletion (a lane rename's +included), a type change, a symlink, a gitlink and any other path. It is a +path filter, not an ownership check: another lane's register write passes it, +so read `git cherry -v origin/main HEAD` before you publish a shared queue. +Only `commit` publishes that queue: any other write with nothing to stage +leaves it local, and with nothing ahead `commit` is the no-op it always was. ## THE ONE HAZARD — `sed -i` DESTROYS THE SYMLINK @@ -1182,7 +1184,7 @@ else. |---|---| | 0 | done | | 1 | environment — no register, no writer | -| 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased, or `commit` refusing to publish local commits that are not register or handoff writes, or from a checkout whose HEAD is off the branch (#194; it pushes none of them) | +| 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased, or `commit` refusing to publish local commits that are not register or handoff writes, from a checkout whose HEAD is off the branch, or that it cannot read (#194; it pushes none of them) | | 3 | the edit is never a permanent loss, but this attempt cannot confirm whether it also reached origin — a rebase conflict (not pushed by this attempt; a later write from this checkout may already carry it to origin, so look by content before you retry), a network timeout on a push or a pull (a hung one often already landed), or six attempts exhausted because a peer's uncommitted file blocks every rebase | | 4 | the mutex could not be taken within 60s | | 5 | an edit moved more than one line and was refused | diff --git a/lanes-edit.sh b/lanes-edit.sh index 4df8c5e8..4caade66 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -58,7 +58,9 @@ # (attribution: $LANES_LANE, else the # "lane " the text itself names) # lanes-edit.sh [--no-sweep|--sweep] add-row "" -# lanes-edit.sh commit "" # commit a hand edit +# lanes-edit.sh commit "" # commit a hand edit; with +# nothing staged, publish the stranded +# register and handoff commits (#194) # lanes-edit.sh migrate-state-cells [--yes] # Amendment 13(e), once # lanes-edit.sh retire-rows … [--reason ""] [--writer ] # lanes-edit.sh archive-rows [--yes] # Amendment 19(d) @@ -432,9 +434,9 @@ # PROMPT, or — opensoft/openRepoTools#162 — a commit whose pathspec # would stage bytecode, a cache or a virtual environment # (`commit_push`, `pathspec-check`), or — #194 — `commit` refusing to -# publish local commits that are not register or handoff writes, or a -# checkout whose HEAD is off the branch (`cp_publish_fence`), which -# pushes none of them. EIGHT MEANINGS ON ONE NUMBER, and they +# publish local commits that are not register or handoff writes, from a +# checkout whose HEAD is off the branch, or that it cannot read +# (`cp_publish_fence`), and pushes none of them. EIGHT MEANINGS ON ONE NUMBER, and they # stay readable only because of where each can occur: bad arguments to a # subcommand that predates Amendment 11; `register-row`'s "not lane-shaped"; # AN UNKNOWN SUBCOMMAND (the `*)` arm below), which is how a caller detects @@ -1978,8 +1980,12 @@ BRT_EOF # ref cannot show the fence one commit while the push sends another. A change # passes only as an ADDED or MODIFIED REGULAR FILE under the register's own # directory (`$LANES_PREFIX`, derived, as every path of the register's is) or -# `handoffs/`. A deletion, a type change, a symlink or a gitlink refuses, and so -# does a path anywhere else. That includes lane tooling's own lane rename, +# `handoffs/`, and never as bytecode, a cache or an environment (#162's list). +# A deletion, a type change, a symlink or a gitlink refuses, and so does a path +# anywhere else. IT IS A PATH AND KIND FILTER: it vouches for where a change +# lands and what it is, never for whose it is (it reads no subject, author or +# trailer), so another lane's register write passes it. That includes lane +# tooling's own lane rename, # which deletes a log to re-add it: a deletion is the one change origin cannot # get back, and the fence keeps one rule for it. A read or a classification # that fails refuses too (2), because it proves nothing. @@ -1990,7 +1996,7 @@ BRT_EOF cp_publish_fence() { cpf_ref="refs/heads/$LANES_BRANCH" cpf_head="$(git -C "$LANES_REPO" symbolic-ref -q HEAD 2>/dev/null || :)" - [ "$cpf_head" = "$cpf_ref" ] || die "refusing to publish: HEAD is ${cpf_head:-detached}, not $cpf_ref, and the push sends $cpf_ref while \`pull --rebase\` moves HEAD, so what would be published is not what HEAD shows. Nothing was pushed (#194). Whoever moved this shared checkout off $LANES_BRANCH puts it back; never reset or check out over their work." 2 + [ "$cpf_head" = "$cpf_ref" ] || die "refusing to publish: HEAD is ${cpf_head:-detached}, not $cpf_ref, and the push sends $cpf_ref while \`pull --rebase\` moves HEAD, so what would be published is not what HEAD shows, and nothing was pushed (#194). Whoever moved this shared checkout off $LANES_BRANCH puts it back; never reset or check out over their work." 2 cpf_rc=0 cpf_base="$(git --no-replace-objects -C "$LANES_REPO" rev-parse -q --verify "refs/remotes/origin/$LANES_BRANCH^{commit}")" || cpf_rc=$? cpf_tip="$(git --no-replace-objects -C "$LANES_REPO" rev-parse -q --verify "$cpf_ref^{commit}")" || cpf_rc=$? @@ -2011,10 +2017,19 @@ cp_publish_fence() { return (reg != "" && index(p, reg) == 1) || (logp != "" && index(p, logp) == 1) || index(p, "handoffs/") == 1 || p == reg_path || p == alias_path } + function bytecode(p, n, part, i) { + if (p ~ /\.py[co]$/) return 1 + n = split(p, part, "/") + for (i = 1; i <= n; i++) + if (part[i] == "__pycache__" || part[i] == ".pytest_cache" || part[i] == ".mypy_cache" || + part[i] == ".ruff_cache" || part[i] == "node_modules" || part[i] == ".venv" || + part[i] == "venv" || part[i] == "site-packages") return 1 + return 0 + } NF == 0 { next } NF == 1 && $1 ~ /^[0-9a-f]+$/ { c = substr($1, 1, 12); seen++; next } { split($1, f, " "); st = f[5]; mode = f[2] - if (!(NF == 2 && (st == "A" || st == "M") && (mode == "100644" || mode == "100755") && inside($2))) + if (!(NF == 2 && (st == "A" || st == "M") && (mode == "100644" || mode == "100755") && inside($2) && !bytecode($2))) printf " %s %s %s %s\n", c, st, mode, $2 } END { if (seen != want) exit 3 }')" || cpf_rc=$? [ "$cpf_rc" = 0 ] || die "git could not read, or this helper could not classify, the local commits on $cpf_ref ahead of refs/remotes/origin/$LANES_BRANCH, so whether they are \`commit\`'s to publish is unknown; nothing was pushed (#194)" 2 @@ -2135,7 +2150,7 @@ commit_push() { fi cp_n="$(printf '%s\n' "$cp_ahead" | awk 'END { print NR }')" if [ "$CP_PUBLISH_AHEAD" != 1 ]; then - note "nothing staged for ${CP_PATHS[*]} — no commit made; the $cp_n local commit(s) ahead of origin/$LANES_BRANCH stay local, and only \`lanes-edit.sh commit\` publishes them" + note "nothing staged for ${CP_PATHS[*]} — no commit made; the $cp_n local commit(s) ahead of origin/$LANES_BRANCH stay local. \`lanes-edit.sh commit\` would publish all of them, so read \`git -C $LANES_REPO cherry -v origin/$LANES_BRANCH HEAD\` first: another lane's commits are its own to publish" return 0 fi cp_publish_fence diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index aa8944e7..31993ce7 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -325,7 +325,8 @@ def test_the_conflict_recovery_warns_that_a_reset_drops_a_peers_commits(box): assert count < moved < listing < warning < drops < reset, err assert f"cherry origin/main {aborted_at}" in err.splitlines()[count] captures = "\n".join(err.splitlines()[listing:drops + 1]) - assert "LANES(concurrent@" in captures and "LANES(pre-existing@" in captures, captures + assert "LANES(concurrent@" in captures, captures + assert "LANES(pre-existing@" in captures, captures assert "names no lane, so it is NOT yours" in captures, captures # The listing reads HEAD, which is what the reset acts on: a peer's commit @@ -354,48 +355,27 @@ def test_the_dirty_checkout_refusal_names_the_act_to_re_run(box): assert "then re-run the refused write NOTED" in result.stderr, result.stderr -def test_a_no_op_write_never_publishes_what_the_checkout_queued(box): +@pytest.mark.parametrize("exported", [False, True], ids=["plain", "CP_PUBLISH_AHEAD-exported"]) +def test_a_no_op_write_never_publishes_what_the_checkout_queued(box, exported): """THE #214 REVIEW'S BLOCKER. This shared checkout holds another lane's unpushed commit that deletes a file outside lane tooling's trees. A write whose result is the line already there must stay what it is on `main`: - exit 0, no network call, origin untouched. Only `commit` publishes.""" + exit 0, no network call, origin untouched. Only `commit` publishes, and + `CP_PUBLISH_AHEAD=1` EXPORTED changes nothing: the flag is the `commit` + arm's alone (lane openRepoShape-2's mutant M01 read it from the + environment and survived every other case).""" queued = box.queue("another lane's commit: retire the tool", delete=TOOL) head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") - result = box.helper("replace-in-row", LANE, "seeded", "seeded") + extra = {"CP_PUBLISH_AHEAD": "1"} if exported else {} + result = box.helper("replace-in-row", LANE, "seeded", "seeded", **extra) assert result.returncode == 0, result.stderr assert box.net_calls() == [], result.stderr assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) assert box.origin_file(TOOL) == "#!/usr/bin/env bash\n" assert box.unpublished()[0].startswith(queued) assert f"nothing staged for {REGISTER} — no commit made" in result.stderr - assert "stay local, and only `lanes-edit.sh commit` publishes them" in result.stderr - - -@pytest.mark.parametrize("queued", [ - {"delete": TOOL, "status": "D 000000", "path": TOOL}, - {"write": "workspaces/repoP.yaml", "status": "A 100644", "path": "workspaces/repoP.yaml"}, - {"delete": PEER_HANDOFF, "status": "D 000000", "path": PEER_HANDOFF}, -], ids=["deletes-a-tool", "outside-lane-tooling", "deletes-a-handoff"]) -def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, queued): - """`commit` publishes the register and handoff commits lane tooling strands - and nothing else: one queued commit that deletes a file, or touches a path - outside `lanes/` and `handoffs/`, refuses the WHOLE publish with 2, names - the commit and the path, and pushes nothing, not even the stranded edit.""" - box.set_state(box.wip, LANE, "LIVE · edited by hand") - box.wgit("commit", "-q", "-m", f"LANES({LANE}@Eagle): {SUBJECT}", "--", REGISTER) - if "write" in queued: - (box.wip / queued["write"]).parent.mkdir(parents=True, exist_ok=True) - sha = box.queue("a queued commit", write=queued.get("write", ""), - delete=queued.get("delete", "")) - head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") - - result = box.helper("commit", SUBJECT) - assert result.returncode == 2, result.stderr - assert f" {sha[:12]} {queued['status']} {queued['path']}" in result.stderr, result.stderr - assert "nothing was pushed" in result.stderr - assert "pushing the" not in result.stderr - assert box.net_calls() == [] - assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) + assert "stay local. `lanes-edit.sh commit` would publish all of them" in result.stderr + assert "another lane's commits are its own to publish" in result.stderr def test_the_publish_counts_what_its_list_leaves_out(box): @@ -409,9 +389,9 @@ def test_the_publish_counts_what_its_list_leaves_out(box): assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") -# ---- #214's adversarial check: three ordinary checkout states got a deletion -# past the first fence, and a fourth was a race. Every case below exits 0 and -# deletes `scripts/link-estates` on origin against the first fence (`fab2cbc`). +# ---- #214's adversarial check and its two reviews: three ordinary checkout +# states got a deletion past the first fence (`fab2cbc`), and a fourth was a +# race. The cases below pin each, and the fence's other rules beside them. def _stranded_edit(box: Box) -> None: box.set_state(box.wip, LANE, "LIVE · edited by hand") @@ -426,10 +406,50 @@ def _refused_untouched(box: Box, result, head: str, origin: str) -> None: assert box.origin_file(TOOL) == "#!/usr/bin/env bash\n" -def test_a_merge_commits_own_deletion_is_refused(box): +# ---- `commit` refuses a queue it cannot vouch for. Each setup leaves the +# stranded register edit queued with one more commit, or in one more checkout +# state, and returns the words the refusal must name. The last three are the +# adversarial check's bypasses, and each published a deletion at `fab2cbc`. + +def _deletes_a_tool(box: Box) -> str: + _stranded_edit(box) + return f" {box.queue('a queued commit', delete=TOOL)[:12]} D 000000 {TOOL}" + + +def _outside_lane_tooling(box: Box) -> str: + _stranded_edit(box) + (box.wip / "workspaces").mkdir(exist_ok=True) + sha = box.queue("a queued commit", write="workspaces/repoP.yaml") + return f" {sha[:12]} A 100644 workspaces/repoP.yaml" + + +def _deletes_a_handoff(box: Box) -> str: + _stranded_edit(box) + return f" {box.queue('a queued commit', delete=PEER_HANDOFF)[:12]} D 000000 {PEER_HANDOFF}" + + +def _type_change(box: Box) -> str: + """A handoff turned into a symlink is a `T`, not a modification.""" + _stranded_edit(box) + (box.wip / PEER_HANDOFF).unlink() + (box.wip / PEER_HANDOFF).symlink_to(f"../{TOOL}") + box.wgit("add", "--", PEER_HANDOFF) + box.wgit("commit", "-q", "-m", "peer: the handoff becomes a symlink to the tool") + return f" {box.wgit('rev-parse', 'HEAD').strip()[:12]} T 120000 {PEER_HANDOFF}" + + +def _bytecode_in_handoffs(box: Box) -> str: + """#162's list rides out of a publish no more than out of a commit.""" + _stranded_edit(box) + (box.wip / "handoffs" / "__pycache__").mkdir() + path = "handoffs/__pycache__/x.cpython-312.pyc" + return f" {box.queue('peer: bytecode beside a handoff', write=path)[:12]} A 100644 {path}" + + +def _merge_in_queue(box: Box) -> str: """BYPASS 1. `git log --name-status` prints no diff for a merge, so a deletion made while resolving one went out unread, by the straight push a - peer's dirty file takes. A merge in the queue refuses.""" + peer's dirty file takes.""" _stranded_edit(box) box.wgit("checkout", "-q", "-b", "side", "origin/main") box.queue(f"handoff({PEER_LANE}@Eagle): a side line", write=PEER_HANDOFF) @@ -439,55 +459,51 @@ def test_a_merge_commits_own_deletion_is_refused(box): box.wgit("commit", "-q", "-m", "Merge branch side") merge = box.wgit("rev-parse", "HEAD").strip() box.peer_edit_in_wip("a peer mid-write") - head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") - - result = box.helper("commit", SUBJECT) - _refused_untouched(box, result, head, origin) - assert f" {merge[:12]} is a merge commit" in result.stderr, result.stderr + return f" {merge[:12]} is a merge commit" -def test_a_checkout_left_on_another_branch_is_refused(box): +def _head_off_branch(box: Box) -> str: """BYPASS 2. The fence read HEAD while the push sent `refs/heads/main`: with a deletion queued on local main and HEAD on a scratch branch holding a - register edit, the fence saw only the edit. HEAD must be the branch.""" + register edit, the fence saw only the edit.""" box.queue("another lane: retire the tool", delete=TOOL) box.wgit("checkout", "-q", "-b", "scratch", "origin/main") _stranded_edit(box) - head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") - - result = box.helper("commit", SUBJECT) - _refused_untouched(box, result, head, origin) - assert "HEAD is refs/heads/scratch, not refs/heads/main" in result.stderr, result.stderr + return "HEAD is refs/heads/scratch, not refs/heads/main" -def test_a_local_branch_named_origin_main_does_not_shadow_the_tracking_ref(box): - """BYPASS 3. A local branch called `origin/main` made `origin/main..HEAD` - an empty-looking range for the commits beneath it. The range is spelled in - full, so the queued deletion is read and refused.""" +def _shadowing_origin_main(box: Box) -> str: + """BYPASS 3. A local branch named `origin/main` shadowed the tracking ref.""" sha = box.queue("peer: retire the tool", delete=TOOL) box.wgit("branch", "origin/main", "HEAD") _stranded_edit(box) - head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + return f" {sha[:12]} D 000000 {TOOL}" - result = box.helper("commit", SUBJECT) - _refused_untouched(box, result, head, origin) - assert f" {sha[:12]} D 000000 {TOOL}" in result.stderr, result.stderr +REFUSALS = { + "deletes-a-tool": _deletes_a_tool, + "outside-lane-tooling": _outside_lane_tooling, + "deletes-a-handoff": _deletes_a_handoff, + "type-change": _type_change, + "bytecode-in-handoffs": _bytecode_in_handoffs, + "merge-in-queue": _merge_in_queue, + "head-off-branch": _head_off_branch, + "shadowing-origin-main": _shadowing_origin_main, +} -def test_a_type_change_is_refused(box): - """A handoff turned into a symlink is a `T`, not a modification: the fence - passes only regular files, added or modified.""" - _stranded_edit(box) - (box.wip / PEER_HANDOFF).unlink() - (box.wip / PEER_HANDOFF).symlink_to(f"../{TOOL}") - box.wgit("add", "--", PEER_HANDOFF) - box.wgit("commit", "-q", "-m", "peer: the handoff becomes a symlink to the tool") - sha = box.wgit("rev-parse", "HEAD").strip() - head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") +@pytest.mark.parametrize("variant", list(REFUSALS)) +def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, variant): + """`commit` publishes the register and handoff commits lane tooling strands + and nothing else. One queued commit, or one checkout state, it cannot vouch + for refuses the WHOLE publish with 2, names it, makes no network call and + pushes nothing, not even the stranded register edit.""" + expect = REFUSALS[variant](box) + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") result = box.helper("commit", SUBJECT) _refused_untouched(box, result, head, origin) - assert f" {sha[:12]} T 120000 {PEER_HANDOFF}" in result.stderr, result.stderr + assert expect in result.stderr, result.stderr + assert "pushing the" not in result.stderr @pytest.mark.parametrize("path", ["rebase", "straight"]) From ee33a330940e56fbccb0b40f78cf77687dcd3011 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 01:21:34 +0000 Subject: [PATCH 27/42] Say what the publish fence checks: register and handoff writes by path and kind, never whose they are (lane openRepoShape-2's SHOULD 2 wording) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 6 +++--- tests/test_commit_push_published_edit_194.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 4caade66..acebf1db 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1607,7 +1607,7 @@ replace_line() { # `migrate-state-cells`) wanted the file to SAY something rather than to # differ from what it said. Nor does it push: a write with nothing staged # publishes nothing, whatever this shared checkout has queued ahead of - # origin; only `commit` does that, and only lane tooling's own commits (#194). + # origin; only `commit` does that, and only what its path filter passes (#194). if cmp -s -- "$pre" "$out"; then REPLACE_LINE_NOOP=1 note "line $n of $LANES_FILE is already exactly what this write asked for — nothing rewritten, and nothing to commit" @@ -2037,7 +2037,7 @@ cp_publish_fence() { note "of the local commits on $cpf_ref ahead of origin/$LANES_BRANCH, these are not register or handoff writes (a merge or root commit, or a change that is not an added or modified regular file under ${LANES_PREFIX:-the register directory} or handoffs/):" [ -z "$cpf_odd" ] || printf '%s\n' "$cpf_odd" >&2 [ -z "$cpf_bad" ] || printf '%s\n' "$cpf_bad" >&2 - die "refusing to publish: \`commit\` pushes only the register and handoff commits lane tooling strands (#194), so nothing was pushed. A lane rename (Amendment 16) deletes and re-adds its log and is refused here too; the next register write from this checkout carries it. Anything else, whoever queued it publishes it; never reset it away. Read them: git -C $LANES_REPO log --stat refs/remotes/origin/$LANES_BRANCH..$cpf_ref" 2 + die "refusing to publish: \`commit\` pushes only register and handoff writes (#194), so nothing was pushed. A lane rename (Amendment 16) deletes and re-adds its log and is refused here too; the next register write from this checkout carries it. Anything else, whoever queued it publishes it; never reset it away. Read them: git -C $LANES_REPO log --stat refs/remotes/origin/$LANES_BRANCH..$cpf_ref" 2 fi CP_FENCE_TIP="$cpf_tip" } @@ -2136,7 +2136,7 @@ commit_push() { # into a push of whatever this SHARED checkout has queued (the #214 review # measured exactly that deleting `scripts/link-estates` on origin). # - # AND `commit` PUBLISHES ONLY WHAT LANE TOOLING STRANDS, and only what it read: + # AND `commit` PUBLISHES ONLY REGISTER AND HANDOFF WRITES, and only what it read: # `cp_publish_fence` (above) reads the queue on the branch the push sends and # refuses the whole publish (2) for anything that is not a register or # handoff write. The push is pinned to the commit the fence read, and every diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 31993ce7..6f723bec 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -494,9 +494,9 @@ def _shadowing_origin_main(box: Box) -> str: @pytest.mark.parametrize("variant", list(REFUSALS)) def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, variant): - """`commit` publishes the register and handoff commits lane tooling strands - and nothing else. One queued commit, or one checkout state, it cannot vouch - for refuses the WHOLE publish with 2, names it, makes no network call and + """`commit` publishes register and handoff writes, by path and kind, and + nothing else. One queued commit, or one checkout state, it cannot vouch for + refuses the WHOLE publish with 2, names it, makes no network call and pushes nothing, not even the stranded register edit.""" expect = REFUSALS[variant](box) head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") From a4e0b17fa58b0e25740af53730cfd18a8f3164d9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 02:29:46 +0000 Subject: [PATCH 28/42] T018: the daily report lists a leg that is also a canonical clone once (measurement finding 1) lane-worktrees, the report's _branches, _root_main and _aging iterated `canonical + [g.path for g in legs if g.checkout]`. A leg whose .git is a directory (a clone, not a submodule's gitfile) is also canonical, so it was read twice: the read-only triad measurement of 2026-10-17 (finding 1) saw 8 branch rows printed twice on this workstation, and _root_main would do the same for a leg whose main is ahead. _aging's `seen` set hid it there only where the two spellings matched. All three now read Report._checkouts(canonical): the canonical checkouts, then the checked-out legs, each once by os.path.realpath, first occurrence kept. With no leg checked out it returns canonical untouched, so a single-repository report cannot move. _leg_cell also matches a leg by real path, so the row kept (the canonical spelling) still names its leg. Test: test_t018_the_report_lists_a_leg_that_is_also_a_canonical_clone_once (an assembly whose project.yaml declares two legs that are clones: the code leg's unpushed branch, its aged rescue branch and the spec leg's main ahead of origin each appear once, each naming its leg). It needs no template. An authoring smoke check outside pytest: it passes on this tree and fails on 69f0017's lane-worktrees, on the duplicated branch row. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lane-worktrees | 40 ++++++++++++++++++++--- tests/test_triad_lane_tooling.py | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/lane-worktrees b/lane-worktrees index f5c7c830..342b2955 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -5391,12 +5391,42 @@ class Report: return legs def _leg_cell(self, path: str) -> str: - """'' for a canonical checkout; for a leg, which assembly it is under.""" - for g in getattr(self, "legs", []): + """'' for a canonical checkout; for a leg, which assembly it is under - + by its real path too, because `_checkouts` keeps the FIRST spelling of + a checkout that is both, and that is the canonical one.""" + legs = getattr(self, "legs", []) + if not legs: + return "" + for g in legs: if g.path == path: return f"the {g.decl} leg of {g.assembly}" + rp = real(path) + for g in legs: + if g.real == rp: + return f"the {g.decl} leg of {g.assembly}" return "" + def _checkouts(self, canonical: list) -> list: + """The checkouts the branch, `main` and rescue sections read: every + canonical checkout, then every leg checked out here (T018), EACH ONCE + BY ITS REAL PATH, in that order. A leg whose `.git` is a directory - a + clone, not a submodule's gitfile - is also one of the estate's + canonical checkouts, and read once as each it printed every branch row + and its `main` row twice (the read-only triad measurement of + 2026-10-17, finding 1). With no leg checked out it is `canonical` + itself, untouched, so a report of single repositories cannot move.""" + legs = [g.path for g in getattr(self, "legs", []) if g.checkout] + if not legs: + return list(canonical) + out, seen = [], set() + for c in canonical + legs: + r = real(c) + if r in seen: + continue + seen.add(r) + out.append(c) + return out + def _legs_section(self) -> None: """T018: every leg checkout holding edits no commit carries or commits no origin ref holds - or that could not be read - under its assembly.""" @@ -5497,7 +5527,7 @@ class Report: rows = [] fmt = ("%(refname:short)%1f%(objectname:short=12)%1f%(upstream:short)%1f" "%(upstream:track)%1f%(committerdate:iso-strict)%1f%(contents:trailers)%00") - for c in canonical + [g.path for g in getattr(self, "legs", []) if g.checkout]: + for c in self._checkouts(canonical): proc = git(c, "for-each-ref", f"--format={fmt}", "refs/heads/") if proc.returncode != 0: continue @@ -5534,7 +5564,7 @@ class Report: def _root_main(self, canonical: list) -> None: rows = [] - for c in canonical + [g.path for g in getattr(self, "legs", []) if g.checkout]: + for c in self._checkouts(canonical): if git(c, "rev-parse", "-q", "--verify", "refs/heads/main").returncode != 0 or \ git(c, "rev-parse", "-q", "--verify", "refs/remotes/origin/main").returncode != 0: continue @@ -5557,7 +5587,7 @@ class Report: rows = [] rescue_count = 0 seen = set() - for c in canonical + [g.path for g in getattr(self, "legs", []) if g.checkout]: + for c in self._checkouts(canonical): proc = git(c, "for-each-ref", "--format=%(refname)\t%(committerdate:iso-strict)", "refs/heads/rescue/", "refs/remotes/origin/rescue/") for line in proc.stdout.splitlines(): diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py index def4b1c2..3b6551e3 100644 --- a/tests/test_triad_lane_tooling.py +++ b/tests/test_triad_lane_tooling.py @@ -820,6 +820,62 @@ def test_t018_a_report_with_no_assembly_has_no_legs_section(tmp_path): assert "Assembly legs" not in proc.stdout +def test_t018_the_report_lists_a_leg_that_is_also_a_canonical_clone_once(tmp_path): + """FINDING 1 OF THE READ-ONLY TRIAD MEASUREMENT (2026-10-17): a leg whose + `.git` is a DIRECTORY - a clone, not a submodule's gitfile - is also one of + the estate's canonical checkouts, and the report read it once as each, so + its branch rows printed twice (8 rows on that workstation) and its `main` + row would have. Each leg's rows now appear once, still naming the leg, in + the branch, `main` and rescue sections. The manifest is written here, so + no template is needed.""" + b = Bench(tmp_path / "bench", REPO) + a = b.projects / "cl" + b.origin("cl") + b.git("clone", "-q", b.remotes / "cl.git", a) + b.commit(a, "the assembly, its legs clones", { + ".gitignore": "spec/\ncode/\n", + "project.yaml": ("schema_version: 1\nkind: project-manifest\nid: \"cl\"\nlegs:\n" + " - role: assembly\n repository: opensoft/cl\n path: \".\"\n" + " - role: spec\n repository: opensoft/cl-spec\n path: \"spec\"\n" + " - role: code\n repository: opensoft/cl-code\n path: \"code\"\n")}) + b.git("push", "-q", "origin", "main", cwd=a) + spec, code = a / "spec", a / "code" + b.git("clone", "-q", b.seeded("cl-spec", {"s.md": "s\n"}), spec) + b.git("clone", "-q", b.seeded("cl-code", {"c.txt": "c\n"}), code) + assert (spec / ".git").is_dir() and (code / ".git").is_dir() + # the code leg: a branch with no upstream, and a rescue branch past the aging line + b.git("checkout", "-q", "-b", "leg-unpushed", cwd=code) + b.commit(code, "leg work", {"l.txt": "l\n"}, lane="cl-1") + b.git("checkout", "-q", "-b", "rescue/cl-1/old", "main", cwd=code) + old = {"GIT_AUTHOR_DATE": "2020-01-01T00:00:00+00:00", + "GIT_COMMITTER_DATE": "2020-01-01T00:00:00+00:00"} + made = b.run("git", "commit", "-q", "--allow-empty", "-m", "old rescue", cwd=code, env=old) + assert made.returncode == 0, made.stderr + b.git("checkout", "-q", "main", cwd=code) + # the spec leg: local main ahead of origin + b.commit(spec, "spec work on main", {"m.md": "m\n"}, lane="cl-1") + + proc = b.tool("lane-worktrees", "sweep", "--all", "--dry-run", "--report", "--estate", + b.projects) + assert proc.returncode == 0, proc.stderr + foreign = _section(proc.stdout, "FOREIGN repositories") + assert str(spec) not in foreign and str(code) not in foreign, foreign # both canonical + branches = _section(proc.stdout, "Unmerged branches with a missing, diverged or unpushed upstream") + rows = [ln for ln in branches.splitlines() if ln.startswith(f"- {code} leg-unpushed ·")] + assert len(rows) == 1, branches + assert rows[0].endswith(f"the code leg of {a}"), rows + root_main = _section(proc.stdout, "Root main divergence") + rows = [ln for ln in root_main.splitlines() if ln.startswith(f"- {spec} · local main ahead")] + assert len(rows) == 1, root_main + assert rows[0].endswith(f"the spec leg of {a}"), rows + aging = _section(proc.stdout, "Awaiting disposition") + rows = [ln for ln in aging.splitlines() if ln.startswith(f"- {code} rescue/cl-1/old ·")] + assert len(rows) == 1, aging + assert rows[0].endswith(f"the code leg of {a}"), rows + legs = _section(proc.stdout, "Assembly legs with uncommitted or unpublished work") + assert legs.count(f"- {spec} · the spec leg of {a} ·") == 1, legs + + # ================================================================ (d) add def _inventory(b: Bench, lane: str) -> dict: From a60cf1cc4be3e49d9686763c156a681bce2b5de3 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 02:29:46 +0000 Subject: [PATCH 29/42] T018 docs: the analysis spells bench paths ~/projects/..., as the hygiene rule of #229 item 22 asks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f595e44 made tests/test_repo_hygiene.py refuse a committed path that starts at the bench root, except inside a single-quoted grep pattern. docs/triad-lane-tooling-analysis.md had seven such lines (§0, §4.2, §4.5, §4.9, §4.12 and two rows of §4.15). Each now says ~/projects/..., the bench's projects directory, as f595e44 respelled the same path in ideation/; the §4.15 grep pattern keeps the spelling it searched for, under the rule's allowance. A dated note under the header says so. host_absolute_path_hits over every file this branch adds or changes now finds nothing. Docs only. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/triad-lane-tooling-analysis.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index 45d13bc1..0dfa34f2 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -18,6 +18,9 @@ that changed only `specs/`, `openspec/`, `contracts/openreposhape-pin.yaml` and branch stays on `c4864ac`. A claim marked **measured** was run on a throwaway fixture in this lane's scratch directory; one marked **derived** follows from the code or from git's documented behaviour and was not run. +**2026-10-18:** paths on the bench where this was read are written `~/projects/…`, the +bench's projects directory, as `tests/test_repo_hygiene.py` asks of a committed file since +#229 item 22; the one quoted `grep` pattern in §4.15 keeps the spelling it searched for. This document decides nothing. Plan tasks are T006 (root collisions and leg guidance), T007 (installer entry point), T009 (separate roots in fixtures and hygiene), T010 (CI and pin checks) and T018 (feature selection, park/resume and bindings). @@ -69,7 +72,7 @@ checks) and T018 (feature selection, park/resume and bindings). `lanes-index`, `lane-handoff`, `lane-rename`, `lane`, `lanes`, `link-estates`, `repos.tsv`), `openRepoTools --install` and `wip init`, the hooks it merges, the documents that name checkout paths, the tests and CI that assume one root, and the register/handoff data in -`opensoft/brett-wip` that names `/workspace/projects/openRepoTools` (read, never written). +`opensoft/brett-wip` that names `~/projects/openRepoTools` (read, never written). **Out of scope:** the estate verbs `park`, `resume` and `status`. They find estates by `$PROJECTS_DIR` and run `make park`/`make resume`, and they already know three-leg roots @@ -153,7 +156,7 @@ copies in `~/.local/bin` are unaffected. **No change; T006 confirms it.** | Line | What it does | Under the triad | | --- | --- | --- | | :608 | `PROJECTS_ROOT=${PROJECTS_ROOT:-$HOME/projects}` | unchanged | -| :1116-1357 | The lane's directory, by six rungs: `--dir`, the swap record, the lane log's `dir`, `$PROJECTS_ROOT/`, the estate `project.yaml` legs (:1291-1318), then a checkout named for the home and proved by its origin (:1319-1328) | Lanes openRepoTools-1, -2 and -3 record `dir /workspace/projects/openRepoTools` (§4.15), so rungs 2 and 3 answer. If the assembly stays at that path (an in-place adoption of the live checkout), **nothing changes**. A lane homed at `opensoft/openRepoTools-code` (none exists) would be resolved by rung 5: the reader at :1301-1309 matches `repository:` against the home and resolves `path: code` against the manifest's directory. The shell suite already covers rung 5 on a declared `legs:` manifest (tests/test_lane_helpers.sh:5691-5704). IRRS, IRSS and MedxEHR carry the same block on this workstation today. | +| :1116-1357 | The lane's directory, by six rungs: `--dir`, the swap record, the lane log's `dir`, `$PROJECTS_ROOT/`, the estate `project.yaml` legs (:1291-1318), then a checkout named for the home and proved by its origin (:1319-1328) | Lanes openRepoTools-1, -2 and -3 record `dir ~/projects/openRepoTools` (§4.15), so rungs 2 and 3 answer. If the assembly stays at that path (an in-place adoption of the live checkout), **nothing changes**. A lane homed at `opensoft/openRepoTools-code` (none exists) would be resolved by rung 5: the reader at :1301-1309 matches `repository:` against the home and resolves `path: code` against the manifest's directory. The shell suite already covers rung 5 on a declared `legs:` manifest (tests/test_lane_helpers.sh:5691-5704). IRRS, IRSS and MedxEHR carry the same block on this workstation today. | | :1118-1128 | "the harness keys a session to the directory it was started in" (Evidence 3) | **This is why a lane's `dir` should stay at the assembly root.** Re-homing a lane into `code/` changes the Claude project folder (`-workspace-projects-openRepoTools` → `-workspace-projects-openRepoTools-code`). That strands the lane's memory, and `--resume ` stops finding the transcript. **T018 proposal:** bindings keep `dir` = assembly root, and nothing is rebound. | | :1180-1199 | The home is written from the directory's `origin` | At the assembly root the origin is still `opensoft/openRepoTools`, so the home is unchanged. | | :4294-4340 | Starts the daily report (`lane-worktrees sweep --all --dry-run --report`) for `$PROJECTS_ROOT` | Inherits the report's blind spot: §4.4. | @@ -257,7 +260,7 @@ line moves for a checkout with no `project.yaml`. | :2754-2765 `alias_lookup`; :2776-2847 `canon_object` | resolves a key's repository through repos.tsv. A spelled `owner/repo` is **always accepted** (:2834) | `opensoft/openRepoTools-code#n` works with no repos.tsv row. The bare `openRepoTools-code#n` is refused until the row exists (§4.9). | | :2791-2813 | `#n` expands against the lane's **home** | Home stays `opensoft/openRepoTools`, so `#n` always means the **assembly's** issue or PR. The code leg numbers its PRs from #1, so `#3` from an openRepoTools lane is the assembly's #3, never the code leg's. **T018 proposal:** code-leg and spec-leg objects are spelled in full. The lane-collision protocol text should say so. | | :2814-2818, :2895-2897 | `owner/repo:openspec/changes/` keys | Rule 1's sibling reads run `gh pr list --repo --search ` (:10249-10272). A change keyed on `opensoft/openRepoTools` therefore searches the **assembly's** PRs, while spec PRs land in `openRepoTools-spec`. **T018 proposal:** at cutover, each open claim on `opensoft/openRepoTools:openspec/changes/` is released and re-claimed as `opensoft/openRepoTools-spec:openspec/changes/`, through `lanes-edit.sh release`/`claim`. The log is append-only and is never rewritten. | -| :5053-5105 `project_legs_file`, `same_project` | manifests at `$PROJECTS_ROOT/*/project.yaml` and `*/*/project.yaml` | `/workspace/projects/openRepoTools/project.yaml` is depth 1, so a claim on `opensoft/openRepoTools-code#n` from a lane homed at the assembly is "legs of one project.yaml — not a crossing" (:5120-5122). This works with no change. | +| :5053-5105 `project_legs_file`, `same_project` | manifests at `$PROJECTS_ROOT/*/project.yaml` and `*/*/project.yaml` | `~/projects/openRepoTools/project.yaml` is depth 1, so a claim on `opensoft/openRepoTools-code#n` from a lane homed at the assembly is "legs of one project.yaml — not a crossing" (:5120-5122). This works with no change. | | :12555-12595 `tree_id_for`, `lane_tree_put` | sidecar per tree: absolute `path`, `checkout`, branch, head … | Formats are unchanged. Rows written before cutover stay **true**: they describe worktrees of the monorepo history, which is the assembly's own history (FR-001). | | :12699-12705 `lane_worktree_roots` | `/.claude/worktrees`, `${dir%/*}/.lane-worktrees/` | Same gap as §4.4, proposal 2. | | :16791-16829 `set-lane-tree` | takes an absolute path and an **unvalidated** `--checkout` | Records a code-leg tree today: `--checkout /code`. | @@ -303,7 +306,7 @@ if [ -n "$super" ] && [ -f "$super/project.yaml" ]; then here_root="$super"; fi (:89-295). It links `~/projects//handoffs` → `/handoffs/` for each estate directory that exists (:399-409), and `~/projects/xFactory/lanes-edit.sh` → `$OPENREPOTOOLS_BIN_DIR/lanes-edit.sh` (:421). **Under the triad:** -`/workspace/projects/openRepoTools/handoffs` is an untracked symlink in the live checkout today, +`~/projects/openRepoTools/handoffs` is an untracked symlink in the live checkout today, and it stays one at the assembly root. The hook link points at the installed copy. **No change.** @@ -414,7 +417,7 @@ name and register row, never the cwd or the repository layout. **No change.** | docs/README-lanes.md:573-594, :3979-3997 | `tests/run.sh` | code-leg path | T006 | | docs/README-lanes.md:1793 | example `STARTED` line, `dir ~/projects…` | an example; true as long as the assembly stays at its path | none | | docs/README-claude-current.md:172 | "looks for `claude-current` beside itself" | true in `code/` | none | -| ideation/brainstorm/supervised-context-restart-durable-intent.md:31 | `directory: /workspace/projects/openRepoTools` (an example intent record) | spec-leg prose; stays true if the assembly keeps that path | none | +| ideation/brainstorm/supervised-context-restart-durable-intent.md:31 | `directory: ~/projects/openRepoTools` (an example intent record) | spec-leg prose; stays true if the assembly keeps that path | none | **Hygiene caps that T006 will trip:** test_repo_hygiene.py:1735-1736 caps AGENTS.md at 316 lines, and it **is** 316. test_repo_hygiene.py:2232-2233 caps README.md at 486, and it **is** @@ -515,8 +518,8 @@ at brett-wip `c311a78`: | File | Hits | What they are | Carry-over proposal | | --- | ---: | --- | --- | | lanes/LANES.md | 0 | Rows carry home `opensoft/openRepoTools` | none: the assembly keeps the name | -| lanes/log/openRepoTools-3.md / -1.md / -2.md | 32 / 11 / 11 | `dir /workspace/projects/openRepoTools` on STARTED/RESUMED/PAUSED lines (29 + 1, 11, 11 of them) | Append-only, never rewritten. They stay valid **if the assembly keeps this path**. If it moves, run one `lane-start --dir openRepoTools ` per lane at its breakpoint; that writes the new `dir`. | -| `/workspace/projects/.lane-state/openRepoTools-{1,2,3}/trees/*.yaml` (beside, not in, brett-wip) | 18 / 8 / 14 sidecars | every row `checkout: /workspace/projects/openRepoTools`; 19 trees under `.claude/worktrees` | They stay true: they describe monorepo-history worktrees of the same repository. Translate each tree's delta through T016/T017 receipts, then retire it with `lane-worktrees sweep --yes`. The sweep's "landed" test compares against the assembly's default branch, where a monorepo branch's content never lands after the split, so expect **rescue to a bundle**, not "landed". | +| lanes/log/openRepoTools-3.md / -1.md / -2.md | 32 / 11 / 11 | `dir ~/projects/openRepoTools` on STARTED/RESUMED/PAUSED lines (29 + 1, 11, 11 of them) | Append-only, never rewritten. They stay valid **if the assembly keeps this path**. If it moves, run one `lane-start --dir openRepoTools ` per lane at its breakpoint; that writes the new `dir`. | +| `~/projects/.lane-state/openRepoTools-{1,2,3}/trees/*.yaml` (beside, not in, brett-wip) | 18 / 8 / 14 sidecars | every row `checkout: ~/projects/openRepoTools`; 19 trees under `.claude/worktrees` | They stay true: they describe monorepo-history worktrees of the same repository. Translate each tree's delta through T016/T017 receipts, then retire it with `lane-worktrees sweep --yes`. The sweep's "landed" test compares against the assembly's default branch, where a monorepo branch's content never lands after the split, so expect **rescue to a bundle**, not "landed". | | handoffs/openRepoTools/session-handoff-2026-09-13-lane-openRepoTools-3.md, migration-handoff-2026-10-06.md, …-2026-10-02-…-2.md, …-2026-09-16-…-1.md | 56, 42, 26, 24 | historical reads and paths | none: each live lane's next Rule 3 top block restates the current paths | | handoffs/xFactory/session-handoff-2026-09-05-lane-opsXfactory-1.md | 11 | **"register writes via … `~/projects/openRepoTools/lanes-edit.sh`"**, the **checkout copy** | **Act before cutover:** this lane is live (PAUSED 2026-10-07T11:12:58Z, swap). That path disappears when the live checkout pulls the assembly main. Point its next handoff at the installed copy (`~/.local/bin/lanes-edit.sh` or `~/projects/xFactory/lanes-edit.sh`), and tell its owner. | | handoffs/xFactory/attachments/opsXfactory-1/land-444.sh | 2 | `LANES_EDIT="${LANES_EDIT:-$HOME/projects/openRepoTools/lanes-edit.sh}"` (:329) | Same. A script that defaults to the checkout copy breaks at the split. | From 656b1914526e2e3be94500d11c7eddc98064126f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 04:09:27 +0000 Subject: [PATCH 30/42] #194: a publish the pull empties exits 0 and says so; pin the fence's rename and prefix guards and the conflict RECOVERY's two STOP lines Lane openRepoShape-2's take-or-leave items on #214 (6243280936), taken: - A queue that `pull --rebase` empties, because origin already holds every commit in it by content, no longer pushes origin its own tip and prints "pushed". `cp_publish_refence` reads the count it re-fenced (CP_FENCE_AHEAD); at 0 it says so and exits 0, no push. Exit 0's row in both tables names the case. - SHOULD 4: two refusal variants pin the fence's guards. A log renamed out of lanes/ is named as its deletion (red under `-M`), and lanes-archive/ beside lanes/ is not the register's (red without the trailing slash). The fence now adds that slash itself to both register prefixes. - NIT 5: the conflict RECOVERY's "STOP and take it to the lane its subject names" line and its reset line's "only if every '+' line is yours" are asserted word for word, in order. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 5 +- lanes-edit.sh | 31 +++++++++-- tests/test_commit_push_published_edit_194.py | 58 ++++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 5a630481..941759d0 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -107,6 +107,9 @@ path filter, not an ownership check: another lane's register write passes it, so read `git cherry -v origin/main HEAD` before you publish a shared queue. Only `commit` publishes that queue: any other write with nothing to stage leaves it local, and with nothing ahead `commit` is the no-op it always was. +When `pull --rebase` finds every queued commit on origin already, by content +under other shas, it drops them all. The publish then says so and exits 0 +without a push. ## THE ONE HAZARD — `sed -i` DESTROYS THE SYMLINK @@ -1182,7 +1185,7 @@ else. | code | meaning | |---|---| -| 0 | done | +| 0 | done, including `commit`'s publish of a queue that `pull --rebase` emptied because origin already held every commit in it by content (#194; it says so, and sends no push) | | 1 | environment — no register, no writer | | 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased, or `commit` refusing to publish local commits that are not register or handoff writes, from a checkout whose HEAD is off the branch, or that it cannot read (#194; it pushes none of them) | | 3 | the edit is never a permanent loss, but this attempt cannot confirm whether it also reached origin — a rebase conflict (not pushed by this attempt; a later write from this checkout may already carry it to origin, so look by content before you retry), a network timeout on a push or a pull (a hung one often already landed), or six attempts exhausted because a peer's uncommitted file blocks every rebase | diff --git a/lanes-edit.sh b/lanes-edit.sh index acebf1db..f46de30a 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -425,7 +425,7 @@ # may write the marker's vocabulary into a row at all. # # EXIT CODES — every subcommand, one table, no two meanings on one number -# 0 done +# 0 done (and #194's publish whose queue `pull --rebase` emptied) # 1 environment (no register, no writer) # 2 refusal: bad arguments, the object is held, an unknown alias, a # checkout that cannot be rebased, — Amendment 15 — a register holding @@ -1111,6 +1111,7 @@ CP_PUBLISH_AHEAD=0 # empty, every push sends the branch as it stands, as it always has (#194). CP_PUSH_SPEC="" CP_FENCE_TIP="" +CP_FENCE_AHEAD=0 # die "" [] — the MESSAGE is $1 alone. It used to be "$*", # which joined the exit code on to the end of every refusal that passed one: @@ -1990,9 +1991,16 @@ BRT_EOF # get back, and the fence keeps one rule for it. A read or a classification # that fails refuses too (2), because it proves nothing. # -# It leaves CP_FENCE_TIP at the commit it read, and the publish pushes exactly -# that commit (`:refs/heads/`). A commit another lane makes in this -# shared checkout between the read and the push stays local. +# Two guards have a test each (lane openRepoShape-2's SHOULD 4). `--no-renames` +# reads a rename as the deletion it is: read as an `R` line, the refusal would +# name a rename and not the path origin loses. And the register's directories +# are matched with their trailing `/`, so `lanes-archive/` beside `lanes/` is +# not inside it. +# +# It leaves CP_FENCE_TIP at the commit it read and CP_FENCE_AHEAD at how many +# it read, and the publish pushes exactly that commit (`:refs/heads/`). +# A commit another lane makes in this shared checkout between the read and the +# push stays local. cp_publish_fence() { cpf_ref="refs/heads/$LANES_BRANCH" cpf_head="$(git -C "$LANES_REPO" symbolic-ref -q HEAD 2>/dev/null || :)" @@ -2011,7 +2019,8 @@ cp_publish_fence() { cpf_n="$(printf '%s\n' "$cpf_pairs" | awk 'END { print NR }')" || cpf_rc=$? cpf_raw="$(printf '%s\n' "$cpf_pairs" | git --no-replace-objects -C "$LANES_REPO" -c core.quotePath=false diff-tree --stdin --always -r --no-renames --raw --ignore-submodules=none)" || cpf_rc=$? fi - cpf_bad="$(printf '%s\n' "$cpf_raw" | awk -F '\t' -v want="$cpf_n" -v reg="$LANES_PREFIX" -v logp="$LANES_LOG_PREFIX" \ + cpf_reg="${LANES_PREFIX:+${LANES_PREFIX%/}/}"; cpf_logp="${LANES_LOG_PREFIX:+${LANES_LOG_PREFIX%/}/}" + cpf_bad="$(printf '%s\n' "$cpf_raw" | awk -F '\t' -v want="$cpf_n" -v reg="$cpf_reg" -v logp="$cpf_logp" \ -v reg_path="$LANES_PATH" -v alias_path="$LANES_ALIASES_PATH" ' function inside(p) { return (reg != "" && index(p, reg) == 1) || (logp != "" && index(p, logp) == 1) || @@ -2040,13 +2049,25 @@ cp_publish_fence() { die "refusing to publish: \`commit\` pushes only register and handoff writes (#194), so nothing was pushed. A lane rename (Amendment 16) deletes and re-adds its log and is refused here too; the next register write from this checkout carries it. Anything else, whoever queued it publishes it; never reset it away. Read them: git -C $LANES_REPO log --stat refs/remotes/origin/$LANES_BRANCH..$cpf_ref" 2 fi CP_FENCE_TIP="$cpf_tip" + CP_FENCE_AHEAD="$cpf_n" } # The publish's CP_AFTER_REBASE: `pull --rebase` has rewritten the queue onto # what origin holds now, and may have taken in a commit made since the first # read, so the fence reads it again and the push is pinned to what it read. +# THE PULL CAN EMPTY THE QUEUE (lane openRepoShape-2, #214 6243280936): origin +# may hold every queued commit already, by content under other shas, because +# another workstation made the same edit or a peer's write carried it. The +# rebase drops each one, and a push would send origin its own tip and print +# "pushed". So the publish ends here, exit 0, as the no-op it now is, and it +# says so. Only `commit` installs this hook, and this commit_push is its last +# act, so the exit is that return. cp_publish_refence() { cp_publish_fence + if [ "$CP_FENCE_AHEAD" = 0 ]; then + note "origin/${LANES_BRANCH} already holds every commit this publish listed: \`pull --rebase\` found each one there by content and dropped it, so none is left to push and no push was sent (#194)" + exit 0 + fi CP_PUSH_SPEC="$CP_FENCE_TIP:refs/heads/$LANES_BRANCH" } diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 6f723bec..d27e44ee 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -328,6 +328,12 @@ def test_the_conflict_recovery_warns_that_a_reset_drops_a_peers_commits(box): assert "LANES(concurrent@" in captures, captures assert "LANES(pre-existing@" in captures, captures assert "names no lane, so it is NOT yours" in captures, captures + # NIT 5 (lane openRepoShape-2, 6243280936): the two lines that stop a + # reader from resetting a peer's commit away, word for word, because + # lane openRepoTools-5's #68 waits on these words. + stop = _line_of(err, "if any '+' line is not your own write, STOP and take it to the lane its subject names") + assert listing < stop < warning, err + assert "# only if every '+' line is yours" in err.splitlines()[reset], err # The listing reads HEAD, which is what the reset acts on: a peer's commit # made AFTER the abort is in it too, and the moved-checkout STOP fires. @@ -480,6 +486,30 @@ def _shadowing_origin_main(box: Box) -> str: return f" {sha[:12]} D 000000 {TOOL}" +def _renames_out_of_lanes(box: Box) -> str: + """SHOULD 4 (lane openRepoShape-2, 6243280936), THE RENAME GUARD. A log + moved out of `lanes/` is a deletion there, and `--no-renames` names it so. + Read with rename detection it would be one `R100` line instead.""" + log = f"lanes/log/{PEER_LANE}.md" + box.write(box.wip, log, f"# {PEER_LANE}'s log\n") + box.wgit("add", "--", log) + box.wgit("commit", "-q", "-m", f"LANES({PEER_LANE}@Eagle): its log") + box.wgit("push", "-q", "origin", "main") + _stranded_edit(box) + box.wgit("mv", log, f"scripts/{PEER_LANE}.md") + box.wgit("commit", "-q", "-m", "peer: move a log out of lanes/") + return f" {box.wgit('rev-parse', 'HEAD').strip()[:12]} D 000000 {log}" + + +def _beside_the_register_directory(box: Box) -> str: + """SHOULD 4, THE PREFIX GUARD. `lanes/` is a directory: `lanes-archive/` + beside it is not the register's, though its name starts the same way.""" + _stranded_edit(box) + (box.wip / "lanes-archive").mkdir() + path = "lanes-archive/notes.md" + return f" {box.queue('peer: notes beside the register', write=path)[:12]} A 100644 {path}" + + REFUSALS = { "deletes-a-tool": _deletes_a_tool, "outside-lane-tooling": _outside_lane_tooling, @@ -489,6 +519,8 @@ def _shadowing_origin_main(box: Box) -> str: "merge-in-queue": _merge_in_queue, "head-off-branch": _head_off_branch, "shadowing-origin-main": _shadowing_origin_main, + "renames-out-of-lanes": _renames_out_of_lanes, + "beside-the-register-directory": _beside_the_register_directory, } @@ -525,6 +557,32 @@ def test_the_push_sends_the_commit_the_fence_read(box, path): assert box.wgit("rev-parse", "HEAD~1") == box.ogit("rev-parse", "main") +def test_a_publish_the_pull_empties_says_so_and_sends_no_push(box): + """Lane openRepoShape-2's trap (6243280936). The queue `commit` read can + already be on origin by content, under other shas: here another workstation + made the same edit. `pull --rebase` drops it, and the push that followed + sent origin its own tip and printed "pushed". The re-read after the pull + finds nothing left, says so, sends no push and exits 0.""" + _stranded_edit(box) + box.set_state(box.peer, LANE, "LIVE · edited by hand") + box.git("-C", str(box.peer), "commit", "-q", "-am", "the same edit, made elsewhere") + box.git("-C", str(box.peer), "push", "-q", "origin", "main") + origin = box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + assert result.returncode == 0, result.stderr + assert "pushing the 1 local commit(s) origin/main does not have" in result.stderr + assert ("origin/main already holds every commit this publish listed: `pull --rebase` " + "found each one there by content and dropped it") in result.stderr, result.stderr + assert "pushed origin/" not in result.stderr, result.stderr + calls = [f" {c} " for c in box.net_calls()] + assert any(" pull " in c for c in calls), calls + assert not any(" push " in c for c in calls), calls + assert box.ogit("rev-parse", "main") == origin + assert box.wgit("rev-parse", "HEAD") == origin + assert box.origin_subjects().count("the same edit, made elsewhere") == 1 + + def test_a_lane_rename_is_refused_and_told_what_carries_it(box): """A DELIBERATE OVER-REFUSAL. A lane rename deletes its log to re-add it, and a deletion is the one change the fence never lets through: the refusal From 3d0642c00f2a175b10defbb59f050abdb3b9f45c Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 08:33:42 +0000 Subject: [PATCH 31/42] T018: verify 88ee9d9: 224 passed, 1 failed (test_the_lane_helper_suite_passes: two #180 row-stamp cuts, main's own); selection triad_lane_tooling or repo_hygiene or test_the_lane_helper_suite_passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log: briefs/t018-verify-20261018T034203Z.log (lane openRepoTools-3's briefs directory), through briefs/verify.sh and tests/run.sh, with every LANES_* and TMUX* variable unset. start 2026-10-18T03:42:03Z head 88ee9d9 tree-of-index 07fa62ee780876dcfaef17bf94c9a0cd7c47a228 1 failed, 224 passed, 1 deselected in 4045.64s (1:07:25) exit=1 end 2026-10-18T05:34:13Z The run queued behind other lanes' pytest from 03:42Z and took the lock at about 04:26Z. Every test of tests/test_triad_lane_tooling.py, tests/test_triad_lane_tooling_handoff.py and tests/test_repo_hygiene.py passed, both goldens unregenerated among them. The one failure, tests/test_lane_helpers_suite.py:: test_the_lane_helper_suite_passes: the shell suite ended "4620 passed, 2 failed, 0 skipped, 0 pending". Both FAILs are lane-start row-stamp tail cases of #180's group: - log line 100, "...with clause (c)'s directory and window in the tail, for the person reading the row" (repoA): the cell line is cut to "...home/projects/repoA; ..." (228 chars), so "; window " is gone; - log line 1967, "...having found the checkout by its real directory name, not the typed one" (repoCase): the cell line is cut to "...no launch; dir ..." (123 chars), the whole path dropped. Cause: lane-start's cut_to_line caps the state line at 230 characters, cuts there and trims back to the last space, then appends " ...". The "; dir ; window " tail comes last, and here is about 110 characters (the run root under ~/.local/state/openRepoTools/tmp/...), so the tail is cut: in repoA after the path, in repoCase inside it, where trimming to the last space drops the path whole. Both "got" strings are exactly that cut. Not T018's: `git diff origin/main...HEAD -- lane-start tests/test_lane_helpers.sh tests/test_lane_helpers_suite.py` is empty, and T018's lanes-edit.sh hunks (usage, project_legs/assembly_legs, lane_reconcile, a comment on lane_worktree_roots, the guard exemption, the project-legs arm and the refusal list) touch neither set-row-state nor the stamp. Lane 2 measured the same group failing on main's own tree at a TMPDIR length of 86 (#186 comment 6244714538: repoA, repoA11b, repoCase and repoMix); #215 (61a7378) moved only repoA11b to a short fixed path, and repoA11b passed here. CI's tests job, with a short run root, passes this test on main (83d2690: 1498 passed, 19 skipped; f5c3bcd and 2cb14a9: success). Not re-run on main locally: the two cases run only inside the hour-long shell suite. This run covers 9f23a6b and bf2a220 (the unreadable-leg reads) and this round: the S1, S2 and N1-N4 fixes (5d2c1c8), lane 1's rulings in §7 (9a8f175), measurement finding 1 (a4e0b17), the bench-root respelling (a60cf1c), and the merges of main 2732d59, 83d2690, f595e44 (the openRepoShape pin at v1.1.0) and f5c3bcd (cf7db4a, 107958c, 69f0017, 88ee9d9). The earlier run on 107958c (t018-verify-20261018T011535Z.log) was killed at a session exit after one F (first in order: test_the_lane_helper_suite_passes) and 176 passes, with no summary. Correction to a60cf1c's message: the first of the seven respelled lines is in §1 (Scope and method), not §0. Main has since moved to 2cb14a9 and a41adb6; this head does not include them. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 From ea4db0d1c61548ddce8dbc7eba5a3959456ea9e8 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 09:50:36 +0000 Subject: [PATCH 32/42] T018 tests: the unreadable-leg tests ask the git under test what it says, instead of expecting git 2.43's wording (PR #246 CI) PR #246's Linux tests job (run 39113292535, job 117633782691) failed two tests: test_t018_reconcile_reports_a_leg_git_cannot_read_as_the_ sweep_counts_it and test_t018_the_handoff_reports_a_leg_git_cannot_ read_as_the_sweep_counts_it. Both wrote a spec leg's .git as `gitdir: /nonexistent/t018-spec` and expected git's quoted answer to contain that path. Git words that condition by version: - 2.43 (this bench): fatal: not a git repository: /nonexistent/t018-spec - 2.54 (alpine/git:latest, a throwaway container): fatal: not a git repository (or any parent up to mount point /) - 2.55 (CI's runner): fatal: not a git repository: (null) On CI the tools still classified the leg `unreadable`, counted it and named it: lane-handoff's stderr shows "the leg .../triad/spec is unreadable: ... git could not read it: fatal: not a git repository: (null)". The code reads the condition (git's toplevel there is not the leg, and a .git stands in it) and only quotes git's line, so the code is right and the tests were wrong. tests/test_triad_lane_tooling_handoff.py: a new helper, git_says(b, leg), returns what every reader quotes (probe_leg's _first_line, lane-reconcile's and lane-handoff's first non-empty line, or "git answers there"), asked of the git under test. The three gitfile checks use it. The empty-.git-directory checks keep "git answers there", the tools' own words, which passed on CI. No tool changes; S1 and S2 are untouched. Before this commit: under a PATH shim that rewrites only the /nonexistent error line to 2.55's "(null)" form, both tests passed when called directly outside pytest (13 s and 37 s). Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- tests/test_triad_lane_tooling_handoff.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/test_triad_lane_tooling_handoff.py b/tests/test_triad_lane_tooling_handoff.py index 5f4d924f..0dbdfad0 100644 --- a/tests/test_triad_lane_tooling_handoff.py +++ b/tests/test_triad_lane_tooling_handoff.py @@ -112,6 +112,23 @@ def top_block(b: Bench, lane: str) -> str: return (b.wip / "handoffs" / f"{lane}.md").read_text().split("\n---\n", 1)[0] +def git_says(b: Bench, leg: Path) -> str: + """WHAT EVERY READER QUOTES for a leg git cannot read - the sweep's + `probe_leg`, `lane-reconcile` and `lane-handoff` alike: git's own answer to + `rev-parse --show-toplevel` in the leg, its first non-empty error line, or + `git answers there` where git names another checkout. Asked of + the git under test, never spelled here, because git words the same + condition differently by version: for a `.git` file naming a directory that + does not exist, 2.43 says `fatal: not a git repository: ` and + 2.55 (CI's ubuntu runner, PR #246) says `fatal: not a git repository: + (null)`. The tools read the CONDITION (git's toplevel there is not the leg, + and a `.git` stands in it); only this quoted clause is git's.""" + proc = b.run("git", "-C", leg, "rev-parse", "--show-toplevel") + if proc.returncode == 0: + return f"git answers {proc.stdout.strip()} there" + return next((ln.strip() for ln in proc.stderr.splitlines() if ln.strip()), "no answer") + + def leg_tree(b: Bench, leg: Path, path: Path, branch: str) -> Path: """A worktree of `leg`'s repository at `path`, on a new branch from origin/main.""" b.git("worktree", "add", "-q", "-b", branch, path, "origin/main", cwd=leg) @@ -484,7 +501,7 @@ def check(answer: str) -> None: assert "git could not be read through it" in leg["why"] and answer in leg["why"], leg (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") - check("/nonexistent/t018-spec") + check(git_says(b, spec)) (spec / ".git").unlink() (spec / ".git").mkdir() check(f"git answers {real(a)} there") @@ -562,7 +579,7 @@ def check(answer: str, trees: tuple, unplaced: tuple, state: str) -> None: assert (f"- `{tree}` — branch `" in writers) is (tree not in unplaced), (tree, writers) (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") - check("/nonexistent/t018-spec", (lane_root,), (), "SWAPPED") + check(git_says(b, spec), (lane_root,), (), "SWAPPED") assert inventory(b, "triad-1") == {str(lane_root): str(a)} rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") assert reconcile_rows(rec.stdout)[real(spec)][0] == "unreadable", rec.stdout @@ -580,7 +597,7 @@ def check(answer: str, trees: tuple, unplaced: tuple, state: str) -> None: want = {str(lane_spec): str(spec), str(lane_root): str(a)} assert inventory(b, "triad-1") == want (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") - check("/nonexistent/t018-spec", (lane_spec, lane_root), (lane_spec,), "SWAPPING") + check(git_says(b, spec), (lane_spec, lane_root), (lane_spec,), "SWAPPING") assert inventory(b, "triad-1") == want (spec / ".git").write_text(gitfile) From d0010a299d1773d9a941be11fe8ef480ff533d79 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 16:30:54 +0000 Subject: [PATCH 33/42] WIP: every register write goes through its lane's own checkout -lanes//, made on first use; a LANES_REPO naming W refuses; the locks name their holder's container and steal by claim; refresh-workspace fast-forwards W (Amendment 23 S1, S2, S5) (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Act 2's slice 1a, written and not yet fully tested (a checkpoint: lane 2's usage window, the coordinator's word). What changed, in lanes-edit.sh: - S2 routing. After the existing gates, the dispatcher works out the lane a write is attributed to (write_lane_of). Under default resolution the file re-runs itself by exec, same pid, with LANES_REPO/DIR/FILE at the lane's checkout and two internal markers (LANES__CHECKOUT_LANE, LANES__PID). Inherited markers are dropped. An explicit LANES_REPO is honoured in place, and LANES_DIR/LANES_FILE now default from it. Reads, dry runs and publish-handoff are not routed. A write that names no lane refuses (2). - S1. lane_checkout_path/_verify/_ensure clone W's origin with --reference W --single-branch under a creation lock, through a .partial, then mv. Anything at the path that is not a clone of the register is refused (1), and nothing there is deleted or repaired. Also covered: the renamed-lane move, the old name reported, the row's spelling, W's repo-local identity copied, and the clone's own ten-minute budget. - S5. A LANES_REPO whose real path is W refuses every write (2). A per-profile clone beside W is honoured with a warning naming S1's path. LANES_DIR/LANES_FILE outside the checkout refuse (2). - The locks. The pid file is " ". A live holder in this container is never aged out; another container's holder is judged by the lock's age, which git_net refreshes before every network call. The steal is a rename-claim that re-reads what it claimed. A lock is released only while it is still this process's. A lock taken before an exec is adopted only with the marker. The lifecycle, restart and tree sidecars and publish-handoff share one workstation lock, -lanes/.lane-state.lock (ort186 Q3), and take no lock in W. - New verbs: lane-checkout [--create] and refresh-workspace (fetch and merge --ff-only only; every block is reported and skipped, exit 2). Also the verb list, the exit-code table, the usage lines and the env doc. Tests: - New modules: test_a23_lane_checkout.py, test_a23_write_routing.py, test_a23_lock.py and test_a23_refresh_workspace.py. On this tree: 22, 21, 15 and 10 passed. - Existing fixtures that asserted writes in W now name their old W by the explicit seam, with W a reader copy (decisions D21, D24-D26 in the slice's progress record): lane-start's Sandbox, #214's Box, row-stamp's Sandbox, T018's Bench (a gitfile view, with W's path normalised), the two supervised-restart mutex cases (the sidecar lock), and the shell suite's head (it clears every inherited LANES_* and TMUX*, then sets LANES_REPO=$WIP; ort186 Q9) and its set-lane-tree wait. WHY WIP — what has not run on this commit: - The after-run of the existing modules on this tree stopped at the checkpoint, after the four new modules. In a scratch export with the same files, these passed: commit_push_194 25/25, lane_start_claude_current 34/34, row_stamp 3/3, triad 5 passed 16 skipped and 1 passed 13 skipped, deferred_97 9/9, handoff_skill 24/24, rename_158 20/20, hardening_138 20/20, pathspec 3/3, lane_worktrees 118/118, lane_worktrees_report 20/20, openrepotools_command 97/97, wip_init 63 skipped, and hygiene 189 and guard_launch_mode 13 on this tree. - Not yet run against the new helper: test_supervised_restart_regressions, test_lanes_index, the install modules and test_claude_current. - The one-line mutants are not run yet. - The shell suite cannot be run in part (the repository gives no way), so it has not run. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 934 +++++++++++++++++-- tests/test_a23_lane_checkout.py | 466 +++++++++ tests/test_a23_lock.py | 483 ++++++++++ tests/test_a23_refresh_workspace.py | 248 +++++ tests/test_a23_write_routing.py | 358 +++++++ tests/test_commit_push_published_edit_194.py | 12 +- tests/test_lane_helpers.sh | 154 ++- tests/test_lane_helpers_suite.py | 10 +- tests/test_lane_start_claude_current.py | 15 +- tests/test_lane_start_row_stamp.py | 12 +- tests/test_supervised_restart_regressions.py | 15 +- tests/test_triad_lane_tooling.py | 26 +- 12 files changed, 2606 insertions(+), 127 deletions(-) create mode 100644 tests/test_a23_lane_checkout.py create mode 100644 tests/test_a23_lock.py create mode 100644 tests/test_a23_refresh_workspace.py create mode 100644 tests/test_a23_write_routing.py diff --git a/lanes-edit.sh b/lanes-edit.sh index e9ea7880..12d70982 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -366,6 +366,15 @@ # lanes-edit.sh lane-reconcile # lanes-edit.sh worktrees [ | --all] [--index] [--fetch] # lanes-edit.sh pathspec-check ... # opensoft/openRepoTools#162 +# lanes-edit.sh lane-checkout [--create] # Amendment 23 S1 +# lanes-edit.sh refresh-workspace # Amendment 23 S5 +# +# `lane-checkout` prints the lane's own register checkout, `-lanes//` +# (0), says it has none yet (8), or refuses what is at that path and is not a +# clone of the register (1); `--create` makes it as a first write would. +# `refresh-workspace` fetches W's origin and fast-forwards W, which is the +# only change any tool makes in W; whatever would stop that is reported and +# skipped (2), and nothing in W is ever reset, stashed, committed or aborted. # # A lane is RUNNING, SWAPPING, SWAPPED or CLOSED, and WHICH OF THOSE IT IS # WITH NO LIVE HOLDER is what says where its session stopped: `RUNNING` with @@ -428,7 +437,10 @@ # # EXIT CODES — every subcommand, one table, no two meanings on one number # 0 done (and #194's publish whose queue `pull --rebase` emptied) -# 1 environment (no register, no writer) +# 1 environment (no register, no writer) — and, AMENDMENT 23 S1, a lane +# checkout's path that holds something other than a clone of the register +# (every routed write, and `lane-checkout`), which is refused and never +# deleted, moved or repaired; `refresh-workspace` with no W # 2 refusal: bad arguments, the object is held, an unknown alias, a # checkout that cannot be rebased, — Amendment 15 — a register holding # two rows whose lane names differ only by case, which every writer and @@ -438,7 +450,11 @@ # (`commit_push`, `pathspec-check`), or — #194 — `commit` refusing to # publish local commits that are not register or handoff writes, from a # checkout whose HEAD is off the branch, or that it cannot read -# (`cp_publish_fence`), and pushes none of them. EIGHT MEANINGS ON ONE NUMBER, and they +# (`cp_publish_fence`), and pushes none of them; or — AMENDMENT 23 — a +# write that names no lane, a `LANES_REPO` that IS W (S5), a `LANES_DIR` +# or `LANES_FILE` outside the checkout the write goes through (S2), a +# lane checkout's clone that failed (S1), and `refresh-workspace`'s +# skip, which changed nothing. EIGHT MEANINGS ON ONE NUMBER, and they # stay readable only because of where each can occur: bad arguments to a # subcommand that predates Amendment 11; `register-row`'s "not lane-shaped"; # AN UNKNOWN SUBCOMMAND (the `*)` arm below), which is how a caller detects @@ -459,6 +475,7 @@ # `canon-lane`, which takes the same # contract because its four callers sit in front of a launch as those do. IT WAS IN NO TABLE AT ALL until this round (F-X16), while # being the code the contract gives every one of those reads. +# Amendment 23's `lane-checkout` and `refresh-workspace` spend it too. # # 66 EX_NOINPUT — AMENDMENT 16(e)'s ALIAS TABLE COULD NOT BE READ, and # `canon-lane` is the one verb that spends it. `lanes/aliases.tsv` is what @@ -489,7 +506,8 @@ # push OR a pull, where a hung one often already landed; or six # attempts exhausted because a peer's own uncommitted file blocks # every rebase. -# 4 the mutex could not be taken within 60s +# 4 the mutex could not be taken within 60s — or, Amendment 23 S1, a lane +# checkout's creation lock within the clone's budget and a minute # 5 an edit moved more than one line and was refused — or, Amendment 15, a # lane's object log could not be renamed to the row's own spelling # 6 git add / commit / push failed @@ -506,7 +524,8 @@ # lane; `live-holder` READ this workstation's session records and none of # them holds it; `swapped` found no lane swapped on the workstation; # `history` READ the lane's log and it carries no `NOTED` or `RULED` line; -# `window-session` found no live session in the window. A read that could +# `window-session` found no live session in the window; `lane-checkout` +# found no checkout for the lane (Amendment 23). A read that could # not be performed is never 8 (R22). `session-start` never exits 8, or # anything but 0: it is a hook. `guard` never exits 8 either — it is a hook # too, and a BLOCKING one, so its two codes are 0 and 2 (see 2 above). @@ -557,13 +576,28 @@ # ENVIRONMENT # LANES_FILE registry path (default: /LANES.md) # LANES_DIR dir holding it (default: /lanes) -# LANES_REPO checkout root (default: the `path:` in -# $AGENT_PROTOCOL_ROOT/workspace.yaml, -# checked to be a checkout of the -# `repository:` beside it — Amendment -# 9(a); never a literal path) -# LANES_WORKSPACE_ROOT that `path:`, overridden (the test seam; nothing else +# LANES_REPO the checkout a WRITE goes through. Unset — the normal +# case — a write goes through its lane's own checkout, +# -lanes//, made on first use (Amendment 23 S1, +# S2), and a READ reads W itself. W is the `path:` in +# $AGENT_PROTOCOL_ROOT/workspace.yaml, checked to be a +# checkout of the `repository:` beside it (Amendment 9(a); +# never a literal path). SET, it is the test seam and an +# operator's override and is honoured for reads and writes +# alike — except that one naming W by real path refuses +# every write (S5), and one naming a per-profile clone +# beside W is honoured with a warning naming S1's path +# (S6.2). LANES_DIR and LANES_FILE locate files INSIDE the +# checkout and never choose it: a write whose LANES_DIR or +# LANES_FILE is outside the checkout refuses (S2) +# LANES_WORKSPACE_ROOT W, overridden (the test seam; nothing else # sets it) +# LANES__CHECKOUT_LANE, LANES__PID INTERNAL — exported only by this file to +# itself when it re-runs a write in the lane's checkout +# (`exec` keeps the pid, so LANES__PID equal to `$$` marks +# them as this process's own). Never set either: with any +# other pid they are read as inherited from a parent and +# dropped, with the LANES_REPO/DIR/FILE they came beside # AGENT_PROTOCOL_ROOT dir holding workspace.yaml (default: ~/.agents) # LANES_PATH pathspec of the register RELATIVE to LANES_REPO # (default: `lanes/LANES.md`, derived @@ -591,6 +625,16 @@ # log at (default 15). The test seam, and only that # LANES_IN_CONTAINER 1|0 force the container probe (test seam) # LANES_NO_GIT=1 edit only, no commit/push (used by the test harness) +# THE LOCKS (Amendment 23 S2, design decision 14). /.lanes-edit.lock +# is the CHECKOUT's — one lane's writers; -lanes/ +# .lane-state.lock is the WORKSTATION's, taken alone by the +# lifecycle, restart and tree sidecars and by +# `publish-handoff`, and by `lane_state_follow` inside the +# checkout lock (always that order). No lock is taken in W. +# Each holds a `pid` file, ` `: a +# live holder in this container is never aged out; one in +# another container is judged by the directory's age, which +# the holder refreshes before every network call # LANES_LOG_DIR the object logs (default: /log) # LANES_REPOS_TSV the per-wip alias OVERRIDE (default: /repos.tsv) # LANES_REPOS_TSV_SHIPPED the organisation's alias table shipped by @@ -620,6 +664,12 @@ set -u +# AMENDMENT 23 S2 — THE ARGV AS IT ARRIVED, before the first `shift` below. A +# write routed to its lane's checkout runs this file again with exactly these +# words (`lane_checkout_enter`). `${1+"$@"}`, because bash 3.2 under `set -u` +# reads a bare `"$@"` with no arguments as an unbound variable. +ORIG_ARGV=(${1+"$@"}) + # Parse the global flags before probing paths, git or workstation identity. # A profile-only prompt must not depend on any of that lane infrastructure. SWEEP_MODE="no-sweep" @@ -1036,16 +1086,39 @@ lanes_binding_value_ok() { # # machine with no register at all — still run. The refusal is the # `registry not found` guard below, which is exit 1: the code this script has # always used for it and the code Amendment 9(a) quotes. -LANES_WS_ROOT="" -if [ -z "${LANES_REPO:-}" ] || { [ -z "${LANES_DIR:-}" ] && [ -z "${LANES_FILE:-}" ]; }; then - LANES_WS_ROOT="$(lanes_workspace_root || :)" +# +# AMENDMENT 23 S2 — WHO CHOSE THESE THREE, AND THEN WHERE THEY POINT. +# +# A write that resolves by default runs this file AGAIN in its lane's checkout +# (`lane_checkout_enter`), with `LANES_REPO`, `LANES_DIR` and `LANES_FILE` +# exported at that checkout and two markers beside them. `exec` keeps the pid, +# so `LANES__PID` equal to `$$` is the re-run process itself. Any other value +# was INHERITED — a process a routed write started, which ran a lane command +# of its own — and then all five are the tool's exports and not its caller's +# choice: they are dropped, and that process resolves for itself. +if [ -n "${LANES__CHECKOUT_LANE:-}${LANES__PID:-}" ] && [ "${LANES__PID:-}" != "$$" ]; then + unset LANES__CHECKOUT_LANE LANES__PID LANES_REPO LANES_DIR LANES_FILE fi +LANES_ROUTED="${LANES__CHECKOUT_LANE:-}" # the lane, in the re-run process only +LANES_REPO_GIVEN=0 # a caller's LANES_REPO: the seam (S2.1) +[ -n "$LANES_ROUTED" ] || [ -z "${LANES_REPO:-}" ] || LANES_REPO_GIVEN=1 +LANES_DIR_GIVEN="${LANES_DIR:-}" +LANES_FILE_GIVEN="${LANES_FILE:-}" +# W IS RESOLVED EVERY TIME NOW, an explicit `LANES_REPO` included: S5 refuses a +# write whose checkout IS W, and the workstation's sidecar lock lives beside it, +# so neither can be decided without it. It is two `sed`s and three `git` reads. +# AND `LANES_DIR`/`LANES_FILE` DEFAULT FROM `LANES_REPO`, which they did not: an +# explicit `LANES_REPO` alone sent both to W, so git ran in one checkout while +# the edit landed in another (#198's defect at the old `:1030-1041`). +LANES_WS_ROOT="$(lanes_workspace_root || :)" +LANES_WS_REAL="" +[ -z "$LANES_WS_ROOT" ] || LANES_WS_REAL="$(cd -P -- "$LANES_WS_ROOT" 2>/dev/null && pwd -P)" || LANES_WS_REAL="" : "${LANES_REPO:=$LANES_WS_ROOT}" if [ -z "${LANES_DIR:-}" ]; then if [ -n "${LANES_FILE:-}" ]; then LANES_DIR="$(dirname -- "$LANES_FILE")" else - LANES_DIR="${LANES_WS_ROOT:+$LANES_WS_ROOT/lanes}" + LANES_DIR="${LANES_REPO:+$LANES_REPO/lanes}" fi fi LANES_FILE="${LANES_FILE:-$LANES_DIR/LANES.md}" @@ -1099,6 +1172,11 @@ LANES_INDEX_NUDGE=0 LANES_WT_IDX_DIR="" LANES_INDEX_NUDGE_INV=0 LOCK_HELD=0 +# AMENDMENT 23 — THE TWO OTHER MUTEXES, held only by this process and so set +# here, before the trap that releases them: the workstation's sidecar lock +# (`-lanes/.lane-state.lock`) where `lane_state_follow` took it, and the +# creation lock (`-lanes/..lock`) while a lane checkout is cloned. +SIDECAR_HELD=0; SIDECAR_LOCK=""; CREATE_LOCK="" LANES_PATH="${LANES_PATH:-$(git -C "${LANES_DIR:-.}" rev-parse --show-prefix 2>/dev/null || :)${LANES_FILE##*/}}" # The pathspecs the current invocation is writing. Every subcommand that @@ -1133,13 +1211,147 @@ note() { printf 'lanes-edit: %s\n' "$*" >&2; } # AMENDMENT 8, ruling (h): THE LOCK NAMES ITS HOLDER, so a lock nobody holds can # be told from a lock somebody does. The directory alone could only be aged out, # and ten minutes of every lane on the workstation blocked is not a recovery. +# +# AMENDMENT 23 S2 — AND IT NAMES WHERE THE HOLDER RUNS. A pid does not cross a +# pid namespace (Amendment 18), and every bench container on a host sees the +# projects-root mount these locks live on, so the `pid` file is ONE line, +# ` `, written by one `printf`: +# * this container and host: the pid test alone. A LIVE HOLDER IS NEVER AGED +# OUT — six push attempts at a minute each is longer than ten minutes, and a +# write that is still running is not stale however long it takes (S2); +# * another container or host: `kill -0` here says nothing about it, so the +# ten-minute age of the directory decides, and the holder refreshes that age +# before every network call (`git_net`); +# * a pid alone (an older helper's lock) reads as this container's; no pid at +# all keeps the age test, which is all that can be said about it. +# The same functions serve the checkout's lock, the creation lock and the +# workstation's sidecar lock, so the three obey one rule. +MX_PID=""; MX_CONT=""; MX_HOST=""; MX_WHY="" +lock_ident() { # this process's ` `, each one token + li_c="${LANES_CONTAINER_NAME:-none}"; li_h="${LANES_HOST_NAME:-unknown}" + lanes_binding_value_ok "$li_c" || li_c=unknown + lanes_binding_value_ok "$li_h" || li_h=unknown + printf '%s %s\n' "$li_c" "$li_h" +} +mutex_read() { # — MX_PID/MX_CONT/MX_HOST from its pid file; 1 for none + MX_PID=""; MX_CONT=""; MX_HOST="" + [ -f "$1/pid" ] || return 1 + IFS=' ' read -r MX_PID MX_CONT MX_HOST _ < "$1/pid" 2>/dev/null || : + case "$MX_PID" in ''|*[!0-9]*) MX_PID=""; MX_CONT=""; MX_HOST=""; return 1 ;; esac + return 0 +} +mutex_local() { # after mutex_read: is that holder in this pid namespace? + [ -z "$MX_CONT" ] && return 0 + [ "$MX_CONT $MX_HOST" = "$(lock_ident)" ] +} +# 0 and MX_WHY when is stale, 1 while it is held. Never touches it. +mutex_stale() { # + MX_WHY="" + if ! mutex_read "$1"; then + if [ -z "$(find "$1" -maxdepth 0 -mmin -10 2>/dev/null)" ]; then + MX_WHY="it names no holder and is over ten minutes old"; return 0 + fi + return 1 + fi + if mutex_local; then + [ "$MX_PID" = "$$" ] && return 1 + kill -0 "$MX_PID" 2>/dev/null && return 1 + MX_WHY="its holder (pid $MX_PID) is gone"; return 0 + fi + if [ -z "$(find "$1" -maxdepth 0 -mmin -10 2>/dev/null)" ]; then + MX_WHY="its holder (pid $MX_PID in container $MX_CONT on $MX_HOST, beyond this one's pid test) has not refreshed it for over ten minutes" + return 0 + fi + return 1 +} +# A TAKEOVER IS A CLAIM, NOT A DELETE (the rule `tests/run.sh` states for its +# own lock). Two waiters that both read a dead holder both used to `rm` the pid +# and `rmdir` the name, and the second could remove the lock the first had just +# made: two holders. One rename has one winner, and the claimed copy is read +# AGAIN before it is removed, so a holder that appeared between the read and +# the rename is put back. +mutex_steal() { # + [ -d "$1" ] && [ ! -L "$1" ] || return 1 + ms_claim="$1.taken.$$" + if [ -e "$ms_claim" ]; then + rm -f -- "$ms_claim/pid" 2>/dev/null || : + rmdir -- "$ms_claim" 2>/dev/null || return 1 + fi + mv -- "$1" "$ms_claim" 2>/dev/null || return 1 + if ! mutex_stale "$ms_claim"; then + [ -e "$1" ] || mv -- "$ms_claim" "$1" 2>/dev/null || : + return 1 + fi + note "taking over $1: $MX_WHY" + rm -f -- "$ms_claim/pid" 2>/dev/null || : + rmdir -- "$ms_claim" 2>/dev/null || : + return 0 +} +mutex_try() { # [] — 0 taken, 1 not; never exits + mt_d="$1"; mt_max="${2:-60}"; mt_i=0 + while :; do + if mkdir -- "$mt_d" 2>/dev/null; then + printf '%s %s\n' "$$" "$(lock_ident)" > "$mt_d/pid" 2>/dev/null || : + return 0 + fi + if mutex_stale "$mt_d" && mutex_steal "$mt_d"; then continue; fi + [ "$mt_i" -lt "$mt_max" ] || return 1 + mt_i=$((mt_i + 1)) + sleep 1 + done +} +# A LOCK IS RELEASED ONLY WHILE IT IS STILL THIS PROCESS'S. One another holder +# took over (another container's waiter, past a heartbeat this one missed) is +# that holder's now, and removing it would leave its write under no lock. +mutex_release() { # + if mutex_read "$1" && [ "$MX_PID" != "$$" ]; then + note "$1 is held by pid $MX_PID now, not by this process, so it is left as it is" + return 0 + fi + rm -f -- "$1/pid" 2>/dev/null || : + rmdir -- "$1" 2>/dev/null || : + return 0 +} release_lock() { - rm -f -- "$LOCK/pid" 2>/dev/null || : - rmdir -- "$LOCK" 2>/dev/null || : + [ "$LOCK_HELD" = 1 ] || return 0 + mutex_release "$LOCK" LOCK_HELD=0 return 0 } +# THE WORKSTATION'S SIDECAR LOCK (Amendment 23 S2; design decision 14's "the +# whole workstation's", ort186 Q3). The lifecycle snapshot, the restart intent, +# the tree records and `publish-handoff`'s fence were serialised by W's mutex; +# W takes no lock now, and a lane's checkout lock serialises one lane only, so +# they share this one beside the lane checkouts, on the mount every container +# sees. ORDER: a writer that holds its checkout lock may take this one inside +# it (`lane_state_follow`); a sidecar verb run on its own takes this one ALONE, +# and never a checkout lock after it. +sidecar_lock_dir() { + [ -n "$LANES_WS_REAL" ] || return 1 + printf '%s\n' "$LANES_WS_REAL-lanes/.lane-state.lock" +} +# For the verbs that take this lock and no other: `LOCK` is pointed at it, so +# their `acquire_lock`/`release_lock` pairs, and `cleanup`, act on it unchanged. +sidecar_lock_use() { + slu_d="$(sidecar_lock_dir)" || die "$(lanes_workspace_why)" 1 + mkdir -p -- "${slu_d%/*}" 2>/dev/null || die "cannot create ${slu_d%/*}, where the workstation's sidecar lock lives (Amendment 23 S2). Nothing was written." 1 + LOCK="$slu_d" +} +sidecar_try() { # [] — taken inside a held checkout lock, or alone + st_d="$(sidecar_lock_dir)" || return 1 + mkdir -p -- "${st_d%/*}" 2>/dev/null || return 1 + mutex_try "$st_d" "${1:-60}" || return 1 + SIDECAR_LOCK="$st_d"; SIDECAR_HELD=1 + return 0 +} +sidecar_release() { + [ "$SIDECAR_HELD" = 1 ] || return 0 + mutex_release "$SIDECAR_LOCK" + SIDECAR_HELD=0 + return 0 +} + # ====================================================== AMENDMENT 16 ======= # # THE RENAME'S UNDO, AND WHY IT IS A TRAP AND NOT A LINE BEFORE EACH `die`. @@ -1235,6 +1447,8 @@ cleanup() { [ -z "${pbh_tmp:-}" ] || rm -f -- "$pbh_tmp" [ -z "${rl_htmp:-}" ] || rm -f -- "$rl_htmp" # rename-lane's stamp before its replace (#158) rename_undo + sidecar_release # Amendment 23: taken inside the + [ -z "$CREATE_LOCK" ] || mutex_release "$CREATE_LOCK" # checkout lock, so out first if [ "$LOCK_HELD" = 1 ]; then release_lock; fi [ -n "${RL_SNAP:-}" ] && [ -d "${RL_SNAP:-}" ] && rm -rf -- "$RL_SNAP" [ -n "${TMPD:-}" ] && [ -d "${TMPD:-}" ] && rm -rf -- "$TMPD" @@ -1250,6 +1464,28 @@ cleanup() { if [ "${LANES_INDEX_NUDGE:-0}" = 1 ] || [ "${LANES_INDEX_NUDGE_INV:-0}" = 1 ]; then index_nudge; fi return 0 } +# AMENDMENT 23 — WHAT `exec` LEAVES BEHIND, cleared before this process becomes +# a new run of this file. `exec` runs no EXIT trap, so every temporary made so +# far would outlive the run that made it; the sidecar and creation locks are +# released, because nothing holds them across the line; and a half-made +# rename's undo is disarmed, because the new run starts from the top. THE +# CHECKOUT LOCK IS KEPT: the process after the `exec` is this one, with this +# pid, and `lock_try` reads a lock naming it as its own (`lock_adopt`). +cleanup_for_exec() { + [ -z "${pbh_tmp:-}" ] || rm -f -- "$pbh_tmp" + [ -z "${rl_htmp:-}" ] || rm -f -- "$rl_htmp" + pbh_tmp=""; rl_htmp=""; RL_ACTIVE=0 + sidecar_release + [ -z "$CREATE_LOCK" ] || mutex_release "$CREATE_LOCK" + CREATE_LOCK="" + [ -n "${RL_SNAP:-}" ] && [ -d "${RL_SNAP:-}" ] && rm -rf -- "$RL_SNAP" + [ -n "${TMPD:-}" ] && [ -d "${TMPD:-}" ] && rm -rf -- "$TMPD" + [ -n "${SE_CACHE_FILE:-}" ] && rm -f -- "$SE_CACHE_FILE" "$SE_CACHE_FILE".* 2>/dev/null + [ -n "${LANES_IDX_DIR:-}" ] && [ -d "${LANES_IDX_DIR:-}" ] && rm -rf -- "$LANES_IDX_DIR" + [ -n "${LANES_WT_IDX_DIR:-}" ] && [ -d "${LANES_WT_IDX_DIR:-}" ] && rm -rf -- "$LANES_WT_IDX_DIR" + RL_SNAP=""; TMPD=""; SE_CACHE_FILE=""; LANES_IDX_DIR=""; LANES_WT_IDX_DIR="" + return 0 +} # AND A SIGNAL ACTUALLY STOPS IT (Amendment 8, ruling (h)). `trap cleanup EXIT # INT TERM` ran the handler and then CARRIED ON, because a trap that returns # resumes the command after the one that was interrupted — so a `kill` of a @@ -1267,21 +1503,10 @@ trap 'cleanup; trap - TERM; kill -TERM $$' TERM # minutes later. A helper killed at a usage reset, or an ssh that took the # process down with it, leaves a directory no living process will ever remove, # and until Amendment 8(h) every writer on the workstation waited out the full -# age-out behind it. A lock with NO pid file is from an older version of this -# script and keeps the age test, which is the only thing that could be said -# about it. -lock_steal_if_dead() { - [ -d "$LOCK" ] || return 0 - lsd_pid="$(cat -- "$LOCK/pid" 2>/dev/null || :)" - case "$lsd_pid" in ''|*[!0-9]*) return 0 ;; esac - [ "$lsd_pid" = "$$" ] && return 0 - kill -0 "$lsd_pid" 2>/dev/null && return 0 - note "taking over $LOCK: its holder (pid $lsd_pid) is gone" - rm -f -- "$LOCK/pid" 2>/dev/null || : - rmdir -- "$LOCK" 2>/dev/null || : - return 0 -} - +# age-out behind it. `mutex_stale` above is that test, with Amendment 23's +# rule on top: the ten-minute age-out never reaches a live holder in this +# container, and is the whole test for one in another. +# # THE MUTEX, TAKEN WITHOUT DYING FOR IT — 0 taken, 1 not taken, and no exit # either way. `acquire_lock` below is this with the refusal on the end, so the # `mkdir` mutex, the pid test and the age-out are written ONCE and every taker @@ -1292,30 +1517,25 @@ lock_steal_if_dead() { # committed, and a `die` there would abort a caller whose write is already on # disk — so the snapshot that could not be taken under the mutex is left alone # and SAID, exactly as a snapshot that could not be written is. +# +# AND A LOCK THIS PROCESS TOOK BEFORE AN `exec` IS STILL ITS OWN (Amendment 23 +# S4's replay re-runs the verb with the lock held). `exec` keeps the pid, and +# the run before the `exec` exported `LANES__PID=$$`: a lock whose pid file +# names this pid in this container, read by a process whose `LANES__PID` is its +# own `$$`, is taken as held. Any other process that finds its own pid there — +# a reused pid — waits like everyone else. +lock_adopt() { + [ -n "${LANES__PID:-}" ] && [ "$LANES__PID" = "$$" ] || return 1 + mutex_read "$LOCK" || return 1 + [ "$MX_PID" = "$$" ] && mutex_local || return 1 + LOCK_HELD=1 + return 0 +} lock_try() { # [] - lt_max="${1:-60}" - lock_steal_if_dead - # Stale lock (>10 min) is removed: a helper run never takes that long. The - # age test is now the FALLBACK — the pid test above is the real one. - if [ -d "$LOCK" ]; then - if [ -z "$(find "$LOCK" -maxdepth 0 -mmin -10 2>/dev/null)" ]; then - note "removing stale lock $LOCK" - rm -f -- "$LOCK/pid" 2>/dev/null || : - rmdir -- "$LOCK" 2>/dev/null || : - fi - fi - lt_i=0 - while [ "$lt_i" -lt "$lt_max" ]; do - if mkdir -- "$LOCK" 2>/dev/null; then - LOCK_HELD=1 - printf '%s\n' "$$" > "$LOCK/pid" 2>/dev/null || : - return 0 - fi - lock_steal_if_dead # it may have died while we were waiting - lt_i=$((lt_i + 1)) - sleep 1 - done - return 1 + lock_adopt && return 0 + mutex_try "$LOCK" "${1:-60}" || return 1 + LOCK_HELD=1 + return 0 } acquire_lock() { @@ -1862,6 +2082,10 @@ GIT_TIMED_OUT=0 git_net() { GIT_TIMED_OUT=0 gn_rc=0 + # AMENDMENT 23 S2 — THE HEARTBEAT. A holder in another container is judged + # by its lock's age alone, so the holder refreshes that age before every + # network call: a write that is still pushing is never aged out from there. + if [ "$LOCK_HELD" = 1 ]; then touch -- "$LOCK" 2>/dev/null || :; fi case "$GIT_TIMEOUT" in ''|0|*[!0-9]*) "$GIT_BIN" "$@" || gn_rc=$? ;; *) @@ -12733,24 +12957,24 @@ lane_state_follow() { # /dev/null || printf 'the sidecar lock') is held by another lanes-edit run and this follow-up will not wait behind an event that has already landed. The $lsf_verb line itself is written; the snapshot is one act behind until the next transition, and \`lanes-edit.sh lane-reconcile $lsf_lane\` says what it holds." + return 0 fi # A SNAPSHOT THIS HELPER CANNOT READ IS ONE IT MUST NOT REPLACE. if ! lane_sidecar_schema_ok "$lsf_f"; then - if [ "$lsf_own" = 1 ]; then release_lock; fi + sidecar_release note "the lane lifecycle snapshot at $lsf_f records a schema this helper does not write, so the $lsf_verb line landed and NOTHING was written over that file: a record an older helper cannot read is one it cannot safely replace. Upgrade this workstation's lanes-edit.sh, or read the file by hand." return 0 fi lsf_seen="$(lane_state_fingerprint "$lsf_f")" if [ "$lsf_seen" != "$lsf_pre" ]; then - if [ "$lsf_own" = 1 ]; then release_lock; fi + sidecar_release note "the lane lifecycle moved under this $lsf_verb: lane $lsf_lane read '$lsf_pre' (state/generation/operation) when this write began and reads '$lsf_seen' now, so the snapshot is LEFT AS IT IS and no $lsf_new was written over it. Another act got there first — a recovery, or a second handoff — and a delayed write is precisely what the generation exists to refuse. The $lsf_verb line itself landed: read the lane with \`lanes-edit.sh lane-reconcile $lsf_lane\` before relaunching anything." return 0 fi @@ -12761,11 +12985,11 @@ lane_state_follow() { # -lanes//`, a clone of the +# register's origin made on first use; W — the `path:` in workspace.yaml — is +# the readers' copy, and no tool writes there. This block is S1 (the path, its +# creation, and the refusal of anything else at it), S2 (which checkout a write +# goes through) and S5's two halves (a write that resolves to W refuses; W is +# only ever fast-forwarded, by `refresh-workspace`). S3-S4 — the fetch before +# the edit and the read-back after it — are the next slice; until then a write +# in a lane's checkout commits and pushes exactly as it always has. +# +# HOW A WRITE GETS THERE: THE DISPATCHER RE-RUNS THIS FILE. The checkout is +# known only once the verb's lane is, which is after every path this file +# derives at load time — the lock, `LANES_PATH`, the log directory, the alias +# table, the archive, the caches. So rather than re-point each of them, the +# write runs this file again (`exec`, same pid) with `LANES_REPO`, `LANES_DIR` +# and `LANES_FILE` exported at the checkout, and every one of them follows. + +# S1 — THE PATH, FROM THE LANE NAME ALONE: canonical, never a profile, session, +# window, container or directory. Derived every time and recorded nowhere. +lane_checkout_path() { # + [ -n "$LANES_WS_REAL" ] || return 1 + printf '%s-lanes/%s\n' "$LANES_WS_REAL" "$1" +} + +# W's object store, absolute and real: the one directory a lane checkout's +# alternates file may name. `--git-common-dir` is relative to `-C` on older +# git, so a relative answer is joined to W. +lane_ws_objects() { + lwo_d="$(git -C "$LANES_WS_REAL" rev-parse --git-common-dir 2>/dev/null)" || return 1 + case "$lwo_d" in ''|/*) : ;; *) lwo_d="$LANES_WS_REAL/$lwo_d" ;; esac + [ -n "$lwo_d" ] || return 1 + (cd -P -- "$lwo_d/objects" 2>/dev/null && pwd -P) +} + +# S1 — A PATH THAT EXISTS AND IS NOT A CLONE OF THE REGISTER IS REFUSED, exit 1 +# (Amendment 9(a)'s "registry not found"), and nothing at it is ever deleted, +# moved, repaired or cloned over. 0 for a clone; 1 with LCV_WHY saying what is +# there, and LCV_ALT=1 where it is the borrowed objects that are wrong. +LCV_WHY=""; LCV_ALT=0 +lane_checkout_verify() { # + lcv_p="$1"; LCV_WHY=""; LCV_ALT=0 + if [ -L "$lcv_p" ]; then + LCV_WHY="a symbolic link (to $(readlink "$lcv_p" 2>/dev/null || printf 'somewhere unreadable')), and a lane checkout is a directory of its own" + return 1 + fi + if [ ! -d "$lcv_p" ]; then LCV_WHY="a file, not a directory"; return 1; fi + lcv_real="$(cd -P -- "$lcv_p" 2>/dev/null && pwd -P)" || { + LCV_WHY="a directory this process cannot enter"; return 1; } + lcv_top="$(git -C "$lcv_p" rev-parse --show-toplevel 2>/dev/null || :)" + if [ -z "$lcv_top" ]; then LCV_WHY="a directory that is not a git checkout"; return 1; fi + if [ "$lcv_top" != "$lcv_real" ]; then + LCV_WHY="a directory inside the checkout $lcv_top, not the root of one"; return 1 + fi + if [ ! -d "$lcv_p/.git" ]; then + LCV_WHY="a checkout whose .git is a file — a linked worktree or a separate git directory — where S1 needs a clone: a linked worktree shares W's refs and index lock" + return 1 + fi + lcv_o="$(git -C "$lcv_p" remote get-url origin 2>/dev/null || :)" + lcv_wo="$(git -C "$LANES_WS_REAL" remote get-url origin 2>/dev/null || :)" + if ! lanes_ws_same_repo "$lcv_o" "$lcv_wo"; then + LCV_WHY="a checkout of ${lcv_o:-no origin at all}, where the register's origin is ${lcv_wo:-unknown}" + return 1 + fi + lcv_alt="$lcv_p/.git/objects/info/alternates" + [ -f "$lcv_alt" ] || return 0 + lcv_wobj="$(lane_ws_objects)" || { + LCV_ALT=1; LCV_WHY="a clone with an alternates file, and W's own object directory cannot be read to check it against"; return 1; } + while IFS= read -r lcv_l || [ -n "$lcv_l" ]; do + case "$lcv_l" in ''|'#'*) continue ;; esac + case "$lcv_l" in /*) lcv_t="$lcv_l" ;; *) lcv_t="$lcv_p/.git/objects/$lcv_l" ;; esac + lcv_tr="$(cd -P -- "$lcv_t" 2>/dev/null && pwd -P)" || lcv_tr="" + if [ -z "$lcv_tr" ]; then + LCV_ALT=1; LCV_WHY="a clone whose borrowed objects are gone: its alternates file names $lcv_l, which is not there"; return 1 + fi + if [ "$lcv_tr" != "$lcv_wobj" ]; then + LCV_ALT=1; LCV_WHY="a clone that borrows objects from $lcv_tr, where a lane checkout borrows from W's $lcv_wobj and nothing else"; return 1 + fi + done < "$lcv_alt" + return 0 +} +lane_checkout_refuse() { # — after lane_checkout_verify said no + lcr_aside="$1.aside-$(date -u +%Y%m%dT%H%M%SZ)" + if [ "$LCV_ALT" = 1 ]; then + die "$1 is not a usable lane checkout: it is $LCV_WHY (Amendment 23 S1). Nothing was written, and nothing there was deleted, moved or repaired. Once \`git -C $1 cherry -v origin/$LANES_BRANCH HEAD\` there shows nothing unpublished, a person moves it aside — mv $1 $lcr_aside — and the next write clones afresh." 1 + fi + die "$1 is not a clone of the register: it is $LCV_WHY (Amendment 23 S1). Nothing was written, and nothing there was deleted, moved or repaired. A person moves it aside — mv $1 $lcr_aside — and runs the write again." 1 +} + +# S1 — AFTER A RENAME, THE OLD NAME'S CHECKOUT: the first `-lanes//` +# whose name `o` is a key of the alias table resolving to this lane. +lane_checkout_old() { # + lco_base="$LANES_WS_REAL-lanes" + lco_keys="$(lane_alias_keys 2>/dev/null)" || return 0 + while IFS= read -r lco_k; do + [ -n "$lco_k" ] || continue + [ "$(lc "$lco_k")" != "$(lc "$1")" ] || continue + lco_t="$(lane_alias_target "$lco_k" 2>/dev/null)" || continue + [ "$(lc "$lco_t")" = "$(lc "$1")" ] || continue + if [ -d "$lco_base/$lco_k" ] && [ ! -L "$lco_base/$lco_k" ]; then + printf '%s\n' "$lco_base/$lco_k"; return 0 + fi + done < + lce_lane="$1" + lce_p="$(lane_checkout_path "$lce_lane")" || die "$(lanes_workspace_why)" 1 + lce_base="${lce_p%/*}" + LCE_PATH="$lce_p" + # AN EMPTY DIRECTORY COUNTS AS ABSENT. `rmdir` refuses anything that is not + # empty, so this deletes nothing. + if [ -d "$lce_p" ] && [ ! -L "$lce_p" ]; then rmdir -- "$lce_p" 2>/dev/null || :; fi + if [ -e "$lce_p" ] || [ -L "$lce_p" ]; then + lane_checkout_verify "$lce_p" || lane_checkout_refuse "$lce_p" + lce_old="$(lane_checkout_old "$lce_lane")" + [ -z "$lce_old" ] || note "$lce_old is lane $lce_lane's checkout under a former name (lanes/aliases.tsv). This write goes through $lce_p, and the old one is left as it is, for lane-end's sweep (Amendment 23 S1, S6)." + return 0 + fi + mkdir -p -- "$lce_base" 2>/dev/null || + die "cannot create $lce_base, where this workstation's lane checkouts live (Amendment 23 S1). Nothing was written." 2 + case "$GIT_TIMEOUT" in + ''|0|*[!0-9]*) lce_budget="$GIT_TIMEOUT"; lce_wait=660 ;; + *) lce_budget=600; [ "$GIT_TIMEOUT" -le 600 ] || lce_budget="$GIT_TIMEOUT"; lce_wait=$((lce_budget + 60)) ;; + esac + lce_lock="$lce_base/.$lce_lane.lock" + mutex_try "$lce_lock" "$lce_wait" || + die "could not take $lce_lock within ${lce_wait}s: another writer is creating lane $lce_lane's checkout (holder pid $(cat -- "$lce_lock/pid" 2>/dev/null || printf 'unrecorded')). Nothing was written; run the write again." 4 + CREATE_LOCK="$lce_lock" + # ANOTHER CREATOR MAY HAVE FINISHED while this one waited. + if [ -d "$lce_p" ] && [ ! -L "$lce_p" ]; then rmdir -- "$lce_p" 2>/dev/null || :; fi + if [ ! -e "$lce_p" ] && [ ! -L "$lce_p" ]; then + lce_old="$(lane_checkout_old "$lce_lane")" + if [ -n "$lce_old" ]; then + # THE RENAME CASE: one `mv`, no git act, so nothing is left under a name + # no reader resolves. + mv -- "$lce_old" "$lce_p" || + die "lane $lce_lane was renamed, and its checkout $lce_old could not be moved to $lce_p. Nothing was written." 2 + note "lane $lce_lane was renamed (lanes/aliases.tsv), so its checkout $lce_old is now $lce_p: one move, and no git act (Amendment 23 S1)." + else + lce_part="$lce_base/.$lce_lane.partial" + # A `.partial` LEFT BY AN EARLIER ATTEMPT IS MOVED ASIDE, never cloned + # into and never deleted. + if [ -e "$lce_part" ] || [ -L "$lce_part" ]; then + lce_aside="$lce_part.aside-$(date -u +%Y%m%dT%H%M%SZ)" + if [ -e "$lce_aside" ] || [ -L "$lce_aside" ]; then lce_aside="$lce_aside.$$"; fi + mv -- "$lce_part" "$lce_aside" || + die "a leftover $lce_part could not be moved aside, so lane $lce_lane's checkout cannot be cloned. Nothing was written." 2 + note "moved $lce_part aside to $lce_aside: an earlier creation of lane $lce_lane's checkout did not finish. Nothing in it was deleted." + fi + lce_url="$(git -C "$LANES_WS_REAL" remote get-url origin 2>/dev/null || :)" + [ -n "$lce_url" ] || + die "W ($LANES_WS_REAL) names no origin, so there is nothing to clone lane $lce_lane's checkout from (Amendment 23 S1). Nothing was written." 1 + # `--reference` NAMES W AND NOTHING ELSE, and only where W itself borrows + # from nowhere: alternates stay one level deep by construction. + lce_ref="$LANES_WS_REAL" + lce_wobj="$(lane_ws_objects 2>/dev/null || :)" + if [ -n "$lce_wobj" ] && [ -f "$lce_wobj/info/alternates" ]; then lce_ref=""; fi + note "creating lane $lce_lane's checkout $lce_p: a clone of $lce_url${lce_ref:+ that borrows the objects of W} (Amendment 23 S1)" + touch -- "$lce_lock" 2>/dev/null || : + lce_t="$GIT_TIMEOUT"; GIT_TIMEOUT="$lce_budget"; lce_rc=0 + git_net clone -q ${lce_ref:+--reference "$lce_ref"} --single-branch --branch "$LANES_BRANCH" \ + -- "$lce_url" "$lce_part" || lce_rc=$? + GIT_TIMEOUT="$lce_t" + if [ "$lce_rc" != 0 ]; then + lce_how="exit $lce_rc"; [ "$GIT_TIMED_OUT" = 1 ] && lce_how="timed out after ${lce_budget}s" + die "the clone of $lce_url for lane $lce_lane failed ($lce_how), so this write has no checkout to go through and was refused: nothing was written. A failed clone leaves only $lce_part, which the next attempt moves aside (Amendment 23 S1)." 2 + fi + # W'S REPO-LOCAL IDENTITY, key by key and only where W sets one: a lane + # checkout commits as W's own commits would have. + for lce_k in user.name user.email; do + lce_v="$(git -C "$LANES_WS_REAL" config --local --get "$lce_k" 2>/dev/null)" || continue + git -C "$lce_part" config "$lce_k" "$lce_v" 2>/dev/null || : + done + # THE TARGET IS RE-CHECKED ABSENT FIRST: `mv` into a directory nests. + if [ -e "$lce_p" ] || [ -L "$lce_p" ]; then + note "$lce_p appeared while lane $lce_lane's checkout was being cloned, so the clone was not moved into it (that would nest one inside the other): it is left at $lce_part, and the next attempt moves it aside." + else + mv -- "$lce_part" "$lce_p" || + die "the clone for lane $lce_lane is at $lce_part and could not be moved to $lce_p. Nothing was written." 2 + fi + fi + fi + mutex_release "$lce_lock" + CREATE_LOCK="" + lane_checkout_verify "$lce_p" || lane_checkout_refuse "$lce_p" + return 0 +} + +# S2 — THE LANE A WRITE IS ATTRIBUTED TO, which is the lane its commit subject +# carries. Prints it and returns 0; returns 1 for an invocation that is NOT a +# routed write; 2 for a write that names no lane. +# +# NOT A WRITE, AND SO NOT ROUTED: every read; `publish-handoff` (it writes the +# file its caller names, which is still W's until slices 2a-3 move its callers, +# and it takes the workstation's sidecar lock alone); the dry runs of +# `archive-rows`, `migrate-state-cells` and `request-handoff`, which lock and +# write nothing; and a writer missing its lane argument, whose arm refuses as +# its first act — with its own usage words and code — before reading anything. +write_lane_of() { # + wlo_v="${1-}"; shift || : + case "$wlo_v" in + set-row-state|replace-in-row|append-session-id) + [ -n "${1-}" ] || return 1 + printf '%s\n' "$1" ;; + request-handoff) + [ -n "${1-}" ] || return 1 + for wlo_a in ${1+"$@"}; do [ "$wlo_a" != --dry-run ] || return 1; done + printf '%s\n' "$1" ;; + rename-lane) + wlo_o="" + while [ $# -gt 0 ]; do + case "$1" in + --why) shift 2 || return 1 ;; + --verbatim|--no-github|--why=*|--) shift ;; + -*) return 1 ;; + *) wlo_o="$1"; break ;; + esac + done + [ -n "$wlo_o" ] || return 1 + printf '%s\n' "$wlo_o" ;; + add-row) + [ -n "${1-}" ] || return 1 + wlo_l="" + case "$1" in *'`'*'`'*) wlo_l="${1#*\`}"; wlo_l="${wlo_l%%\`*}" ;; esac + [ -n "$wlo_l" ] || return 2 + printf '%s\n' "$wlo_l" ;; + append-line) + [ -n "${1-}" ] || return 1 + if [ -n "${LANES_LANE:-}" ]; then printf '%s\n' "$LANES_LANE"; return 0; fi + # The arm's own attribution (`lane_from_text`, then the row's spelling): a + # line that names no lane with a row is a write with no lane. + wlo_c="$(lane_from_text "$1")" + [ -n "$wlo_c" ] || return 2 + wlo_cc="$(canon_lane "$wlo_c" 2>/dev/null)" || { printf '%s\n' "$wlo_c"; return 0; } + wlo_l="$(row_lane_ci "${wlo_cc:-$wlo_c}")" + [ -n "$wlo_l" ] || return 2 + printf '%s\n' "$wlo_l" ;; + commit|log|claim|release) + [ -n "${LANES_LANE:-}" ] || return 2 + printf '%s\n' "$LANES_LANE" ;; + retire-rows) + # S2: a sweep goes through the checkout of the lane that RUNS it. + wlo_w="${LANES_LANE:-}" + while [ $# -gt 0 ]; do + case "$1" in + --writer) wlo_w="${2-}"; shift 2 || break ;; + --writer=*) wlo_w="${1#--writer=}"; shift ;; + *) shift ;; + esac + done + [ -n "$wlo_w" ] || return 2 + printf '%s\n' "$wlo_w" ;; + archive-rows|migrate-state-cells) + wlo_y=0 + for wlo_a in ${1+"$@"}; do + case "$wlo_a" in --yes) wlo_y=1 ;; --dry-run) wlo_y=0 ;; esac + done + [ "$wlo_y" = 1 ] || return 1 + [ -n "${LANES_LANE:-}" ] || return 2 + printf '%s\n' "$LANES_LANE" ;; + *) return 1 ;; + esac + return 0 +} + +# A path's real spelling — its parent's, where the leaf does not exist yet. +path_real() { # + if [ -d "$1" ]; then (cd -P -- "$1" 2>/dev/null && pwd -P) && return 0; fi + pr_par="$(cd -P -- "$(dirname -- "$1")" 2>/dev/null && pwd -P)" || { printf '%s\n' "$1"; return 0; } + printf '%s/%s\n' "$pr_par" "$(basename -- "$1")" +} +path_under() { # + case "$1" in "$2"|"$2"/*) return 0 ;; esac + return 1 +} +# S2 — `LANES_DIR` AND `LANES_FILE` LOCATE FILES INSIDE THE CHECKOUT AND NEVER +# CHOOSE IT. A caller's value outside the checkout this order resolved is a +# refusal (lane windows exported both, at a per-profile clone, on 2026-10-13). +lane_checkout_inside() { # + if [ -n "$LANES_DIR_GIVEN" ] && ! path_under "$(path_real "$LANES_DIR_GIVEN")" "$1"; then + die "LANES_DIR is $LANES_DIR_GIVEN, outside the checkout this write goes through ($1). LANES_DIR and LANES_FILE locate files inside the checkout and never choose it (Amendment 23 S2). Nothing was written. Unset it, or point it inside $1." 2 + fi + if [ -n "$LANES_FILE_GIVEN" ] && ! path_under "$(path_real "$LANES_FILE_GIVEN")" "$1"; then + die "LANES_FILE is $LANES_FILE_GIVEN, outside the checkout this write goes through ($1). LANES_DIR and LANES_FILE locate files inside the checkout and never choose it (Amendment 23 S2). Nothing was written. Unset it, or point it inside $1." 2 + fi + return 0 +} + +# S2, IN ITS ORDER, for a write: (1) an explicit `LANES_REPO` keeps its meaning +# — S5 refuses one that IS W — and is honoured in place; (2) otherwise the lane's +# own checkout, made on first use, and this file runs again there; (3) no lane, +# no write: there is no fallback to W. +lane_checkout_enter() { # + lcn_st="$1"; lcn_lane="${2-}" + if [ "$LANES_REPO_GIVEN" = 1 ]; then + lcn_real="$(cd -P -- "$LANES_REPO" 2>/dev/null && pwd -P)" || lcn_real="" + lcn_s1="$LANES_WS_REAL-lanes/${lcn_lane:-}" + if [ -n "$lcn_real" ] && [ "$lcn_real" = "$LANES_WS_REAL" ]; then + lcn_via=""; [ "$LANES_REPO" = "$lcn_real" ] || lcn_via=" (through $LANES_REPO)" + die "LANES_REPO names the workspace checkout $LANES_WS_REAL$lcn_via, and that checkout is the readers' copy: no tool writes there (Amendment 23 S5). Nothing was written. Write through the lane's own checkout instead — unset LANES_REPO and name the lane, LANES_LANE=${lcn_lane:-} $SELF $cmd … — and the write goes through $lcn_s1/." 2 + fi + # S6.2 — A PER-PROFILE CLONE BESIDE W is honoured, and warned about. + case "$lcn_real" in + "${LANES_WS_REAL%/*}/${LANES_WS_REAL##*/}-"*) + if [ "${lcn_real%/*}" = "${LANES_WS_REAL%/*}" ] && [ "$lcn_real" != "$LANES_WS_REAL-lanes" ] && + lanes_ws_same_repo "$(git -C "$lcn_real" remote get-url origin 2>/dev/null || :)" \ + "$(git -C "$LANES_WS_REAL" remote get-url origin 2>/dev/null || :)"; then + note "WARNING: LANES_REPO names $lcn_real, a per-profile clone of the register beside W. It is honoured, and nothing new should write through it (Amendment 23 S6.2): this lane's own checkout is $lcn_s1/. Unset LANES_REPO, LANES_DIR and LANES_FILE in this window, or relaunch it." + fi ;; + esac + [ -z "$lcn_real" ] || lane_checkout_inside "$lcn_real" + return 0 + fi + [ "$lcn_st" != 2 ] || + die "no lane, no write (Amendment 23 S2): \`$cmd\` names no lane here, so there is no lane checkout for it to go through, and a write never falls back to the workspace checkout. Name the lane it is for — LANES_LANE= $SELF $cmd … — and it goes through that lane's checkout, carrying that lane in its subject. Nothing was written." 2 + check_lane_name "$lcn_lane" + lcn_c="$(canon_lane "$lcn_lane")" || exit $? + check_lane_name "$lcn_c" + lcn_p="$(lane_checkout_path "$lcn_c")" || die "$(lanes_workspace_why)" 1 + lane_checkout_inside "$lcn_p" + lane_checkout_ensure "$lcn_c" + if [ -n "$LANES_DIR_GIVEN" ]; then LANES_DIR="$LANES_DIR_GIVEN" + elif [ -n "$LANES_FILE_GIVEN" ]; then LANES_DIR="$(dirname -- "$LANES_FILE_GIVEN")" + else LANES_DIR="$lcn_p/lanes"; fi + LANES_FILE="${LANES_FILE_GIVEN:-$LANES_DIR/LANES.md}" + LANES_REPO="$lcn_p" + LANES__CHECKOUT_LANE="$lcn_c"; LANES__PID="$$" + export LANES_REPO LANES_DIR LANES_FILE LANES__CHECKOUT_LANE LANES__PID + cleanup_for_exec + exec "${BASH:-bash}" "$RESOLVED" ${ORIG_ARGV[@]+"${ORIG_ARGV[@]}"} + die "could not run $RESOLVED again in lane $lcn_c's checkout $lcn_p. Nothing was written." 1 +} + +# S2 — HANDOFF PATHS ARE TRANSLATED, NEVER FOLLOWED. A repository-relative cell +# (`handoffs//`) is joined to the lane's checkout; a `~/…` or +# absolute path whose physical parent is under W becomes the same path under +# the lane's checkout; anything else is another repository's (Amendment 8(d)), +# published and never committed by these tools. The row's cell keeps its +# spelling: this answers where the file is WRITTEN. +ws_relative() { # — 0 and its path relative to W; 1 and the real path + wsr_in="$1" + case "$wsr_in" in + '~') wsr_in="$HOME" ;; + '~/'*) wsr_in="$HOME/${wsr_in#'~/'}" ;; + /*) : ;; + *) printf '%s\n' "$wsr_in"; return 0 ;; + esac + wsr_n=0 + while [ -L "$wsr_in" ] && [ "$wsr_n" -lt 40 ]; do + wsr_l="$(readlink "$wsr_in")" || break + case "$wsr_l" in /*) wsr_in="$wsr_l" ;; *) wsr_in="$(dirname -- "$wsr_in")/$wsr_l" ;; esac + wsr_n=$((wsr_n + 1)) + done + wsr_par="$(cd -P -- "$(dirname -- "$wsr_in")" 2>/dev/null && pwd -P)" || { printf '%s\n' "$wsr_in"; return 1; } + wsr_real="$wsr_par/$(basename -- "$wsr_in")" + case "$wsr_real" in + "$LANES_WS_REAL"/*) printf '%s\n' "${wsr_real#"$LANES_WS_REAL"/}"; return 0 ;; + esac + printf '%s\n' "$wsr_real" + return 1 +} +handoff_in_checkout() { # — 0 its path in the lane's checkout; 3 another repository's + hic_p="$(lane_checkout_path "$1")" || return 1 + hic_rel=""; hic_rc=0 + hic_rel="$(ws_relative "$2")" || hic_rc=$? + if [ "$hic_rc" = 0 ]; then printf '%s/%s\n' "$hic_p" "$hic_rel"; return 0; fi + if path_under "$hic_rel" "$hic_p"; then printf '%s\n' "$hic_rel"; return 0; fi + printf '%s\n' "$hic_rel" + return 3 +} + +# S5 — W IS ONLY EVER FAST-FORWARDED, and `refresh-workspace` is the one act +# that does it: `git fetch origin main` and `git merge --ff-only origin/main`, +# the only two git acts any tool runs in W. Whatever would stop a fast-forward +# is REPORTED AND SKIPPED, and nothing repairs W: no reset, stash, commit or +# abort — that is a person's act, as the 2026-10-17T15:27Z repair was. +# 0 fast-forwarded, or already current · 2 skipped, nothing changed · 1 no W +refresh_workspace() { + rw_w="$LANES_WS_REAL" + [ -n "$rw_w" ] || die "$(lanes_workspace_why)" 1 + rw_why="" + # A LIVE LEGACY LOCK IN W is an older helper writing there right now. + rw_lk="$rw_w/lanes/.lanes-edit.lock" + if [ -e "$rw_lk" ] || [ -L "$rw_lk" ]; then + if [ -d "$rw_lk" ] && [ ! -L "$rw_lk" ] && mutex_stale "$rw_lk"; then + note "W carries a stale lock, $rw_lk: $MX_WHY. It is an older helper's leftover, and it is left as it is." + else + rw_why="${rw_why} + a lock held: $rw_lk (holder: $(cat -- "$rw_lk/pid" 2>/dev/null || printf 'unrecorded'))" + fi + fi + rw_head="$(git -C "$rw_w" symbolic-ref -q --short HEAD 2>/dev/null || :)" + if [ "$rw_head" != "$LANES_BRANCH" ]; then + if [ -z "$rw_head" ]; then + rw_head="$(git -C "$rw_w" rev-parse --short HEAD 2>/dev/null || :)" + rw_head="detached at ${rw_head:-an unreadable commit}" + fi + rw_why="${rw_why} + not on $LANES_BRANCH: HEAD is $rw_head" + fi + rw_gd="$(git -C "$rw_w" rev-parse --absolute-git-dir 2>/dev/null || :)" + for rw_op in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD; do + if [ -n "$rw_gd" ] && [ -e "$rw_gd/$rw_op" ]; then + rw_why="${rw_why} + an operation in progress: $rw_gd/$rw_op" + fi + done + if [ -n "$(git -C "$rw_w" ls-files -u 2>/dev/null)" ]; then + rw_why="${rw_why} + unmerged entries (git -C $rw_w status)" + fi + rw_staged="$(git -C "$rw_w" diff --cached --name-only 2>/dev/null | tr '\n' ' ')" + if [ -n "$rw_staged" ]; then + rw_why="${rw_why} + staged entries: $rw_staged" + fi + if [ "${LANES_NO_FETCH:-0}" = 1 ]; then + note "LANES_NO_FETCH=1 — not fetching; W is brought to origin/$LANES_BRANCH as the ref already stands here" + elif ! git_net -C "$rw_w" fetch -q origin "$LANES_BRANCH"; then + rw_why="${rw_why} + the fetch of origin $LANES_BRANCH failed$([ "$GIT_TIMED_OUT" = 1 ] && printf ' (timed out after %ss)' "$GIT_TIMEOUT")" + fi + rw_ahead=""; rw_behind="" + rw_lr="$(git -C "$rw_w" rev-list --left-right --count "HEAD...origin/$LANES_BRANCH" 2>/dev/null || :)" + rw_ahead="${rw_lr%%[!0-9]*}"; rw_behind="${rw_lr##*[!0-9]}" + if [ -z "$rw_lr" ]; then + rw_why="${rw_why} + origin/$LANES_BRANCH cannot be compared with HEAD here" + elif [ "${rw_ahead:-0}" -gt 0 ] && [ "${rw_behind:-0}" -gt 0 ]; then + rw_why="${rw_why} + diverged from origin/$LANES_BRANCH: $rw_ahead commit(s) here origin lacks, and $rw_behind there" + elif [ "${rw_ahead:-0}" -gt 0 ]; then + rw_why="${rw_why} + ahead of origin/$LANES_BRANCH by $rw_ahead commit(s) — git -C $rw_w cherry -v origin/$LANES_BRANCH HEAD" + fi + if [ -n "$rw_why" ]; then + note "refresh-workspace SKIPPED: W ($rw_w) was not fast-forwarded, and nothing in it was changed. What blocks it:$rw_why" + note "Repairing W is a person's act (Amendment 23 S5, S6.5); until then readers read what W has." + refresh_workspace_strays "$rw_w" + exit 2 + fi + if [ "${rw_behind:-0}" = 0 ]; then + note "W ($rw_w) is current with origin/$LANES_BRANCH." + refresh_workspace_strays "$rw_w" + exit 0 + fi + rw_from="$(git -C "$rw_w" rev-parse --short HEAD 2>/dev/null || :)" + if ! git -C "$rw_w" merge --ff-only -q "origin/$LANES_BRANCH"; then + note "refresh-workspace SKIPPED: git refused to fast-forward W ($rw_w) — its own words are above — and nothing in it was changed. A file in the way is a person's, or a lane's, to carry to its own checkout." + refresh_workspace_strays "$rw_w" + exit 2 + fi + note "fast-forwarded W ($rw_w) from $rw_from to $(git -C "$rw_w" rev-parse --short HEAD 2>/dev/null || :), $rw_behind commit(s)." + refresh_workspace_strays "$rw_w" + exit 0 +} +# S5, "PEOPLE TOO" — a file written into W through an estate link lands there +# uncommitted, where nothing will commit it. Each one under `handoffs/` or +# `lanes/` is named by path, and where a row's handoff cell names it, by the +# lane whose handoff it is and the place in that lane's checkout it belongs. +refresh_workspace_strays() { # + rws_list="$(git -C "$1" status --porcelain --untracked-files=all -- handoffs lanes 2>/dev/null || :)" + [ -n "$rws_list" ] || return 0 + rws_map="$(awk -F'|' 'substr($0,1,1) == "|" { + p1 = index($0, "`"); if (p1 == 0) next + rest = substr($0, p1 + 1); p2 = index(rest, "`"); if (p2 == 0) next + cell = $7; gsub(/^[ \t]+|[ \t]+$/, "", cell) + if (cell != "") print substr(rest, 1, p2 - 1) "\t" cell + }' "$1/lanes/LANES.md" 2>/dev/null || :)" + rws_rel_map="" + while IFS=' ' read -r rws_lane rws_cell; do + [ -n "${rws_lane:-}" ] || continue + rws_rel="$(ws_relative "$rws_cell")" || continue + rws_rel_map="$rws_rel_map$rws_rel $rws_lane +" + done <" 2 @@ -16729,7 +17474,7 @@ EOF [ "${#pbh_digest}" = 64 ] || die "publication requires the complete prior SHA-256" 64 case "$pbh_digest" in *[!0-9a-fA-F]*) die "publication requires SHA-256" 64 ;; esac [ -f "$pbh_source" ] && [ -r "$pbh_source" ] && [ -s "$pbh_source" ] || die "the prepared handoff is unreadable or empty" 1 - acquire_lock + sidecar_lock_use; acquire_lock # the workstation's sidecar lock, and no other (Amendment 23 S2) managed_seam_refuse "$lane" "publish-handoff" pbh_links=0 while [ -L "$pbh_target" ]; do @@ -16906,7 +17651,7 @@ EOF lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 # Select the physical intent under the CAS mutex: a waiting writer must # see a reservation another writer established before releasing the lock. - acquire_lock + sidecar_lock_use; acquire_lock # the workstation's sidecar lock, and no other (Amendment 23 S2) managed_seam_refuse "$lane" "set-restart-intent" sri_root=""; sri_rrc=0 sri_root="$(lane_restart_control_root "$lane")" || sri_rrc=$? @@ -17194,7 +17939,7 @@ EOF die "lane $lane has no lifecycle control root, so there is nowhere to record that it is $sls_state: its record names no directory (Amendment 11(c)) and \$PROJECTS_ROOT is not a directory here. Set \$LANES_LANE_STATE_ROOT, or start the lane through lane-start so that its record carries a dir." 1 # THE FENCE, read under the same mutex the write takes, so that two # transitions cannot both read the state before either writes it. - acquire_lock + sidecar_lock_use; acquire_lock # the workstation's sidecar lock, and no other (Amendment 23 S2) # AND THE SCHEMA IS ASKED ABOUT BEFORE ANY TRANSITION FIELD IS READ OR # WRITTEN (Copilot round 5 on #97). `lane_state_read` fails closed for a # version it does not know; this writer used to walk past that check into @@ -17371,7 +18116,7 @@ EOF # is the whole workstation's (design decision 14, and the same argument that # keeps `lane-reconcile` out of it): what must be serialized is the compare # and the write, not the reading of a repository. - acquire_lock + sidecar_lock_use; acquire_lock # the workstation's sidecar lock, and no other (Amendment 23 S2) if [ -n "$slt_g" ] || [ -n "$slt_op" ]; then if ! lane_sidecar_schema_ok "$slt_root/lane-state.yaml"; then release_lock @@ -17546,7 +18291,52 @@ EOF fi ;; + # AMENDMENT 23 S1 — THE LANE'S OWN CHECKOUT, read, or made on first use. It + # prints `-lanes/`, from the lane name alone (canonical, through the + # alias table). Read-side: it writes nothing to the register, and `--create` + # is the clone every write makes on first use, made ahead of one. + # 0 the path · 8 none there yet (no --create) · 1 something there that is + # not a clone of the register · 2 a name refused, or the clone failed · + # 4 the creation lock · 64 usage + lane-checkout) + lck_lane=""; lck_create=0 + while [ $# -gt 0 ]; do + case "$1" in + --create) lck_create=1; shift ;; + --) shift ;; + -*) die "unknown option '$1' for lane-checkout (usage: lane-checkout [--create])" 64 ;; + *) [ -z "$lck_lane" ] || die "lane-checkout takes ONE lane (usage: lane-checkout [--create])" 64 + lck_lane="$1"; shift ;; + esac + done + [ -n "$lck_lane" ] || die "usage: lane-checkout [--create]" 64 + check_lane_name "$lck_lane" + lck_lane="$(canon_lane "$lck_lane")" || exit $? + check_lane_name "$lck_lane" + if [ "$lck_create" = 1 ]; then + lane_checkout_ensure "$lck_lane" + printf '%s\n' "$LCE_PATH" + exit 0 + fi + lck_p="$(lane_checkout_path "$lck_lane")" || die "$(lanes_workspace_why)" 1 + [ -e "$lck_p" ] || [ -L "$lck_p" ] || exit 8 + if [ -d "$lck_p" ] && [ ! -L "$lck_p" ] && [ -z "$(ls -A -- "$lck_p" 2>/dev/null)" ]; then exit 8; fi + lane_checkout_verify "$lck_p" || lane_checkout_refuse "$lck_p" + printf '%s\n' "$lck_p" + ;; + + # AMENDMENT 23 S5 (2) — THE READ-SIDE FAST-FORWARD OF W, on demand: for a + # person, for a reader of a `handoffs` link, for a listing that wants W + # current — and, from the next slice, for `lane-start`. It never resets, + # stashes, commits or aborts; see `refresh_workspace`. + # 0 fast-forwarded or already current · 2 skipped, nothing changed · + # 1 W not found · 64 usage + refresh-workspace) + [ "$#" = 0 ] || die "refresh-workspace takes no arguments" 64 + refresh_workspace + ;; + *) - die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|retire-rows|archive-rows|migrate-state-cells|commit|rename-lane|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|binding|request-handoff|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|duplicate-holder|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|register-row-local|retired-identity|canon-lane|resolve-home|managed-projection|lane-state|set-lane-state|lane-trees|set-lane-tree|lane-tree-now|project-legs|lane-reconcile|lane-holders|legacy-restart-check|publish-handoff|restart-intent|set-restart-intent|worktrees|pathspec-check)" 2 + die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|retire-rows|archive-rows|migrate-state-cells|commit|rename-lane|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|binding|request-handoff|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|duplicate-holder|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|register-row-local|retired-identity|canon-lane|resolve-home|managed-projection|lane-state|set-lane-state|lane-trees|set-lane-tree|lane-tree-now|project-legs|lane-reconcile|lane-holders|legacy-restart-check|publish-handoff|restart-intent|set-restart-intent|worktrees|pathspec-check|lane-checkout|refresh-workspace)" 2 ;; esac diff --git a/tests/test_a23_lane_checkout.py b/tests/test_a23_lane_checkout.py new file mode 100644 index 00000000..a831fbc7 --- /dev/null +++ b/tests/test_a23_lane_checkout.py @@ -0,0 +1,466 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Amendment 23 S1: one register checkout per lane, made on first use. + +Every write resolves by default here — no `LANES_REPO` — so what runs is the +path a lane takes on a workstation: `workspace.yaml` names W, and the write goes +through `-lanes//`, a clone of W's origin that borrows W's objects, +made under a creation lock the first time the lane writes. Anything at that path +that is not a clone of the register is refused with exit 1, and nothing at it is +deleted, moved or repaired (S1). + +Each case is the real `lanes-edit.sh` in a sandbox: a bare origin (with a second +branch, so `--single-branch` shows), W cloned from it, and `workspace.yaml` +joining the two. Nothing reaches a real remote, `gh` or `tmux`. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from conftest import REPO, WINDOWS_SKIP + +pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, + reason="lanes-edit.sh is a bash script"), + pytest.mark.skipif(shutil.which("git") is None, + reason="the register is a git repository"), + WINDOWS_SKIP] + +LANE = "repoCK-1" +REGISTER = "lanes/LANES.md" +UUID = "a23c0001-0c0c-4000-8000-a23c00010c0c" + +SCRUBBED_PREFIXES = ("LANES_", "LANE_START_", "CLAUDE_", "OPENREPOTOOLS_", + "FAKE_", "GIT_", "TMUX") +SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", + "XDG_CACHE_HOME"} + +REFUSE = """#!/usr/bin/env bash +printf 'FAKE %s REFUSED: %s -- this suite reaches no real surface\\n' "${0##*/}" "$*" >&2 +exit 90 +""" + + +def _row(lane: str, state: str = "LIVE · seeded") -> str: + return (f"| `{lane}` | harness `{UUID}` | Eagle / test / tester | 2026-10-17T00:00Z " + f"| — | handoffs/repoCK/{lane}.md | {state} |") + + +class Box: + """A bare origin, W cloned from it, and the workspace.yaml naming W.""" + + def __init__(self, root: Path, *, rows=("repoCK-1", "repoCK-2", "repoCK-3", "repoCK-5"), + aliases: str = "", w_reference: Path | None = None) -> None: + # REAL PATHS, because the helper prints W and its lane checkouts as + # `pwd -P` spells them (a temp root under a symlink would not match). + root = Path(os.path.realpath(root)) + root.mkdir(parents=True, exist_ok=True) + self.root = root + self.home = root / "home" + self.origin = root / "origin.git" + self.w = self.home / "projects" / "brett-wip" + self.lanes_root = self.home / "projects" / "brett-wip-lanes" + self.fakebin = root / "fakebin" + for d in (self.home / ".agents", self.home / "projects", root / "tmp", self.fakebin): + d.mkdir(parents=True, exist_ok=True) + for name in ("gh", "tmux"): + (self.fakebin / name).write_text(REFUSE, encoding="utf-8") + (self.fakebin / name).chmod(0o755) + env = {k: v for k, v in os.environ.items() + if k not in SCRUBBED and not k.startswith(SCRUBBED_PREFIXES)} + env.update({ + "HOME": str(self.home), + "TMPDIR": str(root / "tmp"), + "XDG_CONFIG_HOME": str(self.home / ".config"), + "XDG_CACHE_HOME": str(self.home / ".cache"), + "PATH": f"{self.fakebin}{os.pathsep}{env.get('PATH', '')}", + "AGENT_PROTOCOL_ROOT": str(self.home / ".agents"), + "LANES_WORKSTATION": "Eagle", + "LANES_HOST": "hosta", + "LANES_CONTAINER": "bench-a", + "LANES_NO_GITHUB": "1", + "LANES_INDEX": "off", + "LANE_WORKTREES_REPORT": "off", + "GIT_AUTHOR_NAME": "a23 tests", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "a23 tests", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + }) + self.env = env + self.git("init", "-q", "--bare", "-b", "main", str(self.origin)) + self.quiet(self.origin, bare=True) + seed = root / "seed" + self.git("clone", "-q", str(self.origin), str(seed)) + self.quiet(seed) + register = ("# LANES.md -- the sandbox register\n\n" + "| lane | session id | workstation / env / user | started (UTC) " + "| objects owned | handoff path | state |\n" + "|---|---|---|---|---|---|---|\n" + + "".join(f"{_row(lane)}\n" for lane in rows)) + self.write(seed, REGISTER, register) + self.write(seed, "handoffs/README.md", "# handoffs\n") + paths = [REGISTER, "handoffs/README.md"] + if aliases: + self.write(seed, "lanes/aliases.tsv", aliases) + paths.append("lanes/aliases.tsv") + self.git("-C", str(seed), "add", "--", *paths) + self.git("-C", str(seed), "commit", "-q", "-m", "seed") + self.git("-C", str(seed), "push", "-q", "origin", "main") + # A SECOND BRANCH ON ORIGIN, so a clone that took every branch shows. + self.git("-C", str(seed), "push", "-q", "origin", "main:refs/heads/side") + self.seed = seed + clone = ["clone", "-q"] + if w_reference is not None: + clone += ["--reference", str(w_reference)] + self.git(*clone, str(self.origin), str(self.w)) + self.quiet(self.w) + (self.home / ".agents" / "workspace.yaml").write_text( + f"repository: {self.origin}\npath: {self.w}\n", encoding="utf-8") + + def quiet(self, repo: Path, *, bare: bool = False) -> None: + """gc.auto=0 and maintenance.auto=false: W is what lane checkouts borrow.""" + where = ["--git-dir", str(repo)] if bare else ["-C", str(repo)] + self.git(*where, "config", "gc.auto", "0") + self.git(*where, "config", "maintenance.auto", "false") + + def git(self, *args: str, check: bool = True) -> str: + return subprocess.run(["git", *args], env=self.env, check=check, + capture_output=True, text=True).stdout + + @staticmethod + def write(checkout: Path, rel: str, text: str) -> None: + path = checkout / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def helper(self, *args: str, **extra: str) -> subprocess.CompletedProcess: + return subprocess.run(["bash", str(REPO / "lanes-edit.sh"), *args], + env=dict(self.env, **extra), cwd=str(self.root), + capture_output=True, text=True, timeout=180, check=False) + + def popen(self, *args: str, **extra: str) -> subprocess.Popen: + return subprocess.Popen(["bash", str(REPO / "lanes-edit.sh"), *args], + env=dict(self.env, **extra), cwd=str(self.root), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + def stamp(self, lane: str, words: str, **extra: str) -> subprocess.CompletedProcess: + return self.helper("set-row-state", lane, f"LIVE · {words}", **extra) + + def checkout(self, lane: str) -> Path: + return self.lanes_root / lane + + def origin_register(self) -> str: + return self.git("--git-dir", str(self.origin), "show", f"main:{REGISTER}") + + def origin_head(self) -> str: + return self.git("--git-dir", str(self.origin), "rev-parse", "main").strip() + + def w_state(self) -> tuple[str, str, str]: + return (self.git("-C", str(self.w), "rev-parse", "HEAD").strip(), + self.git("-C", str(self.w), "rev-parse", "HEAD^{tree}").strip(), + self.git("-C", str(self.w), "status", "--porcelain")) + + def clone_by_hand(self, dest: Path, *, origin: Path | None = None, + reference: Path | None = None) -> None: + args = ["clone", "-q"] + if reference is not None: + args += ["--reference", str(reference)] + self.git(*args, str(origin or self.origin), str(dest)) + self.quiet(dest) + + +@pytest.fixture +def box(tmp_path: Path) -> Box: + return Box(tmp_path) + + +def real(path: Path) -> str: + return os.path.realpath(str(path)) + + +def assert_refused_exit_1(box: Box, result: subprocess.CompletedProcess, path: Path) -> None: + assert result.returncode == 1, (result.stdout, result.stderr) + assert str(path) in result.stderr, result.stderr + assert "Nothing was written" in result.stderr, result.stderr + assert "mv " in result.stderr and ".aside-" in result.stderr, result.stderr + + +# --------------------------------------------------------------- creation + +def test_first_write_creates_the_lane_checkout_by_reference(box: Box): + before = box.w_state() + result = box.stamp(LANE, "first write") + assert result.returncode == 0, (result.stdout, result.stderr) + c = box.checkout(LANE) + assert (c / ".git").is_dir(), "the lane's checkout is a clone of its own" + alternates = (c / ".git" / "objects" / "info" / "alternates").read_text(encoding="utf-8").split() + assert [real(Path(a)) for a in alternates] == [real(box.w / ".git" / "objects")], alternates + remote_branches = [b for b in box.git("-C", str(c), "for-each-ref", "--format=%(refname)", + "refs/remotes").split() if not b.endswith("/HEAD")] + assert remote_branches == ["refs/remotes/origin/main"], \ + f"--single-branch: origin's other branch is not borrowed: {remote_branches}" + assert box.git("-C", str(c), "config", "--get-all", "remote.origin.fetch").split() == \ + ["+refs/heads/main:refs/remotes/origin/main"] + assert " · first write |" in box.origin_register() + assert box.git("-C", str(c), "rev-parse", "HEAD").strip() == box.origin_head() + # W IS THE READERS' COPY: its HEAD, its tree and its working tree are as + # they were, though the write landed on origin. + assert box.w_state() == before + + +def test_two_first_writers_race_on_creation(box: Box): + one = box.popen("set-row-state", LANE, "LIVE · writer one") + two = box.popen("set-row-state", LANE, "LIVE · writer two") + out_one = one.communicate(timeout=240) + out_two = two.communicate(timeout=240) + assert one.returncode == 0, out_one + assert two.returncode == 0, out_two + subjects = box.git("--git-dir", str(box.origin), "log", "--format=%s", "main") + assert "writer one" in subjects and "writer two" in subjects, subjects + entries = sorted(p.name for p in box.lanes_root.iterdir()) + assert entries == [LANE], f"one checkout, no partial and no lock left: {entries}" + c = box.checkout(LANE) + assert sorted(p.name for p in c.iterdir() if p.name.startswith(".")) == [".git"], \ + "no clone nested inside the checkout" + assert sum("creating lane" in o[1] for o in (out_one, out_two)) == 1, \ + "exactly one of the two writers cloned" + + +def test_leftover_partial_is_moved_aside(box: Box): + partial = box.lanes_root / f".{LANE}.partial" + partial.mkdir(parents=True) + (partial / "half-cloned.txt").write_text("an earlier attempt\n", encoding="utf-8") + result = box.stamp(LANE, "after a leftover") + assert result.returncode == 0, (result.stdout, result.stderr) + asides = [p for p in box.lanes_root.iterdir() if p.name.startswith(f".{LANE}.partial.aside-")] + assert len(asides) == 1, list(box.lanes_root.iterdir()) + assert (asides[0] / "half-cloned.txt").read_text(encoding="utf-8") == "an earlier attempt\n", \ + "the leftover is moved aside whole, never deleted or cloned into" + assert not partial.exists() + assert (box.checkout(LANE) / ".git").is_dir() + assert "moved" in result.stderr and "aside" in result.stderr + + +def test_target_appearing_before_mv_is_not_nested(box: Box): + target = box.checkout(LANE) + shim = box.fakebin / "appearing-git" + shim.write_text(f"""#!/usr/bin/env bash +if [ "${{1-}}" = clone ]; then + git "$@" || exit $? + mkdir -p '{target}' && printf 'made by someone else\\n' > '{target}/theirs.txt' + exit 0 +fi +exec git "$@" +""", encoding="utf-8") + shim.chmod(0o755) + head = box.origin_head() + result = box.stamp(LANE, "never written", LANES_GIT=str(shim)) + assert result.returncode == 1, (result.stdout, result.stderr) + assert sorted(p.name for p in target.iterdir()) == ["theirs.txt"], \ + "the clone was not moved INTO the directory that appeared" + assert (box.lanes_root / f".{LANE}.partial" / ".git").is_dir(), "the clone is left at .partial" + assert box.origin_head() == head, "nothing was written" + + +def _not_a_clone(box: Box, kind: str, path: Path) -> None: + if kind == "file": + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("not a checkout\n", encoding="utf-8") + elif kind == "plain directory": + path.mkdir(parents=True) + (path / "notes.txt").write_text("a directory, not a checkout\n", encoding="utf-8") + elif kind == "another repository": + path.mkdir(parents=True) + box.git("init", "-q", "-b", "main", str(path)) + box.git("-C", str(path), "remote", "add", "origin", "https://github.com/opensoft/not-the-register.git") + elif kind == "linked worktree": + # A checkout whose `.git` is a FILE pointing at its git directory — + # the shape a linked worktree has — with the register's own origin, so + # the `.git`-is-a-directory test is the one that refuses it. + path.parent.mkdir(parents=True, exist_ok=True) + box.git("init", "-q", "-b", "main", f"--separate-git-dir={box.root / 'separate.git'}", str(path)) + box.git("-C", str(path), "remote", "add", "origin", str(box.origin)) + elif kind == "clone of another origin": + other = box.root / "other.git" + box.git("init", "-q", "--bare", "-b", "main", str(other)) + path.parent.mkdir(parents=True, exist_ok=True) + box.git("clone", "-q", str(other), str(path)) + else: # pragma: no cover + raise AssertionError(kind) + + +def _listing(path: Path) -> list[str]: + if path.is_file(): + return ["", path.read_text(encoding="utf-8")] + return sorted(str(p.relative_to(path)) for p in path.rglob("*")) + + +#: What each refusal says is there — the one test, of S1's several, that refused it. +WHAT_IS_THERE = { + "file": "it is a file, not a directory", + "plain directory": "it is a directory that is not a git checkout", + "another repository": "it is a checkout of https://github.com/opensoft/not-the-register.git, where", + "linked worktree": "it is a checkout whose .git is a file", + "clone of another origin": "it is a checkout of {other}, where", +} + + +@pytest.mark.parametrize("kind", ["file", "plain directory", "another repository", + "linked worktree", "clone of another origin"]) +def test_non_clone_at_the_path_is_refused_exit_1(box: Box, kind: str): + path = box.checkout(LANE) + _not_a_clone(box, kind, path) + before = _listing(path) + head = box.origin_head() + result = box.stamp(LANE, "never written") + assert_refused_exit_1(box, result, path) + assert "not a clone of the register" in result.stderr, result.stderr + assert WHAT_IS_THERE[kind].format(other=box.root / "other.git") in result.stderr, result.stderr + assert _listing(path) == before, "nothing at the path was deleted, moved or repaired" + assert box.origin_head() == head + assert not (box.lanes_root / f".{LANE}.partial").exists(), "nothing was cloned beside it" + + +def test_empty_directory_counts_as_absent(box: Box): + box.checkout(LANE).mkdir(parents=True) + result = box.stamp(LANE, "into an empty directory") + assert result.returncode == 0, (result.stdout, result.stderr) + assert (box.checkout(LANE) / ".git").is_dir() + assert " · into an empty directory |" in box.origin_register() + + +def test_alternates_not_naming_W_is_refused(box: Box): + other = box.root / "other-store" + box.clone_by_hand(other) + c = box.checkout(LANE) + box.clone_by_hand(c, reference=other) + head = box.origin_head() + result = box.stamp(LANE, "never written") + assert_refused_exit_1(box, result, c) + assert "borrows objects from" in result.stderr, result.stderr + assert "cherry -v" in result.stderr, "the fix names the look-first read" + assert box.origin_head() == head + + +def test_alternates_naming_a_missing_store_is_refused(box: Box): + c = box.checkout(LANE) + box.clone_by_hand(c, reference=box.w) + gone = box.root / "gone" / "objects" + (c / ".git" / "objects" / "info" / "alternates").write_text(f"{gone}\n", encoding="utf-8") + head = box.origin_head() + result = box.stamp(LANE, "never written") + assert_refused_exit_1(box, result, c) + assert "borrowed objects are gone" in result.stderr, result.stderr + assert "cherry -v" in result.stderr + assert box.origin_head() == head + + +def test_W_with_alternates_clones_without_reference(tmp_path: Path): + store = tmp_path / "store.git" + subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(store)], check=True) + box = Box(tmp_path / "b", w_reference=store) + assert (box.w / ".git" / "objects" / "info" / "alternates").is_file() + result = box.stamp(LANE, "one level only") + assert result.returncode == 0, (result.stdout, result.stderr) + c = box.checkout(LANE) + assert not (c / ".git" / "objects" / "info" / "alternates").exists(), \ + "W borrows already, so the lane checkout borrows from nobody: one level" + assert " · one level only |" in box.origin_register() + + +def test_the_clone_outlasts_a_short_network_bound(box: Box): + """A COLD CLONE IS NOT A PUSH. Every other network call is bounded by + `LANES_GIT_TIMEOUT`; the clone gets ten minutes, or that bound where it is + longer, so a one-second bound does not kill a clone that takes three.""" + shim = box.fakebin / "slow-clone-git" + shim.write_text("""#!/usr/bin/env bash +if [ "${1-}" = clone ]; then sleep 3; fi +exec git "$@" +""", encoding="utf-8") + shim.chmod(0o755) + made = box.helper("lane-checkout", LANE, "--create", LANES_GIT=str(shim), LANES_GIT_TIMEOUT="1") + assert made.returncode == 0, (made.stdout, made.stderr) + assert made.stdout.strip() == str(box.checkout(LANE)) + assert (box.checkout(LANE) / ".git").is_dir() + + +# --------------------------------------------------------------- names + +ALIAS = "repoCK-7\trepoCK-8\t2026-10-17T00:00:00Z\n" + + +def test_renamed_lane_directory_is_moved_once(tmp_path: Path): + box = Box(tmp_path, rows=("repoCK-8",), aliases=ALIAS) + old = box.checkout("repoCK-7") + box.clone_by_hand(old, reference=box.w) + (old / ".git" / "made-under-the-old-name").write_text("x\n", encoding="utf-8") + first = box.stamp("repoCK-8", "after the rename") + assert first.returncode == 0, (first.stdout, first.stderr) + new = box.checkout("repoCK-8") + assert (new / ".git" / "made-under-the-old-name").is_file(), "the directory itself was moved" + assert not old.exists() + assert "is now" in first.stderr + second = box.stamp("repoCK-8", "and again") + assert second.returncode == 0, (second.stdout, second.stderr) + assert "is now" not in second.stderr, "moved once, and never again" + assert " · and again |" in box.origin_register() + + +def test_both_names_present_writes_new_reports_old(tmp_path: Path): + box = Box(tmp_path, rows=("repoCK-8",), aliases=ALIAS) + assert box.stamp("repoCK-8", "made the new one").returncode == 0 + old = box.checkout("repoCK-7") + box.clone_by_hand(old, reference=box.w) + old_head = box.git("-C", str(old), "rev-parse", "HEAD").strip() + result = box.stamp("repoCK-8", "through the new one") + assert result.returncode == 0, (result.stdout, result.stderr) + assert str(old) in result.stderr and "former name" in result.stderr, result.stderr + assert box.git("-C", str(old), "rev-parse", "HEAD").strip() == old_head, "the old one is left as it is" + assert "through the new one" in box.git("-C", str(box.checkout("repoCK-8")), "log", "-1", "--format=%s") + + +def test_case_variant_writes_the_row_spelling_checkout(box: Box): + result = box.stamp("REPOck-3", "typed in another case") + assert result.returncode == 0, (result.stdout, result.stderr) + assert [p.name for p in box.lanes_root.iterdir()] == ["repoCK-3"], \ + "the checkout is named for the row's own spelling (Amendment 15)" + assert " · typed in another case |" in box.origin_register() + + +@pytest.mark.parametrize("name", ["repoCK/1", ".repoCK-1", "repoCK 1"]) +def test_name_the_log_cannot_carry_is_refused(box: Box, name: str): + head = box.origin_head() + result = box.stamp(name, "never written") + assert result.returncode == 2, (result.stdout, result.stderr) + assert "is not a lane name" in result.stderr, result.stderr + assert not box.lanes_root.exists(), "nothing was created for a name no log file can carry" + assert box.origin_head() == head + + +def test_W_local_identity_is_copied(box: Box): + box.git("-C", str(box.w), "config", "user.name", "The Workspace Person") + result = box.stamp(LANE, "under W's own name") + assert result.returncode == 0, (result.stdout, result.stderr) + c = box.checkout(LANE) + assert box.git("-C", str(c), "config", "--local", "--get", "user.name").strip() == "The Workspace Person" + unset = subprocess.run(["git", "-C", str(c), "config", "--local", "--get", "user.email"], + env=box.env, capture_output=True, text=True, check=False) + assert unset.returncode == 1, "a key W does not set locally is not copied" + + +# --------------------------------------------------------------- the read verb + +def test_lane_checkout_verb_reads_and_creates(box: Box): + absent = box.helper("lane-checkout", LANE) + assert absent.returncode == 8 and absent.stdout == "", (absent.stdout, absent.stderr) + made = box.helper("lane-checkout", LANE, "--create") + assert made.returncode == 0, (made.stdout, made.stderr) + assert made.stdout.strip() == str(box.checkout(LANE)) + again = box.helper("lane-checkout", "REPOCK-1") + assert again.returncode == 0 and again.stdout.strip() == str(box.checkout(LANE)) + usage = box.helper("lane-checkout") + assert usage.returncode == 64 diff --git a/tests/test_a23_lock.py b/tests/test_a23_lock.py new file mode 100644 index 00000000..0591b537 --- /dev/null +++ b/tests/test_a23_lock.py @@ -0,0 +1,483 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Amendment 23 S2's locks: the lane checkout's mutex and the workstation's. + +* A live holder is never aged out: the mutex's ten-minute age test yields to + its pid test, so a write that is still running keeps its lock (S2). +* A pid does not cross a pid namespace (Amendment 18), so the pid file records + ` `, and a holder in another container is judged by + the lock's age alone — an age its holder refreshes before every network call. +* A lock this very process took before an `exec` is still its own (S4's replay + re-runs the verb with the lock held); an inherited marker is not. +* The lifecycle, restart and tree sidecars, and `publish-handoff`, share ONE + lock beside the lane checkouts, `-lanes/.lane-state.lock` (ort186 Q3), and + no lock is ever taken in W. + +The sandbox is the real `lanes-edit.sh`, with `LANES_CONTAINER` and +`LANES_HOST` pinned so "this container" means the same thing on every runner. +""" + +from __future__ import annotations + +import hashlib +import os +import re +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + +from conftest import REPO, WINDOWS_SKIP + +pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, + reason="lanes-edit.sh is a bash script"), + pytest.mark.skipif(shutil.which("git") is None, + reason="the register is a git repository"), + WINDOWS_SKIP] + +LANE = "repoLK-1" +REGISTER = "lanes/LANES.md" +UUID = "a23b0001-1c1c-4000-8000-a23b00011c1c" +ME = "bench-a hosta" # LANES_CONTAINER and LANES_HOST, below +#: How long a waiter is watched to prove it is still waiting. The helper polls +#: once a second, so a writer that was going to take the lock has long done so. +WATCH = 4 + +SCRUBBED_PREFIXES = ("LANES_", "LANE_START_", "CLAUDE_", "OPENREPOTOOLS_", + "FAKE_", "GIT_", "TMUX") +SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", + "XDG_CACHE_HOME"} + +REFUSE = """#!/usr/bin/env bash +printf 'FAKE %s REFUSED: %s -- this suite reaches no real surface\\n' "${0##*/}" "$*" >&2 +exit 90 +""" + + +def _row(lane: str, state: str = "LIVE · seeded") -> str: + return (f"| `{lane}` | harness `{UUID}` | Eagle / test / tester | 2026-10-17T00:00Z " + f"| — | handoffs/repoLK/{lane}.md | {state} |") + + +class Box: + """A bare origin, W cloned from it, and the workspace.yaml naming W.""" + + def __init__(self, root: Path) -> None: + root = Path(os.path.realpath(root)) + root.mkdir(parents=True, exist_ok=True) + self.root = root + self.home = root / "home" + self.origin = root / "origin.git" + self.w = self.home / "projects" / "brett-wip" + self.lanes_root = self.home / "projects" / "brett-wip-lanes" + self.state = root / "state" + self.fakebin = root / "fakebin" + for d in (self.home / ".agents", self.home / "projects", root / "tmp", self.fakebin): + d.mkdir(parents=True, exist_ok=True) + for name in ("gh", "tmux"): + (self.fakebin / name).write_text(REFUSE, encoding="utf-8") + (self.fakebin / name).chmod(0o755) + env = {k: v for k, v in os.environ.items() + if k not in SCRUBBED and not k.startswith(SCRUBBED_PREFIXES)} + env.update({ + "HOME": str(self.home), + "TMPDIR": str(root / "tmp"), + "XDG_CONFIG_HOME": str(self.home / ".config"), + "XDG_CACHE_HOME": str(self.home / ".cache"), + "PATH": f"{self.fakebin}{os.pathsep}{env.get('PATH', '')}", + "AGENT_PROTOCOL_ROOT": str(self.home / ".agents"), + "LANES_WORKSTATION": "Eagle", + "LANES_HOST": "hosta", + "LANES_CONTAINER": "bench-a", + "LANES_NO_GITHUB": "1", + "LANES_INDEX": "off", + "LANE_WORKTREES_REPORT": "off", + "LANES_LANE_STATE_ROOT": str(self.state), + "GIT_AUTHOR_NAME": "a23 tests", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "a23 tests", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + }) + self.env = env + self.git("init", "-q", "--bare", "-b", "main", str(self.origin)) + self.quiet(self.origin, bare=True) + seed = root / "seed" + self.git("clone", "-q", str(self.origin), str(seed)) + self.quiet(seed) + self.write(seed, REGISTER, + "# LANES.md -- the sandbox register\n\n" + "| lane | session id | workstation / env / user | started (UTC) " + "| objects owned | handoff path | state |\n" + "|---|---|---|---|---|---|---|\n" + f"{_row(LANE)}\n{_row('repoLK-2')}\n") + self.git("-C", str(seed), "add", "--", REGISTER) + self.git("-C", str(seed), "commit", "-q", "-m", "seed") + self.git("-C", str(seed), "push", "-q", "origin", "main") + self.git("clone", "-q", str(self.origin), str(self.w)) + self.quiet(self.w) + (self.home / ".agents" / "workspace.yaml").write_text( + f"repository: {self.origin}\npath: {self.w}\n", encoding="utf-8") + + def quiet(self, repo: Path, *, bare: bool = False) -> None: + where = ["--git-dir", str(repo)] if bare else ["-C", str(repo)] + self.git(*where, "config", "gc.auto", "0") + self.git(*where, "config", "maintenance.auto", "false") + + def git(self, *args: str) -> str: + return subprocess.run(["git", *args], env=self.env, check=True, + capture_output=True, text=True).stdout + + @staticmethod + def write(checkout: Path, rel: str, text: str) -> None: + path = checkout / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def cmd(self, *args: str) -> list[str]: + return ["bash", str(REPO / "lanes-edit.sh"), *args] + + def helper(self, *args: str, timeout: int = 120, **extra: str) -> subprocess.CompletedProcess: + return subprocess.run(self.cmd(*args), env=dict(self.env, **extra), cwd=str(self.root), + capture_output=True, text=True, timeout=timeout, check=False) + + def popen(self, *args: str, **extra: str) -> subprocess.Popen: + return subprocess.Popen(self.cmd(*args), env=dict(self.env, **extra), cwd=str(self.root), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + def stamp(self, words: str, **extra: str) -> subprocess.CompletedProcess: + return self.helper("set-row-state", LANE, f"LIVE · {words}", **extra) + + def checkout(self) -> Path: + """The lane's checkout, made by an ordinary first write.""" + c = self.lanes_root / LANE + if not c.exists(): + made = self.stamp("made the checkout") + assert made.returncode == 0, (made.stdout, made.stderr) + return c + + def lock(self) -> Path: + return self.checkout() / "lanes" / ".lanes-edit.lock" + + def origin_register(self) -> str: + return self.git("--git-dir", str(self.origin), "show", f"main:{REGISTER}") + + +@pytest.fixture +def box(tmp_path: Path) -> Box: + return Box(tmp_path) + + +def hold(lock: Path, pid_line: str, *, age_minutes: float = 0) -> None: + lock.mkdir(parents=True) + (lock / "pid").write_text(pid_line + "\n", encoding="utf-8") + if age_minutes: + then = time.time() - age_minutes * 60 + os.utime(lock, (then, then)) + + +def still_waiting(proc: subprocess.Popen) -> bool: + time.sleep(WATCH) + return proc.poll() is None + + +def stop(proc: subprocess.Popen) -> None: + if proc.poll() is None: + proc.terminate() + proc.communicate(timeout=30) + + +def gone_pid() -> int: + p = subprocess.Popen(["true"]) + p.wait() + return p.pid + + +# ----------------------------------------------------- the checkout's lock + +def test_live_holder_is_not_aged_out(box: Box): + lock = box.lock() + holder = subprocess.Popen(["sleep", "300"]) + try: + hold(lock, f"{holder.pid} {ME}", age_minutes=20) + writer = box.popen("set-row-state", LANE, "LIVE · after the holder") + assert still_waiting(writer), \ + "a lock held by a LIVE process in this container is never aged out, however old" + assert (lock / "pid").read_text(encoding="utf-8") == f"{holder.pid} {ME}\n" + finally: + holder.kill() + holder.wait() + out, err = writer.communicate(timeout=90) + assert writer.returncode == 0, (out, err) + assert f"taking over {lock}" in err and f"(pid {holder.pid}) is gone" in err, err + assert " · after the holder |" in box.origin_register() + + +@pytest.mark.parametrize("line", ["{pid} " + ME, "{pid}"], ids=["pid-container-host", "pid-only"]) +def test_dead_holder_is_taken_over(box: Box, line: str): + lock = box.lock() + dead = gone_pid() + hold(lock, line.format(pid=dead)) + result = box.stamp("after a dead holder", timeout=45) + assert result.returncode == 0, (result.stdout, result.stderr) + assert f"(pid {dead}) is gone" in result.stderr, result.stderr + assert not lock.exists(), "the writer released the lock it took over" + assert " · after a dead holder |" in box.origin_register() + + +def test_a_lock_naming_no_holder_is_judged_by_its_age(box: Box): + """A LOCK WITH NO PID names nobody a pid test could ask about, so its age + decides: it is held while fresh, and taken over once ten minutes old.""" + lock = box.lock() + lock.mkdir(parents=True) + writer = box.popen("set-row-state", LANE, "LIVE · never written") + assert still_waiting(writer), "a fresh lock that names no holder is held" + stop(writer) + then = time.time() - 11 * 60 + os.utime(lock, (then, then)) + result = box.stamp("after a lock that named nobody", timeout=45) + assert result.returncode == 0, (result.stdout, result.stderr) + assert "names no holder and is over ten minutes old" in result.stderr, result.stderr + assert " · after a lock that named nobody |" in box.origin_register() + + +def test_lock_survives_exec(box: Box): + c = box.checkout() + lock = c / "lanes" / ".lanes-edit.lock" + script = box.root / "held-across-exec" + script.write_text(f"""#!/usr/bin/env bash +# THIS process takes the checkout's lock and then BECOMES lanes-edit.sh, as +# S4's replay does: same pid, the lock naming it, LANES__PID its own. +mkdir '{lock}' && printf '%s {ME}\\n' "$$" > '{lock}/pid' || exit 99 +export LANES__CHECKOUT_LANE='{LANE}' LANES__PID="$$" +export LANES_REPO='{c}' LANES_DIR='{c}/lanes' LANES_FILE='{c}/lanes/LANES.md' +exec bash '{REPO / "lanes-edit.sh"}' set-row-state '{LANE}' 'LIVE · held across exec' +""", encoding="utf-8") + script.chmod(0o755) + result = subprocess.run([str(script)], env=box.env, cwd=str(box.root), capture_output=True, + text=True, timeout=45, check=False) + assert result.returncode == 0, (result.stdout, result.stderr) + assert "taking over" not in result.stderr + assert not lock.exists(), "the lock is released at the end, as any holder's is" + assert " · held across exec |" in box.origin_register() + + +@pytest.mark.parametrize("how", ["inherited marker", "own pid, no marker"]) +def test_a_lock_naming_a_pid_is_not_adopted_without_the_marker(box: Box, how: str): + c = box.checkout() + lock = c / "lanes" / ".lanes-edit.lock" + run = f"bash '{REPO / 'lanes-edit.sh'}' set-row-state '{LANE}' 'LIVE · never written'" + if how == "inherited marker": + # A CHILD of the holder inherits LANES__PID naming its parent: not its own. + tail = f"""export LANES__CHECKOUT_LANE='{LANE}' LANES__PID="$$" +export LANES_REPO='{c}' LANES_DIR='{c}/lanes' LANES_FILE='{c}/lanes/LANES.md' +{run} & child=$!""" + else: + # The holder's own pid, and no marker: a reused pid, which waits. + tail = f"""LANES_REPO='{c}' exec {run}""" + script = box.root / "not-adopted" + script.write_text(f"""#!/usr/bin/env bash +mkdir '{lock}' && printf '%s {ME}\\n' "$$" > '{lock}/pid' || exit 99 +{tail} +sleep {WATCH} +if kill -0 "$child" 2>/dev/null; then printf 'WAITING\\n'; kill "$child"; fi +wait "$child" 2>/dev/null +exit 0 +""", encoding="utf-8") + script.chmod(0o755) + before = box.origin_register() + proc = subprocess.Popen([str(script)], env=box.env, cwd=str(box.root), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if how == "inherited marker": + out, err = proc.communicate(timeout=60) + assert "WAITING" in out, (out, err) + else: + assert still_waiting(proc), "its own pid in the lock is not its own lock without the marker" + stop(proc) + assert box.origin_register() == before, "nothing was written by a process that did not hold the lock" + + +def test_other_container_holder_judged_by_heartbeat_age(box: Box): + lock = box.lock() + # A pid that is ALIVE here — this test's own — recorded by another + # container: `kill -0` here says nothing about it, so only the age counts. + hold(lock, f"{os.getpid()} bench-b hosta") + writer = box.popen("set-row-state", LANE, "LIVE · never written") + assert still_waiting(writer), "a fresh lock from another container is held" + stop(writer) + then = time.time() - 11 * 60 + os.utime(lock, (then, then)) + result = box.stamp("after another container went quiet", timeout=45) + assert result.returncode == 0, (result.stdout, result.stderr) + assert "container bench-b on hosta" in result.stderr and "ten minutes" in result.stderr, result.stderr + assert " · after another container went quiet |" in box.origin_register() + + +def test_holder_refreshes_its_lock_before_every_network_call(box: Box): + lock = box.lock() + out = box.root / "lock-age-at-push" + shim = box.fakebin / "heartbeat-git" + shim.write_text(f"""#!/usr/bin/env bash +case " $* " in + *" pull "*) python3 -c 'import os,sys,time; t=time.time()-3600; os.utime(sys.argv[1], (t, t))' '{lock}' ;; + *" push "*) python3 -c 'import os,sys,time; print(int(time.time()-os.stat(sys.argv[1]).st_mtime))' '{lock}' >> '{out}' ;; +esac +exec git "$@" +""", encoding="utf-8") + shim.chmod(0o755) + result = box.stamp("with a heartbeat", LANES_GIT=str(shim)) + assert result.returncode == 0, (result.stdout, result.stderr) + ages = [int(a) for a in out.read_text(encoding="utf-8").split()] + assert ages and all(a < 60 for a in ages), \ + f"the lock aged an hour before the push, and the holder refreshed it first: {ages}" + + +def test_a_claim_that_finds_a_new_holder_puts_the_lock_back(box: Box): + """THE TAKEOVER IS A CLAIM, NOT A DELETE. Two waiters read the same dead + holder; the first takes over and makes its own lock; the second's rename + then claims THAT one. It reads what it claimed again, finds a live holder, + and puts the lock back whole, so the first writer is never left unlocked.""" + lock = box.lock() + hold(lock, f"{gone_pid()} {ME}") + holder = subprocess.Popen(["sleep", "300"]) + claimed = box.root / "claimed-once" + real_mv = shutil.which("mv") + (box.fakebin / "mv").write_text(f"""#!/usr/bin/env bash +# THE CLAIM'S RENAME: just before it, another waiter took over and made its +# own lock, so the directory this rename claims is that waiter's, not the dead one. +case " $* " in + *" {lock} {lock}.taken."*) + [ -e '{claimed}' ] || {{ printf '%s {ME}\\n' '{holder.pid}' > '{lock}/pid'; : > '{claimed}'; }} ;; +esac +exec '{real_mv}' "$@" +""", encoding="utf-8") + (box.fakebin / "mv").chmod(0o755) + try: + writer = box.popen("set-row-state", LANE, "LIVE · after the claim") + deadline = time.monotonic() + 90 + while not claimed.exists() and writer.poll() is None and time.monotonic() < deadline: + time.sleep(.1) + assert claimed.exists(), "the waiter did claim the lock it read as dead" + assert still_waiting(writer), "a claim that finds a live holder waits for it" + assert (lock / "pid").read_text(encoding="utf-8") == f"{holder.pid} {ME}\n", \ + "the live holder's lock is back at its name, whole" + assert not list(lock.parent.glob(".lanes-edit.lock.taken.*")), "no claimed copy is left" + finally: + holder.kill() + holder.wait() + out, err = writer.communicate(timeout=90) + assert writer.returncode == 0, (out, err) + assert " · after the claim |" in box.origin_register() + + +def test_release_leaves_a_lock_another_holder_took(box: Box): + """A LOCK IS RELEASED ONLY WHILE IT IS STILL THIS PROCESS'S, and the one a + writer makes names its container and host beside its pid. Here the lock + changes hands while the writer pushes (another container's waiter judged it + by an age it missed): the writer finishes, and leaves that holder's lock.""" + lock = box.lock() + seen = box.root / "pid-line-at-push" + holder = subprocess.Popen(["sleep", "300"]) + shim = box.fakebin / "handover-git" + shim.write_text(f"""#!/usr/bin/env bash +case " $* " in + *" push "*) + cat '{lock}/pid' >> '{seen}' + printf '%s bench-b hostb\\n' '{holder.pid}' > '{lock}/pid' ;; +esac +exec git "$@" +""", encoding="utf-8") + shim.chmod(0o755) + try: + result = box.stamp("handed over mid-write", LANES_GIT=str(shim)) + assert result.returncode == 0, (result.stdout, result.stderr) + first = seen.read_text(encoding="utf-8").splitlines()[0] + assert re.fullmatch(r"[0-9]+ bench-a hosta", first), \ + f"the writer's pid file is ` `: {first!r}" + assert (lock / "pid").read_text(encoding="utf-8") == f"{holder.pid} bench-b hostb\n", \ + "the lock another holder took is that holder's, and is left as it is" + assert f"held by pid {holder.pid} now" in result.stderr, result.stderr + assert " · handed over mid-write |" in box.origin_register() + finally: + holder.kill() + holder.wait() + + +# ----------------------------------------------------- the workstation's lock + +@pytest.mark.parametrize("act", ["set-lane-state", "set-lane-tree", "publish-handoff"]) +def test_sidecar_acts_share_the_workstation_lock(box: Box, act: str): + box.lanes_root.mkdir(parents=True, exist_ok=True) + sidecar = box.lanes_root / ".lane-state.lock" + # W'S OLD LOCK IS A FILE HERE, so anything that still took a lock in W + # would wedge on it for its whole minute. + (box.w / "lanes" / ".lanes-edit.lock").write_text("a wedge\n", encoding="utf-8") + target = box.root / "handoff.md" + prepared = box.root / "prepared.md" + target.write_text("# before\n", encoding="utf-8") + prepared.write_text("# after\n", encoding="utf-8") + tree = ("set-lane-tree", LANE, str(box.root / "a-tree"), "--branch", "main", "--head", "0" * 40, + "--upstream", "origin/main", "--dirty", "0", "--unpushed", "0") + if act == "set-lane-state": + args = ("set-lane-state", LANE, "RUNNING", "--expect", "none", "--owner", UUID) + written = lambda: (box.state / LANE / "lane-state.yaml").is_file() # noqa: E731 + elif act == "set-lane-tree": + args = tree + written = lambda: any((box.state / LANE / "trees").glob("*.yaml")) # noqa: E731 + else: + digest = hashlib.sha256(target.read_bytes()).hexdigest() + args = ("publish-handoff", LANE, str(target), str(prepared), "--expect-digest", digest) + written = lambda: target.read_text(encoding="utf-8") == "# after\n" # noqa: E731 + holder = subprocess.Popen(["sleep", "300"]) + try: + hold(sidecar, f"{holder.pid} {ME}") + waiter = box.popen(*args) + assert still_waiting(waiter), f"{act} waits on the workstation's sidecar lock" + assert not written() + finally: + holder.kill() + holder.wait() + out, err = waiter.communicate(timeout=90) + assert waiter.returncode == 0, (out, err) + assert written() + assert not sidecar.exists() + # …and neither it nor a routed write ever took a lock in W, or each would + # have waited out its minute on the file there. + if act == "set-lane-state": + again_args = ("set-lane-state", LANE, "RUNNING", "--expect", "RUNNING", "--owner", UUID) + elif act == "set-lane-tree": + again_args = tree + else: + digest = hashlib.sha256(target.read_bytes()).hexdigest() + again_args = ("publish-handoff", LANE, str(target), str(prepared), "--expect-digest", digest) + again = box.helper(*again_args, timeout=45) + assert again.returncode == 0, (again.stdout, again.stderr) + routed = box.stamp("past a wedged W", timeout=45) + assert routed.returncode == 0, (routed.stdout, routed.stderr) + assert (box.w / "lanes" / ".lanes-edit.lock").read_text(encoding="utf-8") == "a wedge\n" + + +def test_a_lifecycle_follow_takes_the_workstation_lock(box: Box): + """A LIFECYCLE LINE'S FOLLOW-UP moves the lane's snapshot under the + workstation's sidecar lock, taken inside the checkout lock its write holds. + With that lock held elsewhere, the line lands and the snapshot is left one + act behind, as the follow-up says: it is never moved unlocked.""" + made = box.helper("set-lane-state", LANE, "RUNNING", "--expect", "none", "--owner", UUID) + assert made.returncode == 0, (made.stdout, made.stderr) + snapshot = box.state / LANE / "lane-state.yaml" + before = snapshot.read_text(encoding="utf-8") + box.lanes_root.mkdir(parents=True, exist_ok=True) + sidecar = box.lanes_root / ".lane-state.lock" + holder = subprocess.Popen(["sleep", "300"]) + try: + hold(sidecar, f"{holder.pid} {ME}") + result = box.helper("log", "ENDED", f"lane:{LANE}", LANES_LANE=LANE, LANES_SESSION=UUID) + assert (sidecar / "pid").read_text(encoding="utf-8") == f"{holder.pid} {ME}\n" + finally: + holder.kill() + holder.wait() + assert result.returncode == 0, (result.stdout, result.stderr) + assert "was NOT moved to CLOSED" in result.stderr, result.stderr + assert snapshot.read_text(encoding="utf-8") == before, "the snapshot was not moved unlocked" + log = box.git("--git-dir", str(box.origin), "show", f"main:lanes/log/{LANE}.md") + assert f"ENDED — lane {LANE}" in log, log diff --git a/tests/test_a23_refresh_workspace.py b/tests/test_a23_refresh_workspace.py new file mode 100644 index 00000000..10d290db --- /dev/null +++ b/tests/test_a23_refresh_workspace.py @@ -0,0 +1,248 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Amendment 23 S5: W is read-only, and it is only ever fast-forwarded. + +`lanes-edit.sh refresh-workspace` is the read-side verb S5 (2) names. It runs +two git acts in W — `fetch origin main` and `merge --ff-only origin/main` — and +nothing else. Whatever would stop a fast-forward is reported and skipped (exit +2, nothing changed): W not on main, an operation in progress, staged entries, +W ahead of or diverged from origin, a live legacy lock in W, or git's own +refusal over a file in the way. It never resets, stashes, commits or aborts. +And a file written into W through an estate link is reported by path and, where +a row's handoff cell names it, by its lane ("People too"). +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from conftest import REPO, WINDOWS_SKIP + +pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, + reason="lanes-edit.sh is a bash script"), + pytest.mark.skipif(shutil.which("git") is None, + reason="the register is a git repository"), + WINDOWS_SKIP] + +REGISTER = "lanes/LANES.md" +UUID = "a23f0001-ffff-4000-8000-a23f0001ffff" +ME = "bench-a hosta" + +SCRUBBED_PREFIXES = ("LANES_", "LANE_START_", "CLAUDE_", "OPENREPOTOOLS_", + "FAKE_", "GIT_", "TMUX") +SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", + "XDG_CACHE_HOME"} + +REFUSE = """#!/usr/bin/env bash +printf 'FAKE %s REFUSED: %s -- this suite reaches no real surface\\n' "${0##*/}" "$*" >&2 +exit 90 +""" + + +def _row(lane: str, cell: str) -> str: + return (f"| `{lane}` | harness `{UUID}` | Eagle / test / tester | 2026-10-17T00:00Z " + f"| — | {cell} | LIVE · seeded |") + + +class Box: + """A bare origin, W and a peer cloned from it, and workspace.yaml naming W.""" + + def __init__(self, root: Path) -> None: + root = Path(os.path.realpath(root)) + root.mkdir(parents=True, exist_ok=True) + self.root = root + self.home = root / "home" + self.origin = root / "origin.git" + self.w = self.home / "projects" / "brett-wip" + self.lanes_root = self.home / "projects" / "brett-wip-lanes" + self.peer = root / "peer" + self.fakebin = root / "fakebin" + for d in (self.home / ".agents", self.home / "projects", root / "tmp", self.fakebin): + d.mkdir(parents=True, exist_ok=True) + for name in ("gh", "tmux"): + (self.fakebin / name).write_text(REFUSE, encoding="utf-8") + (self.fakebin / name).chmod(0o755) + env = {k: v for k, v in os.environ.items() + if k not in SCRUBBED and not k.startswith(SCRUBBED_PREFIXES)} + env.update({ + "HOME": str(self.home), + "TMPDIR": str(root / "tmp"), + "XDG_CONFIG_HOME": str(self.home / ".config"), + "XDG_CACHE_HOME": str(self.home / ".cache"), + "PATH": f"{self.fakebin}{os.pathsep}{env.get('PATH', '')}", + "AGENT_PROTOCOL_ROOT": str(self.home / ".agents"), + "LANES_WORKSTATION": "Eagle", + "LANES_HOST": "hosta", + "LANES_CONTAINER": "bench-a", + "LANES_NO_GITHUB": "1", + "LANES_INDEX": "off", + "LANE_WORKTREES_REPORT": "off", + "GIT_AUTHOR_NAME": "a23 tests", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "a23 tests", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + }) + self.env = env + self.git("init", "-q", "--bare", "-b", "main", str(self.origin)) + self.quiet(self.origin, bare=True) + self.git("clone", "-q", str(self.origin), str(self.peer)) + self.quiet(self.peer) + self.write(self.peer, REGISTER, + "# LANES.md -- the sandbox register\n\n" + "| lane | session id | workstation / env / user | started (UTC) " + "| objects owned | handoff path | state |\n" + "|---|---|---|---|---|---|---|\n" + f"{_row('repoFF-1', 'handoffs/repoFF/repoFF-1.md')}\n" + f"{_row('repoFF-2', '~/projects/repoFF/handoffs/repoFF-2.md')}\n") + self.write(self.peer, "handoffs/repoFF/README.md", "# repoFF\n") + self.commit(self.peer, "seed", REGISTER, "handoffs/repoFF/README.md") + self.git("-C", str(self.peer), "push", "-q", "origin", "main") + self.git("clone", "-q", str(self.origin), str(self.w)) + self.quiet(self.w) + (self.home / ".agents" / "workspace.yaml").write_text( + f"repository: {self.origin}\npath: {self.w}\n", encoding="utf-8") + + def quiet(self, repo: Path, *, bare: bool = False) -> None: + where = ["--git-dir", str(repo)] if bare else ["-C", str(repo)] + self.git(*where, "config", "gc.auto", "0") + self.git(*where, "config", "maintenance.auto", "false") + + def git(self, *args: str) -> str: + return subprocess.run(["git", *args], env=self.env, check=True, + capture_output=True, text=True).stdout + + def wgit(self, *args: str) -> str: + return self.git("-C", str(self.w), *args) + + @staticmethod + def write(checkout: Path, rel: str, text: str) -> None: + path = checkout / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def commit(self, checkout: Path, message: str, *paths: str) -> None: + self.git("-C", str(checkout), "add", "--", *paths) + self.git("-C", str(checkout), "commit", "-q", "-m", message) + + def peer_pushes(self, rel: str = "handoffs/repoFF/peer.md", text: str = "from a peer\n") -> None: + self.write(self.peer, rel, text) + self.commit(self.peer, f"a peer writes {rel}", rel) + self.git("-C", str(self.peer), "push", "-q", "origin", "main") + + def refresh(self, **extra: str) -> subprocess.CompletedProcess: + return subprocess.run(["bash", str(REPO / "lanes-edit.sh"), "refresh-workspace"], + env=dict(self.env, **extra), cwd=str(self.root), + capture_output=True, text=True, timeout=120, check=False) + + def head(self) -> str: + return self.wgit("rev-parse", "HEAD").strip() + + def origin_main(self) -> str: + return self.git("--git-dir", str(self.origin), "rev-parse", "main").strip() + + def snapshot(self) -> tuple[str, str, str]: + return (self.head(), self.wgit("status", "--porcelain", "--untracked-files=all"), + self.wgit("for-each-ref", "--format=%(refname) %(objectname)", "refs/heads")) + + +@pytest.fixture +def box(tmp_path: Path) -> Box: + return Box(tmp_path) + + +def test_behind_is_fast_forwarded(box: Box): + box.peer_pushes() + assert box.head() != box.origin_main() + result = box.refresh() + assert result.returncode == 0, (result.stdout, result.stderr) + assert box.head() == box.origin_main() + assert "fast-forwarded W" in result.stderr + again = box.refresh() + assert again.returncode == 0 and "is current" in again.stderr, again.stderr + assert not box.lanes_root.exists(), "a read-side act makes no lane checkout" + + +def _block(box: Box, how: str) -> str: + """Put W in the state `how` names; return the words its report carries.""" + if how == "ahead": + box.write(box.w, "handoffs/repoFF/local.md", "committed in W\n") + box.commit(box.w, "a commit made in W", "handoffs/repoFF/local.md") + return "ahead of origin/main" + box.peer_pushes() # W is behind for every other case + if how == "diverged": + box.write(box.w, "handoffs/repoFF/local.md", "committed in W\n") + box.commit(box.w, "a commit made in W", "handoffs/repoFF/local.md") + return "diverged from origin/main" + if how == "not on main": + box.wgit("switch", "-q", "-c", "elsewhere") + return "not on main: HEAD is elsewhere" + if how == "an operation": + sha = box.head() + (box.w / ".git" / "MERGE_HEAD").write_text(sha + "\n", encoding="utf-8") + return "an operation in progress" + if how == "staged entries": + box.write(box.w, "handoffs/repoFF/staged.md", "staged in W\n") + box.wgit("add", "--", "handoffs/repoFF/staged.md") + return "staged entries: handoffs/repoFF/staged.md" + if how == "a live legacy lock": + lock = box.w / "lanes" / ".lanes-edit.lock" + lock.mkdir() + (lock / "pid").write_text(f"{os.getpid()} {ME}\n", encoding="utf-8") + return "a lock held" + if how == "an untracked file in the way": + box.write(box.w, "handoffs/repoFF/peer.md", "a different file, never committed\n") + return "git refused to fast-forward" + raise AssertionError(how) # pragma: no cover + + +@pytest.mark.parametrize("how", ["ahead", "diverged", "not on main", "an operation", "staged entries", + "a live legacy lock", "an untracked file in the way"]) +def test_blocked_is_reported_and_skipped(box: Box, how: str): + words = _block(box, how) + before = box.snapshot() + result = box.refresh() + assert result.returncode == 2, (result.stdout, result.stderr) + assert words in result.stderr, result.stderr + assert "nothing in it was changed" in result.stderr + assert box.snapshot() == before, "no reset, stash, commit, abort or fast-forward" + if how == "an operation": + assert (box.w / ".git" / "MERGE_HEAD").is_file(), "the operation is not aborted" + + +def test_stray_handoff_named_by_lane(box: Box): + # repoFF-1's cell is repository-relative; repoFF-2's is the estate spelling, + # through an estate link into W — the way a person writes one by habit. + estate = box.home / "projects" / "repoFF" + estate.mkdir() + (estate / "handoffs").symlink_to(box.w / "handoffs" / "repoFF") + box.write(box.w, "handoffs/repoFF/repoFF-1.md", "a handoff written into W\n") + (estate / "handoffs" / "repoFF-2.md").write_text("through the estate link\n", encoding="utf-8") + box.write(box.w, "handoffs/repoFF/nobody.md", "no row names this one\n") + result = box.refresh() + assert result.returncode == 0, (result.stdout, result.stderr) + err = result.stderr + assert ("a stray in W: handoffs/repoFF/repoFF-1.md (??) — lane repoFF-1's handoff, which " + f"belongs in its own checkout: {box.lanes_root}/repoFF-1/handoffs/repoFF/repoFF-1.md") in err, err + assert ("a stray in W: handoffs/repoFF/repoFF-2.md (??) — lane repoFF-2's handoff, which " + f"belongs in its own checkout: {box.lanes_root}/repoFF-2/handoffs/repoFF/repoFF-2.md") in err, err + assert "a stray in W: handoffs/repoFF/nobody.md (??)\n" in err, err + assert (box.w / "handoffs" / "repoFF" / "repoFF-1.md").is_file(), "a stray is reported, never moved" + + +def test_W_reflog_shows_only_fetch_and_ff(box: Box): + box.peer_pushes() + head_log = box.wgit("reflog", "--format=%gs", "HEAD").splitlines() + remote_log = box.wgit("reflog", "--format=%gs", "refs/remotes/origin/main").splitlines() + refs = box.wgit("for-each-ref", "--format=%(refname)").split() + result = box.refresh() + assert result.returncode == 0, (result.stdout, result.stderr) + new_head = box.wgit("reflog", "--format=%gs", "HEAD").splitlines() + assert new_head[:-len(head_log)] == ["merge origin/main: Fast-forward"], new_head + new_remote = box.wgit("reflog", "--format=%gs", "refs/remotes/origin/main").splitlines() + assert len(new_remote) == len(remote_log) + 1 and new_remote[0].startswith("fetch"), new_remote + assert box.wgit("for-each-ref", "--format=%(refname)").split() == refs, "no ref made or deleted" + assert box.wgit("stash", "list") == "" diff --git a/tests/test_a23_write_routing.py b/tests/test_a23_write_routing.py new file mode 100644 index 00000000..995a3381 --- /dev/null +++ b/tests/test_a23_write_routing.py @@ -0,0 +1,358 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Amendment 23 S2 and S5: which checkout a write goes through. + +S2's order: an explicit `LANES_REPO` keeps its meaning (the test seam and an +operator's override), and one that names W by real path is refused (S5); +otherwise the write goes through the checkout of the lane it is attributed to; +and with no lane there is no write — never a fallback to W. `LANES_DIR` and +`LANES_FILE` locate files inside the checkout and never choose it. Reads keep +W, through workspace.yaml. + +The sandbox is the real `lanes-edit.sh`: a bare origin, W cloned from it, and +`workspace.yaml` naming W. Nothing reaches a real remote, `gh` or `tmux`. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from conftest import REPO, WINDOWS_SKIP + +pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, + reason="lanes-edit.sh is a bash script"), + pytest.mark.skipif(shutil.which("git") is None, + reason="the register is a git repository"), + WINDOWS_SKIP] + +LANE = "repoA23-1" +DORMANT = "repoA23-9" +TARGET = "repoA23-4" +REGISTER = "lanes/LANES.md" +UUID = "a23a0001-a2a2-4000-8000-a23a0001a2a2" +TARGET_UUID = "a23a0004-a2a2-4000-8000-a23a0004a2a2" +ASKER_UUID = "a23a0005-a2a2-4000-8000-a23a0005a2a2" + +SCRUBBED_PREFIXES = ("LANES_", "LANE_START_", "CLAUDE_", "OPENREPOTOOLS_", + "FAKE_", "GIT_", "TMUX") +SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", + "XDG_CACHE_HOME"} + +REFUSE = """#!/usr/bin/env bash +printf 'FAKE %s REFUSED: %s -- this suite reaches no real surface\\n' "${0##*/}" "$*" >&2 +exit 90 +""" + + +def _row(lane: str, uuid: str = UUID, state: str = "LIVE · seeded") -> str: + return (f"| `{lane}` | harness `{uuid}` | Eagle / test / tester | 2026-10-17T00:00Z " + f"| — | handoffs/repoA23/{lane}.md | {state} |") + + +class Box: + """A bare origin, W cloned from it, and the workspace.yaml naming W.""" + + def __init__(self, root: Path) -> None: + root = Path(os.path.realpath(root)) + root.mkdir(parents=True, exist_ok=True) + self.root = root + self.home = root / "home" + self.origin = root / "origin.git" + self.w = self.home / "projects" / "brett-wip" + self.lanes_root = self.home / "projects" / "brett-wip-lanes" + self.fakebin = root / "fakebin" + for d in (self.home / ".agents", self.home / "projects", root / "tmp", self.fakebin): + d.mkdir(parents=True, exist_ok=True) + for name in ("gh", "tmux"): + (self.fakebin / name).write_text(REFUSE, encoding="utf-8") + (self.fakebin / name).chmod(0o755) + env = {k: v for k, v in os.environ.items() + if k not in SCRUBBED and not k.startswith(SCRUBBED_PREFIXES)} + env.update({ + "HOME": str(self.home), + "TMPDIR": str(root / "tmp"), + "XDG_CONFIG_HOME": str(self.home / ".config"), + "XDG_CACHE_HOME": str(self.home / ".cache"), + "PATH": f"{self.fakebin}{os.pathsep}{env.get('PATH', '')}", + "AGENT_PROTOCOL_ROOT": str(self.home / ".agents"), + "LANES_WORKSTATION": "Eagle", + "LANES_HOST": "hosta", + "LANES_CONTAINER": "bench-a", + "LANES_NO_GITHUB": "1", + "LANES_INDEX": "off", + "LANE_WORKTREES_REPORT": "off", + "GIT_AUTHOR_NAME": "a23 tests", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "a23 tests", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + }) + self.env = env + self.git("init", "-q", "--bare", "-b", "main", str(self.origin)) + self.quiet(self.origin, bare=True) + seed = root / "seed" + self.git("clone", "-q", str(self.origin), str(seed)) + self.quiet(seed) + rows = [_row(LANE), _row("repoA23-2"), _row(DORMANT, uuid="a23a0009-a2a2-4000-8000-a23a0009a2a2"), + _row(TARGET, uuid=TARGET_UUID)] + self.write(seed, REGISTER, + "# LANES.md -- the sandbox register\n\n" + "| lane | session id | workstation / env / user | started (UTC) " + "| objects owned | handoff path | state |\n" + "|---|---|---|---|---|---|---|\n" + "".join(f"{r}\n" for r in rows)) + # THE TARGET OF A REQUEST IS BOUND ELSEWHERE: its last lane-kind line is + # a STARTED from another container on this host (Amendment 18). + self.write(seed, f"lanes/log/{TARGET}.md", + f"# lane {TARGET} — object log (lane-collision-protocol Amendment 7)\n" + f"STARTED — lane {TARGET}, session {TARGET_UUID}@Eagle, 2026-10-17T09:00:00Z, " + f"lane:{TARGET} → home opensoft/repoA23; estate repoA23; dir {root}/lanedir; " + f"profile team-05a; window othersess:4 @62; host hosta; os linux; container bench-b\n") + self.write(seed, f"lanes/log/{LANE}.md", + f"# lane {LANE} — object log (lane-collision-protocol Amendment 7)\n") + self.write(seed, "handoffs/README.md", "# handoffs\n") + self.git("-C", str(seed), "add", "--", REGISTER, "handoffs/README.md", "lanes/log") + self.git("-C", str(seed), "commit", "-q", "-m", "seed") + self.git("-C", str(seed), "push", "-q", "origin", "main") + self.seed = seed + self.git("clone", "-q", str(self.origin), str(self.w)) + self.quiet(self.w) + (self.home / ".agents" / "workspace.yaml").write_text( + f"repository: {self.origin}\npath: {self.w}\n", encoding="utf-8") + + def quiet(self, repo: Path, *, bare: bool = False) -> None: + where = ["--git-dir", str(repo)] if bare else ["-C", str(repo)] + self.git(*where, "config", "gc.auto", "0") + self.git(*where, "config", "maintenance.auto", "false") + + def git(self, *args: str) -> str: + return subprocess.run(["git", *args], env=self.env, check=True, + capture_output=True, text=True).stdout + + @staticmethod + def write(checkout: Path, rel: str, text: str) -> None: + path = checkout / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def helper(self, *args: str, **extra: str) -> subprocess.CompletedProcess: + return subprocess.run(["bash", str(REPO / "lanes-edit.sh"), *args], + env=dict(self.env, **extra), cwd=str(self.root), + capture_output=True, text=True, timeout=180, check=False) + + def checkout(self, lane: str) -> Path: + return self.lanes_root / lane + + def clone(self, dest: Path) -> Path: + self.git("clone", "-q", str(self.origin), str(dest)) + self.quiet(dest) + return dest + + def origin_head(self) -> str: + return self.git("--git-dir", str(self.origin), "rev-parse", "main").strip() + + def origin_file(self, rel: str) -> str: + return self.git("--git-dir", str(self.origin), "show", f"main:{rel}") + + def w_state(self) -> tuple[str, str]: + return (self.git("-C", str(self.w), "rev-parse", "HEAD").strip(), + self.git("-C", str(self.w), "status", "--porcelain", "--untracked-files=all")) + + +@pytest.fixture +def box(tmp_path: Path) -> Box: + return Box(tmp_path) + + +def assert_nothing_written(box: Box, head: str, w_before) -> None: + assert box.origin_head() == head, "nothing reached origin" + assert box.w_state() == w_before, "W is as it was" + + +# ------------------------------------------------------- (3) no lane, no write + +@pytest.mark.parametrize("args", [ + ("commit", "a hand edit"), + ("append-line", "a line that names nobody at all"), + ("add-row", "| no backticked lane | x | Eagle / t / u | 2026-10-17T00:00Z | — | h | LIVE |"), +], ids=["commit", "append-line", "add-row"]) +def test_no_lane_no_write_exit_2(box: Box, args): + head, w_before = box.origin_head(), box.w_state() + result = box.helper(*args) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "no lane, no write" in result.stderr, result.stderr + assert "LANES_LANE=" in result.stderr, "the refusal names the form that writes" + assert not box.lanes_root.exists(), "no checkout was made for a write with no lane" + assert_nothing_written(box, head, w_before) + + +# ------------------------------------------------------- (1) explicit LANES_REPO + +@pytest.mark.parametrize("how", ["direct", "symlink"]) +def test_LANES_REPO_naming_W_is_refused(box: Box, how: str): + named = box.w + if how == "symlink": + named = box.root / "a-link-to-w" + named.symlink_to(box.w) + head, w_before = box.origin_head(), box.w_state() + result = box.helper("set-row-state", LANE, "LIVE · into W", LANES_REPO=str(named)) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "readers' copy" in result.stderr and "Amendment 23 S5" in result.stderr, result.stderr + assert f"LANES_LANE={LANE}" in result.stderr, "it names the form that writes" + assert f"{box.lanes_root}/{LANE}/" in result.stderr, "it names the lane's own checkout" + if how == "symlink": + assert "through" in result.stderr + assert_nothing_written(box, head, w_before) + assert not box.lanes_root.exists() + + +def test_explicit_LANES_REPO_is_honoured_and_derives_dir_and_file(box: Box): + other = box.clone(box.root / "elsewhere" / "register") + w_before = box.w_state() + result = box.helper("set-row-state", LANE, "LIVE · in the override", LANES_REPO=str(other)) + assert result.returncode == 0, (result.stdout, result.stderr) + assert " · in the override |" in (other / REGISTER).read_text(encoding="utf-8"), \ + "the edit landed in the checkout LANES_REPO names, where git committed it" + assert " · in the override |" in box.origin_file(REGISTER) + assert box.git("-C", str(other), "status", "--porcelain") == "" + assert box.w_state() == w_before, "LANES_DIR and LANES_FILE no longer default to W" + assert not box.lanes_root.exists(), "an explicit LANES_REPO makes no lane checkout" + assert "WARNING" not in result.stderr + + +def test_per_profile_clone_warns_with_S1_path(box: Box): + profile = box.clone(box.home / "projects" / "brett-wip-team02e-lane2") + result = box.helper("set-row-state", LANE, "LIVE · through a profile clone", + LANES_REPO=str(profile)) + assert result.returncode == 0, (result.stdout, result.stderr) + assert "per-profile clone" in result.stderr, result.stderr + assert f"{box.lanes_root}/{LANE}/" in result.stderr, "the warning names S1's path" + assert " · through a profile clone |" in box.origin_file(REGISTER), "and it is honoured" + + +@pytest.mark.parametrize("how", ["default", "explicit"]) +def test_LANES_DIR_outside_checkout_is_refused(box: Box, how: str): + extra = {"LANES_DIR": str(box.w / "lanes")} + if how == "explicit": + extra["LANES_REPO"] = str(box.clone(box.root / "elsewhere" / "register")) + head, w_before = box.origin_head(), box.w_state() + result = box.helper("set-row-state", LANE, "LIVE · never written", **extra) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "outside the checkout" in result.stderr, result.stderr + assert_nothing_written(box, head, w_before) + assert not box.checkout(LANE).exists(), "nothing was cloned for a refused write" + + +@pytest.mark.parametrize("verb", ["set-row-state", "add-row", "append-line"]) +def test_a_writer_missing_its_lane_keeps_its_own_usage_refusal(box: Box, verb: str): + """A WRITER WITH NO ARGUMENTS IS A USAGE ERROR, NOT A WRITE WITH NO LANE: + routing steps aside, and the arm refuses with its own words and code before + reading anything, as it always has.""" + head, w_before = box.origin_head(), box.w_state() + result = box.helper(verb) + assert result.returncode == 2, (result.stdout, result.stderr) + assert f"usage: {verb}" in result.stderr, result.stderr + assert "no lane, no write" not in result.stderr, result.stderr + assert not box.lanes_root.exists() + assert_nothing_written(box, head, w_before) + + +@pytest.mark.parametrize("verb", ["archive-rows", "migrate-state-cells"]) +def test_dry_runs_make_no_checkout(box: Box, verb: str): + """Without `--yes` these two print a plan, take no lock and write nothing, + so they are reads, and a lane named beside them makes no checkout.""" + head, w_before = box.origin_head(), box.w_state() + result = box.helper(verb, LANES_LANE=LANE) + assert not box.lanes_root.exists(), (result.stdout, result.stderr) + assert_nothing_written(box, head, w_before) + + +# ------------------------------------------------------- the re-run + +def test_inherited_markers_are_dropped(box: Box): + """A PROCESS A ROUTED WRITE STARTED is not the re-run itself: its + `LANES__PID` names another process, so both markers, and the three paths + only the re-run exports beside them, are dropped, and its own write goes + through its own lane's checkout (S2), not the one it inherited.""" + made = box.helper("set-row-state", LANE, "LIVE · made the first checkout") + assert made.returncode == 0, (made.stdout, made.stderr) + c = box.checkout(LANE) + inherited = {"LANES__CHECKOUT_LANE": LANE, "LANES__PID": "1", "LANES_REPO": str(c), + "LANES_DIR": str(c / "lanes"), "LANES_FILE": str(c / REGISTER)} + result = box.helper("set-row-state", "repoA23-2", "LIVE · through its own checkout", **inherited) + assert result.returncode == 0, (result.stdout, result.stderr) + other = box.checkout("repoA23-2") + assert (other / ".git").is_dir(), "the write resolved its own lane's checkout" + assert "through its own checkout" in box.git("-C", str(other), "log", "-1", "--format=%s") + assert "through its own checkout" not in box.git("-C", str(c), "log", "-1", "--format=%s") + + +def test_the_re_run_leaves_no_temporary_file_behind(box: Box): + """`exec` runs no EXIT trap, so what the first run made before it — its + event cache in TMPDIR — is removed first, by `cleanup_for_exec`.""" + tmp = box.root / "tmp-of-one-write" + tmp.mkdir() + result = box.helper("set-row-state", LANE, "LIVE · one write", TMPDIR=str(tmp)) + assert result.returncode == 0, (result.stdout, result.stderr) + assert (box.checkout(LANE) / ".git").is_dir(), "the write was routed, so the file ran twice" + assert sorted(p.name for p in tmp.glob("lanes-edit-*")) == [], "neither run left its cache" + + +# ------------------------------------------------------- (2) the attributed lane + +def test_sweep_writes_through_runner_lane(box: Box): + result = box.helper("retire-rows", DORMANT, LANES_LANE=LANE, LANES_SESSION=UUID) + assert result.returncode == 0, (result.stdout, result.stderr) + assert (box.checkout(LANE) / ".git").is_dir(), "the sweep went through the runner's checkout" + assert not box.checkout(DORMANT).exists(), "and never through the swept lane's" + register = box.origin_file(REGISTER) + assert any(line.startswith(f"| `{DORMANT}` |") and "RETIRED" in line + for line in register.splitlines()), register + assert f"RETIRED — lane {DORMANT}, session {UUID}@Eagle" in box.origin_file(f"lanes/log/{DORMANT}.md") + + +def test_request_handoff_writes_through_target_lane(box: Box): + result = box.helper("request-handoff", TARGET, "--no-wait", LANES_SESSION=ASKER_UUID) + # An empty wait refuses (2) — the request is written FIRST, and that is + # the write under test here. + assert result.returncode == 2, (result.stdout, result.stderr) + assert (box.checkout(TARGET) / ".git").is_dir(), "the request went through the TARGET lane's checkout" + assert sorted(p.name for p in box.lanes_root.iterdir()) == [TARGET] + log = box.origin_file(f"lanes/log/{TARGET}.md") + assert f"HANDOFF-REQUESTED — lane {TARGET}, session {ASKER_UUID}@Eagle" in log, log + + +def test_request_handoff_dry_run_is_a_read(box: Box): + head = box.origin_head() + result = box.helper("request-handoff", TARGET, "--dry-run", LANES_SESSION=ASKER_UUID) + assert "PLAN:" in result.stderr, (result.stdout, result.stderr) + assert not box.lanes_root.exists(), "a dry run writes nothing, so it makes no checkout" + assert box.origin_head() == head + + +def test_reads_still_resolve_W(box: Box): + assert box.helper("set-row-state", LANE, "LIVE · written through C").returncode == 0 + root = box.helper("workspace-root") + assert root.returncode == 0 and root.stdout.strip() == str(box.w) + local = box.helper("register-row-local", LANE) + assert local.returncode == 0, (local.stdout, local.stderr) + assert "LIVE · seeded" in local.stdout, "a read of the working tree reads W's, which no write touched" + assert " · written through C |" in (box.checkout(LANE) / REGISTER).read_text(encoding="utf-8") + before = sorted(p.name for p in box.lanes_root.iterdir()) + for read in (("verify-row", "repoA23-2"), ("register-row-local", "repoA23-2"), + ("canon-lane", "REPOA23-2"), ("lane-checkout", "repoA23-2")): + box.helper(*read) + assert sorted(p.name for p in box.lanes_root.iterdir()) == before, "a read makes no checkout" + + +def test_guard_no_lane_exemption_still_first(box: Box): + (box.home / ".agents" / "workspace.yaml").unlink() + for extra in ({}, {"LANES_REPO": str(box.w)}): + result = subprocess.run(["bash", str(REPO / "lanes-edit.sh"), "guard"], + env=dict(box.env, CLAUDE_NO_LANE="1", **extra), + input="{}", capture_output=True, text=True, timeout=60, check=False) + assert result.returncode == 0, (extra, result.stdout, result.stderr) + assert result.stdout == "" and result.stderr == "" + assert not box.lanes_root.exists() diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index d27e44ee..7fa5f7aa 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -126,8 +126,18 @@ def __init__(self, root: Path) -> None: self.git("-C", str(self.wip), "commit", "-q", "-m", "seed") self.git("-C", str(self.wip), "push", "-q", "origin", "main") self.git("clone", "-q", str(self.origin), str(self.peer)) + # AMENDMENT 23 S5: W, the readers' copy that workspace.yaml names, takes + # no write, and a write that resolves by default goes through the lane's + # own checkout beside it. These cases are about `commit_push` in the + # checkout a write runs in, so they name `self.wip` by the explicit seam + # (`LANES_REPO`, ort186 Q9), and W is a separate reader clone. + self.ws = root / "ro" / "wip" + self.git("clone", "-q", str(self.origin), str(self.ws)) + for key, value in (("gc.auto", "0"), ("maintenance.auto", "false")): + self.git("-C", str(self.ws), "config", key, value) (self.home / ".agents" / "workspace.yaml").write_text( - f"repository: {self.origin}\npath: {self.wip}\n", encoding="utf-8") + f"repository: {self.origin}\npath: {self.ws}\n", encoding="utf-8") + self.env["LANES_REPO"] = str(self.wip) def git(self, *args: str) -> str: return subprocess.run(["git", *args], env=self.env, check=True, diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index b06a7cdd..c67c66b5 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -125,12 +125,24 @@ mkdir -p "$HOME/projects" "$TMPDIR" "$SANDBOX/fakebin" export PATH="$SANDBOX/fakebin:$PATH" export CLAUDE_CONFIG_DIR="$HOME/.claude" # `LANES_WORKSPACE_ROOT` joins the list Amendment 9(a) made it part of: with -# every seam unset, the DEFAULT resolution — `$AGENT_PROTOCOL_ROOT/workspace.yaml` -# — is what every case below exercises, which is the whole point of unsetting -# them. `AGENT_PROTOCOL_ROOT` is then re-exported into the sandbox, two lines +# every seam unset, the resolution through `$AGENT_PROTOCOL_ROOT/workspace.yaml` +# is what finds W for every case below — and, since Amendment 23, the cases +# write through the ONE seam set back further down, `LANES_REPO=$WIP`. +# `AGENT_PROTOCOL_ROOT` is then re-exported into the sandbox, two lines # down, so the default finds the sandbox's file and never the operator's. unset LANES_FILE LANES_EDIT LANES_REPO LANES_PATH LANES_LANE LANES_WORKSPACE_ROOT \ - LANES_REPOS_TSV LANES_REPOS_TSV_SHIPPED PROJECTS_ROOT CLAUDE_PROJECTS_DIR CLAUDE_BIN CLAUDE_NO_LANE 2>/dev/null + LANES_REPOS_TSV LANES_REPOS_TSV_SHIPPED PROJECTS_ROOT CLAUDE_PROJECTS_DIR CLAUDE_BIN CLAUDE_NO_LANE \ + LANES_DIR LANES_NO_FETCH LANES_NO_GIT LANES_GIT LANES_SESSION 2>/dev/null +# AND EVERY OTHER `LANES_*` AND `TMUX*` THE CALLER'S SHELL CARRIES (opensoft/ +# openRepoTools#193 item 25; lane openRepoTools-3's condition on act 2's seam, +# ort186 Q9). A launched lane exports `LANES_REPO` and `LANES_WORKSPACE_ROOT` +# into every shell it starts, and Amendment 23 adds two internal markers, so a +# run from inside a lane window would otherwise read — and write — the real +# register. The names above stay spelled out; this catches the ones nobody has +# named yet. The suite sets its own `TMUX`, `LANES_WORKSTATION` and seam below. +for a23_var in $(compgen -v); do + case "$a23_var" in LANES_*|TMUX*) unset "$a23_var" 2>/dev/null ;; esac +done export AGENT_PROTOCOL_ROOT="$HOME/.agents" mkdir -p "$AGENT_PROTOCOL_ROOT" # THE ONE `LANES_*` SEAM THIS SUITE SETS RATHER THAN UNSETS, for the reason the @@ -515,20 +527,52 @@ export PATH="$SANDBOX/fakebin:$OPENREPOTOOLS_BIN_DIR:$PATH" ORIGIN="$SANDBOX/origin.git" WIP="$HOME/projects/brett-wip" git init -q --bare -b main "$ORIGIN" +git -C "$ORIGIN" config gc.auto 0; git -C "$ORIGIN" config maintenance.auto false git clone -q "$ORIGIN" "$WIP" 2>/dev/null git -C "$WIP" config user.email "test@example.invalid" git -C "$WIP" config user.name "lane helper tests" +git -C "$WIP" config gc.auto 0; git -C "$WIP" config maintenance.auto false mkdir -p "$WIP/lanes" "$WIP/handoffs" printf '# handoffs — one directory per estate\n' > "$WIP/handoffs/README.md" +# AMENDMENT 23 — THE SUITE WRITES THROUGH THE EXPLICIT `LANES_REPO` SEAM, and +# `$WIP` is that checkout (S2.1: "the test seam … with S3–S5 applying in it"). +# Every assertion below on `$WIP` stands because every write still lands +# there: an explicit `LANES_REPO` is honoured in place. The workspace checkout +# W that `workspace.yaml` names is now a SEPARATE checkout path, because S5 +# refuses a write whose `LANES_REPO` is W itself — and it sits outside +# `$HOME/projects`, so the projects root every listing case reads is the one it +# always was. `LANES_FILE` goes beside `LANES_REPO` for `lane-start` and +# `lane-end`, which take the register from W whenever `LANES_FILE` is empty. +# Amendment 23's own behaviour, with the DEFAULT resolution, is proven in +# `tests/test_a23_*.py` (lane openRepoTools-3's ruling on ort186 Q9). +# +# W IS A SECOND VIEW OF `$WIP`'S OWN REPOSITORY, not a clone of its own: its +# `.git` is a gitfile naming `$WIP/.git`, and its `lanes`, `handoffs` and +# `.gitignore` are links into `$WIP`. So every tool that reads W ITSELF rather +# than `LANES_REPO` — `lane-worktrees` greps W's `origin/main`, `workspace-root` +# answers W — reads exactly what it read when W was `$WIP`, while S5's test, +# which compares the two CHECKOUT paths, sees two. Nothing in this suite writes +# W, and nothing may: it shares `$WIP`'s index. +WIP_RO="$SANDBOX/ro/brett-wip" +mkdir -p "$WIP_RO" +printf 'gitdir: %s\n' "$(cd -P -- "$WIP" && pwd -P)/.git" > "$WIP_RO/.git" +ln -s "$WIP/lanes" "$WIP_RO/lanes" +ln -s "$WIP/handoffs" "$WIP_RO/handoffs" +ln -s "$WIP/.gitignore" "$WIP_RO/.gitignore" + # THE POINTER FILE, which is the whole of the new resolution (Amendment 9(a)). # `repository:` is the sandbox's own bare origin rather than a GitHub slug, and # the helpers' checkout test compares the two after the same normalisation, so # a non-GitHub origin is checked exactly as a GitHub one is. { printf 'repository: %s\n' "$ORIGIN" - printf 'path: %s\n' "$WIP" + printf 'path: %s\n' "$WIP_RO" } > "$AGENT_PROTOCOL_ROOT/workspace.yaml" +# The REAL spelling, as the resolver's `pwd -P` gave every helper W before. +LANES_REPO="$(cd -P -- "$WIP" && pwd -P)" +export LANES_REPO +export LANES_FILE="$LANES_REPO/lanes/LANES.md" LANES="$WIP/lanes/LANES.md" { @@ -2455,7 +2499,7 @@ has "…carrying the merge sha as a payload" "$(cat "$LOGD/repoZA-1.md")" ", o ZL_ID="aaaa0006-2222-4000-8000-aaaa00062222" write_record_ns "$sessions_dir/live-zl.json" "$ZL_ID" "$LIVE_PID" "$live_start" "othersess:@9.%9" "repozl-99" "derived" "busy" git -C "$CLONE2" pull -q --rebase origin main 2>/dev/null -( LANES_WORKSPACE_ROOT="$CLONE2" LANES_LANE=repoZL-1 "$E" add-row \ +( LANES_REPO="$CLONE2" LANES_FILE="$CLONE2/lanes/LANES.md" LANES_LANE=repoZL-1 "$E" add-row \ "| \`repoZL-1\` | harness \`$ZL_ID\` | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoZL/x.md | ACTIVE |" ) >/dev/null 2>&1 is "the peer's row is on origin/main and not in this checkout" "$(grep -c '^| `repoZL-1`' "$LANES" || :)" 0 run env LANES_NO_FETCH=1 "$E" live-holder repoZL-1 @@ -2471,7 +2515,7 @@ ZS_ID="aaaa0007-3333-4000-8000-aaaa00073333" write_record_ns "$sessions_dir/live-zs.json" "$ZS_ID" "$LIVE_PID" "$live_start" "othersess:@9.%9" "repozs-99" "derived" "busy" "$E" add-row "| \`repoZS-1\` | pending — set by the session's first act | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoZS/x.md | ACTIVE |" >/dev/null 2>&1 git -C "$CLONE2" pull -q --rebase origin main 2>/dev/null -( LANES_WORKSPACE_ROOT="$CLONE2" LANES_LANE=repoZS-1 "$E" replace-in-row repoZS-1 \ +( LANES_REPO="$CLONE2" LANES_FILE="$CLONE2/lanes/LANES.md" LANES_LANE=repoZS-1 "$E" replace-in-row repoZS-1 \ "pending — set by the session's first act" "harness \`$ZS_ID\`" "the session stamps its own row" ) >/dev/null 2>&1 is "this checkout's row still records no session id" "$(grep '^| `repoZS-1`' "$LANES" | grep -c 'pending' || :)" 1 zs_before="$(grep -c 'rename-window repoZS-1' "$FAKE_TMUX_LOG" || :)" @@ -2484,7 +2528,7 @@ git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : # lane-start: "this lane is new" is decided against what has LANDED, so a # second row is refused rather than written. git -C "$CLONE2" pull -q --rebase origin main 2>/dev/null -( LANES_WORKSPACE_ROOT="$CLONE2" LANES_LANE=repoZD-1 "$E" add-row \ +( LANES_REPO="$CLONE2" LANES_FILE="$CLONE2/lanes/LANES.md" LANES_LANE=repoZD-1 "$E" add-row \ "| \`repoZD-1\` | harness \`$DEAD_ID\` | Raven / test / brett | 2026-09-11T00:00Z | none | handoffs/repoZD/x.md | ACTIVE |" ) >/dev/null 2>&1 is "the peer's row for repoZD-1 is on origin/main and not here" "$(grep -c '^| `repoZD-1`' "$LANES" || :)" 0 zd_before="$(grep -c 'rename-window repoZD-1' "$FAKE_TMUX_LOG" || :)" @@ -2540,7 +2584,7 @@ hasnt "…so no ENDED line is missing from a closed lane" "$(cat "$LOGD/repoZH-1 # working tree that a fetch does not move. "$E" add-row "| \`repoZZ-1\` | harness \`$DEAD_ID\` | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoZZ/x.md | IDLE, NOTHING CLAIMED |" >/dev/null 2>&1 git -C "$CLONE2" pull -q --rebase origin main 2>/dev/null -( LANES_WORKSPACE_ROOT="$CLONE2" LANES_LANE=repoZZ-1 "$E" set-row-state repoZZ-1 \ +( LANES_REPO="$CLONE2" LANES_FILE="$CLONE2/lanes/LANES.md" LANES_LANE=repoZZ-1 "$E" set-row-state repoZZ-1 \ "LANDING #8 · into repoZZ main, posted by the peer" ) >/dev/null 2>&1 is "the peer's LANDING is on origin/main and not in this checkout's row" "$(grep '^| `repoZZ-1`' "$LANES" | grep -c 'LANDING #8' || :)" 0 run "$END" repoZZ-1 @@ -2768,7 +2812,7 @@ printf '{"type":"user"}\n' > "$tdir/$ZC1.jsonl" printf '{"type":"user"}\n' > "$tdir/$ZC2.jsonl" "$E" add-row "| \`repoZC-1\` | harness \`$ZC1\` | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoZC/x.md | ACTIVE |" >/dev/null 2>&1 git -C "$CLONE2" pull -q --rebase origin main 2>/dev/null -( LANES_WORKSPACE_ROOT="$CLONE2" LANES_LANE=repoZC-1 "$E" replace-in-row repoZC-1 \ +( LANES_REPO="$CLONE2" LANES_FILE="$CLONE2/lanes/LANES.md" LANES_LANE=repoZC-1 "$E" replace-in-row repoZC-1 \ "harness \`$ZC1\`" "harness \`$ZC1\` → harness \`$ZC2\`" "the session stamps its own row" ) >/dev/null 2>&1 # First, the reviewer's own sequence: this checkout is BEHIND and has not pulled. @@ -4209,6 +4253,9 @@ PEEK_LOCK="$H_LOCK" PEEK_OUT="$SANDBOX/peek.pid" \ env PEEK_LOCK="$H_LOCK" PEEK_OUT="$SANDBOX/peek.pid" LANES_GIT="$SANDBOX/peekgit" \ "$E" set-row-state repoGH-1 "LIVE · PEEK" >/dev/null 2>&1 || : h_peek="$(cat "$SANDBOX/peek.pid" 2>/dev/null || printf '')" +# THE PID IS THE FIRST FIELD: the file is ` ` since +# Amendment 23 S2, because a pid does not cross a pid namespace. +h_peek="${h_peek%% *}" # THE `case` IS HOISTED OUT OF THE `$( )`, and it has to be (A9 Addendum 4, # R-A9-11). Bash 3.2's parser reads the `)` that closes a case PATTERN as the # one that closes the command substitution, so this exact expression is a @@ -4421,6 +4468,9 @@ FOREIGN="$SANDBOX/foreign" git init -q -b main "$FOREIGN" git -C "$FOREIGN" remote add origin "https://github.com/opensoft/not-your-wip.git" +# EACH HELPER RUNS WITH THE SEAM UNSET (Amendment 23): what these cases refuse +# is the DEFAULT resolution through workspace.yaml, and `lane-start` and +# `lane-end` never ask for W at all while `LANES_REPO` and `LANES_FILE` are set. for helper in lanes-edit.sh lane-start lane-end link-estates; do case "$helper" in lanes-edit.sh) args=(who --lane repoA-1) ;; @@ -4433,33 +4483,33 @@ for helper in lanes-edit.sh lane-start lane-end link-estates; do esac rm -f -- "$WS_YAML" - ws_run "$helper" "${args[@]}" + ws_run env -u LANES_REPO -u LANES_FILE "$helper" "${args[@]}" is "$helper refuses with exit 1 when there is no workspace.yaml" "$WS_RC" 1 has "…and names the file that is missing" "$WS_MSG" "there is no $WS_YAML" has "…and names the one command that writes it" "$WS_MSG" "openRepoTools wip init" printf 'path: %s\n' "$WIP" > "$WS_YAML" - ws_run "$helper" "${args[@]}" + ws_run env -u LANES_REPO -u LANES_FILE "$helper" "${args[@]}" is "$helper refuses a workspace.yaml with no repository:" "$WS_RC" 1 has "…saying which key is missing" "$WS_MSG" "names no \`repository:\`" printf 'repository: %s\n' "$ORIGIN" > "$WS_YAML" - ws_run "$helper" "${args[@]}" + ws_run env -u LANES_REPO -u LANES_FILE "$helper" "${args[@]}" is "$helper refuses a workspace.yaml with no path:" "$WS_RC" 1 has "…saying which key is missing" "$WS_MSG" "names no \`path:\`" { printf 'repository: %s\n' "$ORIGIN"; printf 'path: %s/nowhere\n' "$SANDBOX"; } > "$WS_YAML" - ws_run "$helper" "${args[@]}" + ws_run env -u LANES_REPO -u LANES_FILE "$helper" "${args[@]}" is "$helper refuses a path: that is not a git checkout" "$WS_RC" 1 has "…saying so in those words" "$WS_MSG" "which is not a git checkout" { printf 'repository: %s\n' "$ORIGIN"; printf 'path: %s\n' "$FOREIGN"; } > "$WS_YAML" - ws_run "$helper" "${args[@]}" + ws_run env -u LANES_REPO -u LANES_FILE "$helper" "${args[@]}" is "$helper refuses a path: that is a checkout of something ELSE" "$WS_RC" 1 has "…naming both repositories" "$WS_MSG" "is a checkout of" { printf 'repository: %s\n' "$ORIGIN"; printf 'path: %s/lanes\n' "$WIP"; } > "$WS_YAML" - ws_run "$helper" "${args[@]}" + ws_run env -u LANES_REPO -u LANES_FILE "$helper" "${args[@]}" is "$helper refuses a path: that is a SUBDIRECTORY, not the root" "$WS_RC" 1 has "…naming the root it should have been" "$WS_MSG" "not the ROOT of that checkout" @@ -4470,13 +4520,13 @@ done # path derived from its own location — which is what Amendment 9(a) retired, # and what an installed command in ~/.local/bin could not do anyway. rm -f -- "$WS_YAML" -ws_run lanes-edit.sh who --lane repoA-1 +ws_run env -u LANES_REPO -u LANES_FILE lanes-edit.sh who --lane repoA-1 hasnt "the refusal names no path derived from the command's own location" \ "$WS_MSG" "$OPENREPOTOOLS_BIN_DIR/lanes" cp -- "$WS_YAML_SAVED" "$WS_YAML" is "the sandbox's own pointer file is restored for the guards below" \ - "$(sed -n 's/^path:[[:space:]]*//p' "$WS_YAML" | head -n1)" "$WIP" + "$(sed -n 's/^path:[[:space:]]*//p' "$WS_YAML" | head -n1)" "$WIP_RO" echo "== Amendment 11: the record's fields, and clause (h)'s reads ==" @@ -11974,8 +12024,9 @@ is "…and so is history with no lane" "$rc" 64 # the register it is pointed at and appends to every lane's log. Run against the # suite's own sandbox register it would rewrite the fixtures of every case above # and below it, and what it is being tested for — that it takes a whole register -# at once — is exactly what makes that unsafe. `LANES_WORKSPACE_ROOT` is the -# seam Amendment 9(a) left for pointing a helper at another sandbox. +# at once — is exactly what makes that unsafe. An explicit `LANES_REPO` (with +# its `LANES_FILE`) is the seam for pointing a helper at another sandbox; it +# was `LANES_WORKSPACE_ROOT` until Amendment 23 made that W, which no write uses. MIG_ORIGIN="$SANDBOX/a13-origin.git"; MIG_WIP="$SANDBOX/a13wip" git init -q --bare -b main "$MIG_ORIGIN" git clone -q "$MIG_ORIGIN" "$MIG_WIP" 2>/dev/null @@ -12013,7 +12064,7 @@ git -C "$MIG_WIP" push -q -u origin main MIG_HEAD0="$(git -C "$MIG_WIP" rev-parse HEAD)" a13_tmp_before="$(ls -d "$TMPDIR"/tmp.* 2>/dev/null | grep -c . || :)" -run env LANES_WORKSPACE_ROOT="$MIG_WIP" "$E" migrate-state-cells +run env LANES_REPO="$MIG_WIP" LANES_FILE="$MIG_WIP/lanes/LANES.md" "$E" migrate-state-cells is "the migration is a DRY RUN by default" "$rc" 0 has "…saying so" "$out" "DRY RUN, nothing is written" has "…naming the archive it would write" "$out" "lanes/archive/LANES-pre-amendment-13-" @@ -12032,7 +12083,7 @@ is "…not even a commit" "$(git -C "$MIG_WIP" rev-parse HEAD)" "$MIG_HEAD0" # untracked file is in neither list and this act would commit its first lines # inside the migration's own commit, under the migration's message. printf '# lane repoMig-4 — object log (seeded by a peer, uncommitted)\n' > "$MIG_WIP/lanes/log/repoMig-4.md" -run env LANES_WORKSPACE_ROOT="$MIG_WIP" "$E" migrate-state-cells +run env LANES_REPO="$MIG_WIP" LANES_FILE="$MIG_WIP/lanes/LANES.md" "$E" migrate-state-cells is "…a row whose object log is untracked is SKIPPED, not committed for its author" "$rc" 0 has "…naming the file and whose it is" "$out" "exists here but is NOT TRACKED" has "…and counting it among the skipped" "$out" "1 skipped" @@ -12043,7 +12094,7 @@ git -C "$MIG_WIP" push -q origin main 2>/dev/null || : # this act makes is counted from where the act starts, not from the seed. MIG_HEAD1="$(git -C "$MIG_WIP" rev-parse HEAD)" -run env LANES_WORKSPACE_ROOT="$MIG_WIP" "$E" migrate-state-cells --yes +run env LANES_REPO="$MIG_WIP" LANES_FILE="$MIG_WIP/lanes/LANES.md" "$E" migrate-state-cells --yes is "the act exits 0" "$rc" 0 is "…and it is ONE commit (Rule 9), not one per row" \ "$(git -C "$MIG_WIP" rev-list --count "$MIG_HEAD1"..HEAD)" 1 @@ -12089,7 +12140,7 @@ printf '| `%s` | harness `%s` | Eagle / test / brett | 2026-09-12 | none | h | A git -C "$MIG_WIP" add -A >/dev/null 2>&1 git -C "$MIG_WIP" commit -q -m "seed a lane whose name alone is longer than the cap" git -C "$MIG_WIP" push -q origin main 2>/dev/null || : -run env LANES_WORKSPACE_ROOT="$MIG_WIP" "$E" migrate-state-cells --yes +run env LANES_REPO="$MIG_WIP" LANES_FILE="$MIG_WIP/lanes/LANES.md" "$E" migrate-state-cells --yes is "a lane whose name alone overruns the cap still migrates" "$rc" 0 # TWO LINES, AND THE SPLIT IS NOT A STYLE CHOICE: `grep` and the awk program's # `\|` on ONE line is what `test_no_shipped_bash_reaches_for_gnu_only_utilities_unaccompanied` @@ -12103,17 +12154,17 @@ is "…with its line inside ratified decision O1's 240 characters" "$mig_long has "…and the line says it was cut" "$(grep "^| \`$MIG_LONG\`" "$MIG_WIP/lanes/LANES.md")" " ..." MIG_HEAD2="$(git -C "$MIG_WIP" rev-parse HEAD)" -run env LANES_WORKSPACE_ROOT="$MIG_WIP" "$E" history repoMig-1 --since 2026-09-12T11:00:00Z +run env LANES_REPO="$MIG_WIP" LANES_FILE="$MIG_WIP/lanes/LANES.md" "$E" history repoMig-1 --since 2026-09-12T11:00:00Z is "history reads the migrated narrative" "$rc" 0 hasnt "…--since drops every entry older than the instant" "$out" "opened the PR" has "…and keeps the instant itself" "$out" "land it as drafted" has "…and what came after it" "$out" "PAUSED for the night" -run env LANES_WORKSPACE_ROOT="$MIG_WIP" "$E" migrate-state-cells --yes +run env LANES_REPO="$MIG_WIP" LANES_FILE="$MIG_WIP/lanes/LANES.md" "$E" migrate-state-cells --yes is "a SECOND run refuses: the migration is ONE act" "$rc" 2 has "…saying the register is already in the shape" "$err" "already in Amendment 13(a)'s shape" is "…and changed nothing" "$(git -C "$MIG_WIP" rev-list --count "$MIG_HEAD2"..HEAD)" 0 -run env LANES_WORKSPACE_ROOT="$MIG_WIP" "$E" migrate-state-cells --no-such-flag +run env LANES_REPO="$MIG_WIP" LANES_FILE="$MIG_WIP/lanes/LANES.md" "$E" migrate-state-cells --no-such-flag is "an unknown flag is a refusal, not a silent dry run" "$rc" 2 echo "== Amendment 19: a closed or dormant lane leaves the listing ==" @@ -12121,8 +12172,8 @@ echo "== Amendment 19: a closed or dormant lane leaves the listing ==" # ITS OWN WORKSPACE, for `migrate-state-cells`'s reason one section up: this # section RETIRES a whole repository's dormant rows and creates their logs, and # pointed at the suite's own register it would close fixtures the cases above -# and below it are built on. `LANES_WORKSPACE_ROOT` is the seam Amendment 9(a) -# left for pointing a helper at another sandbox. +# and below it are built on. An explicit `LANES_REPO` with its `LANES_FILE` is +# the seam for pointing a helper at another sandbox (Amendment 23 S2.1). # # THE SEED IS THE AMENDMENT'S OWN: a live lane, a parked one, an ended one and # THREE ROWS WITH NO LOG — the population measured on openxFactory on @@ -12178,7 +12229,7 @@ git -C "$A19_WIP" commit -q -m "seed the Amendment 19 sandbox" git -C "$A19_WIP" push -q -u origin main A19_HEAD0="$(git -C "$A19_WIP" rev-parse HEAD)" -a19() { env LANES_WORKSPACE_ROOT="$A19_WIP" "$@"; } +a19() { env LANES_REPO="$A19_WIP" LANES_FILE="$A19_WIP/lanes/LANES.md" "$@"; } a19_field() { # printf '%s\n' "$1" | awk -F'\t' -v l="$2" -v f="$3" '$1 == l { print $f; exit }' } @@ -14187,16 +14238,20 @@ is "…and its verdict is the cutover one" \ # the fixture the last case of this file asserts is still running — so it cannot # be stolen as a dead holder's, and `timeout`'s own 124 is the proof the writer # was still WAITING for it rather than exiting early on something else. +# THE LOCK IS THE WORKSTATION'S SIDECAR LOCK since Amendment 23 S2 (ort186 Q3): +# the inventory, the lifecycle snapshot and the restart intent share it, beside +# W's lane checkouts, and W's own mutex no longer serialises them. +H_SIDECAR="$(cd -P -- "$WIP_RO" && pwd -P)-lanes/.lane-state.lock" if [ "$HAVE_TIMEOUT" = 1 ]; then - mkdir -p "$H_LOCK"; printf '%s\n' "$LIVE_PID" > "$H_LOCK/pid" + mkdir -p "$H_SIDECAR"; printf '%s\n' "$LIVE_PID" > "$H_SIDECAR/pid" LANES_NO_FETCH=1 timeout 3 "$E" set-lane-tree repoRC-9 "$RC_DIR/.claude/worktrees/w1" \ --checkout "$RC_DIR" --branch feat/rc1 --head "$rc5_w1head" --upstream none \ --dirty 1 --unpushed 0 >/dev/null 2>&1 rc9_lk=$? - is "an UNFENCED inventory write WAITS on the lane mutex another run holds" "$rc9_lk" 124 + is "an UNFENCED inventory write WAITS on the sidecar lock another run holds" "$rc9_lk" 124 run "$E" lane-trees repoRC-9 is "…so nothing of it was written while that run held the lock" "$rc" 8 - rm -rf "$H_LOCK" + rm -rf "$H_SIDECAR" run env LANES_NO_FETCH=1 "$E" set-lane-tree repoRC-9 "$RC_DIR/.claude/worktrees/w1" \ --checkout "$RC_DIR" --branch feat/rc1 --head "$rc5_w1head" --upstream none \ --dirty 1 --unpushed 0 @@ -14205,7 +14260,7 @@ if [ "$HAVE_TIMEOUT" = 1 ]; then is "…which is the inventory this lane then holds" \ "$(printf '%s\n' "$out" | awk -F'\037' '{print $2}' | head -n 1)" "$RC_DIR/.claude/worktrees/w1" else - skip "an UNFENCED inventory write WAITS on the lane mutex another run holds" "$NO_TIMEOUT_WHY" + skip "an UNFENCED inventory write WAITS on the sidecar lock another run holds" "$NO_TIMEOUT_WHY" skip "…so nothing of it was written while that run held the lock" "$NO_TIMEOUT_WHY" skip "…and the same write lands the moment the mutex is free" "$NO_TIMEOUT_WHY" skip "…which is the inventory this lane then holds" "$NO_TIMEOUT_WHY" @@ -14703,9 +14758,9 @@ git -C "$MGM_WIP" push -q -u origin main # (a) THE VALID SHORTHAND: 2, naming the owner, in both modes. mg_b="$(mgm_fp)" -run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" migrate-state-cells +run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" migrate-state-cells mgm_refused "the migration's dry run over the valid shorthand" 2 ledger-2 "$mg_b" -run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" migrate-state-cells --yes +run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" migrate-state-cells --yes mgm_refused "migrate-state-cells --yes over the valid shorthand" 2 ledger-2 "$mg_b" hasnt "…and never rewrites the marker to the migration's own word" "$(cat "$MGM_WIP/lanes/LANES.md")" "MIGRATED" @@ -14714,9 +14769,9 @@ mgm_register "MANAGED OWNER · ledger 2" git -C "$MGM_WIP" commit -q -am "the ledger lands a shorthand whose token does not parse" git -C "$MGM_WIP" push -q origin main mg_b="$(mgm_fp)" -run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" managed-projection repoMG-2 +run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" managed-projection repoMG-2 is "the malformed shorthand reads as unknown (1) before the migration asks" "$rc" 1 -run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" migrate-state-cells --yes +run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" migrate-state-cells --yes mgm_refused "migrate-state-cells --yes over a malformed shorthand" 1 - "$mg_b" # (c) THE ROW REWRITTEN IS THIS CHECKOUT'S. Published as a legacy one-word row, @@ -14729,19 +14784,19 @@ git -C "$MGM_WIP" push -q origin main mgm_register "MANAGED OWNER · ledger-3" git -C "$MGM_WIP" commit -q -am "an unpushed marker, in this checkout only" mg_b="$(mgm_fp)" -run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" managed-projection repoMG-2 +run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" managed-projection repoMG-2 is "the two copies' disagreement reads as unknown (1), never the published row's legacy 8" "$rc" 1 has "…naming the register file it read" "$err" "register file, $MGM_WIP/lanes/LANES.md, carries" has "…and both ways the copies can disagree" "$err" "AHEAD of origin" has "…a fast-forward being the other remedy" "$err" "fast-forward it" -run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" migrate-state-cells --yes +run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" migrate-state-cells --yes mgm_refused "migrate-state-cells --yes over a marker only this checkout carries" 1 - "$mg_b" has "…saying the two copies disagree" "$err" "the two copies disagree on" # (d) AND THE SAME REGISTER WITH NO MARKER IN EITHER COPY MIGRATES, so each # refusal above was the seam's and not the fixture's. git -C "$MGM_WIP" reset -q --hard origin/main -run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" migrate-state-cells --yes +run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" migrate-state-cells --yes is "the legacy register itself migrates" "$rc" 0 has "…taking the diary row" "$out" "1 to migrate" has "…into its log" "$(cat "$MGM_WIP/lanes/log/repoMG-3.md")" "NOTED — lane repoMG-3" @@ -14780,9 +14835,9 @@ git -C "$MGM_WIP" push -q origin main # (a) THE VALID MARKER: 2, naming the owner, in both modes. mg_b="$(mga_fp)" -run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" archive-rows repoMG +run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" archive-rows repoMG mga_refused "archive-rows' dry run over a retired managed row" 2 ledger-1 "$mg_b" -run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" archive-rows repoMG --yes +run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" archive-rows repoMG --yes mga_refused "archive-rows --yes over a retired managed row" 2 ledger-1 "$mg_b" # (b) A MALFORMED ONE — generation=0 — is UNKNOWN: 1. @@ -14792,7 +14847,7 @@ cat "$SANDBOX/mga-register" > "$MGM_WIP/lanes/LANES.md" git -C "$MGM_WIP" commit -q -am "the ledger lands a retired marker whose generation does not parse" git -C "$MGM_WIP" push -q origin main mg_b="$(mga_fp)" -run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" archive-rows repoMG --yes +run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" archive-rows repoMG --yes mga_refused "archive-rows --yes over a malformed retired marker" 1 - "$mg_b" # (c) AND WITH THAT ROW GONE THE SAME MOVE LANDS, so each refusal above was the @@ -14801,7 +14856,7 @@ grep -v '^| `repoMG-11` ' "$MGM_WIP/lanes/LANES.md" > "$SANDBOX/mga-register" cat "$SANDBOX/mga-register" > "$MGM_WIP/lanes/LANES.md" git -C "$MGM_WIP" commit -q -am "the managed ledger takes its row back" git -C "$MGM_WIP" push -q origin main -run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" archive-rows repoMG --yes +run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" archive-rows repoMG --yes is "the legacy retired row alone is archived" "$rc" 0 is "…out of the register" "$(grep -c '^| `repoMG-12` ' "$MGM_WIP/lanes/LANES.md" || :)" 0 is "…and into the archive" "$(grep -c '^| `repoMG-12` ' "$MGM_WIP/lanes/archive/LANES-retired.md" || :)" 1 @@ -16224,6 +16279,15 @@ is "the suite never created a log outside the sandbox" \ "$(ls "$REAL_WS/lanes/log" 2>/dev/null | grep -c '^repo[A-Z]' || :)" 0 is "the suite never wrote the real register" \ "$(cat "$REAL_WS/lanes/LANES.md" 2>/dev/null | grep -c '^| `repo[A-Z]' || :)" 0 +# AMENDMENT 23 — NOR MADE A LANE CHECKOUT BESIDE IT. A write that missed the +# seam would resolve through workspace.yaml and clone `-lanes//`, so the +# operator's `-lanes` must carry no fixture lane, partial or lock… +is "the suite never made a lane checkout beside the real workspace" \ + "$(ls -A -- "${REAL_WS:+$REAL_WS-lanes}" 2>/dev/null | grep -c '^\.\{0,1\}repo[A-Z]' || :)" 0 +# …and nor did it beside the sandbox's own W: every write in this file went +# through the explicit `LANES_REPO` seam, which makes no lane checkout at all. +is "…and every write went through the seam: no lane checkout beside the sandbox's W" \ + "$(ls -A -- "$WIP_RO-lanes" 2>/dev/null | grep -c '^\.\{0,1\}repo[A-Z]' || :)" 0 # BOTH OF THEM READ A PIPE, and the second one did not until A9 Addendum 4 # (R-A9-13, F3). `grep -c PATTERN FILE` on a file that is not there prints # NOTHING and exits 2, so with no `~/.agents/workspace.yaml` on the host diff --git a/tests/test_lane_helpers_suite.py b/tests/test_lane_helpers_suite.py index 602c03dc..ec89cf6c 100644 --- a/tests/test_lane_helpers_suite.py +++ b/tests/test_lane_helpers_suite.py @@ -168,7 +168,15 @@ def test_the_lane_helper_suite_passes(): for name in ("LANES_FILE", "LANES_EDIT", "LANES_REPO", "LANES_PATH", "LANES_LANE", "LANES_WORKSPACE_ROOT", "LANES_REPOS_TSV", "LANES_REPOS_TSV_SHIPPED", "AGENT_PROTOCOL_ROOT", - "OPENREPOTOOLS_BIN_DIR", "PROJECTS_ROOT", "CLAUDE_NO_LANE"): + "OPENREPOTOOLS_BIN_DIR", "PROJECTS_ROOT", "CLAUDE_NO_LANE", + "LANES_DIR", "LANES_NO_FETCH", "LANES_NO_GIT", "LANES_GIT", + "LANES_SESSION"): + env.pop(name, None) + # AND EVERY OTHER `LANES_*` AND `TMUX*` (#193 item 25; ort186 Q9's + # condition on Amendment 23's seam): a launched lane exports `LANES_REPO` + # and `LANES_WORKSPACE_ROOT` into every shell, and the suite clears these + # again itself, so a run from a lane window reads no real register. + for name in [n for n in env if n.startswith(("LANES_", "TMUX"))]: env.pop(name, None) # THE LANE MANUAL IS THE SPEC LEG'S (opensoft/openRepoTools#186, T009): # the suite quotes `docs/README-lanes.md` back against the code, and in the diff --git a/tests/test_lane_start_claude_current.py b/tests/test_lane_start_claude_current.py index 2283b137..9bdb262b 100644 --- a/tests/test_lane_start_claude_current.py +++ b/tests/test_lane_start_claude_current.py @@ -208,8 +208,21 @@ def __init__(self, root: Path) -> None: "handoffs/README.md") self.git("-C", str(self.wip), "commit", "-q", "-m", "seed") self.git("-C", str(self.wip), "push", "-q", "origin", "main") + # AMENDMENT 23 S5: W, the readers' copy that workspace.yaml names, takes + # no write, and a write that resolves by default goes through the lane's + # own checkout beside it. These cases write through `self.wip` by the + # explicit seam (ort186 Q9) — `LANES_REPO`, and `LANES_FILE`, which + # lane-start and lane-end derive from W whenever it is empty — so every + # assertion on `self.wip` reads what the commands wrote. W itself is a + # reader clone outside the projects root, so no listing changes. + self.ws = root / "ro" / "wip" + self.git("clone", "-q", str(self.origin), str(self.ws)) + self.git("-C", str(self.ws), "config", "gc.auto", "0") + self.git("-C", str(self.ws), "config", "maintenance.auto", "false") (self.home / ".agents" / "workspace.yaml").write_text( - f"repository: {self.origin}\npath: {self.wip}\n", encoding="utf-8") + f"repository: {self.origin}\npath: {self.ws}\n", encoding="utf-8") + self.env["LANES_REPO"] = str(self.wip) + self.env["LANES_FILE"] = str(self.wip / "lanes" / "LANES.md") self.git("init", "-q", "-b", "main", str(self.lane_dir)) self.git("-C", str(self.lane_dir), "remote", "add", "origin", diff --git a/tests/test_lane_start_row_stamp.py b/tests/test_lane_start_row_stamp.py index ee451e9e..69bab0d2 100644 --- a/tests/test_lane_start_row_stamp.py +++ b/tests/test_lane_start_row_stamp.py @@ -181,8 +181,18 @@ def __init__(self, tmp_path: Path) -> None: for step in (("add", "--", *WORKSPACE_SEED), ("commit", "-q", "-m", "seed"), ("push", "-q", "origin", "main")): self.git("-C", str(self.wip), *step) + # AMENDMENT 23 S5: W, the readers' copy that workspace.yaml names, takes + # no write. These cases write through `self.wip` by the explicit seam + # (ort186 Q9) — `LANES_REPO`, and `LANES_FILE`, which lane-start derives + # from W whenever it is empty — and W is a reader clone outside $HOME. + self.ws = self.root / "ro" / "wip" + self.git("clone", "-q", str(origin), str(self.ws)) + for key, value in (("gc.auto", "0"), ("maintenance.auto", "false")): + self.git("-C", str(self.ws), "config", key, value) (self.home / ".agents" / "workspace.yaml").write_text( - f"repository: {origin}\npath: {self.wip}\n", encoding="utf-8") + f"repository: {origin}\npath: {self.ws}\n", encoding="utf-8") + self.env["LANES_REPO"] = str(self.wip) + self.env["LANES_FILE"] = str(self.wip / "lanes" / "LANES.md") def git(self, *args: str) -> str: return subprocess.run(["git", *args], env=self.env, check=True, diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index d24c3216..50c094e8 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -33,6 +33,15 @@ def restart_box(tmp_path: Path) -> Sandbox: return box +def sidecar_lock(box): + """Amendment 23 S2: the restart intent, the lifecycle records and + `publish-handoff`'s fence share the WORKSTATION's sidecar lock, beside W's + lane checkouts (ort186 Q3); W's own mutex no longer serialises them.""" + lock = Path(os.path.realpath(box.ws) + "-lanes") / ".lane-state.lock" + lock.parent.mkdir(parents=True, exist_ok=True) + return lock + + def helper(box, *args): return subprocess.run([str(box.bin / "lanes-edit.sh"), *args], cwd=box.lane_dir, env=box.env, text=True, @@ -1454,13 +1463,13 @@ def test_handoff_target_is_resolved_after_waiting_for_writer_mutex(restart_box): prepared = _write(box.root / "prepared.md", "replacement bytes\n", 0o600) digest = hashlib.sha256(original.read_bytes()).hexdigest() before = (original.read_bytes(), other.read_bytes()) - lock = box.wip / "lanes" / ".lanes-edit.lock" + lock = sidecar_lock(box) lock.mkdir() (lock / "pid").write_text(str(os.getpid()) + "\n") marker = box.root / "waiting-for-writer" real_mkdir = shutil.which("mkdir") _write(box.fakebin / "mkdir", f"""#!/usr/bin/env bash -case "$*" in *.lanes-edit.lock*) touch '{marker}' ;; esac +case "$*" in *.lane-state.lock*) touch '{marker}' ;; esac exec '{real_mkdir}' "$@" """) proc = subprocess.Popen([str(box.bin / "lanes-edit.sh"), "publish-handoff", LANE, @@ -1802,7 +1811,7 @@ def test_restart_root_waiting_writer_discovers_new_authority_under_mutex(restart box = restart_box _, fallback, alternate = restart_root_without_recorded_checkout(box) record_nested_restart_checkout(box) - lock = box.wip / "lanes" / ".lanes-edit.lock" + lock = sidecar_lock(box) lock.mkdir() (lock / "pid").write_text(str(os.getpid())) waited = box.root / "writer.waited" diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py index 3b6551e3..8f2349aa 100644 --- a/tests/test_triad_lane_tooling.py +++ b/tests/test_triad_lane_tooling.py @@ -171,8 +171,25 @@ def _seed_workspace(self) -> None: self.git("add", "-A", cwd=self.wip) self.git("commit", "-q", "-m", "seed", cwd=self.wip) self.git("push", "-q", "origin", "main", cwd=self.wip) + # AMENDMENT 23 S5: W, the readers' copy that workspace.yaml names, takes + # no write, and a write that resolves by default goes through the lane's + # own checkout beside it. These cases hold c45a452's single-repository + # outputs, so the tools write through `self.wip` by the explicit seam + # (ort186 Q9): `LANES_REPO`, and `LANES_FILE`, which lane-start, + # lane-end and lane-handoff derive from W whenever it is empty. W is a + # VIEW of `self.wip` outside the projects root — its `.git` names + # `self.wip`'s, and its `lanes` and `handoffs` are links into it — + # because `lane-worktrees` reads W directly and a separate clone would + # stay behind every write these cases make. + self.ws = self.root / "ro" / "wip" + self.ws.mkdir(parents=True) + (self.ws / ".git").write_text(f"gitdir: {os.path.realpath(self.wip / '.git')}\n") + for name in ("lanes", "handoffs"): + (self.ws / name).symlink_to(self.wip / name) (self.home / ".agents" / "workspace.yaml").write_text( - f"repository: {bare}\npath: {self.wip}\n") + f"repository: {bare}\npath: {self.ws}\n") + self.env["LANES_REPO"] = str(self.wip) + self.env["LANES_FILE"] = str(self.wip / "lanes" / "LANES.md") def lanes(self, home: str, checkout: Path, *names: str) -> None: """A register row, a handoff and a STARTED line naming `checkout` for @@ -232,8 +249,11 @@ def normalise(text: str, bench: Bench) -> str: hashes, ages and byte sizes - and a porcelain row's byte count.""" # `$HOME` FIRST, and as a word of its own: `tests/test_repo_hygiene.py` # rightly refuses a committed `/home//` path, and the bench's home - # sits under its root. - for place, word in ((bench.home, ""), (bench.root, "")): + # sits under its root. W GOES FIRST (Amendment 23 S5): the bench's W is a + # view outside the projects root, and a line that names W reads as the + # workspace checkout's path did before W stopped taking writes. + for place, word in ((bench.ws, "/projects/wip"), (bench.home, ""), + (bench.root, "")): for spelling in sorted({str(place), os.path.realpath(place)}, key=len, reverse=True): text = text.replace(spelling, word) for rx, sub in _RULES: From 5dd0df07b6bcf8fd79f1dddc4c05b54d06da8e10 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 17:22:22 +0000 Subject: [PATCH 34/42] Re-run CI on main fe58808: refs/pull/246/merge was stale on 14239b9 No content change: this commit's tree is ea4db0d's. Lane openRepoTools-1, the lander, pushed it on lane openRepoTools-3's word in #186 6250521287, so the landing gate's Linux run tests the merge with current main. Co-Authored-By: Claude Opus 5.5 From 51efdb39d6945477f29a0a1648fb4fd9a3f2adb9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 18:21:41 +0000 Subject: [PATCH 35/42] lane-start and lane-end read a lane's row in its own register checkout, where its writes now go, so a new lane's second start and its end stop reading W's copy that no write updates (Amendment 23 S1, S2; act 2 slice 1a) (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bug: with slice 1a's routing, every write under the default resolution goes through the lane's own checkout, -lanes//, and W moves only by `refresh-workspace`. Both commands still read the lane's row from W's working tree. A new lane's first start added its row through the checkout, so the second start found a row on origin and none "in this checkout" and refused at lane-start:1591 ("ALREADY has a row … refusing to add a second row"). `lane-end` read the same W and said "the register has no row for lane … nothing to end". Coordinator's ruling: add the minimal read fix to 1a. The fix, the same block in both (`lane_register_follow`), once the lane's canonical name is known and before its row is read: - Under the default resolution (neither LANES_FILE nor LANES_REPO set), ask `lanes-edit.sh lane-checkout --create` and read that checkout's lanes/LANES.md. The checkout is made here if absent, as the first write would make it. - `--dry-run` creates nothing, and reads W where there is no checkout yet. - A helper that predates the verb writes W itself, so W is read. - A refusal, a failed clone or the creation lock stops the command before it launches, renames or writes. - Only the read moves. LANES_REPO, and the handoff lane-start publishes and commits, stay W's until slices 2a-3. Also one line in each: a caller's LANES_REPO without LANES_FILE now reads $LANES_REPO/lanes/LANES.md. That is where the helper writes since this slice; it no longer reads W's register. Tests: tests/test_a23_lane_start_read.py drives the real commands with no seam. Four cases, each killed by its one-line mutant: - a new lane's second start; - lane-end after it; - a dry run makes no checkout; - LANES_REPO alone is the register read. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lane-end | 49 +++++++++++++- lane-start | 49 +++++++++++++- tests/test_a23_lane_start_read.py | 107 ++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 tests/test_a23_lane_start_read.py diff --git a/lane-end b/lane-end index 05ec8b84..225e7e31 100755 --- a/lane-end +++ b/lane-end @@ -513,11 +513,18 @@ lanes_workspace_why() { # — so `$SCRIPT_DIR` answers both without a second rule. `$OPENREPOTOOLS_BIN_DIR` # and `$PATH` are the fallbacks for a command copied somewhere on its own. LANES_WS_ROOT="" +# AMENDMENT 23 (act 2, slice 1a). With neither variable set, every write of a +# lane goes through that lane's own register checkout, and the register this +# command READS follows it there once the lane's name is known +# (`lane_register_follow`, below). A caller's `LANES_REPO` without `LANES_FILE` +# is the checkout the helper now writes in, so its own register is the one read. +LANES_REG_DEFAULT=0 +[ -n "${LANES_FILE:-}" ] || [ -n "${LANES_REPO:-}" ] || LANES_REG_DEFAULT=1 if [ -z "${LANES_FILE:-}" ] || [ -z "${LANES_REPO:-}" ]; then LANES_WS_ROOT="$(lanes_workspace_root || :)" fi : "${LANES_REPO:=$LANES_WS_ROOT}" -LANES_FILE="${LANES_FILE:-${LANES_WS_ROOT:+$LANES_WS_ROOT/lanes/LANES.md}}" +LANES_FILE="${LANES_FILE:-${LANES_REPO:+$LANES_REPO/lanes/LANES.md}}" lanes_edit_default() { local d @@ -903,6 +910,46 @@ if [ "$cl_rc" = 0 ] && [ -n "$cl_out" ] && [ "$cl_out" != "$LANE" ]; then LANE="$cl_out" fi +# AMENDMENT 23 S1, S2 — THE REGISTER THIS COMMAND READS IS THE ONE ITS WRITES GO +# THROUGH. Under the default resolution every write of this lane goes through +# its own checkout, `-lanes//`, and W, the readers' copy, moves only by +# `lanes-edit.sh refresh-workspace`. So W's working tree does not carry the +# lane's own rows once they are written: a lane started through its checkout +# read as a lane with no row, and a second start refused to add a duplicate +# that W could not see. The row this command counts and acts on is therefore +# read from that checkout. The helper makes the checkout here if it is not +# there yet, exactly as this command's first write would; a dry run makes +# nothing and reads W where there is no checkout. ONLY THE READ MOVES: the +# handoff this command publishes is still W's until Amendment 23's later slices. +# A helper that predates the verb writes W itself, so W is what is read. +lane_register_follow() { + [ "$LANES_REG_DEFAULT" = 1 ] && [ -n "${LANE:-}" ] || return 0 + lrf_args=(lane-checkout "$LANE") + [ "${dry_run:-0}" = 1 ] || lrf_args+=(--create) + lrf_errf="$(mktemp "${TMPDIR:-/tmp}/${prog}-register.XXXXXX" 2>/dev/null || printf '')" + lrf_out=""; lrf_rc=0 + lrf_out="$("$LANES_EDIT" "${lrf_args[@]}" 2>"${lrf_errf:-/dev/null}")" || lrf_rc=$? + lrf_err="" + if [ -n "$lrf_errf" ]; then lrf_err="$(cat -- "$lrf_errf" 2>/dev/null || :)"; rm -f -- "$lrf_errf"; fi + case "$lrf_rc" in + 0) + [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&2 + [ -f "$lrf_out/lanes/LANES.md" ] || + die "lane $LANE's register checkout $lrf_out carries no lanes/LANES.md, so the lane's row cannot be read (Amendment 23 S1). Nothing was launched, renamed or written." 1 + LANES_FILE="$lrf_out/lanes/LANES.md" ;; + 8) : ;; # a dry run, and no checkout yet: W is what there is + 2) + case "$lrf_err" in *"unknown subcommand 'lane-checkout'"*) return 0 ;; esac + [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&2 + die "lane $LANE's own register checkout could not be made (\`$LANES_EDIT lane-checkout $LANE\` exited 2, above), so the row this command acts on cannot be read where its writes go (Amendment 23 S1). Nothing was launched, renamed or written." 2 ;; + *) + [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&2 + die "lane $LANE's own register checkout could not be read (\`$LANES_EDIT lane-checkout $LANE\` exited $lrf_rc, above), so the row this command acts on cannot be read where its writes go (Amendment 23 S1). Nothing was launched, renamed or written." 1 ;; + esac + return 0 +} +lane_register_follow + # THE SEAM (ruling 2026-10-04): A MANAGED-OWNED LANE IS NOT THIS COMMAND'S. # `lanes-edit.sh managed-projection` answers 0 with the owner where the lane's # register row carries the managed ledger's marker, 8 for a legacy lane, and 1 diff --git a/lane-start b/lane-start index d00e3195..a5bf61fb 100755 --- a/lane-start +++ b/lane-start @@ -590,11 +590,18 @@ lanes_workspace_why() { # — so `$SCRIPT_DIR` answers both without a second rule. `$OPENREPOTOOLS_BIN_DIR` # and `$PATH` are the fallbacks for a command copied somewhere on its own. LANES_WS_ROOT="" +# AMENDMENT 23 (act 2, slice 1a). With neither variable set, every write of a +# lane goes through that lane's own register checkout, and the register this +# command READS follows it there once the lane's name is known +# (`lane_register_follow`, below). A caller's `LANES_REPO` without `LANES_FILE` +# is the checkout the helper now writes in, so its own register is the one read. +LANES_REG_DEFAULT=0 +[ -n "${LANES_FILE:-}" ] || [ -n "${LANES_REPO:-}" ] || LANES_REG_DEFAULT=1 if [ -z "${LANES_FILE:-}" ] || [ -z "${LANES_REPO:-}" ]; then LANES_WS_ROOT="$(lanes_workspace_root || :)" fi : "${LANES_REPO:=$LANES_WS_ROOT}" -LANES_FILE="${LANES_FILE:-${LANES_WS_ROOT:+$LANES_WS_ROOT/lanes/LANES.md}}" +LANES_FILE="${LANES_FILE:-${LANES_REPO:+$LANES_REPO/lanes/LANES.md}}" lanes_edit_default() { local d @@ -1114,6 +1121,46 @@ esac repo="${repo:-$LANE}" # the verbatim form has no of its own estate="${estate:-$repo}" +# AMENDMENT 23 S1, S2 — THE REGISTER THIS COMMAND READS IS THE ONE ITS WRITES GO +# THROUGH. Under the default resolution every write of this lane goes through +# its own checkout, `-lanes//`, and W, the readers' copy, moves only by +# `lanes-edit.sh refresh-workspace`. So W's working tree does not carry the +# lane's own rows once they are written: a lane started through its checkout +# read as a lane with no row, and a second start refused to add a duplicate +# that W could not see. The row this command counts and acts on is therefore +# read from that checkout. The helper makes the checkout here if it is not +# there yet, exactly as this command's first write would; a dry run makes +# nothing and reads W where there is no checkout. ONLY THE READ MOVES: the +# handoff this command publishes is still W's until Amendment 23's later slices. +# A helper that predates the verb writes W itself, so W is what is read. +lane_register_follow() { + [ "$LANES_REG_DEFAULT" = 1 ] && [ -n "${LANE:-}" ] || return 0 + lrf_args=(lane-checkout "$LANE") + [ "${dry_run:-0}" = 1 ] || lrf_args+=(--create) + lrf_errf="$(mktemp "${TMPDIR:-/tmp}/${prog}-register.XXXXXX" 2>/dev/null || printf '')" + lrf_out=""; lrf_rc=0 + lrf_out="$("$LANES_EDIT" "${lrf_args[@]}" 2>"${lrf_errf:-/dev/null}")" || lrf_rc=$? + lrf_err="" + if [ -n "$lrf_errf" ]; then lrf_err="$(cat -- "$lrf_errf" 2>/dev/null || :)"; rm -f -- "$lrf_errf"; fi + case "$lrf_rc" in + 0) + [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&2 + [ -f "$lrf_out/lanes/LANES.md" ] || + die "lane $LANE's register checkout $lrf_out carries no lanes/LANES.md, so the lane's row cannot be read (Amendment 23 S1). Nothing was launched, renamed or written." 1 + LANES_FILE="$lrf_out/lanes/LANES.md" ;; + 8) : ;; # a dry run, and no checkout yet: W is what there is + 2) + case "$lrf_err" in *"unknown subcommand 'lane-checkout'"*) return 0 ;; esac + [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&2 + die "lane $LANE's own register checkout could not be made (\`$LANES_EDIT lane-checkout $LANE\` exited 2, above), so the row this command acts on cannot be read where its writes go (Amendment 23 S1). Nothing was launched, renamed or written." 2 ;; + *) + [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&2 + die "lane $LANE's own register checkout could not be read (\`$LANES_EDIT lane-checkout $LANE\` exited $lrf_rc, above), so the row this command acts on cannot be read where its writes go (Amendment 23 S1). Nothing was launched, renamed or written." 1 ;; + esac + return 0 +} +lane_register_follow + # ----------------------------------------- 1b. THE LANE'S DIRECTORY # # AMENDMENT 11 CLAUSE (c). A lane's checkout is known to every process that is diff --git a/tests/test_a23_lane_start_read.py b/tests/test_a23_lane_start_read.py new file mode 100644 index 00000000..2827f119 --- /dev/null +++ b/tests/test_a23_lane_start_read.py @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Amendment 23, act 2's slice 1a: `lane-start` and `lane-end` read a lane's +row where that lane's writes go. + +Under the default resolution every write of a lane goes through its own register +checkout, `-lanes//`, and W, the readers' copy that `workspace.yaml` +names, moves only by `refresh-workspace`. Both commands used to read the lane's +row from W's working tree. A new lane's first start added its row through the +checkout, so W never showed it. The second start then saw a row on origin and +none "in this checkout", and refused to add a duplicate (`lane-start:1591`). A +`lane-end` read the same W and found no row to end. + +Each case drives the real commands with no `LANES_REPO` and no `LANES_FILE`, so +the helper routes every write and the commands resolve W from `workspace.yaml` +as a lane window does. The sandbox is `test_lane_start_row_stamp`'s, with its +explicit seam taken back out. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +from conftest import REPO +from test_lane_start_row_stamp import LANE, Sandbox, _checkout_of_length, pytestmark, short_root # noqa: F401 + + +def default_resolution(box: Sandbox) -> Path: + """No seam: workspace.yaml names `box.wip` as W again. Returns the lane's checkout.""" + for key in ("LANES_REPO", "LANES_FILE"): + box.env.pop(key, None) + (box.home / ".agents" / "workspace.yaml").write_text( + f"repository: {box.root / 'origin.git'}\npath: {box.wip}\n", encoding="utf-8") + return Path(os.path.realpath(box.wip) + "-lanes") / LANE + + +def rows(register: Path) -> list[str]: + return [r for r in register.read_text(encoding="utf-8").splitlines() + if r.startswith(f"| `{LANE}`")] + + +def started(tmp_path: Path, short_root: Path) -> tuple[Sandbox, Path]: + """A NEW lane's first start: its row is added through its own checkout.""" + box = Sandbox(tmp_path) + checkout = default_resolution(box) + lane_dir = _checkout_of_length(short_root, None) + box.checkout(lane_dir) + first = box.start("--dir", str(lane_dir), window_name="claude") + assert first.returncode == 0, first.stderr + assert (checkout / ".git").is_dir(), "the first write made the lane's checkout" + assert len(rows(checkout / "lanes" / "LANES.md")) == 1 + assert rows(box.wip / "lanes" / "LANES.md") == [], "W's working tree took no write" + return box, checkout + + +def test_a_new_lanes_second_start_reads_its_row_in_its_own_checkout(tmp_path, short_root): + box, checkout = started(tmp_path, short_root) + second = box.start(window_name=LANE) + assert second.returncode == 0, second.stderr + assert "ALREADY has a row" not in second.stderr, second.stderr + assert len(rows(checkout / "lanes" / "LANES.md")) == 1, "one row, and no second one added" + published = box.git("--git-dir", str(box.root / "origin.git"), "show", "main:lanes/LANES.md") + assert sum(1 for r in published.splitlines() if r.startswith(f"| `{LANE}`")) == 1 + assert rows(box.wip / "lanes" / "LANES.md") == [], "and W's working tree still took none" + + +def test_lane_end_finds_the_row_its_lane_start_wrote(tmp_path, short_root): + box, checkout = started(tmp_path, short_root) + for name in ("lane-end", "lane-worktrees"): + shutil.copy2(REPO / name, box.bin / name) + (box.bin / name).chmod(0o755) + end = subprocess.run([str(box.bin / "lane-end"), LANE, "--dry-run"], env=box.env, + capture_output=True, text=True, timeout=120, check=False) + assert "has no row for lane" not in end.stderr, end.stderr + assert end.returncode == 0, (end.stdout, end.stderr) + assert rows(box.wip / "lanes" / "LANES.md") == [] + + +def test_a_dry_run_start_makes_no_checkout(tmp_path, short_root): + """`--dry-run` renames, writes and launches nothing, so it clones nothing + either: with no checkout yet, the row is read from W.""" + box = Sandbox(tmp_path) + checkout = default_resolution(box) + lane_dir = _checkout_of_length(short_root, None) + box.checkout(lane_dir) + plan = box.start("--dir", str(lane_dir), "--dry-run", window_name="claude") + assert plan.returncode == 0, plan.stderr + assert not checkout.exists() and not checkout.parent.exists(), "a dry run made no lane checkout" + + +def test_a_LANES_REPO_alone_is_the_register_read(tmp_path, short_root): + """A caller's `LANES_REPO` without `LANES_FILE` is the checkout the helper + writes in (Amendment 23 S2), so it is the register `lane-start` reads: the + second start finds the row the first one wrote there.""" + box = Sandbox(tmp_path) # W is the reader copy; LANES_REPO is box.wip + box.env.pop("LANES_FILE") + lane_dir = _checkout_of_length(short_root, None) + box.checkout(lane_dir) + first = box.start("--dir", str(lane_dir), window_name="claude") + assert first.returncode == 0, first.stderr + assert len(rows(box.wip / "lanes" / "LANES.md")) == 1, "the write landed where LANES_REPO points" + second = box.start(window_name=LANE) + assert second.returncode == 0, second.stderr + assert "ALREADY has a row" not in second.stderr, second.stderr + assert len(rows(box.wip / "lanes" / "LANES.md")) == 1 From 59b39df22e6641cb8b5e5cbfd40f86e6a104b0ed Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 18:23:06 +0000 Subject: [PATCH 36/42] The shell suite's #67 cases (from main's #221, issue #67) name their second checkout by LANES_REPO and LANES_FILE, as the suite's other 24 cases do under Amendment 23's explicit seam (#198) Main's fe58808 merge brought a new helper, r67(), that pointed the commands at a second clone through LANES_WORKSPACE_ROOT alone. Since this slice, the suite exports LANES_REPO and LANES_FILE at $WIP for every case (ort186 Q9's seam). An explicit LANES_REPO is honoured in place and W no longer chooses the checkout a command runs in, so r67's reads and its refused `claim --force` would have run in $WIP, not the #67 clone. r67 now names the clone the same way the other 24 cases were converted (LANES_REPO= LANES_FILE=/lanes/LANES.md). Its assertions are unchanged. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- tests/test_lane_helpers.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index c7843a75..6b310b5a 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -1862,7 +1862,7 @@ esac exec "${REAL_GIT:?}" "$@" FAKE chmod +x "$SANDBOX/r67-gitfail/git" -r67() { env LANES_WORKSPACE_ROOT="$R67_WIP" LANES_LANE=repo67-2 LANES_SESSION="$DEAD_ID" "$@"; } +r67() { env LANES_REPO="$R67_WIP" LANES_FILE="$R67_WIP/lanes/LANES.md" LANES_LANE=repo67-2 LANES_SESSION="$DEAD_ID" "$@"; } r67x() { r67 env PATH="$SANDBOX/r67-gitfail:$PATH" REAL_GIT="$(command -v git)" "$@"; } R67_PS="$(printf '%s\t%s\t%s' 900671 1 "claude --session-id $R67_ID --fork-session --resume /nonexistent/projects/$R67_ID.jsonl")" From fda7808d64ee31520fa68b88314627916df03d0d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 19 Oct 2026 11:53:40 +0000 Subject: [PATCH 37/42] tests/test_lanes_index.py: its Estate names its old W by the explicit seam (LANES_REPO) and W becomes a view of it, so the indexer cases read the writes they make under Amendment 23's routing (#198) The routed writes went to -lanes//, while lanes-index sync reads W's own origin/main. Four cases went red, against the base's two environmental failures. W is now a gitfile view of the writing clone outside the projects root, as in T018's bench. With it: 70 passed and 2 failed, which are the base's own two (the normaliser and the basetemp's uuid). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- tests/test_lanes_index.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_lanes_index.py b/tests/test_lanes_index.py index e0c11d9c..34558f8e 100644 --- a/tests/test_lanes_index.py +++ b/tests/test_lanes_index.py @@ -203,8 +203,22 @@ def _seed(self) -> None: self._git("add", "-A") self._git("commit", "-q", "-m", "seed the test register") self._git("push", "-q", "origin", "main") + # AMENDMENT 23 S5: W, the readers' copy that workspace.yaml names, takes + # no write, and a write that resolves by default goes through the lane's + # own checkout beside it. These cases are about the indexer, so their + # writes name `self.wip` by the explicit seam (`LANES_REPO`, ort186 Q9). + # W is a VIEW of `self.wip` outside the projects root: its `.git` names + # `self.wip`'s, and its `lanes` is a link into it. That is because + # `lanes-index sync` reads W's own `origin/`, which a separate + # clone would leave behind every write these cases make. + self.ws = self.root / "ro" / "wip" + self.ws.mkdir(parents=True) + (self.ws / ".git").write_text(f"gitdir: {os.path.realpath(self.wip / '.git')}\n", + encoding="utf-8") + (self.ws / "lanes").symlink_to(self.wip / "lanes") (self.agents / "workspace.yaml").write_text( - f"repository: {self.origin}\npath: {self.wip}\n", encoding="utf-8") + f"repository: {self.origin}\npath: {self.ws}\n", encoding="utf-8") + self.env["LANES_REPO"] = str(self.wip) # ------------------------------------------------------------ running def run(self, *argv, env: dict | None = None, timeout: float = 120, From 874e0204192902554ffece916632a613fcff0627 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 10:13:48 +0000 Subject: [PATCH 38/42] Fix round 1 of #282 (lane 3's review): routing is opt-in until slice 1b, W follows a routed push, a W that is a lane checkout refuses, and a vanished lock is never made again (#198) MUST 2: Amendment 23's routing is behind LANES_ROUTE_WRITES=1. Without it every write goes through LANES_REPO (W by default) and every lock is that checkout's, as on main: the sidecar acts and lane_state_follow take $LOCK, and lane-start/lane-end read W. The two tests this slice had moved onto the sidecar lock are main's again. MUST 1: under the opt-in, once a push from a checkout that is not W has landed, cleanup fetches W's origin/main (git_net, gc and maintenance off) before the index nudge, so the prompt guard's offline read sees it. A failed fetch is a note. MUST 3: a W inside a `*-lanes/` directory, or the same checkout LANES_REPO names under LANES_WORKSPACE_ROOT, refuses (2) and names LANES_WORKSPACE_ROOT: lane-checkout and refresh-workspace always, writes and sidecar acts under the opt-in. Nothing is cloned or locked inside a lane checkout. MUST 4: git_net's heartbeat is `touch -c`, and the creation lock too, which a heartbeat now refreshes every minute through the clone (NIT 5). MUST 5: test_register_scan_hardening_138.py writes through `wip` by LANES_REPO, with W a clone of its own. SHOULD 1: append-line attribution reads the published register first, and a routed arm falls back to the lane it was routed by. SHOULD 2: lane-start/lane-end note a checkout that cannot be made (2, 4) and read W. SHOULD 3: S5 compares the checkout's root and git common directory. SHOULD 4: a routed write refuses while W's register holds an uncommitted edit. SHOULD 5: lane-worktrees reports `-lanes/` as a register checkout, never FOREIGN. SHOULD 6: lane-start's carry note says no write carries a commit made in W under the opt-in. NITs: 1 request-handoff's `--` ends the options in the router too; 2 the sidecar lock's wording (taken alone, never inside the checkout lock); 3 mutex_release compares container and host as well as the pid; 7 lane-end's own "Nothing was written"; 8 LANES_NO_GIT=1 makes no clone; 9 an explicit LANES_REPO still needs a lane. Sonar: the four copies of the test Box are one module, tests/a23_box.py. Co-Authored-By: Claude Opus 5.5 --- lane-end | 22 +- lane-start | 30 +- lane-worktrees | 28 ++ lanes-edit.sh | 357 +++++++++++++++---- tests/a23_box.py | 184 ++++++++++ tests/test_a23_lane_checkout.py | 142 +------- tests/test_a23_lane_start_read.py | 58 ++- tests/test_a23_lock.py | 201 +++++------ tests/test_a23_refresh_workspace.py | 109 +----- tests/test_a23_write_routing.py | 296 +++++++++------ tests/test_lane_helpers.sh | 12 +- tests/test_lane_worktrees_report.py | 28 ++ tests/test_register_scan_hardening_138.py | 35 +- tests/test_supervised_restart_regressions.py | 15 +- 14 files changed, 955 insertions(+), 562 deletions(-) create mode 100644 tests/a23_box.py diff --git a/lane-end b/lane-end index 9626fe89..dc803e2b 100755 --- a/lane-end +++ b/lane-end @@ -948,9 +948,10 @@ if [ "$cl_rc" = 0 ] && [ -n "$cl_out" ] && [ "$cl_out" != "$LANE" ]; then fi # AMENDMENT 23 S1, S2 — THE REGISTER THIS COMMAND READS IS THE ONE ITS WRITES GO -# THROUGH. Under the default resolution every write of this lane goes through -# its own checkout, `-lanes//`, and W, the readers' copy, moves only by -# `lanes-edit.sh refresh-workspace`. So W's working tree does not carry the +# THROUGH. Under `LANES_ROUTE_WRITES=1` (opt-in until slice 1b, the review of +# #282's MUST 2) and the default resolution, every write of this lane goes +# through its own checkout, `-lanes//`, and W, the readers' copy, moves +# only by `lanes-edit.sh refresh-workspace`. So W's working tree does not carry the # lane's own rows once they are written: a lane started through its checkout # read as a lane with no row, and a second start refused to add a duplicate # that W could not see. The row this command counts and acts on is therefore @@ -958,9 +959,14 @@ fi # there yet, exactly as this command's first write would; a dry run makes # nothing and reads W where there is no checkout. ONLY THE READ MOVES: the # handoff this command publishes is still W's until Amendment 23's later slices. -# A helper that predates the verb writes W itself, so W is what is read. +# A helper that predates the verb writes W itself, so W is what is read. A +# checkout that could not be made (2) or whose creation lock is held (4) is +# SAID, and W is read (SHOULD 2): Amendment 8(d) — a launch is never refused for +# a register act it can do without, and the writes after it say their own. lane_register_follow() { + [ "${LANES_ROUTE_WRITES:-}" = 1 ] || return 0 [ "$LANES_REG_DEFAULT" = 1 ] && [ -n "${LANE:-}" ] || return 0 + case "$prog" in lane-end) lrf_none="Nothing was written." ;; *) lrf_none="Nothing was launched, renamed or written." ;; esac lrf_args=(lane-checkout "$LANE") [ "${dry_run:-0}" = 1 ] || lrf_args+=(--create) lrf_errf="$(mktemp "${TMPDIR:-/tmp}/${prog}-register.XXXXXX" 2>/dev/null || printf '')" @@ -972,16 +978,16 @@ lane_register_follow() { 0) [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&2 [ -f "$lrf_out/lanes/LANES.md" ] || - die "lane $LANE's register checkout $lrf_out carries no lanes/LANES.md, so the lane's row cannot be read (Amendment 23 S1). Nothing was launched, renamed or written." 1 + die "lane $LANE's register checkout $lrf_out carries no lanes/LANES.md, so the lane's row cannot be read (Amendment 23 S1). $lrf_none" 1 LANES_FILE="$lrf_out/lanes/LANES.md" ;; 8) : ;; # a dry run, and no checkout yet: W is what there is - 2) + 2 | 4) case "$lrf_err" in *"unknown subcommand 'lane-checkout'"*) return 0 ;; esac [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&2 - die "lane $LANE's own register checkout could not be made (\`$LANES_EDIT lane-checkout $LANE\` exited 2, above), so the row this command acts on cannot be read where its writes go (Amendment 23 S1). Nothing was launched, renamed or written." 2 ;; + note "lane $LANE's own register checkout could not be made (\`$LANES_EDIT lane-checkout $LANE --create\` exited $lrf_rc, above), so this command reads W's register, which may not show what this lane's earlier writes put there (Amendment 23 S1, 8(d)). Each write below says for itself whether it went through." ;; *) [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&2 - die "lane $LANE's own register checkout could not be read (\`$LANES_EDIT lane-checkout $LANE\` exited $lrf_rc, above), so the row this command acts on cannot be read where its writes go (Amendment 23 S1). Nothing was launched, renamed or written." 1 ;; + die "lane $LANE's own register checkout could not be read (\`$LANES_EDIT lane-checkout $LANE\` exited $lrf_rc, above), so the row this command acts on cannot be read where its writes go (Amendment 23 S1). $lrf_none" 1 ;; esac return 0 } diff --git a/lane-start b/lane-start index 8d75a818..37231144 100755 --- a/lane-start +++ b/lane-start @@ -1165,9 +1165,10 @@ repo="${repo:-$LANE}" # the verbatim form has no of its own estate="${estate:-$repo}" # AMENDMENT 23 S1, S2 — THE REGISTER THIS COMMAND READS IS THE ONE ITS WRITES GO -# THROUGH. Under the default resolution every write of this lane goes through -# its own checkout, `-lanes//`, and W, the readers' copy, moves only by -# `lanes-edit.sh refresh-workspace`. So W's working tree does not carry the +# THROUGH. Under `LANES_ROUTE_WRITES=1` (opt-in until slice 1b, the review of +# #282's MUST 2) and the default resolution, every write of this lane goes +# through its own checkout, `-lanes//`, and W, the readers' copy, moves +# only by `lanes-edit.sh refresh-workspace`. So W's working tree does not carry the # lane's own rows once they are written: a lane started through its checkout # read as a lane with no row, and a second start refused to add a duplicate # that W could not see. The row this command counts and acts on is therefore @@ -1175,9 +1176,14 @@ estate="${estate:-$repo}" # there yet, exactly as this command's first write would; a dry run makes # nothing and reads W where there is no checkout. ONLY THE READ MOVES: the # handoff this command publishes is still W's until Amendment 23's later slices. -# A helper that predates the verb writes W itself, so W is what is read. +# A helper that predates the verb writes W itself, so W is what is read. A +# checkout that could not be made (2) or whose creation lock is held (4) is +# SAID, and W is read (SHOULD 2): Amendment 8(d) — a launch is never refused for +# a register act it can do without, and the writes after it say their own. lane_register_follow() { + [ "${LANES_ROUTE_WRITES:-}" = 1 ] || return 0 [ "$LANES_REG_DEFAULT" = 1 ] && [ -n "${LANE:-}" ] || return 0 + case "$prog" in lane-end) lrf_none="Nothing was written." ;; *) lrf_none="Nothing was launched, renamed or written." ;; esac lrf_args=(lane-checkout "$LANE") [ "${dry_run:-0}" = 1 ] || lrf_args+=(--create) lrf_errf="$(mktemp "${TMPDIR:-/tmp}/${prog}-register.XXXXXX" 2>/dev/null || printf '')" @@ -1189,16 +1195,16 @@ lane_register_follow() { 0) [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&2 [ -f "$lrf_out/lanes/LANES.md" ] || - die "lane $LANE's register checkout $lrf_out carries no lanes/LANES.md, so the lane's row cannot be read (Amendment 23 S1). Nothing was launched, renamed or written." 1 + die "lane $LANE's register checkout $lrf_out carries no lanes/LANES.md, so the lane's row cannot be read (Amendment 23 S1). $lrf_none" 1 LANES_FILE="$lrf_out/lanes/LANES.md" ;; 8) : ;; # a dry run, and no checkout yet: W is what there is - 2) + 2 | 4) case "$lrf_err" in *"unknown subcommand 'lane-checkout'"*) return 0 ;; esac [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&2 - die "lane $LANE's own register checkout could not be made (\`$LANES_EDIT lane-checkout $LANE\` exited 2, above), so the row this command acts on cannot be read where its writes go (Amendment 23 S1). Nothing was launched, renamed or written." 2 ;; + note "lane $LANE's own register checkout could not be made (\`$LANES_EDIT lane-checkout $LANE --create\` exited $lrf_rc, above), so this command reads W's register, which may not show what this lane's earlier writes put there (Amendment 23 S1, 8(d)). Each write below says for itself whether it went through." ;; *) [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&2 - die "lane $LANE's own register checkout could not be read (\`$LANES_EDIT lane-checkout $LANE\` exited $lrf_rc, above), so the row this command acts on cannot be read where its writes go (Amendment 23 S1). Nothing was launched, renamed or written." 1 ;; + die "lane $LANE's own register checkout could not be read (\`$LANES_EDIT lane-checkout $LANE\` exited $lrf_rc, above), so the row this command acts on cannot be read where its writes go (Amendment 23 S1). $lrf_none" 1 ;; esac return 0 } @@ -4302,7 +4308,13 @@ hs_git_net() { # `lanes-edit.sh` write goes out of it. Promising a carry that will not happen # is worse than printing the command, so the note says which it is. hs_carry_note() { - if [ -n "${LANES_REPO:-}" ] && [ "$hs_repo" = "$LANES_REPO" ]; then + # UNDER `LANES_ROUTE_WRITES=1` NO WRITE GOES OUT OF W (Amendment 23 S2; the + # review of #282, SHOULD 6): with the default resolution each one goes through + # its lane's own checkout, so none will ever push a commit made here. + if [ "${LANES_ROUTE_WRITES:-}" = 1 ] && [ "$LANES_REG_DEFAULT" = 1 ] && + [ -n "${LANES_REPO:-}" ] && [ "$hs_repo" = "$LANES_REPO" ]; then + note " (this handoff is in W ($hs_repo), and under LANES_ROUTE_WRITES=1 every lanes-edit.sh write goes through its lane's own checkout instead, so none will push this commit: the command above is the whole of the recovery.)" + elif [ -n "${LANES_REPO:-}" ] && [ "$hs_repo" = "$LANES_REPO" ]; then note " (this handoff is in the register's own checkout, so the next lanes-edit.sh write out of it pushes the branch and carries this commit with it.)" else note " (this handoff is NOT in the register's checkout ($hs_repo), so no lanes-edit.sh write will ever push it: the command above is the whole of the recovery.)" diff --git a/lane-worktrees b/lane-worktrees index 35e2d0ea..babd21fb 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -5519,7 +5519,17 @@ class Report: def _foreign(self, clones: list, canonical: list) -> None: rows = [] + # AMENDMENT 23 S1 - A LANE'S REGISTER CHECKOUT IS NOT A STRAY CLONE (the + # review of opensoft/openRepoTools#282, SHOULD 5). `-lanes//` is + # a clone of the register's origin beside W, made on purpose and holding + # that lane's writes; listing it here told a person to dispose of it. + wroot = self.helper.workspace_root() if self.helper.path else "" + lanes_home = real(wroot) + "-lanes" if wroot else "" + registers = [] for c in clones: + if lanes_home and os.path.dirname(real(c)) == lanes_home: + registers.append(c) + continue container = _under_container(c, self.root) why = "" if container: @@ -5541,6 +5551,24 @@ class Report: ("LOAD-BEARING: " + _lean_text(lean)) if lean else ""]) self.add("FOREIGN repositories", rows, "none: every clone is the estate's own checkout", "A lane creates worktrees, never clones. Each is a person's to dispose of.") + if registers: + self._register_checkouts(registers) + + def _register_checkouts(self, registers: list) -> None: + """Amendment 23 S1: each lane's own checkout of the register, reported + with what it holds that origin lacks, and never offered for disposal.""" + rows = [] + for c in registers: + last = git_out(c, "log", "-1", "--date=short", "--format=%cd") or "?" + ahead = git_out(c, "rev-list", "--count", "origin/main..HEAD") + held = ("its HEAD could not be compared with origin/main" if ahead is None else + f"{ahead} commit(s) origin/main lacks" if ahead != "0" else "") + rows.append([c, f"lane {os.path.basename(c)}", human_size(dir_size(c)), last, held]) + self.add("Register checkouts (Amendment 23 S1)", rows, "none", + "Each is one lane's own checkout of the register, where its writes go, and " + "not a stray clone: lane-end's sweep retires one (Amendment 23 S6), and a " + "person moves one aside only once `git -C cherry -v origin/main` " + "shows nothing.") def _closed_lanes(self) -> dict: """{lane: why} for every lane whose snapshot is CLOSED or whose log's diff --git a/lanes-edit.sh b/lanes-edit.sh index 961f5fe1..382cb6f7 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -371,7 +371,10 @@ # # `lane-checkout` prints the lane's own register checkout, `-lanes//` # (0), says it has none yet (8), or refuses what is at that path and is not a -# clone of the register (1); `--create` makes it as a first write would. +# clone of the register (1); `--create` makes it as a first write under +# `LANES_ROUTE_WRITES=1` would, which is the only way writes go there until +# Amendment 23's slice 1b: without that opt-in every write goes through +# `LANES_REPO`, W by default, as before. # `refresh-workspace` fetches W's origin and fast-forwards W, which is the # only change any tool makes in W; whatever would stop that is reported and # skipped (2), and nothing in W is ever reset, stashed, committed or aborted. @@ -455,8 +458,11 @@ # (`cp_publish_fence`), and pushes none of them; or — AMENDMENT 23 — a # write that names no lane, a `LANES_REPO` that IS W (S5), a `LANES_DIR` # or `LANES_FILE` outside the checkout the write goes through (S2), a -# lane checkout's clone that failed (S1), and `refresh-workspace`'s -# skip, which changed nothing. EIGHT MEANINGS ON ONE NUMBER, and they +# lane checkout's clone that failed (S1), a W that is itself a lane +# checkout (S1, under the opt-in and for `lane-checkout` and +# `refresh-workspace`), a routed write while W's register holds an +# uncommitted edit (S5), and `refresh-workspace`'s skip, which changed +# nothing. EIGHT MEANINGS ON ONE NUMBER, and they # stay readable only because of where each can occur: bad arguments to a # subcommand that predates Amendment 11; `register-row`'s "not lane-shaped"; # AN UNKNOWN SUBCOMMAND (the `*)` arm below), which is how a caller detects @@ -582,22 +588,32 @@ # ENVIRONMENT # LANES_FILE registry path (default: /LANES.md) # LANES_DIR dir holding it (default: /lanes) -# LANES_REPO the checkout a WRITE goes through. Unset — the normal -# case — a write goes through its lane's own checkout, -# -lanes//, made on first use (Amendment 23 S1, -# S2), and a READ reads W itself. W is the `path:` in -# $AGENT_PROTOCOL_ROOT/workspace.yaml, checked to be a -# checkout of the `repository:` beside it (Amendment 9(a); -# never a literal path). SET, it is the test seam and an -# operator's override and is honoured for reads and writes -# alike — except that one naming W by real path refuses -# every write (S5), and one naming a per-profile clone -# beside W is honoured with a warning naming S1's path -# (S6.2). LANES_DIR and LANES_FILE locate files INSIDE the -# checkout and never choose it: a write whose LANES_DIR or -# LANES_FILE is outside the checkout refuses (S2) -# LANES_WORKSPACE_ROOT W, overridden (the test seam; nothing else -# sets it) +# LANES_REPO the checkout a WRITE goes through. Unset, it is W: the +# `path:` in $AGENT_PROTOCOL_ROOT/workspace.yaml, checked to +# be a checkout of the `repository:` beside it (Amendment +# 9(a); never a literal path). SET, it is the test seam and +# an operator's override, honoured for reads and writes +# alike, and LANES_DIR and LANES_FILE default from it +# LANES_ROUTE_WRITES=1 Amendment 23's routing, OPT-IN until its slice 1b +# (the fetch before the edit) lands: with it, an unset +# LANES_REPO sends a write through its lane's own checkout, +# -lanes//, made on first use (S1, S2), while a +# READ still reads W; a LANES_REPO naming W — or W's own +# directory inside it — refuses every write (S5); one +# naming a per-profile clone beside W is honoured with a +# warning naming S1's path (S6.2); LANES_DIR and LANES_FILE +# locate files INSIDE the checkout and never choose it (S2); +# and the sidecar acts take the workstation's lock (THE +# LOCKS, below). Any other value, or none: every write goes +# through LANES_REPO (W by default) and every lock is that +# checkout's, exactly as before Amendment 23 +# LANES_WORKSPACE_ROOT W, overridden. The test seam, and lane windows set it +# too, at their own checkout. A W that is a lane checkout +# — inside a `*-lanes/` directory, or the same checkout +# LANES_REPO names — refuses (2) `lane-checkout` and +# `refresh-workspace`, and under LANES_ROUTE_WRITES=1 every +# write and sidecar act too, naming this variable to unset: +# `-lanes` is never derived from a lane checkout # LANES__CHECKOUT_LANE, LANES__PID INTERNAL — exported only by this file to # itself when it re-runs a write in the lane's checkout # (`exec` keeps the pid, so LANES__PID equal to `$$` marks @@ -632,15 +648,18 @@ # LANES_IN_CONTAINER 1|0 force the container probe (test seam) # LANES_NO_GIT=1 edit only, no commit/push (used by the test harness) # THE LOCKS (Amendment 23 S2, design decision 14). /.lanes-edit.lock -# is the CHECKOUT's — one lane's writers; -lanes/ -# .lane-state.lock is the WORKSTATION's, taken alone by the -# lifecycle, restart and tree sidecars and by -# `publish-handoff`, and by `lane_state_follow` inside the -# checkout lock (always that order). No lock is taken in W. -# Each holds a `pid` file, ` `: a -# live holder in this container is never aged out; one in -# another container is judged by the directory's age, which -# the holder refreshes before every network call +# is the CHECKOUT's — that checkout's writers. Under +# LANES_ROUTE_WRITES=1, -lanes/.lane-state.lock is the +# WORKSTATION's, taken by the lifecycle, restart and tree +# sidecars, by `publish-handoff` and by `lane_state_follow`, +# each ALONE: the follow-up runs after its write released +# the checkout lock, and no act holds both. No lock is taken +# in W then. Without the opt-in those acts take the +# checkout's lock, as before. Each lock holds a `pid` file, +# ` `: a live holder in this +# container is never aged out; one in another container is +# judged by the directory's age, which the holder refreshes +# before every network call and never re-creates # LANES_LOG_DIR the object logs (default: /log) # LANES_REPOS_TSV the per-wip alias OVERRIDE (default: /repos.tsv) # LANES_REPOS_TSV_SHIPPED the organisation's alias table shipped by @@ -1106,13 +1125,24 @@ if [ -n "${LANES__CHECKOUT_LANE:-}${LANES__PID:-}" ] && [ "${LANES__PID:-}" != " unset LANES__CHECKOUT_LANE LANES__PID LANES_REPO LANES_DIR LANES_FILE fi LANES_ROUTED="${LANES__CHECKOUT_LANE:-}" # the lane, in the re-run process only +# THE ROUTING IS OPT-IN UNTIL SLICE 1B (the review of opensoft/openRepoTools#282, +# MUST 2). A lane checkout is only as fresh as its own last write until S3's +# fetch before the edit lands, and `commit_push` rebases only after a rejected +# push, so a checkout one commit behind wedges on its first conflicting write: +# the stranding Amendment 23 exists to end. So without `LANES_ROUTE_WRITES=1` +# every write goes through `LANES_REPO` (W by default) and every lock is that +# checkout's, exactly as before; the re-run process is routed by definition. +LANES_ROUTE_ON=0 +if [ "${LANES_ROUTE_WRITES:-}" = 1 ] || [ -n "$LANES_ROUTED" ]; then LANES_ROUTE_ON=1; fi LANES_REPO_GIVEN=0 # a caller's LANES_REPO: the seam (S2.1) [ -n "$LANES_ROUTED" ] || [ -z "${LANES_REPO:-}" ] || LANES_REPO_GIVEN=1 LANES_DIR_GIVEN="${LANES_DIR:-}" LANES_FILE_GIVEN="${LANES_FILE:-}" -# W IS RESOLVED EVERY TIME NOW, an explicit `LANES_REPO` included: S5 refuses a -# write whose checkout IS W, and the workstation's sidecar lock lives beside it, -# so neither can be decided without it. It is two `sed`s and three `git` reads. +# W IS RESOLVED EVERY TIME NOW, an explicit `LANES_REPO` included: under the +# opt-in S5 refuses a write whose checkout IS W and the workstation's sidecar +# lock lives beside it, and `lane-checkout` and `refresh-workspace` need it +# either way. It is two `sed`s and three `git` reads, and only the opt-in makes +# an unresolvable W a refusal where `LANES_REPO` and `LANES_DIR` are given. # AND `LANES_DIR`/`LANES_FILE` DEFAULT FROM `LANES_REPO`, which they did not: an # explicit `LANES_REPO` alone sent both to W, so git ran in one checkout while # the edit landed in another (#198's defect at the old `:1030-1041`). @@ -1182,7 +1212,7 @@ LOCK_HELD=0 # here, before the trap that releases them: the workstation's sidecar lock # (`-lanes/.lane-state.lock`) where `lane_state_follow` took it, and the # creation lock (`-lanes/..lock`) while a lane checkout is cloned. -SIDECAR_HELD=0; SIDECAR_LOCK=""; CREATE_LOCK="" +SIDECAR_HELD=0; SIDECAR_LOCK=""; CREATE_LOCK=""; CREATE_HB="" LANES_PATH="${LANES_PATH:-$(git -C "${LANES_DIR:-.}" rev-parse --show-prefix 2>/dev/null || :)${LANES_FILE##*/}}" # The pathspecs the current invocation is writing. Every subcommand that @@ -1308,10 +1338,13 @@ mutex_try() { # [] — 0 taken, 1 not; never exits } # A LOCK IS RELEASED ONLY WHILE IT IS STILL THIS PROCESS'S. One another holder # took over (another container's waiter, past a heartbeat this one missed) is -# that holder's now, and removing it would leave its write under no lock. +# that holder's now, and removing it would leave its write under no lock. THE +# PID ALONE IS NOT "THIS PROCESS" (the review of #282, NIT 3): a holder in +# another container may carry the same number, so its container and host must +# be this process's too. A pid alone (an older helper's line) reads as local. mutex_release() { # - if mutex_read "$1" && [ "$MX_PID" != "$$" ]; then - note "$1 is held by pid $MX_PID now, not by this process, so it is left as it is" + if mutex_read "$1" && { [ "$MX_PID" != "$$" ] || ! mutex_local; }; then + note "$1 is held by pid $MX_PID${MX_CONT:+ in container $MX_CONT on $MX_HOST} now, not by this process, so it is left as it is" return 0 fi rm -f -- "$1/pid" 2>/dev/null || : @@ -1326,26 +1359,35 @@ release_lock() { } # THE WORKSTATION'S SIDECAR LOCK (Amendment 23 S2; design decision 14's "the -# whole workstation's", ort186 Q3). The lifecycle snapshot, the restart intent, -# the tree records and `publish-handoff`'s fence were serialised by W's mutex; -# W takes no lock now, and a lane's checkout lock serialises one lane only, so -# they share this one beside the lane checkouts, on the mount every container -# sees. ORDER: a writer that holds its checkout lock may take this one inside -# it (`lane_state_follow`); a sidecar verb run on its own takes this one ALONE, -# and never a checkout lock after it. +# whole workstation's", ort186 Q3), UNDER `LANES_ROUTE_WRITES=1`. The lifecycle +# snapshot, the restart intent, the tree records and `publish-handoff`'s fence +# were serialised by W's mutex; a routed write takes no lock in W, and a lane's +# checkout lock serialises one lane only, so they share this one beside the +# lane checkouts, on the mount every container sees. EACH TAKER TAKES IT ALONE +# (the review of #282, NIT 2): a sidecar verb, and `lane_state_follow`, which +# runs after its write released the checkout lock. No act holds both, so no +# order between them can deadlock. +# +# WITHOUT THE OPT-IN it is the checkout's own lock, `$LOCK`, as it was before +# Amendment 23: the sidecar verbs keep their `acquire_lock`, and the follow-up +# takes `$LOCK` where its caller does not hold it already. sidecar_lock_dir() { + if [ "$LANES_ROUTE_ON" != 1 ]; then printf '%s\n' "$LOCK"; return 0; fi [ -n "$LANES_WS_REAL" ] || return 1 printf '%s\n' "$LANES_WS_REAL-lanes/.lane-state.lock" } # For the verbs that take this lock and no other: `LOCK` is pointed at it, so # their `acquire_lock`/`release_lock` pairs, and `cleanup`, act on it unchanged. sidecar_lock_use() { + [ "$LANES_ROUTE_ON" = 1 ] || return 0 + lanes_home_check slu_d="$(sidecar_lock_dir)" || die "$(lanes_workspace_why)" 1 mkdir -p -- "${slu_d%/*}" 2>/dev/null || die "cannot create ${slu_d%/*}, where the workstation's sidecar lock lives (Amendment 23 S2). Nothing was written." 1 LOCK="$slu_d" } -sidecar_try() { # [] — taken inside a held checkout lock, or alone +sidecar_try() { # [] — 0 taken (or already the caller's), 1 not; never exits st_d="$(sidecar_lock_dir)" || return 1 + if [ "$LOCK_HELD" = 1 ] && [ "$st_d" = "$LOCK" ]; then return 0; fi mkdir -p -- "${st_d%/*}" 2>/dev/null || return 1 mutex_try "$st_d" "${1:-60}" || return 1 SIDECAR_LOCK="$st_d"; SIDECAR_HELD=1 @@ -1449,12 +1491,21 @@ rename_undo() { # remove is set above, before the trap: `RL_*`, `LANES_IDX_DIR`, # `LANES_WT_IDX_DIR`, `LOCK` and `LOCK_HELD`. TMPD=""; pbh_tmp=""; rl_htmp=""; SE_CACHE_FILE="" +# The creation lock's heartbeat (`lane_checkout_ensure`), stopped by pid. +create_heartbeat_stop() { + [ -n "${CREATE_HB:-}" ] || return 0 + kill "$CREATE_HB" 2>/dev/null || : + wait "$CREATE_HB" 2>/dev/null || : + CREATE_HB="" + return 0 +} cleanup() { [ -z "${pbh_tmp:-}" ] || rm -f -- "$pbh_tmp" [ -z "${rl_htmp:-}" ] || rm -f -- "$rl_htmp" # rename-lane's stamp before its replace (#158) rename_undo - sidecar_release # Amendment 23: taken inside the - [ -z "$CREATE_LOCK" ] || mutex_release "$CREATE_LOCK" # checkout lock, so out first + sidecar_release # Amendment 23: never held with the + create_heartbeat_stop # checkout lock, so the order is free + [ -z "$CREATE_LOCK" ] || mutex_release "$CREATE_LOCK" if [ "$LOCK_HELD" = 1 ]; then release_lock; fi [ -n "${RL_SNAP:-}" ] && [ -d "${RL_SNAP:-}" ] && rm -rf -- "$RL_SNAP" [ -n "${TMPD:-}" ] && [ -d "${TMPD:-}" ] && rm -rf -- "$TMPD" @@ -1467,6 +1518,9 @@ cleanup() { # recorded a snapshot or a tree record it had just replaced. Tested here rather # than inside the function, because `cleanup` also runs for a process that died # before `index_nudge` was ever defined. + # AMENDMENT 23, UNDER THE OPT-IN — W LEARNS WHAT A LANE'S CHECKOUT PUSHED, + # first (`ws_follow`), so the nudged index and the prompt guard read it. + if [ "${LANES_INDEX_NUDGE:-0}" = 1 ] && declare -F ws_follow >/dev/null; then ws_follow; fi if [ "${LANES_INDEX_NUDGE:-0}" = 1 ] || [ "${LANES_INDEX_NUDGE_INV:-0}" = 1 ]; then index_nudge; fi return 0 } @@ -1482,6 +1536,7 @@ cleanup_for_exec() { [ -z "${rl_htmp:-}" ] || rm -f -- "$rl_htmp" pbh_tmp=""; rl_htmp=""; RL_ACTIVE=0 sidecar_release + create_heartbeat_stop [ -z "$CREATE_LOCK" ] || mutex_release "$CREATE_LOCK" CREATE_LOCK="" [ -n "${RL_SNAP:-}" ] && [ -d "${RL_SNAP:-}" ] && rm -rf -- "$RL_SNAP" @@ -2110,7 +2165,12 @@ git_net() { # AMENDMENT 23 S2 — THE HEARTBEAT. A holder in another container is judged # by its lock's age alone, so the holder refreshes that age before every # network call: a write that is still pushing is never aged out from there. - if [ "$LOCK_HELD" = 1 ]; then touch -- "$LOCK" 2>/dev/null || :; fi + # `-c`, NEVER A BARE `touch` (the review of #282, MUST 4): a lock directory + # that vanished under its holder — another container's takeover, a person's + # `rmdir` of a lock that looked stuck — came back as a REGULAR FILE, which no + # release removes and no takeover claims, and every later write waited out + # its minute and exited 4. A heartbeat refreshes a lock; it never makes one. + if [ "$LOCK_HELD" = 1 ]; then touch -c -- "$LOCK" 2>/dev/null || :; fi case "$GIT_TIMEOUT" in ''|0|*[!0-9]*) "$GIT_BIN" "$@" || gn_rc=$? ;; *) @@ -2124,6 +2184,26 @@ git_net() { return "$gn_rc" } +# AMENDMENT 23, UNDER THE OPT-IN — W'S `origin/main` FOLLOWS A PUSH THAT LANDED +# FROM ANOTHER CHECKOUT (the review of #282, MUST 1). A routed write pushes from +# `-lanes//`, so W's own `origin/main` no longer moves with it; and the +# prompt guard reads that ref OFFLINE, as do the nudged index and every read +# under `LANES_NO_FETCH=1`. Measured with the real guard: a fresh session's +# first prompt was refused ("the harness minted a new transcript") although +# its id was on origin, because lane-start's last reads run unfetched. So once +# the push has landed and every lock is released, W is fetched — the one act +# S5 allows in W besides `refresh-workspace`'s fast-forward — bounded by +# `git_net`, and a fetch that fails is said and never fails the write. +ws_follow() { + [ "$LANES_ROUTE_ON" = 1 ] && [ -n "$LANES_WS_REAL" ] || return 0 + wf_r="$(cd -P -- "$LANES_REPO" 2>/dev/null && pwd -P)" || return 0 + [ "$wf_r" != "$LANES_WS_REAL" ] || return 0 + git_net -c gc.auto=0 -c maintenance.auto=false -C "$LANES_WS_REAL" fetch -q origin "$LANES_BRANCH" 2>/dev/null && return 0 + wf_how="it failed"; [ "$GIT_TIMED_OUT" = 1 ] && wf_how="it timed out after ${GIT_TIMEOUT}s" + note "the write landed, but W ($LANES_WS_REAL) did not learn it: its fetch of origin $LANES_BRANCH ($wf_how). Until W is fetched, what reads W's origin/$LANES_BRANCH unfetched — the prompt guard, the index — reads the register before this write. \`$SELF refresh-workspace\` fetches it." + return 0 +} + # What a timed-out network call does. NEVER a retry: the point is to stop # holding the lock, and a second call to the same hung endpoint holds it twice # as long. @@ -13674,11 +13754,13 @@ lane_state_follow() { # /dev/null || printf 'the sidecar lock') is held by another lanes-edit run and this follow-up will not wait behind an event that has already landed. The $lsf_verb line itself is written; the snapshot is one act behind until the next transition, and \`lanes-edit.sh lane-reconcile $lsf_lane\` says what it holds." return 0 @@ -14804,10 +14886,12 @@ WR_LANES # ============= AMENDMENT 23 — ONE REGISTER CHECKOUT PER LANE (S1, S2, S5) ==== # # In force 2026-10-17T20:53:32Z (Brett Heap, "ratify 61" and "apply the edits, -# 7-9 as recommended"); its act 2 is opensoft/openRepoTools#198. Every write -# goes through its lane's own checkout, `-lanes//`, a clone of the -# register's origin made on first use; W — the `path:` in workspace.yaml — is -# the readers' copy, and no tool writes there. This block is S1 (the path, its +# 7-9 as recommended"); its act 2 is opensoft/openRepoTools#198. Under +# `LANES_ROUTE_WRITES=1` — OPT-IN until slice 1b's S3, the review of #282's +# MUST 2 — every write goes through its lane's own checkout, +# `-lanes//`, a clone of the register's origin made on first use; W — +# the `path:` in workspace.yaml — is the readers' copy, and no routed write +# goes there. This block is S1 (the path, its # creation, and the refusal of anything else at it), S2 (which checkout a write # goes through) and S5's two halves (a write that resolves to W refuses; W is # only ever fast-forwarded, by `refresh-workspace`). S3-S4 — the fetch before @@ -14821,6 +14905,36 @@ WR_LANES # write runs this file again (`exec`, same pid) with `LANES_REPO`, `LANES_DIR` # and `LANES_FILE` exported at the checkout, and every one of them follows. +# W IS NEVER A LANE CHECKOUT (the review of #282, MUST 3). Eagle's lane windows +# export `LANES_REPO` AND `LANES_WORKSPACE_ROOT` at the lane's own checkout, +# `/brett-wip-lanes/`, so main's helper, run by path, never +# writes the shared one. Taken as W, that checkout made every write an S5 +# refusal whose advice — unset `LANES_REPO` — cloned a FULL second register +# inside it (`-lanes/`, no alternates), and put the workstation's +# sidecar lock in a per-window `-lanes/`. So before anything derives +# `-lanes`, a W whose parent directory is a `*-lanes` one, or one +# `LANES_WORKSPACE_ROOT` names as the very checkout `LANES_REPO` names, refuses +# (2), naming the variable to unset. Called where `-lanes` is derived: +# `lane-checkout`, `refresh-workspace`, and under the opt-in every write and +# every sidecar act. +lanes_home_check() { + [ -n "$LANES_WS_REAL" ] || return 0 + lhc_why="" + lhc_par="${LANES_WS_REAL%/*}" + case "${lhc_par##*/}" in + ?*-lanes) lhc_why="it stands in $lhc_par/, where a workstation keeps its lane checkouts" ;; + esac + if [ -z "$lhc_why" ] && [ -n "${LANES_WORKSPACE_ROOT:-}" ] && [ "$LANES_REPO_GIVEN" = 1 ]; then + lhc_r="$(cd -P -- "$LANES_REPO" 2>/dev/null && pwd -P)" || lhc_r="" + [ "$lhc_r" != "$LANES_WS_REAL" ] || lhc_why="LANES_REPO names that same checkout, which makes it the checkout this window writes through" + fi + [ -n "$lhc_why" ] || return 0 + if [ -n "${LANES_WORKSPACE_ROOT:-}" ]; then + die "LANES_WORKSPACE_ROOT is $LANES_WORKSPACE_ROOT, so W — the readers' copy, beside which this workstation's lane checkouts and its sidecar lock live — resolves to $LANES_WS_REAL. But $lhc_why: it is a lane checkout, not W (Amendment 23 S1, S5). Nothing was written, and nothing was cloned or locked beside it. Unset LANES_WORKSPACE_ROOT in this window — W is then the \`path:\` in $(lanes_ws_yaml) — and keep LANES_REPO if this window should go on writing through $LANES_WS_REAL." 2 + fi + die "$(lanes_ws_yaml) names path: $LANES_WS_REAL as W, the readers' copy. But $lhc_why: it is a lane checkout, not W (Amendment 23 S1, S5). Nothing was written, and nothing was cloned or locked beside it. Point that \`path:\` at the workspace checkout itself (\`openRepoTools wip init\` writes it)." 2 +} + # S1 — THE PATH, FROM THE LANE NAME ALONE: canonical, never a profile, session, # window, container or directory. Derived every time and recorded nowhere. lane_checkout_path() { # @@ -14933,6 +15047,13 @@ lane_checkout_ensure() { # [ -z "$lce_old" ] || note "$lce_old is lane $lce_lane's checkout under a former name (lanes/aliases.tsv). This write goes through $lce_p, and the old one is left as it is, for lane-end's sweep (Amendment 23 S1, S6)." return 0 fi + # `LANES_NO_GIT=1` IS "NO COMMIT, NO PUSH", and a clone is a git act over the + # network (the review of #282, NIT 8): with no checkout to write through, the + # write refuses rather than clone one. A renamed lane's old checkout is still + # moved below — that is one `mv`, and no git act. + if [ "${NO_GIT:-0}" = 1 ] && [ -z "$(lane_checkout_old "$lce_lane")" ]; then + die "LANES_NO_GIT=1 is set, and lane $lce_lane has no checkout at $lce_p to write through: making one is a clone, which LANES_NO_GIT=1 does not do (Amendment 23 S1). Nothing was written. Unset it, or make the checkout first: $SELF lane-checkout $lce_lane --create" 2 + fi mkdir -p -- "$lce_base" 2>/dev/null || die "cannot create $lce_base, where this workstation's lane checkouts live (Amendment 23 S1). Nothing was written." 2 case "$GIT_TIMEOUT" in @@ -14973,11 +15094,21 @@ lane_checkout_ensure() { # lce_wobj="$(lane_ws_objects 2>/dev/null || :)" if [ -n "$lce_wobj" ] && [ -f "$lce_wobj/info/alternates" ]; then lce_ref=""; fi note "creating lane $lce_lane's checkout $lce_p: a clone of $lce_url${lce_ref:+ that borrows the objects of W} (Amendment 23 S1)" - touch -- "$lce_lock" 2>/dev/null || : + # THE CREATION LOCK'S HEARTBEAT, for as long as the clone runs (the review + # of #282, NIT 5): its budget is ten minutes or more, and a waiter in + # another container takes a lock that has not moved for ten. So it is + # refreshed now and every minute until the clone ends, and only ever with + # `touch -c` (MUST 4): a lock that vanished is never made again as a file. + # Its output is nobody's, and it ends by itself when this process does. + touch -c -- "$lce_lock" 2>/dev/null || : + ( while sleep 60; do kill -0 "$$" 2>/dev/null || exit 0; touch -c -- "$lce_lock" 2>/dev/null || :; done ) \ + /dev/null 2>&1 & + CREATE_HB=$! lce_t="$GIT_TIMEOUT"; GIT_TIMEOUT="$lce_budget"; lce_rc=0 git_net clone -q ${lce_ref:+--reference "$lce_ref"} --single-branch --branch "$LANES_BRANCH" \ -- "$lce_url" "$lce_part" || lce_rc=$? GIT_TIMEOUT="$lce_t" + create_heartbeat_stop if [ "$lce_rc" != 0 ]; then lce_how="exit $lce_rc"; [ "$GIT_TIMED_OUT" = 1 ] && lce_how="timed out after ${lce_budget}s" die "the clone of $lce_url for lane $lce_lane failed ($lce_how), so this write has no checkout to go through and was refused: nothing was written. A failed clone leaves only $lce_part, which the next attempt moves aside (Amendment 23 S1)." 2 @@ -15020,9 +15151,21 @@ write_lane_of() { # [ -n "${1-}" ] || return 1 printf '%s\n' "$1" ;; request-handoff) + # THE ARM'S OWN PARSE (the review of #282, NIT 1): `--` ends the options, + # and every word after it is the `--force` why — so a why that reads + # `--dry-run` is not a dry run, and the write it is goes through the + # target lane's checkout like any other. [ -n "${1-}" ] || return 1 - for wlo_a in ${1+"$@"}; do [ "$wlo_a" != --dry-run ] || return 1; done - printf '%s\n' "$1" ;; + wlo_l="$1"; shift + while [ $# -gt 0 ]; do + case "$1" in + --) break ;; + --dry-run) return 1 ;; + --wait|--session) shift; [ $# -gt 0 ] || break; shift ;; + *) shift ;; + esac + done + printf '%s\n' "$wlo_l" ;; rename-lane) wlo_o="" while [ $# -gt 0 ]; do @@ -15046,10 +15189,16 @@ write_lane_of() { # if [ -n "${LANES_LANE:-}" ]; then printf '%s\n' "$LANES_LANE"; return 0; fi # The arm's own attribution (`lane_from_text`, then the row's spelling): a # line that names no lane with a row is a write with no lane. + # FROM THE PUBLISHED REGISTER FIRST (the review of #282, SHOULD 1): W's + # working tree moves only by `refresh-workspace`, so a lane whose row was + # added through its own checkout is on origin and not in W's file, and a + # `LANDING — lane , …` with no `LANES_LANE` was "no lane, no write". + # The working tree is still asked where the published read finds none. wlo_c="$(lane_from_text "$1")" [ -n "$wlo_c" ] || return 2 wlo_cc="$(canon_lane "$wlo_c" 2>/dev/null)" || { printf '%s\n' "$wlo_c"; return 0; } - wlo_l="$(row_lane_ci "${wlo_cc:-$wlo_c}")" + wlo_l="$(rows_named_ci "${wlo_cc:-$wlo_c}" 2>/dev/null | awk 'NF { n++; t = $0 } END { if (n == 1) print t }')" + [ -n "$wlo_l" ] || wlo_l="$(row_lane_ci "${wlo_cc:-$wlo_c}")" [ -n "$wlo_l" ] || return 2 printf '%s\n' "$wlo_l" ;; commit|log|claim|release) @@ -15103,38 +15252,80 @@ lane_checkout_inside() { # return 0 } +# A directory's git common directory, real; nothing where it is not in one. +git_common_real() { # + gcr_d="$(git -C "$1" rev-parse --git-common-dir 2>/dev/null)" || return 0 + case "$gcr_d" in '') return 0 ;; /*) : ;; *) gcr_d="$1/$gcr_d" ;; esac + (cd -P -- "$gcr_d" 2>/dev/null && pwd -P) || : +} + +# S5'S OTHER HALF FOR PEOPLE (the review of #282, SHOULD 4): A HAND EDIT IN W IS +# NEVER LEFT BEHIND IN SILENCE. Before Amendment 23 the next write captured an +# uncommitted register edit as its own `LANES(pre-existing@…)` commit. A routed +# write goes through the lane's checkout and commits nothing in W, so that edit +# would stay there, unpublished, while `commit` said "nothing staged". So a +# routed write refuses while W's register has one, naming it and the checkout +# it belongs in. +ws_pending_refuse() { # + wpr_st="$(git -C "$LANES_WS_REAL" status --porcelain --untracked-files=no -- lanes/LANES.md 2>/dev/null || :)" + [ -n "$wpr_st" ] || return 0 + die "W ($LANES_WS_REAL) holds an uncommitted edit to its register (${wpr_st# }), and under LANES_ROUTE_WRITES=1 nothing commits in W: this write goes through $2/ and would leave that edit in W, unpublished, where the write that used to capture it no longer runs (Amendment 23 S5). Nothing was written. Carry it to the lane's own checkout — \`git -C $LANES_WS_REAL diff -- lanes/LANES.md\` shows it; make it in $2/lanes/LANES.md and publish it with LANES_LANE=$1 $SELF commit \"\" — then take it out of W by hand and run this write again." 2 +} + # S2, IN ITS ORDER, for a write: (1) an explicit `LANES_REPO` keeps its meaning # — S5 refuses one that IS W — and is honoured in place; (2) otherwise the lane's # own checkout, made on first use, and this file runs again there; (3) no lane, -# no write: there is no fallback to W. +# no write: there is no fallback to W, and an explicit `LANES_REPO` names a +# checkout, never a lane (the review of #282, NIT 9). Under the opt-in only. lane_checkout_enter() { # lcn_st="$1"; lcn_lane="${2-}" + lanes_home_check + if [ "$lcn_st" = 2 ]; then + if [ "$LANES_REPO_GIVEN" = 1 ]; then + die "no lane, no write (Amendment 23 S2): \`$cmd\` names no lane here, and LANES_REPO chooses the checkout a write goes through, never the lane its commit is for. Name that lane — LANES_LANE= $SELF $cmd … — and it carries that lane in its subject. Nothing was written." 2 + fi + die "no lane, no write (Amendment 23 S2): \`$cmd\` names no lane here, so there is no lane checkout for it to go through, and a write never falls back to the workspace checkout. Name the lane it is for — LANES_LANE= $SELF $cmd … — and it goes through that lane's checkout, carrying that lane in its subject. Nothing was written." 2 + fi if [ "$LANES_REPO_GIVEN" = 1 ]; then lcn_real="$(cd -P -- "$LANES_REPO" 2>/dev/null && pwd -P)" || lcn_real="" + lcn_root="$lcn_real" + if [ -n "$lcn_real" ]; then + lcn_top="$(git -C "$lcn_real" rev-parse --show-toplevel 2>/dev/null || :)" + lcn_top="$( [ -n "$lcn_top" ] && cd -P -- "$lcn_top" 2>/dev/null && pwd -P || :)" + [ -z "$lcn_top" ] || lcn_root="$lcn_top" + fi lcn_s1="$LANES_WS_REAL-lanes/${lcn_lane:-}" - if [ -n "$lcn_real" ] && [ "$lcn_real" = "$LANES_WS_REAL" ]; then - lcn_via=""; [ "$LANES_REPO" = "$lcn_real" ] || lcn_via=" (through $LANES_REPO)" + # S5 BY REPOSITORY, NOT BY SPELLING (the review of #282, SHOULD 3): W's own + # `lanes/` directory, or a linked worktree of W, is W — its edit lands in + # W's tree and its commit in W's refs — so the checkout's root and its git + # common directory are compared, not the path as typed. + lcn_gcd=""; lcn_wgcd="" + if [ -n "$lcn_real" ]; then + lcn_gcd="$(git_common_real "$lcn_real")"; lcn_wgcd="$(git_common_real "$LANES_WS_REAL")" + fi + if [ -n "$lcn_real" ] && { [ "$lcn_root" = "$LANES_WS_REAL" ] || + { [ -n "$lcn_gcd" ] && [ "$lcn_gcd" = "$lcn_wgcd" ]; }; }; then + lcn_via=""; [ "$LANES_REPO" = "$LANES_WS_REAL" ] || lcn_via=" (through $LANES_REPO)" die "LANES_REPO names the workspace checkout $LANES_WS_REAL$lcn_via, and that checkout is the readers' copy: no tool writes there (Amendment 23 S5). Nothing was written. Write through the lane's own checkout instead — unset LANES_REPO and name the lane, LANES_LANE=${lcn_lane:-} $SELF $cmd … — and the write goes through $lcn_s1/." 2 fi # S6.2 — A PER-PROFILE CLONE BESIDE W is honoured, and warned about. - case "$lcn_real" in + case "$lcn_root" in "${LANES_WS_REAL%/*}/${LANES_WS_REAL##*/}-"*) - if [ "${lcn_real%/*}" = "${LANES_WS_REAL%/*}" ] && [ "$lcn_real" != "$LANES_WS_REAL-lanes" ] && - lanes_ws_same_repo "$(git -C "$lcn_real" remote get-url origin 2>/dev/null || :)" \ + if [ "${lcn_root%/*}" = "${LANES_WS_REAL%/*}" ] && [ "$lcn_root" != "$LANES_WS_REAL-lanes" ] && + lanes_ws_same_repo "$(git -C "$lcn_root" remote get-url origin 2>/dev/null || :)" \ "$(git -C "$LANES_WS_REAL" remote get-url origin 2>/dev/null || :)"; then - note "WARNING: LANES_REPO names $lcn_real, a per-profile clone of the register beside W. It is honoured, and nothing new should write through it (Amendment 23 S6.2): this lane's own checkout is $lcn_s1/. Unset LANES_REPO, LANES_DIR and LANES_FILE in this window, or relaunch it." + note "WARNING: LANES_REPO names $lcn_root, a per-profile clone of the register beside W. It is honoured, and nothing new should write through it (Amendment 23 S6.2): this lane's own checkout is $lcn_s1/. Unset LANES_REPO, LANES_DIR and LANES_FILE in this window, or relaunch it." fi ;; esac - [ -z "$lcn_real" ] || lane_checkout_inside "$lcn_real" + [ -z "$lcn_root" ] || lane_checkout_inside "$lcn_root" return 0 fi - [ "$lcn_st" != 2 ] || - die "no lane, no write (Amendment 23 S2): \`$cmd\` names no lane here, so there is no lane checkout for it to go through, and a write never falls back to the workspace checkout. Name the lane it is for — LANES_LANE= $SELF $cmd … — and it goes through that lane's checkout, carrying that lane in its subject. Nothing was written." 2 check_lane_name "$lcn_lane" lcn_c="$(canon_lane "$lcn_lane")" || exit $? check_lane_name "$lcn_c" lcn_p="$(lane_checkout_path "$lcn_c")" || die "$(lanes_workspace_why)" 1 lane_checkout_inside "$lcn_p" + ws_pending_refuse "$lcn_c" "$lcn_p" lane_checkout_ensure "$lcn_c" if [ -n "$LANES_DIR_GIVEN" ]; then LANES_DIR="$LANES_DIR_GIVEN" elif [ -n "$LANES_FILE_GIVEN" ]; then LANES_DIR="$(dirname -- "$LANES_FILE_GIVEN")" @@ -15376,9 +15567,11 @@ case "$cmd" in # workstation where no register is configured. session-start|guard|lane-groups|next-free|append-row-status|project-legs) : ;; *) - # AND W ITSELF, an explicit `LANES_REPO` or not (Amendment 23): S5 refuses - # a write whose checkout is W, and the sidecar lock lives beside it. - if [ -z "$LANES_REPO" ] || [ -z "$LANES_DIR" ] || [ -z "$LANES_WS_REAL" ]; then + # AND W ITSELF UNDER THE OPT-IN, an explicit `LANES_REPO` or not + # (Amendment 23): S5 refuses a write whose checkout is W, and the sidecar + # lock lives beside it. + if [ -z "$LANES_REPO" ] || [ -z "$LANES_DIR" ] || + { [ "$LANES_ROUTE_ON" = 1 ] && [ -z "$LANES_WS_REAL" ]; }; then die "$(lanes_workspace_why)" 1 fi [ -f "$LANES_FILE" ] || die "registry not found: $LANES_FILE @@ -15388,12 +15581,14 @@ case "$cmd" in ;; esac -# AMENDMENT 23 S2 — EVERY WRITE GOES THROUGH ITS LANE'S CHECKOUT. Asked after -# the two guards above, so a container with no workstation name and a W with -# no register refuse before any checkout is cloned; and asked once — the -# process this re-runs as (`LANES_ROUTED`) is already in the checkout. A read -# goes on in W, which is what every read verb has always read. -if [ -z "$LANES_ROUTED" ]; then +# AMENDMENT 23 S2 — UNDER `LANES_ROUTE_WRITES=1`, EVERY WRITE GOES THROUGH ITS +# LANE'S CHECKOUT. Asked after the two guards above, so a container with no +# workstation name and a W with no register refuse before any checkout is +# cloned; and asked once — the process this re-runs as (`LANES_ROUTED`) is +# already in the checkout. A read goes on in W, which is what every read verb +# has always read. Without the opt-in nothing here runs (MUST 2 of #282's +# review): the write goes through `LANES_REPO`, W by default. +if [ "$LANES_ROUTE_ON" = 1 ] && [ -z "$LANES_ROUTED" ]; then wlo_rc=0 wlo_lane="$(write_lane_of "$cmd" ${1+"$@"})" || wlo_rc=$? [ "$wlo_rc" = 1 ] || lane_checkout_enter "$wlo_rc" "$wlo_lane" @@ -15633,6 +15828,10 @@ Nothing was written." 2 lane_tag="$(row_lane_ci "${cand_c:-$cand}")" fi fi + # A ROUTED LINE'S LANE IS THE ONE IT WAS ROUTED BY (Amendment 23; the review + # of #282, SHOULD 1): the router read it from the published register, and + # this checkout's copy may predate the row. + [ -n "$lane_tag" ] || lane_tag="${LANES_ROUTED:-}" acquire_lock; handle_preexisting append_text_line "$text" msg="LANES(${lane_tag:-unknown}@$WS): append line — ${text:0:72}" @@ -19237,6 +19436,7 @@ EOF esac done [ -n "$lck_lane" ] || die "usage: lane-checkout [--create]" 64 + lanes_home_check check_lane_name "$lck_lane" lck_lane="$(canon_lane "$lck_lane")" || exit $? check_lane_name "$lck_lane" @@ -19260,6 +19460,7 @@ EOF # 1 W not found · 64 usage refresh-workspace) [ "$#" = 0 ] || die "refresh-workspace takes no arguments" 64 + lanes_home_check refresh_workspace ;; diff --git a/tests/a23_box.py b/tests/a23_box.py new file mode 100644 index 00000000..be3f087f --- /dev/null +++ b/tests/a23_box.py @@ -0,0 +1,184 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The sandbox every Amendment 23 module shares (`tests/test_a23_*.py`). + +A bare origin seeded with a register, W cloned from it, and the +`workspace.yaml` naming W, with the real `lanes-edit.sh` run in a scrubbed +environment: a `$HOME` of its own, `gh` and `tmux` that refuse, and no +inherited `LANES_*`, `TMUX*`, `GIT_*` or `CLAUDE_*`. W is a clone of its own, +never a view of the seed or of a lane's checkout, so W can be seen to lag. + +Amendment 23's routing is opt-in until its slice 1b (the review of +opensoft/openRepoTools#282, MUST 2), so the box exports +`LANES_ROUTE_WRITES=1` unless a case asks for the default with `route=False`. +Nothing reaches a real remote. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from conftest import REPO + +REGISTER = "lanes/LANES.md" +HEADER = ("# LANES.md -- the sandbox register\n\n" + "| lane | session id | workstation / env / user | started (UTC) " + "| objects owned | handoff path | state |\n" + "|---|---|---|---|---|---|---|\n") +#: `LANES_CONTAINER` and `LANES_HOST` as the box pins them: "this container". +ME = "bench-a hosta" + +SCRUBBED_PREFIXES = ("LANES_", "LANE_START_", "CLAUDE_", "OPENREPOTOOLS_", + "FAKE_", "GIT_", "TMUX") +SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", + "XDG_CACHE_HOME"} + +REFUSE = """#!/usr/bin/env bash +printf 'FAKE %s REFUSED: %s -- this suite reaches no real surface\\n' "${0##*/}" "$*" >&2 +exit 90 +""" + + +def register_text(rows) -> str: + return HEADER + "".join(f"{r}\n" for r in rows) + + +class RegisterBox: + """A bare origin, W cloned from it, and the workspace.yaml naming W. + + `files` seeds origin's first commit; `seed_dir` names the clone that made + it, which stays as a peer able to push; `second_branch` adds `side` on + origin; `w_reference` clones W with `--reference`.""" + + def __init__(self, root: Path, files: dict, *, seed_dir: str = "seed", + second_branch: bool = False, w_reference: Path | None = None, + route: bool = True, env: dict | None = None) -> None: + # REAL PATHS, because the helper prints W and its lane checkouts as + # `pwd -P` spells them (a temp root under a symlink would not match). + root = Path(os.path.realpath(root)) + root.mkdir(parents=True, exist_ok=True) + self.root = root + self.home = root / "home" + self.origin = root / "origin.git" + self.w = self.home / "projects" / "brett-wip" + self.lanes_root = self.home / "projects" / "brett-wip-lanes" + self.fakebin = root / "fakebin" + for d in (self.home / ".agents", self.home / "projects", root / "tmp", self.fakebin): + d.mkdir(parents=True, exist_ok=True) + for name in ("gh", "tmux"): + (self.fakebin / name).write_text(REFUSE, encoding="utf-8") + (self.fakebin / name).chmod(0o755) + base = {k: v for k, v in os.environ.items() + if k not in SCRUBBED and not k.startswith(SCRUBBED_PREFIXES)} + base.update({ + "HOME": str(self.home), + "TMPDIR": str(root / "tmp"), + "XDG_CONFIG_HOME": str(self.home / ".config"), + "XDG_CACHE_HOME": str(self.home / ".cache"), + "PATH": f"{self.fakebin}{os.pathsep}{base.get('PATH', '')}", + "AGENT_PROTOCOL_ROOT": str(self.home / ".agents"), + "LANES_WORKSTATION": "Eagle", + "LANES_HOST": "hosta", + "LANES_CONTAINER": "bench-a", + "LANES_NO_GITHUB": "1", + "LANES_INDEX": "off", + "LANE_WORKTREES_REPORT": "off", + "GIT_AUTHOR_NAME": "a23 tests", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "a23 tests", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + }) + if route: + base["LANES_ROUTE_WRITES"] = "1" + base.update(env or {}) + self.env = base + self.git("init", "-q", "--bare", "-b", "main", str(self.origin)) + self.quiet(self.origin, bare=True) + self.seed = root / seed_dir + self.git("clone", "-q", str(self.origin), str(self.seed)) + self.quiet(self.seed) + for rel, text in files.items(): + self.write(self.seed, rel, text) + self.commit(self.seed, "seed", *files) + self.git("-C", str(self.seed), "push", "-q", "origin", "main") + if second_branch: + # A SECOND BRANCH ON ORIGIN, so a clone that took every branch shows. + self.git("-C", str(self.seed), "push", "-q", "origin", "main:refs/heads/side") + clone = ["clone", "-q"] + if w_reference is not None: + clone += ["--reference", str(w_reference)] + self.git(*clone, str(self.origin), str(self.w)) + self.quiet(self.w) + (self.home / ".agents" / "workspace.yaml").write_text( + f"repository: {self.origin}\npath: {self.w}\n", encoding="utf-8") + + # ------------------------------------------------------------- git + def quiet(self, repo: Path, *, bare: bool = False) -> None: + """gc.auto=0 and maintenance.auto=false: W is what lane checkouts borrow.""" + where = ["--git-dir", str(repo)] if bare else ["-C", str(repo)] + self.git(*where, "config", "gc.auto", "0") + self.git(*where, "config", "maintenance.auto", "false") + + def git(self, *args: str, check: bool = True) -> str: + return subprocess.run(["git", *args], env=self.env, check=check, + capture_output=True, text=True).stdout + + @staticmethod + def write(checkout: Path, rel: str, text: str) -> Path: + path = checkout / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + def commit(self, checkout: Path, message: str, *paths: str) -> None: + self.git("-C", str(checkout), "add", "--", *paths) + self.git("-C", str(checkout), "commit", "-q", "-m", message) + + def clone(self, dest: Path, *, origin: Path | None = None, + reference: Path | None = None) -> Path: + args = ["clone", "-q"] + if reference is not None: + args += ["--reference", str(reference)] + self.git(*args, str(origin or self.origin), str(dest)) + self.quiet(dest) + return dest + + def peer_pushes(self, rel: str, text: str) -> None: + """The seed clone, as a peer, publishes one more file.""" + self.write(self.seed, rel, text) + self.commit(self.seed, f"a peer writes {rel}", rel) + self.git("-C", str(self.seed), "push", "-q", "origin", "main") + + # ------------------------------------------------------------- the helper + def cmd(self, *args: str) -> list: + return ["bash", str(REPO / "lanes-edit.sh"), *args] + + def helper(self, *args: str, timeout: int = 180, **extra: str) -> subprocess.CompletedProcess: + return subprocess.run(self.cmd(*args), env=dict(self.env, **extra), cwd=str(self.root), + capture_output=True, text=True, timeout=timeout, check=False) + + def popen(self, *args: str, **extra: str) -> subprocess.Popen: + return subprocess.Popen(self.cmd(*args), env=dict(self.env, **extra), cwd=str(self.root), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + # ------------------------------------------------------------- reading + def checkout(self, lane: str) -> Path: + return self.lanes_root / lane + + def origin_head(self) -> str: + return self.git("--git-dir", str(self.origin), "rev-parse", "main").strip() + + def origin_file(self, rel: str) -> str: + return self.git("--git-dir", str(self.origin), "show", f"main:{rel}") + + def origin_register(self) -> str: + return self.origin_file(REGISTER) + + def w_origin_main(self) -> str: + """W's own `origin/main`, as an offline read of W sees it.""" + return self.git("-C", str(self.w), "rev-parse", "refs/remotes/origin/main").strip() + + def w_state(self) -> tuple: + return (self.git("-C", str(self.w), "rev-parse", "HEAD").strip(), + self.git("-C", str(self.w), "status", "--porcelain", "--untracked-files=all")) diff --git a/tests/test_a23_lane_checkout.py b/tests/test_a23_lane_checkout.py index a831fbc7..40af1425 100644 --- a/tests/test_a23_lane_checkout.py +++ b/tests/test_a23_lane_checkout.py @@ -22,7 +22,8 @@ import pytest -from conftest import REPO, WINDOWS_SKIP +from a23_box import REGISTER, RegisterBox, register_text +from conftest import WINDOWS_SKIP pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, reason="lanes-edit.sh is a bash script"), @@ -31,147 +32,34 @@ WINDOWS_SKIP] LANE = "repoCK-1" -REGISTER = "lanes/LANES.md" UUID = "a23c0001-0c0c-4000-8000-a23c00010c0c" -SCRUBBED_PREFIXES = ("LANES_", "LANE_START_", "CLAUDE_", "OPENREPOTOOLS_", - "FAKE_", "GIT_", "TMUX") -SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", - "XDG_CACHE_HOME"} - -REFUSE = """#!/usr/bin/env bash -printf 'FAKE %s REFUSED: %s -- this suite reaches no real surface\\n' "${0##*/}" "$*" >&2 -exit 90 -""" - def _row(lane: str, state: str = "LIVE · seeded") -> str: return (f"| `{lane}` | harness `{UUID}` | Eagle / test / tester | 2026-10-17T00:00Z " f"| — | handoffs/repoCK/{lane}.md | {state} |") -class Box: - """A bare origin, W cloned from it, and the workspace.yaml naming W.""" +class Box(RegisterBox): + """The rows named, an alias table where one is given, and a second branch + on origin.""" def __init__(self, root: Path, *, rows=("repoCK-1", "repoCK-2", "repoCK-3", "repoCK-5"), aliases: str = "", w_reference: Path | None = None) -> None: - # REAL PATHS, because the helper prints W and its lane checkouts as - # `pwd -P` spells them (a temp root under a symlink would not match). - root = Path(os.path.realpath(root)) - root.mkdir(parents=True, exist_ok=True) - self.root = root - self.home = root / "home" - self.origin = root / "origin.git" - self.w = self.home / "projects" / "brett-wip" - self.lanes_root = self.home / "projects" / "brett-wip-lanes" - self.fakebin = root / "fakebin" - for d in (self.home / ".agents", self.home / "projects", root / "tmp", self.fakebin): - d.mkdir(parents=True, exist_ok=True) - for name in ("gh", "tmux"): - (self.fakebin / name).write_text(REFUSE, encoding="utf-8") - (self.fakebin / name).chmod(0o755) - env = {k: v for k, v in os.environ.items() - if k not in SCRUBBED and not k.startswith(SCRUBBED_PREFIXES)} - env.update({ - "HOME": str(self.home), - "TMPDIR": str(root / "tmp"), - "XDG_CONFIG_HOME": str(self.home / ".config"), - "XDG_CACHE_HOME": str(self.home / ".cache"), - "PATH": f"{self.fakebin}{os.pathsep}{env.get('PATH', '')}", - "AGENT_PROTOCOL_ROOT": str(self.home / ".agents"), - "LANES_WORKSTATION": "Eagle", - "LANES_HOST": "hosta", - "LANES_CONTAINER": "bench-a", - "LANES_NO_GITHUB": "1", - "LANES_INDEX": "off", - "LANE_WORKTREES_REPORT": "off", - "GIT_AUTHOR_NAME": "a23 tests", - "GIT_AUTHOR_EMAIL": "test@example.invalid", - "GIT_COMMITTER_NAME": "a23 tests", - "GIT_COMMITTER_EMAIL": "test@example.invalid", - }) - self.env = env - self.git("init", "-q", "--bare", "-b", "main", str(self.origin)) - self.quiet(self.origin, bare=True) - seed = root / "seed" - self.git("clone", "-q", str(self.origin), str(seed)) - self.quiet(seed) - register = ("# LANES.md -- the sandbox register\n\n" - "| lane | session id | workstation / env / user | started (UTC) " - "| objects owned | handoff path | state |\n" - "|---|---|---|---|---|---|---|\n" - + "".join(f"{_row(lane)}\n" for lane in rows)) - self.write(seed, REGISTER, register) - self.write(seed, "handoffs/README.md", "# handoffs\n") - paths = [REGISTER, "handoffs/README.md"] + files = {REGISTER: register_text(_row(lane) for lane in rows), + "handoffs/README.md": "# handoffs\n"} if aliases: - self.write(seed, "lanes/aliases.tsv", aliases) - paths.append("lanes/aliases.tsv") - self.git("-C", str(seed), "add", "--", *paths) - self.git("-C", str(seed), "commit", "-q", "-m", "seed") - self.git("-C", str(seed), "push", "-q", "origin", "main") - # A SECOND BRANCH ON ORIGIN, so a clone that took every branch shows. - self.git("-C", str(seed), "push", "-q", "origin", "main:refs/heads/side") - self.seed = seed - clone = ["clone", "-q"] - if w_reference is not None: - clone += ["--reference", str(w_reference)] - self.git(*clone, str(self.origin), str(self.w)) - self.quiet(self.w) - (self.home / ".agents" / "workspace.yaml").write_text( - f"repository: {self.origin}\npath: {self.w}\n", encoding="utf-8") - - def quiet(self, repo: Path, *, bare: bool = False) -> None: - """gc.auto=0 and maintenance.auto=false: W is what lane checkouts borrow.""" - where = ["--git-dir", str(repo)] if bare else ["-C", str(repo)] - self.git(*where, "config", "gc.auto", "0") - self.git(*where, "config", "maintenance.auto", "false") - - def git(self, *args: str, check: bool = True) -> str: - return subprocess.run(["git", *args], env=self.env, check=check, - capture_output=True, text=True).stdout - - @staticmethod - def write(checkout: Path, rel: str, text: str) -> None: - path = checkout / rel - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - def helper(self, *args: str, **extra: str) -> subprocess.CompletedProcess: - return subprocess.run(["bash", str(REPO / "lanes-edit.sh"), *args], - env=dict(self.env, **extra), cwd=str(self.root), - capture_output=True, text=True, timeout=180, check=False) - - def popen(self, *args: str, **extra: str) -> subprocess.Popen: - return subprocess.Popen(["bash", str(REPO / "lanes-edit.sh"), *args], - env=dict(self.env, **extra), cwd=str(self.root), - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + files["lanes/aliases.tsv"] = aliases + super().__init__(root, files, second_branch=True, w_reference=w_reference) def stamp(self, lane: str, words: str, **extra: str) -> subprocess.CompletedProcess: return self.helper("set-row-state", lane, f"LIVE · {words}", **extra) - def checkout(self, lane: str) -> Path: - return self.lanes_root / lane - - def origin_register(self) -> str: - return self.git("--git-dir", str(self.origin), "show", f"main:{REGISTER}") - - def origin_head(self) -> str: - return self.git("--git-dir", str(self.origin), "rev-parse", "main").strip() - - def w_state(self) -> tuple[str, str, str]: + def w_state(self) -> tuple: return (self.git("-C", str(self.w), "rev-parse", "HEAD").strip(), self.git("-C", str(self.w), "rev-parse", "HEAD^{tree}").strip(), self.git("-C", str(self.w), "status", "--porcelain")) - def clone_by_hand(self, dest: Path, *, origin: Path | None = None, - reference: Path | None = None) -> None: - args = ["clone", "-q"] - if reference is not None: - args += ["--reference", str(reference)] - self.git(*args, str(origin or self.origin), str(dest)) - self.quiet(dest) - @pytest.fixture def box(tmp_path: Path) -> Box: @@ -335,9 +223,9 @@ def test_empty_directory_counts_as_absent(box: Box): def test_alternates_not_naming_W_is_refused(box: Box): other = box.root / "other-store" - box.clone_by_hand(other) + box.clone(other) c = box.checkout(LANE) - box.clone_by_hand(c, reference=other) + box.clone(c, reference=other) head = box.origin_head() result = box.stamp(LANE, "never written") assert_refused_exit_1(box, result, c) @@ -348,7 +236,7 @@ def test_alternates_not_naming_W_is_refused(box: Box): def test_alternates_naming_a_missing_store_is_refused(box: Box): c = box.checkout(LANE) - box.clone_by_hand(c, reference=box.w) + box.clone(c, reference=box.w) gone = box.root / "gone" / "objects" (c / ".git" / "objects" / "info" / "alternates").write_text(f"{gone}\n", encoding="utf-8") head = box.origin_head() @@ -396,7 +284,7 @@ def test_the_clone_outlasts_a_short_network_bound(box: Box): def test_renamed_lane_directory_is_moved_once(tmp_path: Path): box = Box(tmp_path, rows=("repoCK-8",), aliases=ALIAS) old = box.checkout("repoCK-7") - box.clone_by_hand(old, reference=box.w) + box.clone(old, reference=box.w) (old / ".git" / "made-under-the-old-name").write_text("x\n", encoding="utf-8") first = box.stamp("repoCK-8", "after the rename") assert first.returncode == 0, (first.stdout, first.stderr) @@ -414,7 +302,7 @@ def test_both_names_present_writes_new_reports_old(tmp_path: Path): box = Box(tmp_path, rows=("repoCK-8",), aliases=ALIAS) assert box.stamp("repoCK-8", "made the new one").returncode == 0 old = box.checkout("repoCK-7") - box.clone_by_hand(old, reference=box.w) + box.clone(old, reference=box.w) old_head = box.git("-C", str(old), "rev-parse", "HEAD").strip() result = box.stamp("repoCK-8", "through the new one") assert result.returncode == 0, (result.stdout, result.stderr) diff --git a/tests/test_a23_lane_start_read.py b/tests/test_a23_lane_start_read.py index 2827f119..c6997132 100644 --- a/tests/test_a23_lane_start_read.py +++ b/tests/test_a23_lane_start_read.py @@ -11,9 +11,11 @@ `lane-end` read the same W and found no row to end. Each case drives the real commands with no `LANES_REPO` and no `LANES_FILE`, so -the helper routes every write and the commands resolve W from `workspace.yaml` -as a lane window does. The sandbox is `test_lane_start_row_stamp`'s, with its -explicit seam taken back out. +the commands resolve W from `workspace.yaml` as a lane window does, and with +`LANES_ROUTE_WRITES=1`, the opt-in the routing stays behind until slice 1b (the +review of opensoft/openRepoTools#282, MUST 2); one case leaves it out. The +sandbox is `test_lane_start_row_stamp`'s, with its explicit seam taken back +out. """ from __future__ import annotations @@ -27,10 +29,13 @@ from test_lane_start_row_stamp import LANE, Sandbox, _checkout_of_length, pytestmark, short_root # noqa: F401 -def default_resolution(box: Sandbox) -> Path: - """No seam: workspace.yaml names `box.wip` as W again. Returns the lane's checkout.""" +def default_resolution(box: Sandbox, *, route: bool = True) -> Path: + """No seam: workspace.yaml names `box.wip` as W again, and the routing is + opted into unless `route` is False. Returns the lane's checkout.""" for key in ("LANES_REPO", "LANES_FILE"): box.env.pop(key, None) + if route: + box.env["LANES_ROUTE_WRITES"] = "1" (box.home / ".agents" / "workspace.yaml").write_text( f"repository: {box.root / 'origin.git'}\npath: {box.wip}\n", encoding="utf-8") return Path(os.path.realpath(box.wip) + "-lanes") / LANE @@ -105,3 +110,46 @@ def test_a_LANES_REPO_alone_is_the_register_read(tmp_path, short_root): assert second.returncode == 0, second.stderr assert "ALREADY has a row" not in second.stderr, second.stderr assert len(rows(box.wip / "lanes" / "LANES.md")) == 1 + + +# ------------------------------------------------------- fix round 1 (#282's review) + +def test_without_the_opt_in_a_new_lane_starts_through_W(tmp_path, short_root): + """MUST 2: with no `LANES_ROUTE_WRITES` a start reads and writes W, as on + main: no lane checkout is made, and a second start finds its row in W.""" + box = Sandbox(tmp_path) + checkout = default_resolution(box, route=False) + lane_dir = _checkout_of_length(short_root, None) + box.checkout(lane_dir) + first = box.start("--dir", str(lane_dir), window_name="claude") + assert first.returncode == 0, first.stderr + assert not checkout.parent.exists(), "no lane checkout, and no -lanes/" + assert len(rows(box.wip / "lanes" / "LANES.md")) == 1, "the row is W's" + second = box.start(window_name=LANE) + assert second.returncode == 0, second.stderr + assert not checkout.parent.exists() + + +def test_a_checkout_that_cannot_be_made_is_said_and_the_start_goes_on(tmp_path, short_root): + """SHOULD 2: where the lane's checkout cannot be cloned, the start says so + and reads W, as it does for a dry run with none: a register act it can do + without never refuses a launch (Amendment 8(d)).""" + box = Sandbox(tmp_path) + checkout = default_resolution(box, route=False) + lane_dir = _checkout_of_length(short_root, None) + box.checkout(lane_dir) + first = box.start("--dir", str(lane_dir), window_name="claude") + assert first.returncode == 0, first.stderr + real = shutil.which("git") + wrapper = box.root / "no-clone-git" + wrapper.write_text(f"""#!/usr/bin/env bash +case " $* " in *" clone "*) echo "test: no clone today" >&2; exit 1 ;; esac +exec '{real}' "$@" +""", encoding="utf-8") + wrapper.chmod(0o755) + box.env.update(LANES_ROUTE_WRITES="1", LANES_GIT=str(wrapper)) + second = box.start(window_name=LANE) + assert "own register checkout could not be made" in second.stderr, second.stderr + assert "so this command reads W's register" in second.stderr + assert not checkout.exists() + assert second.returncode == 0, second.stderr diff --git a/tests/test_a23_lock.py b/tests/test_a23_lock.py index 0591b537..4e5f8fef 100644 --- a/tests/test_a23_lock.py +++ b/tests/test_a23_lock.py @@ -28,6 +28,7 @@ import pytest +from a23_box import ME, REGISTER, RegisterBox, register_text from conftest import REPO, WINDOWS_SKIP pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, @@ -37,130 +38,39 @@ WINDOWS_SKIP] LANE = "repoLK-1" -REGISTER = "lanes/LANES.md" UUID = "a23b0001-1c1c-4000-8000-a23b00011c1c" -ME = "bench-a hosta" # LANES_CONTAINER and LANES_HOST, below #: How long a waiter is watched to prove it is still waiting. The helper polls #: once a second, so a writer that was going to take the lock has long done so. WATCH = 4 -SCRUBBED_PREFIXES = ("LANES_", "LANE_START_", "CLAUDE_", "OPENREPOTOOLS_", - "FAKE_", "GIT_", "TMUX") -SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", - "XDG_CACHE_HOME"} - -REFUSE = """#!/usr/bin/env bash -printf 'FAKE %s REFUSED: %s -- this suite reaches no real surface\\n' "${0##*/}" "$*" >&2 -exit 90 -""" - def _row(lane: str, state: str = "LIVE · seeded") -> str: return (f"| `{lane}` | harness `{UUID}` | Eagle / test / tester | 2026-10-17T00:00Z " f"| — | handoffs/repoLK/{lane}.md | {state} |") -class Box: - """A bare origin, W cloned from it, and the workspace.yaml naming W.""" +class Box(RegisterBox): + """Two rows, and the lanes' sidecars under a state root of the box's own.""" def __init__(self, root: Path) -> None: - root = Path(os.path.realpath(root)) - root.mkdir(parents=True, exist_ok=True) - self.root = root - self.home = root / "home" - self.origin = root / "origin.git" - self.w = self.home / "projects" / "brett-wip" - self.lanes_root = self.home / "projects" / "brett-wip-lanes" - self.state = root / "state" - self.fakebin = root / "fakebin" - for d in (self.home / ".agents", self.home / "projects", root / "tmp", self.fakebin): - d.mkdir(parents=True, exist_ok=True) - for name in ("gh", "tmux"): - (self.fakebin / name).write_text(REFUSE, encoding="utf-8") - (self.fakebin / name).chmod(0o755) - env = {k: v for k, v in os.environ.items() - if k not in SCRUBBED and not k.startswith(SCRUBBED_PREFIXES)} - env.update({ - "HOME": str(self.home), - "TMPDIR": str(root / "tmp"), - "XDG_CONFIG_HOME": str(self.home / ".config"), - "XDG_CACHE_HOME": str(self.home / ".cache"), - "PATH": f"{self.fakebin}{os.pathsep}{env.get('PATH', '')}", - "AGENT_PROTOCOL_ROOT": str(self.home / ".agents"), - "LANES_WORKSTATION": "Eagle", - "LANES_HOST": "hosta", - "LANES_CONTAINER": "bench-a", - "LANES_NO_GITHUB": "1", - "LANES_INDEX": "off", - "LANE_WORKTREES_REPORT": "off", - "LANES_LANE_STATE_ROOT": str(self.state), - "GIT_AUTHOR_NAME": "a23 tests", - "GIT_AUTHOR_EMAIL": "test@example.invalid", - "GIT_COMMITTER_NAME": "a23 tests", - "GIT_COMMITTER_EMAIL": "test@example.invalid", - }) - self.env = env - self.git("init", "-q", "--bare", "-b", "main", str(self.origin)) - self.quiet(self.origin, bare=True) - seed = root / "seed" - self.git("clone", "-q", str(self.origin), str(seed)) - self.quiet(seed) - self.write(seed, REGISTER, - "# LANES.md -- the sandbox register\n\n" - "| lane | session id | workstation / env / user | started (UTC) " - "| objects owned | handoff path | state |\n" - "|---|---|---|---|---|---|---|\n" - f"{_row(LANE)}\n{_row('repoLK-2')}\n") - self.git("-C", str(seed), "add", "--", REGISTER) - self.git("-C", str(seed), "commit", "-q", "-m", "seed") - self.git("-C", str(seed), "push", "-q", "origin", "main") - self.git("clone", "-q", str(self.origin), str(self.w)) - self.quiet(self.w) - (self.home / ".agents" / "workspace.yaml").write_text( - f"repository: {self.origin}\npath: {self.w}\n", encoding="utf-8") - - def quiet(self, repo: Path, *, bare: bool = False) -> None: - where = ["--git-dir", str(repo)] if bare else ["-C", str(repo)] - self.git(*where, "config", "gc.auto", "0") - self.git(*where, "config", "maintenance.auto", "false") - - def git(self, *args: str) -> str: - return subprocess.run(["git", *args], env=self.env, check=True, - capture_output=True, text=True).stdout - - @staticmethod - def write(checkout: Path, rel: str, text: str) -> None: - path = checkout / rel - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - def cmd(self, *args: str) -> list[str]: - return ["bash", str(REPO / "lanes-edit.sh"), *args] - - def helper(self, *args: str, timeout: int = 120, **extra: str) -> subprocess.CompletedProcess: - return subprocess.run(self.cmd(*args), env=dict(self.env, **extra), cwd=str(self.root), - capture_output=True, text=True, timeout=timeout, check=False) - - def popen(self, *args: str, **extra: str) -> subprocess.Popen: - return subprocess.Popen(self.cmd(*args), env=dict(self.env, **extra), cwd=str(self.root), - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + state = Path(os.path.realpath(root)) / "state" + super().__init__(root, {REGISTER: register_text([_row(LANE), _row("repoLK-2")])}, + env={"LANES_LANE_STATE_ROOT": str(state)}) + self.state = state def stamp(self, words: str, **extra: str) -> subprocess.CompletedProcess: return self.helper("set-row-state", LANE, f"LIVE · {words}", **extra) - def checkout(self) -> Path: + def made_checkout(self) -> Path: """The lane's checkout, made by an ordinary first write.""" - c = self.lanes_root / LANE + c = self.checkout(LANE) if not c.exists(): made = self.stamp("made the checkout") assert made.returncode == 0, (made.stdout, made.stderr) return c def lock(self) -> Path: - return self.checkout() / "lanes" / ".lanes-edit.lock" - - def origin_register(self) -> str: - return self.git("--git-dir", str(self.origin), "show", f"main:{REGISTER}") + return self.made_checkout() / "lanes" / ".lanes-edit.lock" @pytest.fixture @@ -242,7 +152,7 @@ def test_a_lock_naming_no_holder_is_judged_by_its_age(box: Box): def test_lock_survives_exec(box: Box): - c = box.checkout() + c = box.made_checkout() lock = c / "lanes" / ".lanes-edit.lock" script = box.root / "held-across-exec" script.write_text(f"""#!/usr/bin/env bash @@ -264,7 +174,7 @@ def test_lock_survives_exec(box: Box): @pytest.mark.parametrize("how", ["inherited marker", "own pid, no marker"]) def test_a_lock_naming_a_pid_is_not_adopted_without_the_marker(box: Box, how: str): - c = box.checkout() + c = box.made_checkout() lock = c / "lanes" / ".lanes-edit.lock" run = f"bash '{REPO / 'lanes-edit.sh'}' set-row-state '{LANE}' 'LIVE · never written'" if how == "inherited marker": @@ -396,16 +306,39 @@ def test_release_leaves_a_lock_another_holder_took(box: Box): f"the writer's pid file is ` `: {first!r}" assert (lock / "pid").read_text(encoding="utf-8") == f"{holder.pid} bench-b hostb\n", \ "the lock another holder took is that holder's, and is left as it is" - assert f"held by pid {holder.pid} now" in result.stderr, result.stderr + assert f"held by pid {holder.pid} in container bench-b on hostb now" in result.stderr, \ + result.stderr assert " · handed over mid-write |" in box.origin_register() finally: holder.kill() holder.wait() +def test_release_leaves_a_lock_with_its_pid_in_another_container(box: Box): + """NIT 3: a pid is not "this process" in another container. The lock that + changes hands here names the WRITER's own pid with another container and + host, and the writer still leaves it.""" + lock = box.lock() + shim = box.fakebin / "same-pid-git" + shim.write_text(f"""#!/usr/bin/env bash +case " $* " in + *" push "*) + read -r p _ < '{lock}/pid' + printf '%s bench-b hostb\\n' "$p" > '{lock}/pid' ;; +esac +exec git "$@" +""", encoding="utf-8") + shim.chmod(0o755) + result = box.stamp("same pid, another container", LANES_GIT=str(shim)) + assert result.returncode == 0, (result.stdout, result.stderr) + assert re.fullmatch(r"[0-9]+ bench-b hostb\n", (lock / "pid").read_text(encoding="utf-8")) + assert "in container bench-b on hostb now, not by this process" in result.stderr, result.stderr + + # ----------------------------------------------------- the workstation's lock -@pytest.mark.parametrize("act", ["set-lane-state", "set-lane-tree", "publish-handoff"]) +@pytest.mark.parametrize("act", ["set-lane-state", "set-lane-tree", "publish-handoff", + "set-restart-intent"]) def test_sidecar_acts_share_the_workstation_lock(box: Box, act: str): box.lanes_root.mkdir(parents=True, exist_ok=True) sidecar = box.lanes_root / ".lane-state.lock" @@ -424,6 +357,9 @@ def test_sidecar_acts_share_the_workstation_lock(box: Box, act: str): elif act == "set-lane-tree": args = tree written = lambda: any((box.state / LANE / "trees").glob("*.yaml")) # noqa: E731 + elif act == "set-restart-intent": + args = ("set-restart-intent", LANE, "pending", "--expect", "none") + written = lambda: (box.state / LANE / "restart-intent.yaml").is_file() # noqa: E731 else: digest = hashlib.sha256(target.read_bytes()).hexdigest() args = ("publish-handoff", LANE, str(target), str(prepared), "--expect-digest", digest) @@ -447,6 +383,8 @@ def test_sidecar_acts_share_the_workstation_lock(box: Box, act: str): again_args = ("set-lane-state", LANE, "RUNNING", "--expect", "RUNNING", "--owner", UUID) elif act == "set-lane-tree": again_args = tree + elif act == "set-restart-intent": + again_args = ("set-restart-intent", LANE, "pending", "--expect", "pending") else: digest = hashlib.sha256(target.read_bytes()).hexdigest() again_args = ("publish-handoff", LANE, str(target), str(prepared), "--expect-digest", digest) @@ -481,3 +419,58 @@ def test_a_lifecycle_follow_takes_the_workstation_lock(box: Box): assert snapshot.read_text(encoding="utf-8") == before, "the snapshot was not moved unlocked" log = box.git("--git-dir", str(box.origin), "show", f"main:lanes/log/{LANE}.md") assert f"ENDED — lane {LANE}" in log, log + + +# ----------------------------------------------------- fix round 1 (#282's review) + +def test_without_the_opt_in_a_sidecar_act_takes_the_checkouts_lock(box: Box): + """MUST 2: with no `LANES_ROUTE_WRITES`, the sidecar acts take W's own + lock, as before Amendment 23, and no `-lanes/` is made for one.""" + w_lock = box.w / "lanes" / ".lanes-edit.lock" + holder = subprocess.Popen(["sleep", "300"]) + try: + hold(w_lock, f"{holder.pid} {ME}") + waiter = box.popen("set-lane-state", LANE, "RUNNING", "--expect", "none", "--owner", UUID, + LANES_ROUTE_WRITES="") + assert still_waiting(waiter), "it waits on W's lock" + finally: + holder.kill() + holder.wait() + out, err = waiter.communicate(timeout=90) + assert waiter.returncode == 0, (out, err) + assert (box.state / LANE / "lane-state.yaml").is_file() + assert not box.lanes_root.exists(), "no workstation lock, so no -lanes/" + assert not w_lock.exists() + + +def test_a_lock_that_vanished_under_its_holder_is_not_made_again(box: Box): + """MUST 4: the heartbeat refreshes a lock and never makes one. A lock + directory removed under its holder (another container's takeover, a + person's `rmdir`) used to come back from `git_net`'s `touch` as a REGULAR + FILE that no release removes, and every later write waited out its + minute and exited 4.""" + lock = box.lock() + peer = box.clone(box.root / "peer") + box.write(peer, "handoffs/peer.md", "from a peer\n") + box.commit(peer, "a peer writes", "handoffs/peer.md") + box.git("-C", str(peer), "push", "-q", "origin", "main") + # The lane's checkout is now behind, so its write pulls and then pushes: + # two network calls under the lock, and the lock is taken away between. + real = shutil.which("git") + wrapper = box.root / "takes-the-lock" + mark = box.root / "taken" + wrapper.write_text(f"""#!/usr/bin/env bash +case " $* " in *" pull "*) + if [ ! -e '{mark}' ]; then : > '{mark}'; rm -f '{lock}/pid'; rmdir '{lock}'; fi ;; +esac +exec '{real}' "$@" +""", encoding="utf-8") + wrapper.chmod(0o755) + first = box.stamp("the lock went away under me", LANES_GIT=str(wrapper)) + assert first.returncode == 0, (first.stdout, first.stderr) + assert mark.exists(), "the lock was taken away mid-write" + assert not lock.exists(), "nothing was made in its place" + started = time.monotonic() + second = box.stamp("the next write", timeout=45) + assert second.returncode == 0, (second.stdout, second.stderr) + assert time.monotonic() - started < 30, "it did not wait on a leftover" diff --git a/tests/test_a23_refresh_workspace.py b/tests/test_a23_refresh_workspace.py index 10d290db..96ec90c4 100644 --- a/tests/test_a23_refresh_workspace.py +++ b/tests/test_a23_refresh_workspace.py @@ -20,7 +20,8 @@ import pytest -from conftest import REPO, WINDOWS_SKIP +from a23_box import ME, REGISTER, RegisterBox, register_text +from conftest import WINDOWS_SKIP pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, reason="lanes-edit.sh is a bash script"), @@ -28,19 +29,7 @@ reason="the register is a git repository"), WINDOWS_SKIP] -REGISTER = "lanes/LANES.md" UUID = "a23f0001-ffff-4000-8000-a23f0001ffff" -ME = "bench-a hosta" - -SCRUBBED_PREFIXES = ("LANES_", "LANE_START_", "CLAUDE_", "OPENREPOTOOLS_", - "FAKE_", "GIT_", "TMUX") -SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", - "XDG_CACHE_HOME"} - -REFUSE = """#!/usr/bin/env bash -printf 'FAKE %s REFUSED: %s -- this suite reaches no real surface\\n' "${0##*/}" "$*" >&2 -exit 90 -""" def _row(lane: str, cell: str) -> str: @@ -48,103 +37,33 @@ def _row(lane: str, cell: str) -> str: f"| — | {cell} | LIVE · seeded |") -class Box: - """A bare origin, W and a peer cloned from it, and workspace.yaml naming W.""" +class Box(RegisterBox): + """Two rows whose handoff cells W's strays are named by, and the seed + clone kept as a peer that pushes.""" def __init__(self, root: Path) -> None: - root = Path(os.path.realpath(root)) - root.mkdir(parents=True, exist_ok=True) - self.root = root - self.home = root / "home" - self.origin = root / "origin.git" - self.w = self.home / "projects" / "brett-wip" - self.lanes_root = self.home / "projects" / "brett-wip-lanes" - self.peer = root / "peer" - self.fakebin = root / "fakebin" - for d in (self.home / ".agents", self.home / "projects", root / "tmp", self.fakebin): - d.mkdir(parents=True, exist_ok=True) - for name in ("gh", "tmux"): - (self.fakebin / name).write_text(REFUSE, encoding="utf-8") - (self.fakebin / name).chmod(0o755) - env = {k: v for k, v in os.environ.items() - if k not in SCRUBBED and not k.startswith(SCRUBBED_PREFIXES)} - env.update({ - "HOME": str(self.home), - "TMPDIR": str(root / "tmp"), - "XDG_CONFIG_HOME": str(self.home / ".config"), - "XDG_CACHE_HOME": str(self.home / ".cache"), - "PATH": f"{self.fakebin}{os.pathsep}{env.get('PATH', '')}", - "AGENT_PROTOCOL_ROOT": str(self.home / ".agents"), - "LANES_WORKSTATION": "Eagle", - "LANES_HOST": "hosta", - "LANES_CONTAINER": "bench-a", - "LANES_NO_GITHUB": "1", - "LANES_INDEX": "off", - "LANE_WORKTREES_REPORT": "off", - "GIT_AUTHOR_NAME": "a23 tests", - "GIT_AUTHOR_EMAIL": "test@example.invalid", - "GIT_COMMITTER_NAME": "a23 tests", - "GIT_COMMITTER_EMAIL": "test@example.invalid", - }) - self.env = env - self.git("init", "-q", "--bare", "-b", "main", str(self.origin)) - self.quiet(self.origin, bare=True) - self.git("clone", "-q", str(self.origin), str(self.peer)) - self.quiet(self.peer) - self.write(self.peer, REGISTER, - "# LANES.md -- the sandbox register\n\n" - "| lane | session id | workstation / env / user | started (UTC) " - "| objects owned | handoff path | state |\n" - "|---|---|---|---|---|---|---|\n" - f"{_row('repoFF-1', 'handoffs/repoFF/repoFF-1.md')}\n" - f"{_row('repoFF-2', '~/projects/repoFF/handoffs/repoFF-2.md')}\n") - self.write(self.peer, "handoffs/repoFF/README.md", "# repoFF\n") - self.commit(self.peer, "seed", REGISTER, "handoffs/repoFF/README.md") - self.git("-C", str(self.peer), "push", "-q", "origin", "main") - self.git("clone", "-q", str(self.origin), str(self.w)) - self.quiet(self.w) - (self.home / ".agents" / "workspace.yaml").write_text( - f"repository: {self.origin}\npath: {self.w}\n", encoding="utf-8") - - def quiet(self, repo: Path, *, bare: bool = False) -> None: - where = ["--git-dir", str(repo)] if bare else ["-C", str(repo)] - self.git(*where, "config", "gc.auto", "0") - self.git(*where, "config", "maintenance.auto", "false") - - def git(self, *args: str) -> str: - return subprocess.run(["git", *args], env=self.env, check=True, - capture_output=True, text=True).stdout + rows = [_row("repoFF-1", "handoffs/repoFF/repoFF-1.md"), + _row("repoFF-2", "~/projects/repoFF/handoffs/repoFF-2.md")] + super().__init__(root, {REGISTER: register_text(rows), + "handoffs/repoFF/README.md": "# repoFF\n"}, seed_dir="peer") + self.peer = self.seed def wgit(self, *args: str) -> str: return self.git("-C", str(self.w), *args) - @staticmethod - def write(checkout: Path, rel: str, text: str) -> None: - path = checkout / rel - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - def commit(self, checkout: Path, message: str, *paths: str) -> None: - self.git("-C", str(checkout), "add", "--", *paths) - self.git("-C", str(checkout), "commit", "-q", "-m", message) - def peer_pushes(self, rel: str = "handoffs/repoFF/peer.md", text: str = "from a peer\n") -> None: - self.write(self.peer, rel, text) - self.commit(self.peer, f"a peer writes {rel}", rel) - self.git("-C", str(self.peer), "push", "-q", "origin", "main") + super().peer_pushes(rel, text) def refresh(self, **extra: str) -> subprocess.CompletedProcess: - return subprocess.run(["bash", str(REPO / "lanes-edit.sh"), "refresh-workspace"], - env=dict(self.env, **extra), cwd=str(self.root), - capture_output=True, text=True, timeout=120, check=False) + return self.helper("refresh-workspace", timeout=120, **extra) def head(self) -> str: return self.wgit("rev-parse", "HEAD").strip() def origin_main(self) -> str: - return self.git("--git-dir", str(self.origin), "rev-parse", "main").strip() + return self.origin_head() - def snapshot(self) -> tuple[str, str, str]: + def snapshot(self) -> tuple: return (self.head(), self.wgit("status", "--porcelain", "--untracked-files=all"), self.wgit("for-each-ref", "--format=%(refname) %(objectname)", "refs/heads")) diff --git a/tests/test_a23_write_routing.py b/tests/test_a23_write_routing.py index 995a3381..b011aea5 100644 --- a/tests/test_a23_write_routing.py +++ b/tests/test_a23_write_routing.py @@ -21,6 +21,7 @@ import pytest +from a23_box import REGISTER, RegisterBox, register_text from conftest import REPO, WINDOWS_SKIP pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, @@ -32,133 +33,38 @@ LANE = "repoA23-1" DORMANT = "repoA23-9" TARGET = "repoA23-4" -REGISTER = "lanes/LANES.md" UUID = "a23a0001-a2a2-4000-8000-a23a0001a2a2" TARGET_UUID = "a23a0004-a2a2-4000-8000-a23a0004a2a2" ASKER_UUID = "a23a0005-a2a2-4000-8000-a23a0005a2a2" -SCRUBBED_PREFIXES = ("LANES_", "LANE_START_", "CLAUDE_", "OPENREPOTOOLS_", - "FAKE_", "GIT_", "TMUX") -SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", - "XDG_CACHE_HOME"} - -REFUSE = """#!/usr/bin/env bash -printf 'FAKE %s REFUSED: %s -- this suite reaches no real surface\\n' "${0##*/}" "$*" >&2 -exit 90 -""" - def _row(lane: str, uuid: str = UUID, state: str = "LIVE · seeded") -> str: return (f"| `{lane}` | harness `{uuid}` | Eagle / test / tester | 2026-10-17T00:00Z " f"| — | handoffs/repoA23/{lane}.md | {state} |") -class Box: - """A bare origin, W cloned from it, and the workspace.yaml naming W.""" +class Box(RegisterBox): + """Four rows; the TARGET of a request bound in another container.""" - def __init__(self, root: Path) -> None: + def __init__(self, root: Path, **kw) -> None: root = Path(os.path.realpath(root)) - root.mkdir(parents=True, exist_ok=True) - self.root = root - self.home = root / "home" - self.origin = root / "origin.git" - self.w = self.home / "projects" / "brett-wip" - self.lanes_root = self.home / "projects" / "brett-wip-lanes" - self.fakebin = root / "fakebin" - for d in (self.home / ".agents", self.home / "projects", root / "tmp", self.fakebin): - d.mkdir(parents=True, exist_ok=True) - for name in ("gh", "tmux"): - (self.fakebin / name).write_text(REFUSE, encoding="utf-8") - (self.fakebin / name).chmod(0o755) - env = {k: v for k, v in os.environ.items() - if k not in SCRUBBED and not k.startswith(SCRUBBED_PREFIXES)} - env.update({ - "HOME": str(self.home), - "TMPDIR": str(root / "tmp"), - "XDG_CONFIG_HOME": str(self.home / ".config"), - "XDG_CACHE_HOME": str(self.home / ".cache"), - "PATH": f"{self.fakebin}{os.pathsep}{env.get('PATH', '')}", - "AGENT_PROTOCOL_ROOT": str(self.home / ".agents"), - "LANES_WORKSTATION": "Eagle", - "LANES_HOST": "hosta", - "LANES_CONTAINER": "bench-a", - "LANES_NO_GITHUB": "1", - "LANES_INDEX": "off", - "LANE_WORKTREES_REPORT": "off", - "GIT_AUTHOR_NAME": "a23 tests", - "GIT_AUTHOR_EMAIL": "test@example.invalid", - "GIT_COMMITTER_NAME": "a23 tests", - "GIT_COMMITTER_EMAIL": "test@example.invalid", - }) - self.env = env - self.git("init", "-q", "--bare", "-b", "main", str(self.origin)) - self.quiet(self.origin, bare=True) - seed = root / "seed" - self.git("clone", "-q", str(self.origin), str(seed)) - self.quiet(seed) - rows = [_row(LANE), _row("repoA23-2"), _row(DORMANT, uuid="a23a0009-a2a2-4000-8000-a23a0009a2a2"), + rows = [_row(LANE), _row("repoA23-2"), + _row(DORMANT, uuid="a23a0009-a2a2-4000-8000-a23a0009a2a2"), _row(TARGET, uuid=TARGET_UUID)] - self.write(seed, REGISTER, - "# LANES.md -- the sandbox register\n\n" - "| lane | session id | workstation / env / user | started (UTC) " - "| objects owned | handoff path | state |\n" - "|---|---|---|---|---|---|---|\n" + "".join(f"{r}\n" for r in rows)) - # THE TARGET OF A REQUEST IS BOUND ELSEWHERE: its last lane-kind line is - # a STARTED from another container on this host (Amendment 18). - self.write(seed, f"lanes/log/{TARGET}.md", - f"# lane {TARGET} — object log (lane-collision-protocol Amendment 7)\n" - f"STARTED — lane {TARGET}, session {TARGET_UUID}@Eagle, 2026-10-17T09:00:00Z, " - f"lane:{TARGET} → home opensoft/repoA23; estate repoA23; dir {root}/lanedir; " - f"profile team-05a; window othersess:4 @62; host hosta; os linux; container bench-b\n") - self.write(seed, f"lanes/log/{LANE}.md", - f"# lane {LANE} — object log (lane-collision-protocol Amendment 7)\n") - self.write(seed, "handoffs/README.md", "# handoffs\n") - self.git("-C", str(seed), "add", "--", REGISTER, "handoffs/README.md", "lanes/log") - self.git("-C", str(seed), "commit", "-q", "-m", "seed") - self.git("-C", str(seed), "push", "-q", "origin", "main") - self.seed = seed - self.git("clone", "-q", str(self.origin), str(self.w)) - self.quiet(self.w) - (self.home / ".agents" / "workspace.yaml").write_text( - f"repository: {self.origin}\npath: {self.w}\n", encoding="utf-8") - - def quiet(self, repo: Path, *, bare: bool = False) -> None: - where = ["--git-dir", str(repo)] if bare else ["-C", str(repo)] - self.git(*where, "config", "gc.auto", "0") - self.git(*where, "config", "maintenance.auto", "false") - - def git(self, *args: str) -> str: - return subprocess.run(["git", *args], env=self.env, check=True, - capture_output=True, text=True).stdout - - @staticmethod - def write(checkout: Path, rel: str, text: str) -> None: - path = checkout / rel - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - def helper(self, *args: str, **extra: str) -> subprocess.CompletedProcess: - return subprocess.run(["bash", str(REPO / "lanes-edit.sh"), *args], - env=dict(self.env, **extra), cwd=str(self.root), - capture_output=True, text=True, timeout=180, check=False) - - def checkout(self, lane: str) -> Path: - return self.lanes_root / lane - - def clone(self, dest: Path) -> Path: - self.git("clone", "-q", str(self.origin), str(dest)) - self.quiet(dest) - return dest - - def origin_head(self) -> str: - return self.git("--git-dir", str(self.origin), "rev-parse", "main").strip() - - def origin_file(self, rel: str) -> str: - return self.git("--git-dir", str(self.origin), "show", f"main:{rel}") - - def w_state(self) -> tuple[str, str]: - return (self.git("-C", str(self.w), "rev-parse", "HEAD").strip(), - self.git("-C", str(self.w), "status", "--porcelain", "--untracked-files=all")) + files = { + REGISTER: register_text(rows), + # THE TARGET OF A REQUEST IS BOUND ELSEWHERE: its last lane-kind line + # is a STARTED from another container on this host (Amendment 18). + f"lanes/log/{TARGET}.md": + f"# lane {TARGET} — object log (lane-collision-protocol Amendment 7)\n" + f"STARTED — lane {TARGET}, session {TARGET_UUID}@Eagle, 2026-10-17T09:00:00Z, " + f"lane:{TARGET} → home opensoft/repoA23; estate repoA23; dir {root}/lanedir; " + f"profile team-05a; window othersess:4 @62; host hosta; os linux; container bench-b\n", + f"lanes/log/{LANE}.md": + f"# lane {LANE} — object log (lane-collision-protocol Amendment 7)\n", + "handoffs/README.md": "# handoffs\n", + } + super().__init__(root, files, **kw) @pytest.fixture @@ -356,3 +262,165 @@ def test_guard_no_lane_exemption_still_first(box: Box): assert result.returncode == 0, (extra, result.stdout, result.stderr) assert result.stdout == "" and result.stderr == "" assert not box.lanes_root.exists() + + +# ------------------------------------------------------- fix round 1 (#282's review) + +def _git_wrapper(box: Box, name: str, body: str) -> Path: + """A `LANES_GIT` — the binary the helper's NETWORK calls use, and only + those — that runs `body` first and then the real git.""" + real = shutil.which("git") + path = box.root / name + path.write_text(f"#!/usr/bin/env bash\n{body}\nexec '{real}' \"$@\"\n", encoding="utf-8") + path.chmod(0o755) + return path + + +def test_without_the_opt_in_a_write_goes_through_W_as_before(box: Box): + """MUST 2: routing is opt-in until slice 1b. With no `LANES_ROUTE_WRITES`, + the default resolution writes, commits and pushes in W, and no lane + checkout or workstation lock is made — main's behaviour, byte for byte.""" + head = box.w_state()[0] + result = box.helper("set-row-state", LANE, "LIVE · written in W", LANES_ROUTE_WRITES="") + assert result.returncode == 0, (result.stdout, result.stderr) + assert not box.lanes_root.exists(), "no lane checkout, no -lanes/ at all" + assert box.w_state()[0] != head, "the commit is W's" + assert box.w_state()[1] == "" + assert " · written in W |" in box.origin_file(REGISTER) + assert box.git("-C", str(box.w), "rev-parse", "HEAD").strip() == box.origin_head() + + +def test_a_routed_write_brings_W_origin_main_along(box: Box): + """MUST 1: a routed write pushes from the lane's checkout, and the prompt + guard reads W's `origin/main` OFFLINE. So W is fetched once the push has + landed — W's ref, and an unfetched read of W, see the write — and W's + branch and working tree stay as they were (S5: a fetch, nothing else).""" + w_before = box.w_state() + result = box.helper("set-row-state", LANE, "LIVE · what the guard reads") + assert result.returncode == 0, (result.stdout, result.stderr) + assert box.w_origin_main() == box.origin_head(), "W's origin/main followed the push" + read = box.helper("register-row", LANE, LANES_NO_FETCH="1") + assert read.returncode == 0, (read.stdout, read.stderr) + assert " · what the guard reads |" in read.stdout, "an offline read of W sees the write" + assert box.w_state() == w_before, "W itself was only fetched" + + +def test_a_follow_fetch_that_fails_is_said_and_fails_nothing(box: Box): + """The write has landed by then: a W that cannot be fetched is a note.""" + assert box.helper("set-row-state", LANE, "LIVE · made the checkout").returncode == 0 + wrapper = _git_wrapper(box, "w-fetch-fails", + f'case " $* " in *" {box.w} fetch "*) exit 1 ;; esac') + result = box.helper("set-row-state", LANE, "LIVE · W could not follow", LANES_GIT=str(wrapper)) + assert result.returncode == 0, (result.stdout, result.stderr) + assert "did not learn it" in result.stderr and "refresh-workspace" in result.stderr + assert " · W could not follow |" in box.origin_file(REGISTER) + assert box.w_origin_main() != box.origin_head() + + +@pytest.mark.parametrize("how", ["both", "root-only", "same-checkout"]) +def test_a_W_that_is_a_lane_checkout_refuses_naming_LANES_WORKSPACE_ROOT(box: Box, how: str): + """MUST 3: Eagle's lane windows export `LANES_REPO` and + `LANES_WORKSPACE_ROOT` at the lane's own checkout. That is not W: every + write refuses, naming the variable to unset, and nothing is cloned inside + it (`-lanes/`) and no lock is made beside it.""" + if how == "same-checkout": + c = box.clone(box.root / "elsewhere" / "openRepoTools-3") + else: + box.lanes_root.mkdir(parents=True) + c = box.clone(box.checkout(LANE)) + extra = {"LANES_WORKSPACE_ROOT": str(c)} + if how != "root-only": + extra["LANES_REPO"] = str(c) + head = box.origin_head() + result = box.helper("set-row-state", LANE, "LIVE · never written", **extra) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "Unset LANES_WORKSPACE_ROOT" in result.stderr, result.stderr + assert "readers' copy" in result.stderr + assert not Path(f"{c}-lanes").exists(), "nothing cloned or locked beside the lane checkout" + assert box.origin_head() == head + checkout = box.helper("lane-checkout", LANE, "--create", LANES_ROUTE_WRITES="", **extra) + assert checkout.returncode == 2, (checkout.stdout, checkout.stderr) + assert not Path(f"{c}-lanes").exists(), "lane-checkout refuses with or without the opt-in" + + +def test_S5_compares_the_repository_not_the_spelling(box: Box): + """SHOULD 3: `LANES_REPO` at W's own `lanes/` directory is W — its edit + would land in W's tree — so S5 refuses it before anything is written.""" + head, w_before = box.origin_head(), box.w_state() + result = box.helper("set-row-state", LANE, "LIVE · never written", + LANES_REPO=str(box.w / "lanes"), LANES_FILE=str(box.w / REGISTER)) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "readers' copy" in result.stderr and "Amendment 23 S5" in result.stderr, result.stderr + assert_nothing_written(box, head, w_before) + + +def test_a_hand_edit_in_W_refuses_a_routed_write(box: Box): + """SHOULD 4: W's register carries a hand edit. A routed write — `commit` + among them — refuses, naming the edit and the lane's checkout, where + before it said "nothing staged" with exit 0 and left the edit in W.""" + register = box.w / REGISTER + edited = register.read_text(encoding="utf-8").replace("LIVE · seeded |", "LIVE · by hand |", 1) + register.write_text(edited, encoding="utf-8") + head = box.origin_head() + result = box.helper("commit", "a hand edit", LANES_LANE=LANE) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "uncommitted edit to its register" in result.stderr, result.stderr + assert f"{box.checkout(LANE)}/lanes/LANES.md" in result.stderr, "it names where the edit belongs" + assert register.read_text(encoding="utf-8") == edited, "the edit is still in W, untouched" + assert box.origin_head() == head + assert not box.checkout(LANE).exists() + + +def _row_published_not_in_W(box: Box, lane: str) -> None: + """A row a peer published, which W has fetched and not fast-forwarded. It + goes at the TOP of the table, clear of the line an append adds at the + foot, so a checkout that predates it rebases over it cleanly.""" + head, sep, rest = box.origin_file(REGISTER).partition("|---|---|---|---|---|---|---|\n") + box.peer_pushes(REGISTER, head + sep + _row(lane) + "\n" + rest) + box.git("-C", str(box.w), "fetch", "-q", "origin", "main") + assert f"`{lane}`" not in (box.w / REGISTER).read_text(encoding="utf-8") + + +@pytest.mark.parametrize("checkout_first", [False, True], ids=["fresh", "stale-checkout"]) +def test_append_line_attribution_reads_the_published_register(box: Box, checkout_first: bool): + """SHOULD 1: a LANDING line with no `LANES_LANE` is attributed from the + published register, not W's working tree, and its commit carries that + lane even where the lane's own checkout predates the row.""" + lane = "repoA23-7" + if checkout_first: + made = box.helper("lane-checkout", lane, "--create") + assert made.returncode == 0, (made.stdout, made.stderr) + _row_published_not_in_W(box, lane) + result = box.helper("append-line", f"LANDING — lane {lane}, the published row decides") + assert result.returncode == 0, (result.stdout, result.stderr) + assert (box.checkout(lane) / ".git").is_dir(), "it went through that lane's checkout" + subject = box.git("--git-dir", str(box.origin), "log", "-1", "--format=%s", "main") + assert subject.startswith(f"LANES({lane}@Eagle): append line"), subject + + +def test_request_handoff_why_after_the_separator_is_no_dry_run(box: Box): + """NIT 1: after `--` every word is the `--force` why, so `--dry-run` there + is text, the arm writes, and the write goes through the target's checkout.""" + result = box.helper("request-handoff", TARGET, "--force", "--no-wait", "--", "--dry-run", + LANES_SESSION=ASKER_UUID) + assert (box.checkout(TARGET) / ".git").is_dir(), (result.stdout, result.stderr) + assert not (box.w / "lanes" / ".lanes-edit.lock").exists() + assert box.w_state()[1] == "", "nothing was written in W" + + +def test_no_git_makes_no_clone(box: Box): + """NIT 8: `LANES_NO_GIT=1` is no commit and no push, so it is no clone.""" + result = box.helper("set-row-state", LANE, "LIVE · no git", LANES_NO_GIT="1") + assert result.returncode == 2, (result.stdout, result.stderr) + assert "LANES_NO_GIT=1" in result.stderr and "lane-checkout" in result.stderr + assert not box.checkout(LANE).exists() + + +def test_an_explicit_LANES_REPO_still_needs_a_lane(box: Box): + """NIT 9: `LANES_REPO` chooses a checkout, never the lane a commit is for.""" + other = box.clone(box.root / "elsewhere" / "register") + head = box.origin_head() + result = box.helper("commit", "a hand edit", LANES_REPO=str(other)) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "no lane, no write" in result.stderr and "LANES_LANE=" in result.stderr + assert box.origin_head() == head diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 4fba0750..aeeddb9b 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -15415,20 +15415,16 @@ is "…and its verdict is the cutover one" \ # the fixture the last case of this file asserts is still running — so it cannot # be stolen as a dead holder's, and `timeout`'s own 124 is the proof the writer # was still WAITING for it rather than exiting early on something else. -# THE LOCK IS THE WORKSTATION'S SIDECAR LOCK since Amendment 23 S2 (ort186 Q3): -# the inventory, the lifecycle snapshot and the restart intent share it, beside -# W's lane checkouts, and W's own mutex no longer serialises them. -H_SIDECAR="$(cd -P -- "$WIP_RO" && pwd -P)-lanes/.lane-state.lock" if [ "$HAVE_TIMEOUT" = 1 ]; then - mkdir -p "$H_SIDECAR"; printf '%s\n' "$LIVE_PID" > "$H_SIDECAR/pid" + mkdir -p "$H_LOCK"; printf '%s\n' "$LIVE_PID" > "$H_LOCK/pid" LANES_NO_FETCH=1 timeout 3 "$E" set-lane-tree repoRC-9 "$RC_DIR/.claude/worktrees/w1" \ --checkout "$RC_DIR" --branch feat/rc1 --head "$rc5_w1head" --upstream none \ --dirty 1 --unpushed 0 >/dev/null 2>&1 rc9_lk=$? - is "an UNFENCED inventory write WAITS on the sidecar lock another run holds" "$rc9_lk" 124 + is "an UNFENCED inventory write WAITS on the lane mutex another run holds" "$rc9_lk" 124 run "$E" lane-trees repoRC-9 is "…so nothing of it was written while that run held the lock" "$rc" 8 - rm -rf "$H_SIDECAR" + rm -rf "$H_LOCK" run env LANES_NO_FETCH=1 "$E" set-lane-tree repoRC-9 "$RC_DIR/.claude/worktrees/w1" \ --checkout "$RC_DIR" --branch feat/rc1 --head "$rc5_w1head" --upstream none \ --dirty 1 --unpushed 0 @@ -15437,7 +15433,7 @@ if [ "$HAVE_TIMEOUT" = 1 ]; then is "…which is the inventory this lane then holds" \ "$(printf '%s\n' "$out" | awk -F'\037' '{print $2}' | head -n 1)" "$RC_DIR/.claude/worktrees/w1" else - skip "an UNFENCED inventory write WAITS on the sidecar lock another run holds" "$NO_TIMEOUT_WHY" + skip "an UNFENCED inventory write WAITS on the lane mutex another run holds" "$NO_TIMEOUT_WHY" skip "…so nothing of it was written while that run held the lock" "$NO_TIMEOUT_WHY" skip "…and the same write lands the moment the mutex is free" "$NO_TIMEOUT_WHY" skip "…which is the inventory this lane then holds" "$NO_TIMEOUT_WHY" diff --git a/tests/test_lane_worktrees_report.py b/tests/test_lane_worktrees_report.py index 5e5ac19e..93118838 100644 --- a/tests/test_lane_worktrees_report.py +++ b/tests/test_lane_worktrees_report.py @@ -692,3 +692,31 @@ def test_an_untracked_listing_that_failed_is_a_finding_never_evidence(tmp_path): assert "its untracked files could not be listed" in unread, unread assert "junit-partial.xml" not in section(proc.stdout, "Evidence-shaped paths") + + +def test_a_lanes_register_checkout_is_reported_and_never_foreign(tmp_path): + """Amendment 23 S1 (the review of opensoft/openRepoTools#282, SHOULD 5): + `-lanes//` is one lane's own checkout of the register, made on + purpose beside W, so the report lists it as that, with what it holds that + origin lacks, and never among the FOREIGN clones "a person's to dispose + of". A per-profile clone beside W is still foreign.""" + e = LW.Estate(tmp_path) + ws_origin = e.root / "workspace.git" + w = e.projects / "brett-wip" + e.git("clone", "-q", ws_origin, w, cwd=e.root) + e.workspace = str(w) + lane_checkout = e.projects / "brett-wip-lanes" / "repoA-1" + e.git("clone", "-q", ws_origin, lane_checkout, cwd=e.root) + e.commit(lane_checkout, "a write origin does not have yet", + {"lanes/log/repoA-1.md": "NOTED — unpublished\n"}) + profile = e.projects / "brett-wip-team02e" + e.git("clone", "-q", ws_origin, profile, cwd=e.root) + proc = report(e) + assert proc.returncode == 0, proc.stdout + proc.stderr + foreign = section(proc.stdout, "FOREIGN repositories") + assert str(lane_checkout) not in foreign, foreign + assert f"{profile} · a second clone of" in foreign, foreign + registers = section(proc.stdout, "Register checkouts (Amendment 23 S1)") + assert f"{lane_checkout} · lane repoA-1 ·" in registers, registers + assert "1 commit(s) origin/main lacks" in registers + assert "cherry -v origin/main" in registers diff --git a/tests/test_register_scan_hardening_138.py b/tests/test_register_scan_hardening_138.py index d145aab9..ab181d01 100644 --- a/tests/test_register_scan_hardening_138.py +++ b/tests/test_register_scan_hardening_138.py @@ -49,7 +49,14 @@ def lane_line(verb: str, lane: str, sid: str, utc: str) -> str: class Register: - """One workspace repository: `origin` bare, `wip` its clone.""" + """One workspace repository: `origin` bare, `wip` its clone, and W. + + THE EXPLICIT SEAM (Amendment 23; the review of opensoft/openRepoTools#282, + MUST 5): every case writes through `wip`, named by `LANES_REPO`, which is + where it puts its faults; W, the readers' copy `LANES_WORKSPACE_ROOT` names, + is a clone of its own beside it. With `LANES_WORKSPACE_ROOT` at `wip` and + no `LANES_REPO`, Amendment 23's routing would send every write to + `-lanes//`, away from the faults.""" def __init__(self, tmp: Path): self.tmp = tmp @@ -65,7 +72,7 @@ def __init__(self, tmp: Path): env.update(HOME=str(self.home), TMPDIR=str(tmp / "tmp"), AGENT_PROTOCOL_ROOT=str(self.home / ".agents"), PROJECTS_ROOT=str(self.home / "projects"), - LANES_WORKSPACE_ROOT=str(self.wip), LANES_WORKSTATION="Eagle", + LANES_WORKSTATION="Eagle", LANES_HOST="Eagle", LANES_CONTAINER="none", LANES_IN_CONTAINER="0", LANES_OS="linux", LANES_NO_GITHUB="1", LANES_INDEX="off", GIT_AUTHOR_NAME="register tests", GIT_AUTHOR_EMAIL="t@example.invalid", @@ -83,6 +90,9 @@ def __init__(self, tmp: Path): "repoX-1", lane_line("STARTED", "repoX-1", WRITER_ID, "2026-09-11T00:00:00Z"), lane_line("PAUSED", "repoX-1", WRITER_ID, "2026-09-11T01:00:00Z"))) self.publish("seed the register") + self.ws = tmp / "ro" / "wip" + self.raw_git("clone", "-q", str(self.origin), str(self.ws)) + self.env.update(LANES_WORKSPACE_ROOT=str(self.ws), LANES_REPO=str(self.wip)) def raw_git(self, *args: str) -> str: return subprocess.run(["git", *args], capture_output=True, text=True, @@ -603,3 +613,24 @@ def test_item_1_an_unreadable_published_register_refuses_the_sweep(reg): assert "is UNKNOWN" in result.stderr assert reg.head() == before assert not (reg.wip / "lanes/log/repoX-2.md").exists() + + +# ------------------------------------- Amendment 23's routing, by default resolution + +def test_a_routed_sweep_refuses_over_a_peer_edit_in_W_and_captures_nothing(reg): + """SHOULD 4, the default-resolution case of the capture above. Under + `LANES_ROUTE_WRITES=1` with W resolved from its root and no `LANES_REPO`, + the sweep goes through the sweeping lane's own checkout, so nothing would + ever capture the peer's edit in W. It refuses, names the edit and that + checkout, and leaves W, origin and the edit as they were.""" + edited = peer_edit(reg) + before = reg.head() + published = reg.raw_git("--git-dir", str(reg.origin), "rev-parse", "main").strip() + result = reg.sweep("repoX-2", LANES_ROUTE_WRITES="1", LANES_REPO="", + LANES_WORKSPACE_ROOT=str(reg.wip)) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "uncommitted edit to its register" in result.stderr, result.stderr + assert f"{os.path.realpath(reg.wip)}-lanes/repoX-1/lanes/LANES.md" in result.stderr + assert reg.head() == before and reg.read("lanes/LANES.md") == edited + assert reg.raw_git("--git-dir", str(reg.origin), "rev-parse", "main").strip() == published + assert not (reg.tmp / "wip-lanes").exists() diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index 50c094e8..d24c3216 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -33,15 +33,6 @@ def restart_box(tmp_path: Path) -> Sandbox: return box -def sidecar_lock(box): - """Amendment 23 S2: the restart intent, the lifecycle records and - `publish-handoff`'s fence share the WORKSTATION's sidecar lock, beside W's - lane checkouts (ort186 Q3); W's own mutex no longer serialises them.""" - lock = Path(os.path.realpath(box.ws) + "-lanes") / ".lane-state.lock" - lock.parent.mkdir(parents=True, exist_ok=True) - return lock - - def helper(box, *args): return subprocess.run([str(box.bin / "lanes-edit.sh"), *args], cwd=box.lane_dir, env=box.env, text=True, @@ -1463,13 +1454,13 @@ def test_handoff_target_is_resolved_after_waiting_for_writer_mutex(restart_box): prepared = _write(box.root / "prepared.md", "replacement bytes\n", 0o600) digest = hashlib.sha256(original.read_bytes()).hexdigest() before = (original.read_bytes(), other.read_bytes()) - lock = sidecar_lock(box) + lock = box.wip / "lanes" / ".lanes-edit.lock" lock.mkdir() (lock / "pid").write_text(str(os.getpid()) + "\n") marker = box.root / "waiting-for-writer" real_mkdir = shutil.which("mkdir") _write(box.fakebin / "mkdir", f"""#!/usr/bin/env bash -case "$*" in *.lane-state.lock*) touch '{marker}' ;; esac +case "$*" in *.lanes-edit.lock*) touch '{marker}' ;; esac exec '{real_mkdir}' "$@" """) proc = subprocess.Popen([str(box.bin / "lanes-edit.sh"), "publish-handoff", LANE, @@ -1811,7 +1802,7 @@ def test_restart_root_waiting_writer_discovers_new_authority_under_mutex(restart box = restart_box _, fallback, alternate = restart_root_without_recorded_checkout(box) record_nested_restart_checkout(box) - lock = sidecar_lock(box) + lock = box.wip / "lanes" / ".lanes-edit.lock" lock.mkdir() (lock / "pid").write_text(str(os.getpid())) waited = box.root / "writer.waited" From ecb96eda1e3d38cdf139659829d45366722e7d52 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 12:01:56 +0000 Subject: [PATCH 39/42] Fix round 1's surviving mutants are killed: 12 cases pin the opt-in gates, the lane-checkout home check, the creation lock's `touch -c` and heartbeat, lane-end's notes, S5's git-directory clause and the alias name check (#198) The mutation run over 874e020 let 13 mutants of fix round 1 survive its own tests. These 12 cases (15 test items) kill 12 of them; the 13th, N26 (S5's `--show-toplevel` compare), is equivalent, because a path whose toplevel is W also has W's git-common-dir, so the common-dir clause already refuses all it refuses, and no test can tell the two apart. Each case is appended to its module unchanged; no helper, fixture or other test is touched. tests/test_a23_lock.py - test_without_the_opt_in_a_lane_windows_sidecar_act_works_as_on_main kills N03b (sidecar_lock_use is a no-op without the opt-in). - test_a_sidecar_act_in_a_W_that_is_a_lane_checkout_refuses[both, root-only] kills N09 (sidecar_lock_use runs lanes_home_check). tests/test_a23_lane_start_read.py - test_without_the_opt_in_lane_end_reads_W kills N05 (lane-end reads its lane checkout only under the opt-in). - test_a_checkout_that_cannot_be_made_is_said_and_the_end_goes_on kills N21 (lane-end notes a checkout that cannot be made and reads W). - test_a_held_creation_lock_is_said_and_the_start_goes_on kills N22 (lane-start's note arm takes the creation lock's 4 too). - test_a_held_creation_lock_is_said_and_the_end_goes_on kills N23 (lane-end's note arm takes the 4 too). tests/test_a23_refresh_workspace.py - test_refresh_refuses_a_W_that_is_a_lane_checkout[both, root-only] kills N11 (the refresh-workspace arm runs lanes_home_check). tests/test_a23_lane_checkout.py - test_a_creation_lock_that_vanished_is_not_made_again kills N13 (the creation lock's first touch is `touch -c`) and N14 (so is the heartbeat's). - test_the_creation_lock_is_refreshed_while_the_clone_runs kills N15 (a heartbeat touches the creation lock while the clone runs). - test_a_name_the_log_cannot_carry_is_refused_though_an_alias_maps_it [repoCK/1, repoCK 1] kills M31 (a name the log cannot carry is refused before any checkout, and before canon_lane reads with it). It adds `import time` to the module's import block. tests/test_a23_write_routing.py - test_S5_refuses_a_checkout_whose_git_directory_is_W kills N25 (S5's git-common-dir compare). tests/test_register_scan_hardening_138.py - test_item_9_refuses_in_the_scan_under_the_opt_in_too kills N16 (the fixture writes through wip by the seam). Runs (every inherited LANES_* and TMUX* unset, one module at a time), before -> after: test_a23_lock.py 19 -> 22 passed; test_a23_lane_start_read.py 6 -> 10; test_a23_refresh_workspace.py 10 -> 12; test_a23_lane_checkout.py 22 -> 26; test_a23_write_routing.py 34 -> 35; test_register_scan_hardening_138.py 28 -> 29; test_repo_hygiene.py 237 passed; test_test_roots.py 39 passed, 1 skipped. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- tests/test_a23_lane_checkout.py | 80 +++++++++++++++ tests/test_a23_lane_start_read.py | 116 ++++++++++++++++++++++ tests/test_a23_lock.py | 33 ++++++ tests/test_a23_refresh_workspace.py | 19 ++++ tests/test_a23_write_routing.py | 15 +++ tests/test_register_scan_hardening_138.py | 19 ++++ 6 files changed, 282 insertions(+) diff --git a/tests/test_a23_lane_checkout.py b/tests/test_a23_lane_checkout.py index 40af1425..190ee3d9 100644 --- a/tests/test_a23_lane_checkout.py +++ b/tests/test_a23_lane_checkout.py @@ -18,6 +18,7 @@ import os import shutil import subprocess +import time from pathlib import Path import pytest @@ -352,3 +353,82 @@ def test_lane_checkout_verb_reads_and_creates(box: Box): assert again.returncode == 0 and again.stdout.strip() == str(box.checkout(LANE)) usage = box.helper("lane-checkout") assert usage.returncode == 64 + + +def _fake(path: Path, text: str) -> Path: + path.write_text(text, encoding="utf-8") + path.chmod(0o755) + return path + + +def _creation_bench(box: Box, clone_body: str) -> dict: + """The heartbeat's minute as a second (a `sleep` first on PATH that turns + `sleep 60` into `sleep 1`), and a `LANES_GIT` whose clone runs + `clone_body` first: so a heartbeat beats while the clone runs.""" + real_sleep, real_git = shutil.which("sleep"), shutil.which("git") + _fake(box.fakebin / "sleep", f"""#!/usr/bin/env bash +if [ "$*" = 60 ]; then exec '{real_sleep}' 1; fi +exec '{real_sleep}' "$@" +""") + slow = _fake(box.root / "slow-clone-git", f"""#!/usr/bin/env bash +case " $* " in *" clone "*) +{clone_body} +;; esac +exec '{real_git}' "$@" +""") + return {"LANES_GIT": str(slow)} + + +def test_a_creation_lock_that_vanished_is_not_made_again(box: Box): + """MUST 4, at the creation lock: its first touch and its heartbeat refresh + it and never make one. The lock directory is moved away by the first touch + that names it, as the clone begins; nothing is made in its place then, nor + by any heartbeat while the clone runs, and the write still lands.""" + lock = box.lanes_root / f".{LANE}.lock" + mark = box.root / "taken" + real_touch = shutil.which("touch") + _fake(box.fakebin / "touch", f"""#!/usr/bin/env bash +case " $* " in *'{lock}'*) + if [ ! -e '{mark}' ]; then : > '{mark}'; mv '{lock}' '{lock}.gone'; fi ;; +esac +exec '{real_touch}' "$@" +""") + extra = _creation_bench(box, f" '{shutil.which('sleep')}' 3") + result = box.stamp(LANE, "past a vanished creation lock", **extra) + assert result.returncode == 0, (result.stdout, result.stderr) + assert mark.exists(), "the creation lock was taken away under its holder" + assert not lock.exists() and not lock.is_symlink(), \ + "nothing was made in its place, by the first touch or by a heartbeat" + assert " · past a vanished creation lock |" in box.origin_register() + + +def test_the_creation_lock_is_refreshed_while_the_clone_runs(box: Box): + """NIT 5: a clone may take its whole budget, ten minutes or more, and a + waiter in another container takes a creation lock that has not moved for + ten. So the holder refreshes it while the clone runs: aged to twenty + minutes as the clone begins, it is fresh again before the clone ends.""" + lock = box.lanes_root / f".{LANE}.lock" + seen = box.root / "seen" + old = int(time.time()) - 20 * 60 + real_touch, real_sleep = shutil.which("touch"), shutil.which("sleep") + extra = _creation_bench(box, f""" '{real_touch}' -c -d '@{old}' '{lock}' + '{real_sleep}' 4 + if [ -n "$(find '{lock}' -maxdepth 0 -mmin -1 2>/dev/null)" ]; then echo fresh > '{seen}'; else echo stale > '{seen}'; fi""") + result = box.stamp(LANE, "a slow first clone", **extra) + assert result.returncode == 0, (result.stdout, result.stderr) + assert seen.read_text(encoding="utf-8").strip() == "fresh", \ + "the creation lock was refreshed while the clone ran" + + +@pytest.mark.parametrize("name", ["repoCK/1", "repoCK 1"]) +def test_a_name_the_log_cannot_carry_is_refused_though_an_alias_maps_it(tmp_path: Path, name: str): + """The typed name is checked BEFORE `canon_lane` reads with it, not only + the canonical one after: a hand-made `aliases.tsv` row that maps a name no + log file can carry onto a real lane lets no write through under it.""" + box = Box(tmp_path, rows=("repoCK-1",), aliases=f"{name}\trepoCK-1\t2026-10-17T00:00:00Z\n") + head = box.origin_head() + result = box.stamp(name, "never written") + assert result.returncode == 2, (result.stdout, result.stderr) + assert "is not a lane name" in result.stderr, result.stderr + assert not box.lanes_root.exists(), "nothing was created for that name" + assert box.origin_head() == head diff --git a/tests/test_a23_lane_start_read.py b/tests/test_a23_lane_start_read.py index c6997132..4c82fef1 100644 --- a/tests/test_a23_lane_start_read.py +++ b/tests/test_a23_lane_start_read.py @@ -153,3 +153,119 @@ def test_a_checkout_that_cannot_be_made_is_said_and_the_start_goes_on(tmp_path, assert "so this command reads W's register" in second.stderr assert not checkout.exists() assert second.returncode == 0, second.stderr + + +def _end(box: Sandbox, *args: str) -> subprocess.CompletedProcess: + for name in ("lane-end", "lane-worktrees"): + shutil.copy2(REPO / name, box.bin / name) + (box.bin / name).chmod(0o755) + return subprocess.run([str(box.bin / "lane-end"), LANE, *args], env=box.env, + capture_output=True, text=True, timeout=180, check=False) + + +def _started_in_W(tmp_path: Path, short_root: Path, *, checkout_first: bool = False): + """A new lane started with no opt-in: its row is W's. With + `checkout_first`, the lane's own checkout is made BEFORE the row, so only + W shows it.""" + box = Sandbox(tmp_path) + checkout = default_resolution(box, route=False) + if checkout_first: + made = subprocess.run([str(box.bin / "lanes-edit.sh"), "lane-checkout", LANE, "--create"], + env=box.env, capture_output=True, text=True, timeout=180, check=False) + assert made.returncode == 0, made.stderr + lane_dir = _checkout_of_length(short_root, None) + box.checkout(lane_dir) + first = box.start("--dir", str(lane_dir), window_name="claude") + assert first.returncode == 0, first.stderr + assert len(rows(box.wip / "lanes" / "LANES.md")) == 1, "the row is W's" + return box, checkout + + +def test_without_the_opt_in_lane_end_reads_W(tmp_path, short_root): + """MUST 2, for lane-end: with no `LANES_ROUTE_WRITES` it reads W's + register, as on main, even where the lane has a checkout of its own — + here one made before the lane's row, so only W shows the row.""" + box, checkout = _started_in_W(tmp_path, short_root, checkout_first=True) + assert rows(checkout / "lanes" / "LANES.md") == [], "the lane's checkout predates its row" + end = _end(box, "--dry-run") + assert "has no row for lane" not in end.stderr, end.stderr + assert end.returncode == 0, (end.stdout, end.stderr) + + +def _no_clone_git(box: Sandbox) -> Path: + real = shutil.which("git") + wrapper = box.root / "no-clone-git" + wrapper.write_text(f"""#!/usr/bin/env bash +case " $* " in *" clone "*) echo "test: no clone today" >&2; exit 1 ;; esac +exec '{real}' "$@" +""", encoding="utf-8") + wrapper.chmod(0o755) + return wrapper + + +def test_a_checkout_that_cannot_be_made_is_said_and_the_end_goes_on(tmp_path, short_root): + """SHOULD 2, for lane-end: where the lane's checkout cannot be cloned, the + end says so and reads W's register — it finds the lane's row there — and + is not refused for a register act it can do without (Amendment 8(d)).""" + box, checkout = _started_in_W(tmp_path, short_root) + box.env.update(LANES_ROUTE_WRITES="1", LANES_GIT=str(_no_clone_git(box))) + end = _end(box) + assert "own register checkout could not be made" in end.stderr, end.stderr + assert "so this command reads W's register" in end.stderr, end.stderr + assert "has no row for lane" not in end.stderr, end.stderr + assert not checkout.exists() + + +class _CreationLockHeld: + """The lane's creation lock, `-lanes/..lock`, held by a live + process in this container, and every `sleep 1` an instant — so the + helper's wait for that lock, ten minutes and a minute of one-second + polls, ends in seconds with its 4.""" + + def __init__(self, box: Sandbox, checkout: Path) -> None: + self.lock = checkout.parent / f".{LANE}.lock" + real_sleep = shutil.which("sleep") + fake = box.root / "fakebin" / "sleep" + fake.write_text(f"""#!/usr/bin/env bash +if [ "$*" = 1 ]; then exit 0; fi +exec '{real_sleep}' "$@" +""", encoding="utf-8") + fake.chmod(0o755) + + def __enter__(self): + self.holder = subprocess.Popen(["sleep", "300"]) + self.lock.mkdir(parents=True) + (self.lock / "pid").write_text(f"{self.holder.pid}\n", encoding="utf-8") + return self + + def __exit__(self, *exc): + self.holder.kill() + self.holder.wait() + return False + + +def test_a_held_creation_lock_is_said_and_the_start_goes_on(tmp_path, short_root): + """SHOULD 2, its 4: where another creator holds the lane's creation lock + past the wait, the start says so and reads W, as for a clone that failed.""" + box, checkout = _started_in_W(tmp_path, short_root) + box.env["LANES_ROUTE_WRITES"] = "1" + with _CreationLockHeld(box, checkout): + second = box.start(window_name=LANE) + assert "own register checkout could not be made" in second.stderr, second.stderr + assert "exited 4" in second.stderr, second.stderr + assert "so this command reads W's register" in second.stderr + assert not checkout.exists() + assert second.returncode == 0, second.stderr + + +def test_a_held_creation_lock_is_said_and_the_end_goes_on(tmp_path, short_root): + """SHOULD 2, its 4, for lane-end.""" + box, checkout = _started_in_W(tmp_path, short_root) + box.env["LANES_ROUTE_WRITES"] = "1" + with _CreationLockHeld(box, checkout): + end = _end(box) + assert "own register checkout could not be made" in end.stderr, end.stderr + assert "exited 4" in end.stderr, end.stderr + assert "so this command reads W's register" in end.stderr + assert "has no row for lane" not in end.stderr, end.stderr + assert not checkout.exists() diff --git a/tests/test_a23_lock.py b/tests/test_a23_lock.py index 4e5f8fef..ab0b068a 100644 --- a/tests/test_a23_lock.py +++ b/tests/test_a23_lock.py @@ -474,3 +474,36 @@ def test_a_lock_that_vanished_under_its_holder_is_not_made_again(box: Box): second = box.stamp("the next write", timeout=45) assert second.returncode == 0, (second.stdout, second.stderr) assert time.monotonic() - started < 30, "it did not wait on a leftover" + + +def test_without_the_opt_in_a_lane_windows_sidecar_act_works_as_on_main(box: Box): + """MUST 2 with MUST 3: Eagle's lane windows name their own lane checkout + by `LANES_REPO` and `LANES_WORKSPACE_ROOT`. Without the opt-in that is + main's behaviour, so a sidecar act there takes that checkout's own lock + and is never refused as "a W that is a lane checkout".""" + box.lanes_root.mkdir(parents=True) + c = box.clone(box.checkout(LANE)) + result = box.helper("set-lane-state", LANE, "RUNNING", "--expect", "none", "--owner", UUID, + LANES_ROUTE_WRITES="", LANES_REPO=str(c), LANES_WORKSPACE_ROOT=str(c)) + assert result.returncode == 0, (result.stdout, result.stderr) + assert (box.state / LANE / "lane-state.yaml").is_file() + assert not Path(f"{c}-lanes").exists(), "no lock beside the lane checkout" + assert not (c / "lanes" / ".lanes-edit.lock").exists(), "its own lock, released" + + +@pytest.mark.parametrize("how", ["both", "root-only"]) +def test_a_sidecar_act_in_a_W_that_is_a_lane_checkout_refuses(box: Box, how: str): + """MUST 3: under the opt-in a sidecar act takes the workstation's lock + beside W, so where W is a lane checkout it refuses (2), naming + `LANES_WORKSPACE_ROOT`, and makes no `-lanes/` lock.""" + box.lanes_root.mkdir(parents=True) + c = box.clone(box.checkout(LANE)) + extra = {"LANES_WORKSPACE_ROOT": str(c)} + if how == "both": + extra["LANES_REPO"] = str(c) + result = box.helper("set-lane-state", LANE, "RUNNING", "--expect", "none", "--owner", UUID, + **extra) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "Unset LANES_WORKSPACE_ROOT" in result.stderr, result.stderr + assert not Path(f"{c}-lanes").exists(), "nothing locked beside the lane checkout" + assert not (box.state / LANE / "lane-state.yaml").exists(), "nothing written" diff --git a/tests/test_a23_refresh_workspace.py b/tests/test_a23_refresh_workspace.py index 96ec90c4..621adb7a 100644 --- a/tests/test_a23_refresh_workspace.py +++ b/tests/test_a23_refresh_workspace.py @@ -165,3 +165,22 @@ def test_W_reflog_shows_only_fetch_and_ff(box: Box): assert len(new_remote) == len(remote_log) + 1 and new_remote[0].startswith("fetch"), new_remote assert box.wgit("for-each-ref", "--format=%(refname)").split() == refs, "no ref made or deleted" assert box.wgit("stash", "list") == "" + + +@pytest.mark.parametrize("how", ["both", "root-only"]) +def test_refresh_refuses_a_W_that_is_a_lane_checkout(box: Box, how: str): + """MUST 3: in a lane window whose `LANES_WORKSPACE_ROOT` names the lane's + own checkout, `refresh-workspace` refuses (2), naming that variable, and + leaves that checkout where it was: it is not W, and W alone is refreshed.""" + box.lanes_root.mkdir(parents=True) + c = box.clone(box.checkout("repoFF-1")) + box.peer_pushes() + before = box.git("-C", str(c), "rev-parse", "HEAD").strip() + extra = {"LANES_WORKSPACE_ROOT": str(c)} + if how == "both": + extra["LANES_REPO"] = str(c) + result = box.refresh(**extra) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "Unset LANES_WORKSPACE_ROOT" in result.stderr, result.stderr + assert box.git("-C", str(c), "rev-parse", "HEAD").strip() == before, "not fast-forwarded" + assert not Path(f"{c}-lanes").exists() diff --git a/tests/test_a23_write_routing.py b/tests/test_a23_write_routing.py index b011aea5..980213c3 100644 --- a/tests/test_a23_write_routing.py +++ b/tests/test_a23_write_routing.py @@ -424,3 +424,18 @@ def test_an_explicit_LANES_REPO_still_needs_a_lane(box: Box): assert result.returncode == 2, (result.stdout, result.stderr) assert "no lane, no write" in result.stderr and "LANES_LANE=" in result.stderr assert box.origin_head() == head + + +def test_S5_refuses_a_checkout_whose_git_directory_is_W(box: Box): + """SHOULD 3, the second clause: a checkout whose `.git` names W's — a + linked worktree of W, or a gitfile view like the shell suite's — commits + into W's own refs although its top level is not W, so S5 refuses it.""" + view = box.root / "a-view-of-W" + view.mkdir() + (view / ".git").write_text(f"gitdir: {box.w / '.git'}\n", encoding="utf-8") + shutil.copytree(box.w / "lanes", view / "lanes") + head, w_before = box.origin_head(), box.w_state() + result = box.helper("set-row-state", LANE, "LIVE · never written", LANES_REPO=str(view)) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "readers' copy" in result.stderr and "Amendment 23 S5" in result.stderr, result.stderr + assert_nothing_written(box, head, w_before) diff --git a/tests/test_register_scan_hardening_138.py b/tests/test_register_scan_hardening_138.py index ab181d01..942ad0d7 100644 --- a/tests/test_register_scan_hardening_138.py +++ b/tests/test_register_scan_hardening_138.py @@ -634,3 +634,22 @@ def test_a_routed_sweep_refuses_over_a_peer_edit_in_W_and_captures_nothing(reg): assert reg.head() == before and reg.read("lanes/LANES.md") == edited assert reg.raw_git("--git-dir", str(reg.origin), "rev-parse", "main").strip() == published assert not (reg.tmp / "wip-lanes").exists() + + +def test_item_9_refuses_in_the_scan_under_the_opt_in_too(reg): + """MUST 5's seam, under the routing: with `LANES_ROUTE_WRITES=1` the + sweep still writes through `wip`, where the case puts its fault, and so + still refuses in the scan. Had the fixture left W at `wip`, the sweep + would go through `-lanes/repoX-1/`, never see the fault, and push.""" + logs = reg.wip / "lanes" / "log" + before = reg.head() + logs.chmod(0) + try: + result = reg.sweep("repoX-2", LANES_ROUTE_WRITES="1") + finally: + logs.chmod(0o755) + assert result.returncode == 1, (result.stdout, result.stderr) + assert "exists and could not be listed" in result.stderr + assert reg.head() == before + assert reg.dirty() == "" + assert not (reg.tmp / "wip-lanes").exists(), "no lane checkout was made beside wip" From eead86da50ee351e86918cc10ca647737c244424 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 17:29:15 +0000 Subject: [PATCH 40/42] Fix round 2 of #282 (lane 3's re-review): without the opt-in a lock is stale at ten minutes as on main, only `commit` refuses over a hand edit in W, a write for a lane with no row clones nothing, and the daily report knows a lane window's register checkouts (#198) Answers lane openRepoTools-3's re-review of 874e020 (N1-N5, SHOULD 5) and its re-check of the 12 cases at ecb96ed (R3-1, R3-2). N1: mutex_stale. Without LANES_ROUTE_WRITES=1 the lock is W's, shared by every lane, and a live holder in this container whose lock is over ten minutes old is stale, as main's lock_try has it; git_net's `touch -c` heartbeat keeps a working holder fresh. Under the opt-in a live holder is still never aged out (S2). refresh-workspace's note on a stale lock in W no longer calls it "an older helper's leftover". The env doc and the lock comments say so. N2: ws_pending_refuse refuses only `commit`, the verb that publishes a hand edit. Every other routed write says the same as a WARNING and goes on; the edit stays in W, untouched and captured by nothing. hardening_138's routed sweep case pinned the refusal N2 removes: it now warns and captures nothing. N3: that check's `git status` runs with GIT_OPTIONAL_LOCKS=0, so it never rewrites W's index under W's index.lock. N4: lanes_home_check calls a checkout both variables name W itself, not a lane checkout, where it is workspace.yaml's own `path:`. Still exit 2 (S5). N5: lane_row_gate. Where a lane has no checkout yet and the verb's arm needs exactly one row of it (set-row-state, replace-in-row, append-session-id, and rename-lane by its old name), a lane with no row on origin refuses (2) and nothing is cloned. A miss is read again after one log_sync of W; a read that could not be made, or a fetch that did not run, refuses nothing. log, claim, release, commit, append-line, request-handoff and the sweeps take a lane with no row (an archived lane keeps its log), and add-row makes the row. SHOULD 5: lane-worktrees takes the parent of real(W) as the lanes home where that parent is a `*-lanes` directory, as lanes_home_check reads one. R3-1: the two lane-end cases assert "in-flight read from lanes/log/", which lane-end prints only after the follow-up (kills lane 3's n21b). R3-2: Sandbox.start takes a timeout (default 120, unchanged elsewhere); the held-creation-lock start and end get 300 s; the creation-heartbeat case waits up to 30 s for the first beat instead of a fixed 4 s. New tests: test_a23_lock.py test_without_the_opt_in_a_live_holder_is_aged_out_at_ten_minutes (2); test_a23_write_routing.py test_a_hand_edit_in_W_warns_every_other_routed_write_and_stops_none, test_the_hand_edit_check_takes_no_lock_in_W, test_both_variables_naming_W_itself_refuse_and_say_it_is_W, test_a_write_that_needs_a_row_clones_nothing_for_a_lane_with_none, test_a_row_W_has_not_fetched_is_no_reason_to_refuse (2); test_lane_worktrees_report.py test_a_W_that_is_a_lane_checkout_lists_its_siblings_as_register_checkouts. Runs (one module per pytest process, every inherited LANES_* and TMUX* unset), ecb96ed -> this commit: test_a23_write_routing.py 35 -> 41 passed; test_a23_lock.py 22 -> 24; test_register_scan_hardening_138.py 29 -> 29; test_lane_worktrees_report.py 21 -> 22; test_a23_lane_checkout.py 26 -> 26; test_a23_refresh_workspace.py 12 -> 12; test_a23_lane_start_read.py 10 -> 10. Also at this commit: test_lane_start_row_stamp.py 3 passed; test_lane_worktrees.py 118 passed; test_wip_init_command.py 63 skipped; test_repo_hygiene.py 237 passed; test_guard_launch_mode.py 13 passed; test_test_roots.py 39 passed, 1 skipped. The new tests against ecb96ed's scripts: 8 failed (N1 x2, N2, N3, N4, N5, the sweep case, SHOULD 5), and the 6 that pin the head's behaviour passed. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lane-worktrees | 7 ++ lanes-edit.sh | 100 ++++++++++++++++++---- tests/test_a23_lane_checkout.py | 13 ++- tests/test_a23_lane_start_read.py | 19 +++- tests/test_a23_lock.py | 30 +++++++ tests/test_a23_write_routing.py | 99 ++++++++++++++++++++- tests/test_lane_start_row_stamp.py | 4 +- tests/test_lane_worktrees_report.py | 24 ++++++ tests/test_register_scan_hardening_138.py | 20 +++-- 9 files changed, 280 insertions(+), 36 deletions(-) diff --git a/lane-worktrees b/lane-worktrees index babd21fb..11833b6e 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -5525,6 +5525,13 @@ class Report: # that lane's writes; listing it here told a person to dispose of it. wroot = self.helper.workspace_root() if self.helper.path else "" lanes_home = real(wroot) + "-lanes" if wroot else "" + # AND A W THAT IS ITSELF A LANE CHECKOUT (the re-review of #282, SHOULD + # 5): Eagle's lane windows resolve W to their own `-lanes//`, + # and lane-start runs this report from there. Its lanes home is then its + # parent, as lanes-edit.sh's `lanes_home_check` reads a `*-lanes` one. + home = os.path.dirname(real(wroot)) if wroot else "" + if len(os.path.basename(home)) > len("-lanes") and home.endswith("-lanes"): + lanes_home = home registers = [] for c in clones: if lanes_home and os.path.dirname(real(c)) == lanes_home: diff --git a/lanes-edit.sh b/lanes-edit.sh index 382cb6f7..82ace099 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -656,9 +656,11 @@ # the checkout lock, and no act holds both. No lock is taken # in W then. Without the opt-in those acts take the # checkout's lock, as before. Each lock holds a `pid` file, -# ` `: a live holder in this -# container is never aged out; one in another container is -# judged by the directory's age, which the holder refreshes +# ` `: under LANES_ROUTE_WRITES=1 a +# live holder in this container is never aged out, and +# without it a lock over ten minutes old is stale whoever +# holds it, as before; one in another container is judged +# by the directory's age, which the holder refreshes # before every network call and never re-creates # LANES_LOG_DIR the object logs (default: /log) # LANES_REPOS_TSV the per-wip alias OVERRIDE (default: /repos.tsv) @@ -1252,9 +1254,12 @@ note() { printf 'lanes-edit: %s\n' "$*" >&2; } # pid namespace (Amendment 18), and every bench container on a host sees the # projects-root mount these locks live on, so the `pid` file is ONE line, # ` `, written by one `printf`: -# * this container and host: the pid test alone. A LIVE HOLDER IS NEVER AGED -# OUT — six push attempts at a minute each is longer than ten minutes, and a -# write that is still running is not stale however long it takes (S2); +# * this container and host, under `LANES_ROUTE_WRITES=1`: the pid test +# alone. A LIVE HOLDER IS NEVER AGED OUT — six push attempts at a minute +# each is longer than ten minutes, and a write that is still running is not +# stale however long it takes (S2). WITHOUT THE OPT-IN the ten-minute age +# test applies to a live holder too, as on main (the re-review of #282, +# N1): the lock is then W's, shared by every lane; # * another container or host: `kill -0` here says nothing about it, so the # ten-minute age of the directory decides, and the holder refreshes that age # before every network call (`git_net`); @@ -1291,7 +1296,18 @@ mutex_stale() { # fi if mutex_local; then [ "$MX_PID" = "$$" ] && return 1 - kill -0 "$MX_PID" 2>/dev/null && return 1 + if kill -0 "$MX_PID" 2>/dev/null; then + # MAIN'S TEN MINUTES, WITHOUT THE OPT-IN (the re-review of #282, N1). + # There this lock is W's mutex, which every lane on the workstation + # shares, and main ages out any lock over ten minutes old: a holder + # stopped by Ctrl-Z, or stuck in a D-state git, blocks every lane for + # ten minutes and no longer. A working holder is never caught by it, + # because it refreshes the lock before every network call (`git_net`). + [ "$LANES_ROUTE_ON" != 1 ] || return 1 + [ -z "$(find "$1" -maxdepth 0 -mmin -10 2>/dev/null)" ] || return 1 + MX_WHY="its holder (pid $MX_PID) is alive but has not refreshed it for over ten minutes, and without LANES_ROUTE_WRITES=1 a lock that old is stale, as it was before Amendment 23" + return 0 + fi MX_WHY="its holder (pid $MX_PID) is gone"; return 0 fi if [ -z "$(find "$1" -maxdepth 0 -mmin -10 2>/dev/null)" ]; then @@ -1565,8 +1581,9 @@ trap 'cleanup; trap - TERM; kill -TERM $$' TERM # process down with it, leaves a directory no living process will ever remove, # and until Amendment 8(h) every writer on the workstation waited out the full # age-out behind it. `mutex_stale` above is that test, with Amendment 23's -# rule on top: the ten-minute age-out never reaches a live holder in this -# container, and is the whole test for one in another. +# rule on top: under `LANES_ROUTE_WRITES=1` the ten-minute age-out never +# reaches a live holder in this container; it is the whole test for one in +# another; and without the opt-in it reaches a live holder here as well. # # THE MUTEX, TAKEN WITHOUT DYING FOR IT — 0 taken, 1 not taken, and no exit # either way. `acquire_lock` below is this with the refusal on the end, so the @@ -14927,6 +14944,13 @@ lanes_home_check() { if [ -z "$lhc_why" ] && [ -n "${LANES_WORKSPACE_ROOT:-}" ] && [ "$LANES_REPO_GIVEN" = 1 ]; then lhc_r="$(cd -P -- "$LANES_REPO" 2>/dev/null && pwd -P)" || lhc_r="" [ "$lhc_r" != "$LANES_WS_REAL" ] || lhc_why="LANES_REPO names that same checkout, which makes it the checkout this window writes through" + # UNLESS IT IS W ITSELF (the re-review of #282, N4): both variables naming + # the `path:` workspace.yaml names is no lane checkout. It is still a + # refusal, S5's — no write goes through W — and it says that instead. + if [ -n "$lhc_why" ] && + [ "$(LANES_WORKSPACE_ROOT='' lanes_workspace_root 2>/dev/null || :)" = "$LANES_WS_REAL" ]; then + die "LANES_WORKSPACE_ROOT and LANES_REPO both name $LANES_WS_REAL, which is W itself — the \`path:\` in $(lanes_ws_yaml) — and W is the readers' copy: under LANES_ROUTE_WRITES=1 nothing is written through it (Amendment 23 S5). Nothing was written, and nothing was cloned or locked. Unset LANES_REPO, and LANES_WORKSPACE_ROOT with it (it only repeats that \`path:\`): each write then goes through its lane's own checkout, $LANES_WS_REAL-lanes//." 2 + fi fi [ -n "$lhc_why" ] || return 0 if [ -n "${LANES_WORKSPACE_ROOT:-}" ]; then @@ -15263,13 +15287,58 @@ git_common_real() { # # NEVER LEFT BEHIND IN SILENCE. Before Amendment 23 the next write captured an # uncommitted register edit as its own `LANES(pre-existing@…)` commit. A routed # write goes through the lane's checkout and commits nothing in W, so that edit -# would stay there, unpublished, while `commit` said "nothing staged". So a -# routed write refuses while W's register has one, naming it and the checkout -# it belongs in. +# would stay there, unpublished, while `commit` said "nothing staged". So +# `commit` — the verb whose whole job is to publish a hand edit — refuses while +# W's register has one, naming it and the checkout it belongs in. +# +# AND ONLY `commit` (the re-review of #282, N2). Every other routed write +# carries its own edit and none of W's, so one stray edit in W — a person's, or +# an older helper's write in progress there during the rollout — would stop +# every lane's writes, a launch's session-id stamp among them. Those say the +# same thing as a WARNING and go on: the edit stays in W, untouched, and no +# write captures it. +# +# THE STATUS TAKES NO LOCK IN W (N3): a plain `git status` refreshes a +# stat-dirty index and writes it under W's `index.lock` — a write in W, which S5 +# forbids, and one that can race an older helper's `git add` there. ws_pending_refuse() { # - wpr_st="$(git -C "$LANES_WS_REAL" status --porcelain --untracked-files=no -- lanes/LANES.md 2>/dev/null || :)" + wpr_st="$(GIT_OPTIONAL_LOCKS=0 git -C "$LANES_WS_REAL" status --porcelain --untracked-files=no -- lanes/LANES.md 2>/dev/null || :)" [ -n "$wpr_st" ] || return 0 - die "W ($LANES_WS_REAL) holds an uncommitted edit to its register (${wpr_st# }), and under LANES_ROUTE_WRITES=1 nothing commits in W: this write goes through $2/ and would leave that edit in W, unpublished, where the write that used to capture it no longer runs (Amendment 23 S5). Nothing was written. Carry it to the lane's own checkout — \`git -C $LANES_WS_REAL diff -- lanes/LANES.md\` shows it; make it in $2/lanes/LANES.md and publish it with LANES_LANE=$1 $SELF commit \"\" — then take it out of W by hand and run this write again." 2 + wpr_carry="Carry it to the lane's own checkout — \`git -C $LANES_WS_REAL diff -- lanes/LANES.md\` shows it; make it in $2/lanes/LANES.md and publish it with LANES_LANE=$1 $SELF commit \"\" — then take it out of W by hand" + if [ "$cmd" != commit ]; then + note "WARNING: W ($LANES_WS_REAL) holds an uncommitted edit to its register (${wpr_st# }), and under LANES_ROUTE_WRITES=1 nothing commits in W: this write goes through $2/ and carries its own edit only, so that one stays in W, unpublished and untouched (Amendment 23 S5). $wpr_carry." + return 0 + fi + die "W ($LANES_WS_REAL) holds an uncommitted edit to its register (${wpr_st# }), and under LANES_ROUTE_WRITES=1 nothing commits in W: this write goes through $2/ and would leave that edit in W, unpublished, where the write that used to capture it no longer runs (Amendment 23 S5). Nothing was written. $wpr_carry, and run this write again." 2 +} + +# A WRITE THAT NEEDS ITS LANE'S ROW CLONES NOTHING FOR A LANE WITH NONE (the +# re-review of #282, N5). Measured: `set-row-state repoX-99`, for a lane with no +# row, cloned `-lanes/repoX-99` — half a gigabyte on Eagle — and then +# refused, "expected exactly 1 row", leaving the clone. So where the lane has +# no checkout yet and the verb's arm needs exactly one row of that lane (the +# four routed `row_line` callers; `rename-lane` goes through its OLD name, +# whose row it needs), a lane with no row in the published register refuses +# first, and nothing is cloned. +# * NOT THE OTHER WRITES: `log`, `claim`, `release`, `commit`, `append-line`, +# `request-handoff` and the sweeps take a lane with no row — an archived +# lane keeps its log (Amendment 16(e)) — and `add-row` makes the row. +# * A MISS IS ASKED AGAIN after one fetch of W (`log_sync`: W is still +# `LANES_REPO` here), so a row, or a rename's new name, that another +# checkout published and W has not fetched yet is never refused. +# * A READ THAT COULD NOT BE MADE, OR A FETCH THAT DID NOT RUN (failed, +# `LANES_NO_FETCH=1`, `LANES_NO_GIT=1`), IS NO ANSWER (R22): the write goes +# on, and its arm decides, as it always has. +lane_row_gate() { # + case "$cmd" in set-row-state|replace-in-row|append-session-id|rename-lane) : ;; *) return 0 ;; esac + [ ! -e "$2/.git" ] || return 0 + lrg_rows="$(rows_named_ci "$1" 2>/dev/null)" || return 0 + [ -z "$lrg_rows" ] || return 0 + log_sync + [ "$LOG_SYNC_FETCH" = yes ] || return 0 + lrg_rows="$(rows_named_ci "$1" 2>/dev/null)" || return 0 + [ -z "$lrg_rows" ] || return 0 + die "lane $1 has no row in the register on origin/$LANES_BRANCH (fetched just now into W), and \`$cmd\` edits that lane's row: there is nothing for it to edit, so its checkout $2/ was not cloned (Amendment 23 S1, S2). Nothing was written. Check the name — \`$SELF lanes\` lists every lane — or add the row first (\`add-row\`)." 2 } # S2, IN ITS ORDER, for a write: (1) an explicit `LANES_REPO` keeps its meaning @@ -15326,6 +15395,7 @@ lane_checkout_enter() { # lcn_p="$(lane_checkout_path "$lcn_c")" || die "$(lanes_workspace_why)" 1 lane_checkout_inside "$lcn_p" ws_pending_refuse "$lcn_c" "$lcn_p" + lane_row_gate "$lcn_c" "$lcn_p" lane_checkout_ensure "$lcn_c" if [ -n "$LANES_DIR_GIVEN" ]; then LANES_DIR="$LANES_DIR_GIVEN" elif [ -n "$LANES_FILE_GIVEN" ]; then LANES_DIR="$(dirname -- "$LANES_FILE_GIVEN")" @@ -15391,7 +15461,7 @@ refresh_workspace() { rw_lk="$rw_w/lanes/.lanes-edit.lock" if [ -e "$rw_lk" ] || [ -L "$rw_lk" ]; then if [ -d "$rw_lk" ] && [ ! -L "$rw_lk" ] && mutex_stale "$rw_lk"; then - note "W carries a stale lock, $rw_lk: $MX_WHY. It is an older helper's leftover, and it is left as it is." + note "W carries a stale lock, $rw_lk: $MX_WHY. A stale lock does not stop a fast-forward, and this act leaves it as it is." else rw_why="${rw_why} a lock held: $rw_lk (holder: $(cat -- "$rw_lk/pid" 2>/dev/null || printf 'unrecorded'))" diff --git a/tests/test_a23_lane_checkout.py b/tests/test_a23_lane_checkout.py index 190ee3d9..023ade27 100644 --- a/tests/test_a23_lane_checkout.py +++ b/tests/test_a23_lane_checkout.py @@ -406,14 +406,21 @@ def test_the_creation_lock_is_refreshed_while_the_clone_runs(box: Box): """NIT 5: a clone may take its whole budget, ten minutes or more, and a waiter in another container takes a creation lock that has not moved for ten. So the holder refreshes it while the clone runs: aged to twenty - minutes as the clone begins, it is fresh again before the clone ends.""" + minutes as the clone begins, it is fresh again before the clone ends. + The clone waits for that, up to 30 s, rather than a fixed 4 s inside which + a beat must land (the re-check of #282's cases, R3-2): the head ends at + the first beat, and a holder with no heartbeat still reads stale.""" lock = box.lanes_root / f".{LANE}.lock" seen = box.root / "seen" old = int(time.time()) - 20 * 60 real_touch, real_sleep = shutil.which("touch"), shutil.which("sleep") extra = _creation_bench(box, f""" '{real_touch}' -c -d '@{old}' '{lock}' - '{real_sleep}' 4 - if [ -n "$(find '{lock}' -maxdepth 0 -mmin -1 2>/dev/null)" ]; then echo fresh > '{seen}'; else echo stale > '{seen}'; fi""") + state=stale + for _ in $(seq 150); do + if [ -n "$(find '{lock}' -maxdepth 0 -mmin -1 2>/dev/null)" ]; then state=fresh; break; fi + '{real_sleep}' 0.2 + done + echo "$state" > '{seen}'""") result = box.stamp(LANE, "a slow first clone", **extra) assert result.returncode == 0, (result.stdout, result.stderr) assert seen.read_text(encoding="utf-8").strip() == "fresh", \ diff --git a/tests/test_a23_lane_start_read.py b/tests/test_a23_lane_start_read.py index 4c82fef1..ab14fd42 100644 --- a/tests/test_a23_lane_start_read.py +++ b/tests/test_a23_lane_start_read.py @@ -155,12 +155,21 @@ def test_a_checkout_that_cannot_be_made_is_said_and_the_start_goes_on(tmp_path, assert second.returncode == 0, second.stderr -def _end(box: Sandbox, *args: str) -> subprocess.CompletedProcess: +#: lane-end prints this line, its in-flight read of the lane's log, only after +#: the register follow-up, so a case whose end must go on past the follow-up's +#: note asserts it (the re-check of #282's cases, R3-1). The end's exit is its +#: own later write's, so it cannot simply be asserted 0. +GOES_ON = "in-flight read from lanes/log/" +#: The re-check's R3-2: the slowest new cases run inside 300 s, not 120 or 180. +SLOW = 300 + + +def _end(box: Sandbox, *args: str, timeout: int = 180) -> subprocess.CompletedProcess: for name in ("lane-end", "lane-worktrees"): shutil.copy2(REPO / name, box.bin / name) (box.bin / name).chmod(0o755) return subprocess.run([str(box.bin / "lane-end"), LANE, *args], env=box.env, - capture_output=True, text=True, timeout=180, check=False) + capture_output=True, text=True, timeout=timeout, check=False) def _started_in_W(tmp_path: Path, short_root: Path, *, checkout_first: bool = False): @@ -213,6 +222,7 @@ def test_a_checkout_that_cannot_be_made_is_said_and_the_end_goes_on(tmp_path, sh assert "own register checkout could not be made" in end.stderr, end.stderr assert "so this command reads W's register" in end.stderr, end.stderr assert "has no row for lane" not in end.stderr, end.stderr + assert GOES_ON in end.stderr, end.stderr assert not checkout.exists() @@ -250,7 +260,7 @@ def test_a_held_creation_lock_is_said_and_the_start_goes_on(tmp_path, short_root box, checkout = _started_in_W(tmp_path, short_root) box.env["LANES_ROUTE_WRITES"] = "1" with _CreationLockHeld(box, checkout): - second = box.start(window_name=LANE) + second = box.start(window_name=LANE, timeout=SLOW) assert "own register checkout could not be made" in second.stderr, second.stderr assert "exited 4" in second.stderr, second.stderr assert "so this command reads W's register" in second.stderr @@ -263,9 +273,10 @@ def test_a_held_creation_lock_is_said_and_the_end_goes_on(tmp_path, short_root): box, checkout = _started_in_W(tmp_path, short_root) box.env["LANES_ROUTE_WRITES"] = "1" with _CreationLockHeld(box, checkout): - end = _end(box) + end = _end(box, timeout=SLOW) assert "own register checkout could not be made" in end.stderr, end.stderr assert "exited 4" in end.stderr, end.stderr assert "so this command reads W's register" in end.stderr assert "has no row for lane" not in end.stderr, end.stderr + assert GOES_ON in end.stderr, end.stderr assert not checkout.exists() diff --git a/tests/test_a23_lock.py b/tests/test_a23_lock.py index ab0b068a..262abb5a 100644 --- a/tests/test_a23_lock.py +++ b/tests/test_a23_lock.py @@ -507,3 +507,33 @@ def test_a_sidecar_act_in_a_W_that_is_a_lane_checkout_refuses(box: Box, how: str assert "Unset LANES_WORKSPACE_ROOT" in result.stderr, result.stderr assert not Path(f"{c}-lanes").exists(), "nothing locked beside the lane checkout" assert not (box.state / LANE / "lane-state.yaml").exists(), "nothing written" + + +# ----------------------------------------------------- fix round 2 (#282's re-review) + +@pytest.mark.parametrize("line", ["{pid} " + ME, "{pid}"], ids=["pid-container-host", "pid-only"]) +def test_without_the_opt_in_a_live_holder_is_aged_out_at_ten_minutes(box: Box, line: str): + """N1: without `LANES_ROUTE_WRITES` the lock is W's, which every lane + shares, and main ages out any lock over ten minutes old, whoever holds it: + a holder stopped by Ctrl-Z, or stuck in a D-state git, blocks every lane + for ten minutes and no longer. The write takes it over at once and lands, + and the holder itself is left running. (Under the opt-in a live holder is + never aged out: `test_live_holder_is_not_aged_out`.)""" + w_lock = box.w / "lanes" / ".lanes-edit.lock" + holder = subprocess.Popen(["sleep", "300"]) + try: + hold(w_lock, line.format(pid=holder.pid), age_minutes=11) + started = time.monotonic() + result = box.stamp("past a quiet holder", LANES_ROUTE_WRITES="", timeout=120) + took = time.monotonic() - started + assert holder.poll() is None, "the holder itself is left running" + finally: + holder.kill() + holder.wait() + assert result.returncode == 0, (result.stdout, result.stderr) + assert took < 30, f"it took the lock over at once, not after its minute's wait: {took:.0f}s" + assert f"taking over {w_lock}" in result.stderr, result.stderr + assert f"(pid {holder.pid}) is alive but has not refreshed it for over ten minutes" in result.stderr + assert " · past a quiet holder |" in box.origin_register() + assert not w_lock.exists(), "the writer released the lock it took over" + assert not box.lanes_root.exists(), "and no lane checkout or -lanes/ was made" diff --git a/tests/test_a23_write_routing.py b/tests/test_a23_write_routing.py index 980213c3..46fa88b7 100644 --- a/tests/test_a23_write_routing.py +++ b/tests/test_a23_write_routing.py @@ -17,6 +17,7 @@ import os import shutil import subprocess +import time from pathlib import Path import pytest @@ -355,9 +356,10 @@ def test_S5_compares_the_repository_not_the_spelling(box: Box): def test_a_hand_edit_in_W_refuses_a_routed_write(box: Box): - """SHOULD 4: W's register carries a hand edit. A routed write — `commit` - among them — refuses, naming the edit and the lane's checkout, where - before it said "nothing staged" with exit 0 and left the edit in W.""" + """SHOULD 4: W's register carries a hand edit. A routed `commit` — the + verb that publishes hand edits, and since N2 the only one refused — + refuses, naming the edit and the lane's checkout, where before it said + "nothing staged" with exit 0 and left the edit in W.""" register = box.w / REGISTER edited = register.read_text(encoding="utf-8").replace("LIVE · seeded |", "LIVE · by hand |", 1) register.write_text(edited, encoding="utf-8") @@ -439,3 +441,94 @@ def test_S5_refuses_a_checkout_whose_git_directory_is_W(box: Box): assert result.returncode == 2, (result.stdout, result.stderr) assert "readers' copy" in result.stderr and "Amendment 23 S5" in result.stderr, result.stderr assert_nothing_written(box, head, w_before) + + +# ------------------------------------------------------- fix round 2 (#282's re-review) + +def test_a_hand_edit_in_W_warns_every_other_routed_write_and_stops_none(box: Box): + """N2: one stray edit in W — a person's, or an older helper's write in + progress there during the rollout — stopped every lane's routed writes, a + launch's session-id stamp among them. Only `commit` refuses now (above). + Another lane's write says the same thing as a WARNING, goes through its + own checkout and lands, and the edit stays in W, untouched and captured + by nothing.""" + register = box.w / REGISTER + lines = register.read_text(encoding="utf-8").splitlines(keepends=True) + edited = "".join(l.replace("LIVE · seeded |", "LIVE · by hand |") if l.startswith("| `repoA23-2` |") + else l for l in lines) + register.write_text(edited, encoding="utf-8") + w_head = box.w_state()[0] + result = box.helper("set-row-state", LANE, "LIVE · past a stray edit in W") + assert result.returncode == 0, (result.stdout, result.stderr) + assert "WARNING" in result.stderr and "uncommitted edit to its register" in result.stderr, result.stderr + assert f"{box.checkout(LANE)}/lanes/LANES.md" in result.stderr, "it names where the edit belongs" + published = box.origin_register() + assert " · past a stray edit in W |" in published, "the write landed" + assert "by hand" not in published, "and the edit in W was captured by nothing" + assert register.read_text(encoding="utf-8") == edited, "the edit is still in W, untouched" + assert box.w_state()[0] == w_head + + +def test_the_hand_edit_check_takes_no_lock_in_W(box: Box): + """N3: the check's `git status` in W ran with git's optional locks, so on + a stat-dirty index it rewrote W's index under W's `index.lock` — a write + in W, which S5 forbids, and one that can race an older helper's `git add` + there. W's index file is left as it was: the same inode, the same time.""" + register = box.w / REGISTER + then = time.time() - 100 + os.utime(register, (then, then)) # its bytes as committed; its stat not the index's + index = box.w / ".git" / "index" + before = os.stat(index) + result = box.helper("set-row-state", LANE, "LIVE · W's index untouched") + assert result.returncode == 0, (result.stdout, result.stderr) + after = os.stat(index) + assert (after.st_ino, after.st_mtime_ns) == (before.st_ino, before.st_mtime_ns), \ + "nothing rewrote W's index" + + +def test_both_variables_naming_W_itself_refuse_and_say_it_is_W(box: Box): + """N4: `LANES_WORKSPACE_ROOT` and `LANES_REPO` both at the `path:` + workspace.yaml names is W, not a lane checkout. It is still refused (2) — + under the opt-in no write goes through W (S5) — and the refusal says that, + where it called W "a lane checkout".""" + head, w_before = box.origin_head(), box.w_state() + result = box.helper("set-row-state", LANE, "LIVE · never written", + LANES_WORKSPACE_ROOT=str(box.w), LANES_REPO=str(box.w)) + assert result.returncode == 2, (result.stdout, result.stderr) + assert f"both name {box.w}, which is W itself" in result.stderr, result.stderr + assert "Amendment 23 S5" in result.stderr and "Unset LANES_REPO" in result.stderr + assert "it is a lane checkout" not in result.stderr, result.stderr + assert_nothing_written(box, head, w_before) + assert not box.lanes_root.exists() + + +def test_a_write_that_needs_a_row_clones_nothing_for_a_lane_with_none(box: Box): + """N5: `set-row-state` for a lane with no row cloned that lane's checkout + — half a gigabyte on Eagle — and then refused, "expected exactly 1 row", + leaving the clone. It refuses first now (2): no row on origin, fetched into + W to make sure, and nothing cloned.""" + head, w_before = box.origin_head(), box.w_state() + result = box.helper("set-row-state", "repoA23-99", "LIVE · never written") + assert result.returncode == 2, (result.stdout, result.stderr) + assert "lane repoA23-99 has no row in the register on origin/main" in result.stderr, result.stderr + assert "expected exactly 1 row" not in result.stderr + assert not box.lanes_root.exists(), "nothing was cloned for a lane with no row" + assert_nothing_written(box, head, w_before) + + +@pytest.mark.parametrize("no_fetch", [False, True], ids=["fetched", "no-fetch"]) +def test_a_row_W_has_not_fetched_is_no_reason_to_refuse(box: Box, no_fetch: bool): + """N5's gate asks W's `origin/main` again after fetching it, so a row + another checkout published, which W has not fetched yet, is never refused. + Where that fetch does not run (`LANES_NO_FETCH=1`), a miss is no answer: + the write goes on, as before, and its arm decides.""" + lane = "repoA23-7" + head, sep, rest = box.origin_file(REGISTER).partition("|---|---|---|---|---|---|---|\n") + box.peer_pushes(REGISTER, head + sep + _row(lane) + "\n" + rest) + assert f"`{lane}`" not in box.git("-C", str(box.w), "show", f"refs/remotes/origin/main:{REGISTER}") + extra = {"LANES_NO_FETCH": "1"} if no_fetch else {} + result = box.helper("set-row-state", lane, "LIVE · a row W had not fetched", **extra) + assert result.returncode == 0, (result.stdout, result.stderr) + assert "has no row in the register" not in result.stderr, result.stderr + assert (box.checkout(lane) / ".git").is_dir() + assert " · a row W had not fetched |" in box.origin_register() diff --git a/tests/test_lane_start_row_stamp.py b/tests/test_lane_start_row_stamp.py index 69bab0d2..21670d7a 100644 --- a/tests/test_lane_start_row_stamp.py +++ b/tests/test_lane_start_row_stamp.py @@ -203,11 +203,11 @@ def checkout(self, path: Path) -> None: self.git("-C", str(path), "remote", "add", "origin", "https://github.com/opensoft/repoZ.git") - def start(self, *args: str, window_name: str) -> subprocess.CompletedProcess: + def start(self, *args: str, window_name: str, timeout: int = 120) -> subprocess.CompletedProcess: return subprocess.run([str(self.bin / "lane-start"), "repoZ", "1", "--no-launch", *args], env=dict(self.env, FAKE_TMUX_WINDOW_NAME=window_name), - capture_output=True, text=True, timeout=120, + capture_output=True, text=True, timeout=timeout, check=False) def stamp_line(self) -> str: diff --git a/tests/test_lane_worktrees_report.py b/tests/test_lane_worktrees_report.py index 93118838..4747f92b 100644 --- a/tests/test_lane_worktrees_report.py +++ b/tests/test_lane_worktrees_report.py @@ -720,3 +720,27 @@ def test_a_lanes_register_checkout_is_reported_and_never_foreign(tmp_path): assert f"{lane_checkout} · lane repoA-1 ·" in registers, registers assert "1 commit(s) origin/main lacks" in registers assert "cherry -v origin/main" in registers + + +def test_a_W_that_is_a_lane_checkout_lists_its_siblings_as_register_checkouts(tmp_path): + """The re-review of #282, SHOULD 5: Eagle's lane windows resolve W to + their own checkout, `brett-wip-lanes//`, and lane-start runs this + report from there. Its lanes home is then its parent, as lanes-edit.sh's + `lanes_home_check` reads a `*-lanes` one, so both lanes' checkouts are + register checkouts and neither is FOREIGN.""" + e = LW.Estate(tmp_path) + ws_origin = e.root / "workspace.git" + w = e.projects / "brett-wip" + e.git("clone", "-q", ws_origin, w, cwd=e.root) + mine = e.projects / "brett-wip-lanes" / "repoA-1" + other = e.projects / "brett-wip-lanes" / "repoB-1" + for c in (mine, other): + e.git("clone", "-q", ws_origin, c, cwd=e.root) + e.workspace = str(mine) + proc = report(e) + assert proc.returncode == 0, proc.stdout + proc.stderr + foreign = section(proc.stdout, "FOREIGN repositories") + assert str(mine) not in foreign and str(other) not in foreign, foreign + registers = section(proc.stdout, "Register checkouts (Amendment 23 S1)") + assert f"{mine} · lane repoA-1 ·" in registers, registers + assert f"{other} · lane repoB-1 ·" in registers, registers diff --git a/tests/test_register_scan_hardening_138.py b/tests/test_register_scan_hardening_138.py index 942ad0d7..881d097f 100644 --- a/tests/test_register_scan_hardening_138.py +++ b/tests/test_register_scan_hardening_138.py @@ -617,23 +617,25 @@ def test_item_1_an_unreadable_published_register_refuses_the_sweep(reg): # ------------------------------------- Amendment 23's routing, by default resolution -def test_a_routed_sweep_refuses_over_a_peer_edit_in_W_and_captures_nothing(reg): +def test_a_routed_sweep_over_a_peer_edit_in_W_warns_and_captures_nothing(reg): """SHOULD 4, the default-resolution case of the capture above. Under `LANES_ROUTE_WRITES=1` with W resolved from its root and no `LANES_REPO`, - the sweep goes through the sweeping lane's own checkout, so nothing would - ever capture the peer's edit in W. It refuses, names the edit and that - checkout, and leaves W, origin and the edit as they were.""" + the sweep goes through the sweeping lane's own checkout, so nothing + captures the peer's edit in W. Since the re-review's N2 only `commit` + refuses over it: the sweep WARNS, naming the edit and that checkout, + retires the row there, and leaves W's HEAD and the edit as they were.""" edited = peer_edit(reg) before = reg.head() - published = reg.raw_git("--git-dir", str(reg.origin), "rev-parse", "main").strip() result = reg.sweep("repoX-2", LANES_ROUTE_WRITES="1", LANES_REPO="", LANES_WORKSPACE_ROOT=str(reg.wip)) - assert result.returncode == 2, (result.stdout, result.stderr) - assert "uncommitted edit to its register" in result.stderr, result.stderr + assert result.returncode == 0, (result.stdout, result.stderr) + assert "WARNING" in result.stderr and "uncommitted edit to its register" in result.stderr, result.stderr assert f"{os.path.realpath(reg.wip)}-lanes/repoX-1/lanes/LANES.md" in result.stderr assert reg.head() == before and reg.read("lanes/LANES.md") == edited - assert reg.raw_git("--git-dir", str(reg.origin), "rev-parse", "main").strip() == published - assert not (reg.tmp / "wip-lanes").exists() + published = reg.raw_git("--git-dir", str(reg.origin), "show", "main:lanes/LANES.md") + assert any(line.startswith("| `repoX-2` |") and "RETIRED" in line for line in published.splitlines()), published + assert "a peer's half-written cell" not in published, "the sweep captured nothing" + assert (reg.tmp / "wip-lanes" / "repoX-1" / ".git").is_dir(), "it went through the sweeper's checkout" def test_item_9_refuses_in_the_scan_under_the_opt_in_too(reg): From 15c8e3f813ed26ee123dfff8b91bd77f51433952 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 23:19:20 +0000 Subject: [PATCH 41/42] Fix round 3 of #282 (lane 3's re-check of eead86d): without the opt-in, `refresh-workspace` and `lane-checkout` no longer refuse a W that both variables name, because nothing is routed and a write goes through W as on main (#198) Answers lane openRepoTools-3's re-check of eead86d (6301653436), the one item that stopped slice 1a, R4-2. R4-2: lanes_home_check's "W itself" branch (both LANES_WORKSPACE_ROOT and LANES_REPO naming the path: workspace.yaml names) is S5's refusal, and S5 exists only under LANES_ROUTE_WRITES=1. The branch ran unconditionally from `lane-checkout` and `refresh-workspace`, so without the opt-in `refresh-workspace` exited 2, naming an opt-in that is off, for a verb that writes no register; and exit 2 is its own "skipped, nothing changed", so a caller went on with a stale W. The first line of the branch is now `[ "$LANES_ROUTE_ON" = 1 ] || return 0`, lane 3's fix. Under the opt-in the refusal and its words are unchanged, and a W that is a lane checkout is still refused with or without it. The other two callers (the sidecar lock and lane_checkout_enter) run only under the opt-in already. Tests: tests/test_a23_refresh_workspace.py test_without_the_opt_in_both_variables_naming_W_still_refresh_it (exit 0 and W fast-forwarded, no refusal), and tests/test_a23_lane_checkout.py test_without_the_opt_in_both_variables_naming_W_are_no_refusal (`lane-checkout` answers 8, nothing made). Both fail at eead86d, with exit 2 and S5's words, and pass here; the N4 case in test_a23_write_routing.py still pins the refusal under the opt-in. R4-1 is slice 1b's and is not touched. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 --- lanes-edit.sh | 6 +++++- tests/test_a23_lane_checkout.py | 13 +++++++++++++ tests/test_a23_refresh_workspace.py | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 82ace099..19d83d49 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -14946,9 +14946,13 @@ lanes_home_check() { [ "$lhc_r" != "$LANES_WS_REAL" ] || lhc_why="LANES_REPO names that same checkout, which makes it the checkout this window writes through" # UNLESS IT IS W ITSELF (the re-review of #282, N4): both variables naming # the `path:` workspace.yaml names is no lane checkout. It is still a - # refusal, S5's — no write goes through W — and it says that instead. + # refusal, S5's — no write goes through W — and it says that instead. ONLY + # UNDER THE OPT-IN (the re-check of #282, R4-2): without it nothing is routed, + # a write goes through W as on main, and `lane-checkout` and + # `refresh-workspace`, which call this unconditionally, write no register. if [ -n "$lhc_why" ] && [ "$(LANES_WORKSPACE_ROOT='' lanes_workspace_root 2>/dev/null || :)" = "$LANES_WS_REAL" ]; then + [ "$LANES_ROUTE_ON" = 1 ] || return 0 die "LANES_WORKSPACE_ROOT and LANES_REPO both name $LANES_WS_REAL, which is W itself — the \`path:\` in $(lanes_ws_yaml) — and W is the readers' copy: under LANES_ROUTE_WRITES=1 nothing is written through it (Amendment 23 S5). Nothing was written, and nothing was cloned or locked. Unset LANES_REPO, and LANES_WORKSPACE_ROOT with it (it only repeats that \`path:\`): each write then goes through its lane's own checkout, $LANES_WS_REAL-lanes//." 2 fi fi diff --git a/tests/test_a23_lane_checkout.py b/tests/test_a23_lane_checkout.py index 023ade27..4d067029 100644 --- a/tests/test_a23_lane_checkout.py +++ b/tests/test_a23_lane_checkout.py @@ -355,6 +355,19 @@ def test_lane_checkout_verb_reads_and_creates(box: Box): assert usage.returncode == 64 +def test_without_the_opt_in_both_variables_naming_W_are_no_refusal(box: Box): + """R4-2: both variables at the `path:` workspace.yaml names is W itself, + not a lane checkout, and without the opt-in no write goes anywhere else + than W. `lane-checkout` took it for a refusal (2) all the same, naming an + opt-in that is off. It answers as it does with `LANES_REPO` unset: no + checkout there yet (8), and nothing made.""" + absent = box.helper("lane-checkout", LANE, LANES_ROUTE_WRITES="", + LANES_WORKSPACE_ROOT=str(box.w), LANES_REPO=str(box.w)) + assert absent.returncode == 8 and absent.stdout == "", (absent.stdout, absent.stderr) + assert "W itself" not in absent.stderr and "Amendment 23 S5" not in absent.stderr, absent.stderr + assert not box.lanes_root.exists(), "the read made no checkout" + + def _fake(path: Path, text: str) -> Path: path.write_text(text, encoding="utf-8") path.chmod(0o755) diff --git a/tests/test_a23_refresh_workspace.py b/tests/test_a23_refresh_workspace.py index 621adb7a..53287067 100644 --- a/tests/test_a23_refresh_workspace.py +++ b/tests/test_a23_refresh_workspace.py @@ -184,3 +184,22 @@ def test_refresh_refuses_a_W_that_is_a_lane_checkout(box: Box, how: str): assert "Unset LANES_WORKSPACE_ROOT" in result.stderr, result.stderr assert box.git("-C", str(c), "rev-parse", "HEAD").strip() == before, "not fast-forwarded" assert not Path(f"{c}-lanes").exists() + + +def test_without_the_opt_in_both_variables_naming_W_still_refresh_it(box: Box): + """R4-2: `LANES_WORKSPACE_ROOT` and `LANES_REPO` both at the `path:` + workspace.yaml names is W itself. Under the opt-in that is S5's refusal + (the N4 case in test_a23_write_routing.py), but without it no write goes + anywhere else than W, as on main, and `refresh-workspace` writes no + register: it refused (2) all the same, naming an opt-in that is off, and + exit 2 is this verb's "skipped, nothing changed", so a caller went on with + a stale W. It fast-forwards W as it does where `LANES_REPO` is unset.""" + box.peer_pushes() + assert box.head() != box.origin_main() + result = box.refresh(LANES_ROUTE_WRITES="", LANES_WORKSPACE_ROOT=str(box.w), + LANES_REPO=str(box.w)) + assert result.returncode == 0, (result.stdout, result.stderr) + assert "W itself" not in result.stderr and "Amendment 23 S5" not in result.stderr, result.stderr + assert "fast-forwarded W" in result.stderr, result.stderr + assert box.head() == box.origin_main(), "W was refreshed" + assert not box.lanes_root.exists(), "a read-side act makes no lane checkout" From 6ca31581c4410e736ce78da373a12388e94da939 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 13:40:38 +0000 Subject: [PATCH 42/42] SC2093: mark 1a's routing exec as deliberate, as lane openRepoTools-3's #198 comment 6341440689 suggests The line is the `exec "${BASH:-bash}" "$RESOLVED" ...` at the end of the routing path in `lane_checkout_enter` (15412), which shellcheck 0.9.0 reports as SC2093 with the baseline flags of `lint.yml` on #277's branch (`ci/actionlint-shellcheck-job-277` at 935eaef): `--severity=warning --exclude=SC1007,SC2034,SC2069,SC2088,SC2128,SC2141,SC2154,SC2178,SC2209,SC2318`. The `exec` is deliberate, so it now carries `# shellcheck disable=SC2093` and a two-line reason (it hands this one write to the routed checkout's copy and does not return, and the `die` after it runs only if the `exec` fails). Before: 15c8e3f gave exactly SC2093 at 15412 (exit 1) and main's 33ce26d was clean (exit 0). After: this file is clean (exit 0, nothing reported). Nothing else changed. Refs #198 Co-Authored-By: Claude Sonnet 5.5 --- lanes-edit.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lanes-edit.sh b/lanes-edit.sh index 19d83d49..a44ab2df 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -15409,6 +15409,9 @@ lane_checkout_enter() { # LANES__CHECKOUT_LANE="$lcn_c"; LANES__PID="$$" export LANES_REPO LANES_DIR LANES_FILE LANES__CHECKOUT_LANE LANES__PID cleanup_for_exec + # THE `exec` IS DELIBERATE: it hands this one write to the routed checkout's copy and does not + # return, and the `die` after it runs only if the `exec` itself fails. + # shellcheck disable=SC2093 exec "${BASH:-bash}" "$RESOLVED" ${ORIG_ARGV[@]+"${ORIG_ARGV[@]}"} die "could not run $RESOLVED again in lane $lcn_c's checkout $lcn_p. Nothing was written." 1 }