diff --git a/lane-end b/lane-end index 49e07622..dc803e2b 100755 --- a/lane-end +++ b/lane-end @@ -523,11 +523,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 @@ -940,6 +947,52 @@ 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 `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 +# 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. 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 '')" + 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). $lrf_none" 1 + LANES_FILE="$lrf_out/lanes/LANES.md" ;; + 8) : ;; # a dry run, and no checkout yet: W is what there is + 2 | 4) + case "$lrf_err" in *"unknown subcommand 'lane-checkout'"*) return 0 ;; esac + [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&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). $lrf_none" 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 b83c07cd..37231144 100755 --- a/lane-start +++ b/lane-start @@ -605,11 +605,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 @@ -1157,6 +1164,52 @@ 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 `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 +# 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. 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 '')" + 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). $lrf_none" 1 + LANES_FILE="$lrf_out/lanes/LANES.md" ;; + 8) : ;; # a dry run, and no checkout yet: W is what there is + 2 | 4) + case "$lrf_err" in *"unknown subcommand 'lane-checkout'"*) return 0 ;; esac + [ -z "$lrf_err" ] || printf '%s\n' "$lrf_err" >&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). $lrf_none" 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 @@ -4255,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..11833b6e 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -5519,7 +5519,24 @@ 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 "" + # 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: + registers.append(c) + continue container = _under_container(c, self.root) why = "" if container: @@ -5541,6 +5558,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 72b74e41..a44ab2df 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -366,6 +366,18 @@ # 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 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. # # 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 @@ -430,7 +442,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 @@ -440,7 +455,14 @@ # (`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), 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 @@ -461,6 +483,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 @@ -491,7 +514,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 @@ -509,7 +533,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). @@ -563,13 +588,38 @@ # 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 -# 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 +# 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 @@ -597,6 +647,21 @@ # 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 — 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, +# ` `: 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) # LANES_REPOS_TSV_SHIPPED the organisation's alias table shipped by @@ -626,6 +691,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" @@ -1042,16 +1113,50 @@ 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 +# 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: 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`). +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}" @@ -1105,6 +1210,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=""; 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 @@ -1139,13 +1249,173 @@ 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, 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`); +# * 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 + 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 + 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. 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" != "$$" ] || ! 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 || : + 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), 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() { # [] — 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 + 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`. @@ -1237,10 +1507,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: 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" @@ -1253,9 +1534,35 @@ 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 } +# 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 + create_heartbeat_stop + [ -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 @@ -1273,21 +1580,11 @@ 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: 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 # `mkdir` mutex, the pid test and the age-out are written ONCE and every taker @@ -1298,30 +1595,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() { @@ -1887,6 +2179,15 @@ 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. + # `-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=$? ;; *) @@ -1900,6 +2201,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. @@ -13450,24 +13771,26 @@ 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 is not a record this helper can read ($LSS_WHY), so the $lsf_verb line landed and NOTHING was written over that file: a record this helper cannot read is one it cannot safely replace. Upgrade this workstation's lanes-edit.sh if a newer tooling wrote it, 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 @@ -13478,11 +13801,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 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 +# 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. + +# 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" + # 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. 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 + [ -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() { # + [ -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 + # `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 + ''|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)" + # 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 + 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) + # 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 + 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 + 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. + # 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="$(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) + [ -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 +} + +# 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 +# `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_OPTIONAL_LOCKS=0 git -C "$LANES_WS_REAL" status --porcelain --untracked-files=no -- lanes/LANES.md 2>/dev/null || :)" + [ -n "$wpr_st" ] || return 0 + 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 +# — 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, 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:-}" + # 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_root" in + "${LANES_WS_REAL%/*}/${LANES_WS_REAL##*/}-"*) + 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_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_root" ] || lane_checkout_inside "$lcn_root" + return 0 + fi + 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_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")" + 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 + # 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 +} + +# 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. 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'))" + 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 @@ -14888,6 +15905,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}" @@ -17608,7 +18629,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 @@ -17785,7 +18806,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=$? @@ -18088,7 +19109,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 @@ -18286,7 +19307,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 @@ -18473,7 +19494,54 @@ 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 + lanes_home_check + 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 + lanes_home_check + 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/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 new file mode 100644 index 00000000..4d067029 --- /dev/null +++ b/tests/test_a23_lane_checkout.py @@ -0,0 +1,454 @@ +# 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 +import time +from pathlib import Path + +import pytest + +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"), + pytest.mark.skipif(shutil.which("git") is None, + reason="the register is a git repository"), + WINDOWS_SKIP] + +LANE = "repoCK-1" +UUID = "a23c0001-0c0c-4000-8000-a23c00010c0c" + + +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(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: + files = {REGISTER: register_text(_row(lane) for lane in rows), + "handoffs/README.md": "# handoffs\n"} + if aliases: + 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 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")) + + +@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(other) + c = box.checkout(LANE) + box.clone(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(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(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(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 + + +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) + 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. + 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}' + 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", \ + "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 new file mode 100644 index 00000000..ab14fd42 --- /dev/null +++ b/tests/test_a23_lane_start_read.py @@ -0,0 +1,282 @@ +# 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 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 + +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, *, 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 + + +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 + + +# ------------------------------------------------------- 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 + + +#: 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=timeout, 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 GOES_ON 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, 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 + 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, 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 new file mode 100644 index 00000000..262abb5a --- /dev/null +++ b/tests/test_a23_lock.py @@ -0,0 +1,539 @@ +# 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 a23_box import ME, REGISTER, RegisterBox, register_text +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" +UUID = "a23b0001-1c1c-4000-8000-a23b00011c1c" +#: 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 + + +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(RegisterBox): + """Two rows, and the lanes' sidecars under a state root of the box's own.""" + + def __init__(self, root: Path) -> None: + 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 made_checkout(self) -> Path: + """The lane's checkout, made by an ordinary first write.""" + 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.made_checkout() / "lanes" / ".lanes-edit.lock" + + +@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.made_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.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": + # 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} 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", + "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" + # 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 + 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) + 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 + 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) + 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 + + +# ----------------------------------------------------- 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" + + +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" + + +# ----------------------------------------------------- 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_refresh_workspace.py b/tests/test_a23_refresh_workspace.py new file mode 100644 index 00000000..53287067 --- /dev/null +++ b/tests/test_a23_refresh_workspace.py @@ -0,0 +1,205 @@ +# 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 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"), + pytest.mark.skipif(shutil.which("git") is None, + reason="the register is a git repository"), + WINDOWS_SKIP] + +UUID = "a23f0001-ffff-4000-8000-a23f0001ffff" + + +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(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: + 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) + + def peer_pushes(self, rel: str = "handoffs/repoFF/peer.md", text: str = "from a peer\n") -> None: + super().peer_pushes(rel, text) + + def refresh(self, **extra: str) -> subprocess.CompletedProcess: + 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.origin_head() + + def snapshot(self) -> tuple: + 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") == "" + + +@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() + + +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" diff --git a/tests/test_a23_write_routing.py b/tests/test_a23_write_routing.py new file mode 100644 index 00000000..46fa88b7 --- /dev/null +++ b/tests/test_a23_write_routing.py @@ -0,0 +1,534 @@ +# 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 +import time +from pathlib import Path + +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, + 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" +UUID = "a23a0001-a2a2-4000-8000-a23a0001a2a2" +TARGET_UUID = "a23a0004-a2a2-4000-8000-a23a0004a2a2" +ASKER_UUID = "a23a0005-a2a2-4000-8000-a23a0005a2a2" + + +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(RegisterBox): + """Four rows; the TARGET of a request bound in another container.""" + + def __init__(self, root: Path, **kw) -> None: + root = Path(os.path.realpath(root)) + rows = [_row(LANE), _row("repoA23-2"), + _row(DORMANT, uuid="a23a0009-a2a2-4000-8000-a23a0009a2a2"), + _row(TARGET, uuid=TARGET_UUID)] + 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 +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() + + +# ------------------------------------------------------- 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 `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") + 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 + + +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) + + +# ------------------------------------------------------- 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_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 27c09c3e..aeeddb9b 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -269,12 +269,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 @@ -682,20 +694,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" { @@ -1975,7 +2019,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")" @@ -2785,7 +2829,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 @@ -2801,7 +2845,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" || :)" @@ -2814,7 +2858,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" || :)" @@ -2870,7 +2914,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 @@ -3098,7 +3142,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. @@ -4539,6 +4583,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 @@ -4751,6 +4798,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) ;; @@ -4763,33 +4813,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" @@ -4800,13 +4850,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" section "== Amendment 11: the record's fields, and clause (h)'s reads ==" @@ -13106,8 +13156,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 @@ -13145,7 +13196,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-" @@ -13164,7 +13215,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" @@ -13175,7 +13226,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 @@ -13221,7 +13272,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` @@ -13235,17 +13286,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 section "== Amendment 19: a closed or dormant lane leaves the listing ==" @@ -13253,8 +13304,8 @@ section "== 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 @@ -13310,7 +13361,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 }' } @@ -15880,9 +15931,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" @@ -15891,9 +15942,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, @@ -15906,19 +15957,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" @@ -15957,9 +16008,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. @@ -15969,7 +16020,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 @@ -15978,7 +16029,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 @@ -17401,6 +17452,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 8c22aab5..f1bbff56 100644 --- a/tests/test_lane_helpers_suite.py +++ b/tests/test_lane_helpers_suite.py @@ -299,7 +299,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..21670d7a 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, @@ -193,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 5e5ac19e..4747f92b 100644 --- a/tests/test_lane_worktrees_report.py +++ b/tests/test_lane_worktrees_report.py @@ -692,3 +692,55 @@ 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 + + +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_lanes_index.py b/tests/test_lanes_index.py index 382af62b..d6faa362 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, diff --git a/tests/test_register_scan_hardening_138.py b/tests/test_register_scan_hardening_138.py index d145aab9..881d097f 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,45 @@ 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_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 + 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() + result = reg.sweep("repoX-2", LANES_ROUTE_WRITES="1", LANES_REPO="", + LANES_WORKSPACE_ROOT=str(reg.wip)) + 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 + 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): + """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" diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py index ee7a6110..296412bb 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: