From 6924ecf8f9dc471936dc1bf0767b867a1786ffe3 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 14:19:47 +0000 Subject: [PATCH 01/11] A lane sidecar of the current schema is read and replaced only when it is whole (#115) `lane_sidecar_schema_ok` asked one question, so a file whose first line is `schema: 1` and that held nothing else passed it: the snapshot's reader printed empty fields for it, the inventory's reader printed a row with an empty id and empty counts (which `lane_reconcile` skipped, or read as `dirty 0, unpushed 0`), and every writer renamed its own file over it. The gate keeps its name and its call sites, and now also asks a record of the current schema for every key its writer writes (not empty), the shapes the readers decide with (`state` one of the four words, every `generation` a number, a tree's `dirty` a number and `unpushed` a number or `unknown`, its `path` absolute) and the id a tree record's file name gives it. Both writers have written exactly these keys since #97 landed. The kind of record comes from where it is filed; `LSS_WHY` says what failed. A malformed record takes the unknown schema's path on both sides: - every writer refuses it, exit 1, nothing written (`set-lane-state`, `set-lane-tree` for the snapshot and the tree record, and the lifecycle follow-up of a STARTED/RESUMED/ENDED/RETIRED line); - `lane_state_read` answers 10, `lane-state` exits 10 and says which of the two 10s it is, and `lane-reconcile` reads the state as `MALFORMED`, verdict `indeterminate`; - `lane-trees` carries a tree record as a row named by its file, every field empty, schema ``; `lane-reconcile` reports it `malformed-sidecar`, counted toward recovery; `worktrees` lists it as `malformed sidecar`. `lane_sidecar_put` asks the same of its temporary file before the rename, so a record cut short as it was written is removed rather than renamed into place, and `set-lane-tree` refuses a `--dirty` or `--unpushed` that is not a count with 64, so no writer files a record its own readers refuse. No lock line moves: the four `if ! lane_sidecar_schema_ok` lines, every `acquire_lock`/`lock_try`/`release_lock` and the lock blocks are byte-identical; only the refusal messages beside them change. Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 188 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 173 insertions(+), 15 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index b7232eca..6ab1023d 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -398,9 +398,11 @@ # THE TWO WRITERS REFUSE A SIDECAR THEY CANNOT READ. A snapshot or a tree # record carrying a schema this helper does not write is never replaced — a # reader that fails closed and a writer that walks past it protect nothing — -# and `set-lane-tree`'s `--generation`/`--operation` are COMPARED with the -# lane's own snapshot under the mutex before the record is filed, so a poll -# from an operation a recovery has superseded cannot be filed over the +# and neither is one of THIS schema that is not a whole record: a key its +# writer writes missing or out of shape, or an id that is not its file's +# (#115). `set-lane-tree`'s `--generation`/`--operation` are COMPARED with +# the lane's own snapshot under the mutex before the record is filed, so a +# poll from an operation a recovery has superseded cannot be filed over the # current one. # # THE SEAM — A MANAGED-OWNED LANE IS NOT THIS TOOLING'S (Brett Heap's ruling of @@ -523,7 +525,10 @@ # takeover; each PR had taken 9 as unused, and the later one moved. # `lane-trees` spends it for an inventory directory that EXISTS and # cannot be listed, which is never "this lane recorded no tree" (#157 -# item 4e). +# item 4e). And `lane-state` spends it for a snapshot of this schema that +# opens and is not a WHOLE record — cut short, or a key missing or out of +# shape (#115): nothing can be read out of it as a snapshot, so it is +# the same answer, and the message says which of the two it is. # # --no-sweep (DEFAULT, added 2026-09-09 after 0d84d34/a1f2438 swept another # lane's uncommitted hand edit into an unrelated commit): every mutating @@ -12310,10 +12315,82 @@ lane_sidecar_field() { # # false for one, so this guard used to wave every writer through to rename its # own file over a record `lane_state_read` itself calls present-and-unreadable. # `[ -L ]` beside `[ -e ]`, exactly as the reader asks it. -lane_sidecar_schema_ok() { # +# +# AND THE RECORD, NOT ONLY ITS VERSION LINE (opensoft/openRepoTools#115). This +# asked one question, so a file whose first line is `schema: 1` and which holds +# nothing else passed it — a record of the current version that is not valid at +# that version — and both sides took it for one: the readers emitted empty +# fields for it and the writers renamed their own file over it, the act this +# guard exists to refuse. A file cut short is not exotic: `lane_sidecar_put`'s +# `cat` can be, and the rename makes a COMPLETE file atomic, not an incomplete +# one impossible. So a record of the current schema passes only when it is +# WHOLE: every key its writer writes is there and is not empty, the keys a +# reader decides with have their shapes, and a tree record claims the id its +# file name gives it. Both writers have written exactly these keys since #97 +# landed (`lane_state_put`, `lane_tree_put`), so no record a released helper +# wrote fails it. A malformed record of the KNOWN schema takes the refusal an +# unknown schema takes: every writer refuses it (1), the snapshot's reader +# answers 10, and the inventory's names it ``. +# +# WHICH KIND OF RECORD IT IS COMES FROM WHERE IT IS FILED — `/lane-state.yaml` +# for the snapshot, `/trees/.yaml` for a tree — and the second argument +# names that place for a file that is not there yet (`lane_sidecar_put`'s +# temporary file). `LSS_WHY` says why a record failed, in words a caller puts +# in its own message; it is set in the caller's shell, so call this directly +# and never inside `$( )`. The awk reads the id through the ENVIRONMENT, because +# `awk -v` processes escapes in a value. +lane_sidecar_schema_ok() { # [] + LSS_WHY="" [ -e "${1-}" ] || [ -L "${1-}" ] || return 0 + if [ ! -r "$1" ]; then + LSS_WHY="it is there and could not be read" + return 1 + fi lss_v="$(lane_sidecar_field "$1" schema 2>/dev/null || :)" - [ "$lss_v" = "$LANE_STATE_SCHEMA" ] + if [ "$lss_v" != "$LANE_STATE_SCHEMA" ]; then + LSS_WHY="it is of a schema this helper does not write (it writes schema $LANE_STATE_SCHEMA)" + return 1 + fi + lss_as="${2:-$1}"; lss_kind=""; lss_id="" + case "$lss_as" in + */trees/*.yaml) lss_kind=tree; lss_id="${lss_as##*/}"; lss_id="${lss_id%.yaml}" ;; + */lane-state.yaml) lss_kind=snapshot ;; + esac + [ -n "$lss_kind" ] || return 0 + lss_rc=0 + LSS_WHY="$(LSS_KIND="$lss_kind" LSS_ID="$lss_id" awk ' + { i = index($0, ": ") + if (i > 1) { k = substr($0, 1, i - 1); v = substr($0, i + 2) + sub(/^[ \t]+/, "", v); sub(/[ \t]+$/, "", v) + if (!(k in val)) val[k] = v } } + END { + kind = ENVIRON["LSS_KIND"] + if (kind == "snapshot") + n = split("lane state generation operation owner agent profile workstation kind updated", req, " ") + else + n = split("lane tree path checkout branch head upstream dirty unpushed writer generation operation observed", req, " ") + miss = "" + for (j = 1; j <= n; j++) + if (!(req[j] in val) || val[req[j]] == "") miss = miss (miss == "" ? "" : ", ") req[j] + if (miss != "") { print "it is not a whole record: it carries no " miss; exit } + if (val["generation"] !~ /^[0-9]+$/) { print "its generation \"" val["generation"] "\" is not a number"; exit } + if (kind == "snapshot") { + if (val["state"] !~ /^(RUNNING|SWAPPING|SWAPPED|CLOSED)$/) { + print "its state \"" val["state"] "\" is none of RUNNING, SWAPPING, SWAPPED and CLOSED"; exit } + } else { + if (val["tree"] != ENVIRON["LSS_ID"]) { + print "it claims to be tree " val["tree"] " and is filed as " ENVIRON["LSS_ID"]; exit } + if (substr(val["path"], 1, 1) != "/") { print "its path \"" val["path"] "\" is not absolute"; exit } + if (val["dirty"] !~ /^[0-9]+$/) { print "its dirty count \"" val["dirty"] "\" is not a number"; exit } + if (val["unpushed"] !~ /^([0-9]+|unknown)$/) { + print "its unpushed count \"" val["unpushed"] "\" is neither a number nor unknown"; exit } + } + }' "$1" 2>/dev/null)" || lss_rc=$? + if [ "$lss_rc" != 0 ]; then + LSS_WHY="it could not be read through (awk exited $lss_rc)" + return 1 + fi + [ -z "$LSS_WHY" ] } # THE SNAPSHOT AS IT STOOD, IN ONE STRING — the pre-image a fenced write @@ -12341,12 +12418,24 @@ lane_sidecar_value() { # # THE ATOMIC REPLACE. Written beside the target and renamed over it, so a # reader never sees half a snapshot and a full disk leaves the old one intact. +# +# AND ONLY A WHOLE RECORD IS RENAMED INTO PLACE (opensoft/openRepoTools#115). +# The rename makes a complete file atomic and nothing more: a `cat` whose input +# was cut short — the writer killed between two of its lines — still exits 0, +# and the half record it wrote was renamed over the whole one. The temporary +# file is asked the question every reader and writer of the record asks, under +# the name it is about to take, and a record that fails it is removed instead. lane_sidecar_put() { # ; the whole body on stdin lsp_f="${1-}"; lsp_d="${lsp_f%/*}" [ -n "$lsp_f" ] || return 1 mkdir -p -- "$lsp_d" 2>/dev/null || return 1 lsp_t="$lsp_f.tmp.$$" cat > "$lsp_t" 2>/dev/null || { rm -f -- "$lsp_t" 2>/dev/null; return 1; } + if ! lane_sidecar_schema_ok "$lsp_t" "$lsp_f"; then + rm -f -- "$lsp_t" 2>/dev/null + note "the record for $lsp_f was NOT written: what was about to replace it is not a whole record ($LSS_WHY), and a half record is never renamed over a whole one." + return 1 + fi mv -- "$lsp_t" "$lsp_f" 2>/dev/null || { rm -f -- "$lsp_t" 2>/dev/null; return 1; } return 0 } @@ -12364,7 +12453,8 @@ lane_op_id() { # THE SNAPSHOT, READ. `` lines, which is what every caller # here parses with one `awk`. 0 with the fields, 8 where the lane has no # snapshot at all (the pre-cutover lane, and not a failure), 10 where a snapshot -# IS there and could not be read, 1 where the control root could not be derived. +# IS there and could not be read — or is of this schema and not a whole record +# (#115) — 1 where the control root could not be derived. # # THE 10 IS THE POINT OF THIS ROUND (Copilot round 6 on openRepoTools#97, where # it was 9 — main's #61 has since spent 9 on `claim`, so this one moved). A bare @@ -12394,8 +12484,13 @@ lane_state_read() { # # files takes too. A newer tooling's snapshot read by an older reader must not # be reported as a lane in a state this reader knows — and must not be # replaced by one either, which is what `lane_sidecar_schema_ok` is for. + # A RECORD OF THIS SCHEMA THAT IS NOT A WHOLE ONE is 10 (#115): it is there + # and nothing can be read out of it as a snapshot, which is what 10 says. Its + # fields used to come out EMPTY, and an empty state reached the verdict table + # as a word nobody wrote. if ! lane_sidecar_schema_ok "$lsr_f"; then lsr_schema="$(lane_sidecar_field "$lsr_f" schema 2>/dev/null || :)" + [ "$lsr_schema" != "$LANE_STATE_SCHEMA" ] || return 10 printf 'state\tUNKNOWN-SCHEMA\n'; printf 'schema\t%s\n' "${lsr_schema:-}" printf 'file\t%s\n' "$lsr_f"; return 0 fi @@ -12512,7 +12607,7 @@ lane_state_follow() { # # fields this reader was guessing at. Its id, its path and its schema are # printed — the three a person needs to find it — and every other field is # EMPTY, which `lane_reconcile` reports as `unknown-schema` rather than -# recomputing against. +# recomputing against. A record of THIS schema that is not a whole one is the +# same row with the schema `` (#115), and `lane_reconcile` reports +# it as `malformed-sidecar`. lane_trees_list() { # ltl_root=""; ltl_rc=0; ltl_n=0 ltl_root="$(lane_control_root "${1-}")" || ltl_rc=$? @@ -12715,6 +12812,19 @@ lane_trees_list() { # printf '%s\n' "$ltl_id$US$ltl_p$US$US$US$US$US$US$US$US$US$US$US${ltl_s:-}" continue fi + # AND A RECORD OF THIS SCHEMA THAT IS NOT A WHOLE ONE IS SAID, NEVER READ + # (#115). A truncated `schema: 1` file came out as a row of empty fields — + # an EMPTY ID, which `lane_reconcile` skipped outright, and an empty dirty + # and unpushed, which it read as 0 and 0: a vanished tree that held work + # reported `missing`, clean and published. Its id is its FILE NAME, because + # the one it claims is not to be trusted, the path it records is kept so + # the tree is still named once, and every other field is empty under the + # schema ``. + if ! lane_sidecar_schema_ok "$ltl_f"; then + ltl_id="${ltl_f##*/}"; ltl_id="${ltl_id%.yaml}" + printf '%s\n' "$ltl_id$US$ltl_p$US$US$US$US$US$US$US$US$US$US$US" + continue + fi printf '%s\n' "$ltl_id$US$ltl_p\ $US$(lane_sidecar_field "$ltl_f" branch)\ $US$(lane_sidecar_field "$ltl_f" head)\ @@ -12903,6 +13013,15 @@ lane_reconcile() { # # pronouncement, the one class this capability exists to close. A read # nobody got is `indeterminate` below, exactly as an unreadable HOLDER is. lrc_state=UNREADABLE + # AND ONE THAT OPENS AND IS NOT A WHOLE RECORD IS SAID AS SUCH (#115). It + # used to come out as fields that were EMPTY, and its empty state word was + # `unknown-state` by the `*)` arm below — failing safe by accident rather + # than by a check. The 10 is the same; the word says which of the two. + lrc_swhy="" + if [ "$lrc_srrc" = 10 ] && [ -r "$lrc_root/lane-state.yaml" ] \ + && ! lane_sidecar_schema_ok "$lrc_root/lane-state.yaml"; then + lrc_state=MALFORMED; lrc_swhy="$LSS_WHY" + fi fi printf 'ROOT%s%s\n' "$US" "$lrc_root" printf 'STATE%s%s%sgeneration %s%soperation %s%sowner %s%supdated %s\n' \ @@ -12997,6 +13116,17 @@ LRC_BIND lrc_recover=$((lrc_recover + 1)) continue fi + # A RECORD OF THIS SCHEMA THAT IS NOT A WHOLE ONE (#115) is reported as the + # unreadable one is: nothing is read out of it, it counts toward recovery, + # and the line says what is missing from it and names the file. + if [ "${lrc_sch:-}" = "" ]; then + lrc_mwhy="the record could not be read again to say why" + if lane_sidecar_schema_ok "$lrc_root/trees/$lrc_id.yaml"; then :; else lrc_mwhy="$LSS_WHY"; fi + printf 'TREE%s%s%smalformed-sidecar%s%s%sits sidecar is of this schema and %s, so the tree it records — where it is, and whether it held uncommitted or unpublished work — is NOT known, nothing is assumed about it, and no writer replaces it; read %s/trees/%s.yaml by hand before relaunching a writer\n' \ + "$US" "$lrc_id" "$US" "$US" "${lrc_p:-}" "$US" "$lrc_mwhy" "$lrc_root" "$lrc_id" + lrc_recover=$((lrc_recover + 1)) + continue + fi if [ "${lrc_sch:-}" != "$LANE_STATE_SCHEMA" ]; then printf 'TREE%s%s%sunknown-schema%s%s%sits sidecar records schema %s and this reader writes %s, so none of its fields is read and nothing is compared against them; the tree itself is untouched\n' \ "$US" "$lrc_id" "$US" "$US" "${lrc_p:-}" "$US" "${lrc_sch:-}" "$LANE_STATE_SCHEMA" @@ -13145,6 +13275,7 @@ EOF CLOSED8) lrc_v=closed; lrc_why="the lane is closed" ;; CLOSED0) lrc_v=inconsistent; lrc_why="the lane is recorded CLOSED and a holder is LIVE" ;; UNREADABLE*) lrc_v=indeterminate; lrc_why="this lane's lifecycle snapshot at $lrc_root/lane-state.yaml IS THERE and could not be read, so where its session stopped is NOT established — which is not the same as a lane that has none, and is no clearance to relaunch anything (R22, Amendment 7(d): a read that failed is never an answer). Read that file by hand; if you know what wrote it, move it aside and this lane's next transition writes a fresh one" ;; + MALFORMED*) lrc_v=indeterminate; lrc_why="this lane's lifecycle snapshot at $lrc_root/lane-state.yaml is of this schema and ${lrc_swhy:-is not a whole record} — truncated, or edited by hand — so where its session stopped is NOT established, and no writer replaces it, exactly as none replaces a schema it does not write (#115). Read that file by hand; if you know what wrote it, move it aside and this lane's next transition writes a fresh one" ;; NONE*) lrc_v=no-state; lrc_why="this lane has no lifecycle snapshot: it has not started or handed off under this capability, so its crash kind cannot be told from its record (Amendment 7(i)'s cutover rule — nothing is backfilled)" ;; *) lrc_v=unknown-state; lrc_why="the snapshot holds the state word '$lrc_state', which this reader does not know; nothing is assumed about it" ;; esac @@ -13309,6 +13440,9 @@ worktree_rows() { # [] if [ "${t_s:-}" = "" ]; then t_branch="unreadable sidecar"; [ -n "${t_p:-}" ] || t_p="$t_id" t_d=""; t_n=""; t_g=""; t_ob="" + elif [ "${t_s:-}" = "" ]; then + t_branch="malformed sidecar"; [ -n "${t_p:-}" ] || t_p="$t_id" + t_d=""; t_n=""; t_g=""; t_ob="" elif [ "${t_s:-}" != "$LANE_STATE_SCHEMA" ]; then t_branch="unknown schema ${t_s:-}"; [ -n "${t_p:-}" ] || t_p="$t_id" t_d=""; t_n=""; t_g=""; t_ob="" @@ -16740,7 +16874,10 @@ EOF # a launch exactly as those do. # # 0 done - # 1 no control root could be derived, or it could not be written + # 1 no control root could be derived, or it could not be written; or a + # WRITER met a record there it cannot read — of a schema it does not + # write, or of its own and not a whole record (#115) — and replaced + # nothing; or `lane-tree-now` met a git read that failed # 2 a refusal of the arguments # 7 THE FENCE DID NOT MATCH — another act got there first, which is what # this file already spends 7 on (`claim`'s CLAIM-LOST). Nothing was @@ -16750,7 +16887,11 @@ EOF # round 6 on #97). 8 tells a launcher *this lane was never migrated, go # on*; 10 tells it *this lane may be mid-crash and nobody could look*. # (It was 9 until main's #61 spent 9 on `claim`'s abandoned takeover.) - # 64 a usage error of this subcommand's own + # A snapshot of this schema that is not a whole record is 10 as well + # (#115): nothing can be read out of it as a snapshot. + # 64 a usage error of this subcommand's own — for `set-lane-tree`, a + # partial observation, or a `--dirty`/`--unpushed` that is not a count + # (`unknown` is one for `--unpushed`) lane-state) lane="${1-}"; [ -n "$lane" ] || die "usage: lane-state " 64 @@ -16763,7 +16904,13 @@ EOF case "$lst_rc" in 0) : ;; 8) exit 8 ;; - 10) die "lane $lane HAS a lifecycle snapshot at its control root and it could not be read. That is NOT 'this lane has no snapshot' — 8 says that, and a launcher answers an 8 by going on, which over an unreadable record would be a launch made in ignorance of a crash nobody could look at (R22, Amendment 7(d)). Read it by hand: $(lane_control_root "$lane" 2>/dev/null || printf '')/lane-state.yaml" 10 ;; + 10) + # WHICH OF THE TWO 10s (#115): a record that opens and is not a whole + # one of this schema is said as that, for the person who reads it. + lst_f="$(lane_control_root "$lane" 2>/dev/null || printf '')/lane-state.yaml" + lst_what="it could not be read" + if [ -r "$lst_f" ] && ! lane_sidecar_schema_ok "$lst_f"; then lst_what="it is of this schema and $LSS_WHY, so nothing can be read out of it as a snapshot"; fi + die "lane $lane HAS a lifecycle snapshot at its control root and $lst_what. That is NOT 'this lane has no snapshot' — 8 says that, and a launcher answers an 8 by going on, which over an unreadable record would be a launch made in ignorance of a crash nobody could look at (R22, Amendment 7(d)). Read it by hand: $lst_f" 10 ;; *) die "lane $lane has no lifecycle control root: its record names no directory (Amendment 11(c)) and \$PROJECTS_ROOT is not a directory here. That is NOT 'this lane is in no state' — a read that could not be made is never an answer (Amendment 7(d))." 1 ;; esac printf '%s\n' "$lst_out" @@ -16826,7 +16973,7 @@ EOF if ! lane_sidecar_schema_ok "$sls_root/lane-state.yaml"; then sls_sch="$(lane_sidecar_field "$sls_root/lane-state.yaml" schema 2>/dev/null || :)" release_lock - die "lane $lane's lifecycle snapshot at $sls_root/lane-state.yaml records schema ${sls_sch:-} and this helper writes schema $LANE_STATE_SCHEMA. NOTHING was read out of it and nothing was written over it: a record this helper cannot read is one it cannot safely replace. Upgrade this workstation's lanes-edit.sh; if you know what wrote that file, move it aside by hand and re-run." 1 + die "lane $lane's lifecycle snapshot at $sls_root/lane-state.yaml records schema ${sls_sch:-} and is not a record this helper can read: $LSS_WHY. NOTHING was read out of it and nothing was written over it: a record this helper cannot read is one it cannot safely replace, whether its schema is one this helper does not write or its own and the record is not whole (#115). Upgrade this workstation's lanes-edit.sh if a newer tooling wrote it; if you know what wrote that file, move it aside by hand and re-run." 1 fi sls_now=""; sls_nowg=""; sls_nowo="" if [ -r "$sls_root/lane-state.yaml" ]; then @@ -16963,6 +17110,17 @@ EOF elif [ -z "$slt_b" ] || [ -z "$slt_h" ] || [ -z "$slt_u" ] || [ -z "$slt_d" ] || [ -z "$slt_n" ]; then die "set-lane-tree takes the caller's observation WHOLE — --branch, --head, --upstream, --dirty and --unpushed together — or none of them, when the tree is read here. A partial one would be filed with values nobody observed, and 'dirty 0, unpushed 0' is the record a later reconciliation reads as clean and published. Nothing was written." 64 fi + # AND IN THE SHAPES A RECORD IS READ BACK IN (#115). The two counts are what + # a reconciliation decides with, and a record carrying one that is neither a + # count nor `unknown` is one no reader may use and no writer may replace — + # so it is refused here, before anything is written, rather than filed. + case "$slt_d" in + ''|*[!0-9]*) die "set-lane-tree's --dirty takes a count of the tree's dirty paths, and '$slt_d' is not one: a record carrying it could be neither read nor replaced. Nothing was written." 64 ;; + esac + case "$slt_n" in + unknown) : ;; + ''|*[!0-9]*) die "set-lane-tree's --unpushed takes a count of commits, or 'unknown' where the upstream is configured and its remote-tracking ref is not here; '$slt_n' is neither, and a record carrying it could be neither read nor replaced. Nothing was written." 64 ;; + esac # THE FENCE, AND IT IS THE LANE'S OWN (Copilot round 5 on #97). A caller # that names the generation and the operation it is recording under is # saying *this observation belongs to that transition* — and until this @@ -16995,7 +17153,7 @@ EOF if [ -n "$slt_g" ] || [ -n "$slt_op" ]; then if ! lane_sidecar_schema_ok "$slt_root/lane-state.yaml"; then release_lock - die "the worktree $slt_path was NOT recorded for lane $lane: its lifecycle snapshot records a schema this helper does not write, so the generation and operation this observation names cannot be compared with anything. Nothing was written. Upgrade this workstation's lanes-edit.sh." 1 + die "the worktree $slt_path was NOT recorded for lane $lane: its lifecycle snapshot is not a record this helper can read ($LSS_WHY), so the generation and operation this observation names cannot be compared with anything. Nothing was written. Upgrade this workstation's lanes-edit.sh if a newer tooling wrote it, or read the snapshot by hand." 1 fi slt_ng=""; slt_no="" if [ -r "$slt_root/lane-state.yaml" ]; then @@ -17015,7 +17173,7 @@ EOF # this helper cannot read, so it is not one this helper may replace. if ! lane_sidecar_schema_ok "$slt_root/trees/$(tree_id_for "$slt_path").yaml"; then release_lock - die "the worktree $slt_path was NOT recorded for lane $lane: its sidecar under $slt_root/trees records a schema this helper does not write, and a record it cannot read is one it cannot safely replace. Nothing was written. Upgrade this workstation's lanes-edit.sh." 1 + die "the worktree $slt_path was NOT recorded for lane $lane: its sidecar under $slt_root/trees is not a record this helper can read ($LSS_WHY), and a record it cannot read is one it cannot safely replace. Nothing was written. Upgrade this workstation's lanes-edit.sh if a newer tooling wrote it; if you know what wrote that file, move it aside by hand and re-run." 1 fi if lane_tree_put "$slt_root" "$lane" "$slt_path" "$slt_co" "$slt_b" "$slt_h" \ "$slt_u" "$slt_d" "$slt_n" "$slt_w" "$slt_g" "$slt_op"; then From 97be67adb6771bd4c0137356ff3437d19ba62ab0 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 14:19:47 +0000 Subject: [PATCH 02/11] set-lane-tree's fence refuses a poll filed under an operation the lane has finalized (#114) `SWAPPING -> SWAPPED` keeps the generation and the operation by design, so the fence's two comparisons accepted a poll from operation O after O's own finalizer had run, and the delayed reading replaced the inventory the commit point closed. Every other transition advances the generation, so `SWAPPED` is the one state in which a matching pair names a finished operation: a fenced `set-lane-tree` now reads the lane's state with the pair, under the same mutex, and is refused with 7 where it is `SWAPPED`. #114 also offered "require SWAPPING". That would refuse a write the suite makes on purpose (repoRC-5, section 11(c): a lane RUNNING after a resume records under its current generation and operation), so the narrower rule is the one taken. An observation filed under no transition is still an unfenced `set-lane-tree`. #114's second item, the partial observation, has been a 64 since 8.12. Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 6ab1023d..a6d8990f 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -402,8 +402,8 @@ # writer writes missing or out of shape, or an id that is not its file's # (#115). `set-lane-tree`'s `--generation`/`--operation` are COMPARED with # the lane's own snapshot under the mutex before the record is filed, so a -# poll from an operation a recovery has superseded cannot be filed over the -# current one. +# poll from an operation a recovery has superseded, or from one already +# finalized (`SWAPPED`, #114), cannot be filed over the current one. # # THE SEAM — A MANAGED-OWNED LANE IS NOT THIS TOOLING'S (Brett Heap's ruling of # 2026-10-04, verbatim: "managed ledger owns enrolled lanes; #97 owns legacy — @@ -495,9 +495,10 @@ # generation or the operation id moved under this process — so a stale # finalizer cannot overwrite a newer owner (openRepoTools#91). # `set-lane-tree`: the `--generation`/`--operation` this observation was -# taken under is no longer the lane's, so a superseded poll cannot be filed -# over the current inventory. ONE MEANING ON THE NUMBER, in three places: -# you lost the race. +# taken under is no longer the lane's — or the lane is `SWAPPED` and the +# operation it names is finished (#114) — so a superseded poll cannot be +# filed over the current inventory. ONE MEANING ON THE NUMBER, in three +# places: you lost the race. # 8 no record — `who` found nothing; `lane-objects` has no log file for the # lane; `live-holder` READ this workstation's session records and none of # them holds it; `swapped` found no lane swapped on the workstation; @@ -16881,7 +16882,9 @@ EOF # 2 a refusal of the arguments # 7 THE FENCE DID NOT MATCH — another act got there first, which is what # this file already spends 7 on (`claim`'s CLAIM-LOST). Nothing was - # written and the current state is printed. + # written and the current state is printed. `set-lane-tree` spends it + # too where the lane is `SWAPPED`: the operation its fence names is + # finished (#114). # 8 there is no such record (no snapshot, no tree) # 10 THE RECORD IS THERE AND COULD NOT BE READ, which is never 8 (Copilot # round 6 on #97). 8 tells a launcher *this lane was never migrated, go @@ -17155,17 +17158,29 @@ EOF release_lock die "the worktree $slt_path was NOT recorded for lane $lane: its lifecycle snapshot is not a record this helper can read ($LSS_WHY), so the generation and operation this observation names cannot be compared with anything. Nothing was written. Upgrade this workstation's lanes-edit.sh if a newer tooling wrote it, or read the snapshot by hand." 1 fi - slt_ng=""; slt_no="" + slt_ng=""; slt_no=""; slt_ns="" if [ -r "$slt_root/lane-state.yaml" ]; then + slt_ns="$(lane_sidecar_field "$slt_root/lane-state.yaml" state)" slt_ng="$(lane_sidecar_field "$slt_root/lane-state.yaml" generation)" slt_no="$(lane_sidecar_field "$slt_root/lane-state.yaml" operation)" fi slt_fence="" [ -z "$slt_g" ] || [ "$slt_g" = "${slt_ng:-0}" ] || slt_fence="generation is ${slt_ng:-0} and --generation named $slt_g" [ -z "$slt_op" ] || [ "$slt_op" = "${slt_no:-none}" ] || slt_fence="${slt_fence:+$slt_fence; }operation is ${slt_no:-none} and --operation named $slt_op" + # AND THE OPERATION MUST STILL BE OPEN (opensoft/openRepoTools#114 item 1). + # `SWAPPING -> SWAPPED` keeps the generation AND the operation by design — + # it is the same operation reaching its commit point, and a fence that + # moved under it would refuse the very finalizer entitled to write — so the + # two values alone accepted a poll from that operation AFTER it was + # finalized: a reading taken before the commit point, filed over the + # inventory the commit point closed, and nothing downstream could tell. Every + # other transition advances the generation, so `SWAPPED` is the one state in + # which a matching pair names a FINISHED operation. An observation of the + # lane's own, under no transition, is an unfenced `set-lane-tree`. + [ "$slt_ns" != SWAPPED ] || slt_fence="${slt_fence:+$slt_fence; }the lane is SWAPPED: operation ${slt_no:-none} reached its commit point and its inventory is closed" if [ -n "$slt_fence" ]; then release_lock - die "the worktree $slt_path was NOT recorded for lane $lane: $slt_fence. The lane moved while this observation was being made — a recovery advanced it, or a second handoff did — so filing this poll now would put a superseded reading where the current one belongs, and nothing downstream could tell. Nothing was written and the tree itself was not touched. Re-read the lane (lanes-edit.sh lane-reconcile $lane) before recording anything under this operation." 7 + die "the worktree $slt_path was NOT recorded for lane $lane: $slt_fence. The lane moved while this observation was being made — a recovery advanced it, a second handoff did, or the operation it names was finalized — so filing this poll now would put a superseded reading where the current one belongs, and nothing downstream could tell. Nothing was written and the tree itself was not touched. Re-read the lane (lanes-edit.sh lane-reconcile $lane) before recording anything under this operation; an observation filed under no transition is set-lane-tree without --generation and --operation." 7 fi fi # AND THE SIDECAR THAT IS THERE IS NOT WALKED OVER EITHER, for the same From b389d0c9ab6c01152ad703455b903744c616b5e9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 14:19:47 +0000 Subject: [PATCH 03/11] lane-reconcile keeps the status of every read it rests on, and a failed one is never an empty one (#113) - The holder ids: `lane_session_ids_read` unions the published row's and this checkout's session ids and answers 1 where either copy could not be read (an empty published register, or a register file that is there and cannot be opened). That is `HOLDER unknown` and `indeterminate`, never an empty set that `live_holder` answers 8 for and the verdict table reads as a crash. - `branch..remote` in `lane_tree_now` gets the `case` its `merge` sibling has: a read that failed is 3 (`lane-tree-now` 1, `set-lane-tree` refuses), never the fabricated local upstream `./`. - `git worktree list --porcelain` in the lane's checkout keeps its status. A failure makes the verdict `indeterminate` and names it; a recorded directory that is NO repository registers nothing and stays an absence (`lane_no_repository`, `lane-worktrees`' `no_repository`, #170 G5). - `lane-trees`' 10 for an inventory directory that cannot be listed, which this read still swallowed after 8.12 and #157 item 4e, and a lane worktree root that cannot be listed or searched, make the verdict `indeterminate` too. #113's second item, the unreadable sidecar, has been its own row since 8.12. Per-record failures stay rows counted toward recovery and leave the verdict as it was, which is the line T018 (#246) draws for an unreadable leg. Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 118 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 110 insertions(+), 8 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index a6d8990f..f538d7ab 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -12955,7 +12955,14 @@ lane_tree_now() { # ltn_cfg="$(git -C "$ltn_p" config --get "branch.$ltn_raw.merge" 2>/dev/null)" || ltn_crc=$? case "$ltn_crc" in 0|1) : ;; *) return 3 ;; esac if [ -n "$ltn_cfg" ]; then - ltn_rem="$(git -C "$ltn_p" config --get "branch.$ltn_raw.remote" 2>/dev/null || :)" + # AND THE REMOTE BESIDE IT IS ASKED THE SAME WAY (opensoft/openRepoTools + # #113 item 3). It ended in `|| :`, so a read that FAILED became an empty + # remote and `${ltn_rem:-.}` then FABRICATED the local upstream + # `./`, which was filed as an observation. Only a remote that is + # NOT CONFIGURED (1) keeps the `.` this read has always written for it. + ltn_rrc=0 + ltn_rem="$(git -C "$ltn_p" config --get "branch.$ltn_raw.remote" 2>/dev/null)" || ltn_rrc=$? + case "$ltn_rrc" in 0|1) : ;; *) return 3 ;; esac ltn_u="${ltn_rem:-.}/${ltn_cfg#refs/heads/}" ltn_n=unknown fi @@ -12965,6 +12972,51 @@ lane_tree_now() { # return 0 } +# THE SESSIONS A LANE'S ROW RECORDS, OR A READ THAT FAILED (opensoft/openRepoTools +# #113 item 1). The published row and THIS checkout's, one id a line, unioned as +# every liveness read here unions them (an id `origin` has not seen yet is one +# more reason a holder may be live). 0 with the ids — none at all is an ANSWER: +# no row, or a row naming no session — and 1 where either copy of the register +# could not be read. Both reads used to end in `|| :`, so a register nobody +# could read came out as an EMPTY id set, `live_holder` answered 8 for it — this +# lane records no session, nothing can be live — and `lane_reconcile` pronounced +# `ungraceful-stop` or `interrupted-swap` on a liveness question nobody answered. +# An EMPTY published register is a failed read, as `lane_is_managed_owned` reads +# it (`register_text` answers empty, not non-zero, where it could not render +# one); this checkout's copy is a failed read where it is there — a dangling +# link included — and awk could not open it. A checkout with no register file +# at all has no row of its own, as `managed_local_hint` reads it. +lane_session_ids_read() { # + lsir_l="${1-}"; lsir_txt=""; lsir_loc=""; lsir_lrc=0 + lsir_txt="$(register_text)" + [ -n "$lsir_txt" ] || return 1 + if [ -e "$LANES_FILE" ] || [ -L "$LANES_FILE" ]; then + lsir_loc="$(row_of_lane_local "$lsir_l" 2>/dev/null)" || lsir_lrc=$? + [ "$lsir_lrc" = 0 ] || return 1 + fi + { lsir_pub="$(printf '%s\n' "$lsir_txt" | awk -v lane="$lsir_l" "$ROW_AWK")" + [ -z "$lsir_pub" ] || row_cell "$lsir_pub" 3 | uuids_in_cell + [ -z "$lsir_loc" ] || row_cell "$lsir_loc" 3 | uuids_in_cell + } | awk 'NF && !seen[$0]++' + return 0 +} + +# NO REPOSITORY AT ALL IS AN ABSENCE, and nothing else is: `lane-worktrees`' +# `no_repository`, the rule #170 G5 settled, so the two readers of one lane's +# checkout cannot disagree about it. A directory with a `.git` of its own IS a +# repository however broken — git answers "not a git repository" for a gitdir +# it cannot read too — and only git's own "not a git repository" for a +# directory with none is the answer "there is nothing registered here". +lane_no_repository() { # + lnr_d="${1-}"; lnr_err="" + if [ -e "$lnr_d/.git" ] || [ -L "$lnr_d/.git" ]; then return 1; fi + if lnr_err="$(git -C "$lnr_d" rev-parse --git-dir 2>&1 >/dev/null lrc_lane="${1-}" # 0. THE SEAM (ruling 2026-10-04): a managed-owned lane's lifecycle is the @@ -13034,14 +13086,22 @@ lane_reconcile() { # # BE READ — which is never "no holder" (R22, Amendment 7(d)). A holder that # could not be established fails CLOSED: the verdict is `indeterminate` and # no crash is pronounced on a read nobody got. - lrc_ids="$( { session_ids_of_lane "$lrc_lane" 2>/dev/null || : - session_ids_local_of_lane "$lrc_lane" 2>/dev/null || :; } | awk 'NF && !seen[$0]++')" + # AND THE SAME DISTINCTION ONE READ EARLIER (#113 item 1): the ids the row + # records. A register nobody could read is not a row naming no session, so it + # never reaches `live_holder` as an empty set, whose answer to one is 8. + lrc_ids=""; lrc_irc=0; lrc_hwhy="" + lrc_ids="$(lane_session_ids_read "$lrc_lane")" || lrc_irc=$? lrc_hold=""; lrc_hrc=0 - lrc_hold="$(live_holder "$lrc_lane" "$lrc_ids" 2>/dev/null)" || lrc_hrc=$? + if [ "$lrc_irc" != 0 ]; then + lrc_hrc=1 + lrc_hwhy="lane $lrc_lane's register row could not be read — the published register, or this checkout's copy of it at $LANES_FILE — so which sessions it records is NOT established, and an id set nobody read is never an empty one" + else + lrc_hold="$(live_holder "$lrc_lane" "$lrc_ids" 2>/dev/null)" || lrc_hrc=$? + fi case "$lrc_hrc" in 0) printf 'HOLDER%slive%s%s\n' "$US" "$US" "$(printf '%s' "$lrc_hold" | tr "$US" ' ')" ;; 8) printf 'HOLDER%snone\n' "$US" ;; - *) printf 'HOLDER%sunknown%s%s\n' "$US" "$US" "${SESSION_FILES_ERR:-the session records of this workstation could not be read}" ;; + *) printf 'HOLDER%sunknown%s%s\n' "$US" "$US" "${lrc_hwhy:-${SESSION_FILES_ERR:-the session records of this workstation could not be read}}" ;; esac # 2b. THE BINDING (Amendment 18(b)): *"Liveness is pronounced only from @@ -13097,6 +13157,19 @@ LRC_BIND lrc_dir="$(lane_payload_field "$lrc_lane" dir 2>/dev/null)" || lrc_drc=$? [ "$lrc_drc" = 0 ] || lrc_dir="" lrc_seen=""; lrc_n=0; lrc_recover=0; lrc_dirty=0 + # AN INVENTORY NOBODY COULD LIST IS NOT AN EMPTY ONE (#113, the unreadable + # sidecar's rule one level up). `lane_trees_list` answers 10 for a `trees` + # directory that is there and cannot be listed (#157 item 4e), and this read + # ended in `|| :`, so every tree the lane recorded vanished from the report and + # its verdict could still be `resumable`. `lrc_unread` gathers what the + # inventory and the sweeps below could not read, and the verdict says so. + lrc_unread=""; lrc_tl=""; lrc_tlrc=0 + lrc_tl="$(lane_trees_list "$lrc_lane" 2>/dev/null)" || lrc_tlrc=$? + case "$lrc_tlrc" in + 0|8) : ;; + 10) lrc_unread="its worktree inventory directory $lrc_root/trees is there and could not be listed" ;; + *) lrc_unread="its worktree inventory could not be read (exit $lrc_tlrc)" ;; + esac while IFS="$US" read -r lrc_id lrc_p lrc_b lrc_h lrc_u lrc_d lrc_np lrc_w lrc_ob lrc_co lrc_tg lrc_to lrc_sch; do [ -n "${lrc_id:-}" ] || continue lrc_n=$((lrc_n + 1)) @@ -13200,7 +13273,7 @@ LRC_BIND "$lrc_nb" "$lrc_nh" "${lrc_nu:-none}" "${lrc_nd:-0}" "${lrc_nn:-0}" \ "${lrc_d:-0}/${lrc_np:-0}" "$lrc_ob" "$lrc_moved" done </dev/null || :) +$lrc_tl EOF # 4. WHAT IS THERE AND IS IN NO SIDECAR — from git's own registrations and @@ -13216,6 +13289,19 @@ EOF # for the reason `lane-start`'s `real_of` gives: `readlink -f` is not in the # stock macOS userland. lrc_dirp="$(lane_real_path "$lrc_dir")" + # THE REGISTRATIONS ARE A READ, AND ITS STATUS IS KEPT (#113 item 4). It + # ended in `|| :`, so a checkout whose registrations could not be read was + # one with none: every stale registration and every unmanaged tree in it + # was omitted, the verdict could still read `resumable`, and the estate's + # own `resume` then failed on the registration the report never named. A + # directory that is NO repository at all registers nothing, and that alone + # is an absence (`lane_no_repository`, `lane-worktrees`' rule). + lrc_wlo=""; lrc_wlrc=0 + lrc_wlo="$(git -C "$lrc_dir" worktree list --porcelain 2>/dev/null /dev/null || :) +$lrc_wlo EOF fi while IFS= read -r lrc_wr; do [ -n "$lrc_wr" ] || continue [ -d "$lrc_wr" ] || continue + # A ROOT THAT CANNOT BE LISTED hides every tree in it — the glob comes back + # as itself and reads as an empty root — so it is a read nobody made (#113 + # item 4, the on-disk half of the same sweep). + if [ ! -r "$lrc_wr" ] || [ ! -x "$lrc_wr" ]; then + lrc_unread="${lrc_unread:+$lrc_unread; }the lane's worktree root $lrc_wr could not be listed" + continue + fi for lrc_c in "$lrc_wr"/*; do [ -d "$lrc_c" ] || continue lrc_cr="$(lane_real_path "$lrc_c")" @@ -13264,7 +13357,8 @@ EOF lrc_v=""; lrc_why="" if [ "$lrc_hrc" != 0 ] && [ "$lrc_hrc" != 8 ]; then lrc_v=indeterminate - lrc_why="this workstation's session records could not be read, so whether a holder is live is NOT established — which is not the same as none" + [ -n "$lrc_hwhy" ] || lrc_hwhy="this workstation's session records could not be read" + lrc_why="$lrc_hwhy, so whether a holder is live is NOT established — which is not the same as none" else case "$lrc_state$lrc_hrc" in RUNNING8) lrc_v=ungraceful-stop; lrc_why="the lane is recorded RUNNING and no verified holder is live: the session stopped before any handoff began, so nothing was polled, refreshed or recorded — inspect every tree below before relaunching a writer" ;; @@ -13298,6 +13392,14 @@ EOF lrc_v=indeterminate lrc_why="lane $lrc_lane's object log could not be read, so where it is bound is NOT established — and liveness may be pronounced only from inside the binding (Amendment 18(b)). A read that failed is never an answer (Amendment 7(d))" ;; esac + # AND NO CLEARANCE OVER PATHS NOBODY READ (#113). The inventory, the + # registrations or a lane root that could not be read leaves trees this + # report never saw — so whatever the snapshot and the holder say, the verdict + # is not one a launcher may go past. + if [ -n "$lrc_unread" ] && [ "$lrc_v" != indeterminate ]; then + lrc_v=indeterminate + lrc_why="not all of lane $lrc_lane's worktrees could be read ($lrc_unread), so trees this report never saw may hold work, and no clearance is pronounced over paths nobody read (R22, Amendment 7(d))" + fi if [ "$lrc_drc" != 0 ] && [ "$lrc_drc" != 8 ] && [ "$lrc_v" != indeterminate ]; then lrc_v=indeterminate lrc_why="lane $lrc_lane's object log could not be read, so its coordinator directory is NOT established and neither the worktrees git registers there nor the trees on disk under its roots were inspected — no clearance is pronounced over paths nobody read (Amendment 7(d))" From 469fc2cc291b4eb60282b4e11b554cda21cbacb5 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 14:19:47 +0000 Subject: [PATCH 04/11] Test #113, #114 and #115 in the lane-start sandbox, every case red at 87a5e9d tests/test_lifecycle_fail_open_113_114_115.py runs the real lane-start and lanes-edit.sh in the sandbox of tests/test_lane_start_claude_current.py, as tests/test_deferred_97_findings_157.py does. A read that failed is injected by a `git` on PATH that passes every call through to the real one except the one FAKE_GIT_FAIL names; the permission fixtures skip under root. Co-Authored-By: Claude Opus 5.5 --- tests/test_lifecycle_fail_open_113_114_115.py | 409 ++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 tests/test_lifecycle_fail_open_113_114_115.py diff --git a/tests/test_lifecycle_fail_open_113_114_115.py b/tests/test_lifecycle_fail_open_113_114_115.py new file mode 100644 index 00000000..c60226ff --- /dev/null +++ b/tests/test_lifecycle_fail_open_113_114_115.py @@ -0,0 +1,409 @@ +# SPDX-License-Identifier: Apache-2.0 +"""opensoft/openRepoTools#113, #114 and #115: the crash-consistent lifecycle's +remaining fail-open reads, `set-lane-tree`'s fence, and a sidecar's validation. +Each case runs the real `lane-start` and `lanes-edit.sh` in the lane-start +sandbox (a bare origin, a register clone, a fake tmux and a fake gh; nothing +reaches the network). A read that FAILED is injected by a `git` on PATH that +passes every call through to the real one except the one a case names. + + * #113 item 1. `lane_reconcile`'s holder ids ended in `|| :`, so this + checkout's register copy that could not be read came out as an id set with + nothing of its own in it, and a crash was pronounced on liveness nobody + established. (The published half is caught one step earlier by the managed + seam, which already reads an empty register as unknown.) + * #113 item 2's remainder. An unreadable sidecar became a row of its own in + 8.12, but `lane_reconcile` still swallowed `lane-trees`' 10 for a `trees` + directory that cannot be listed (#157 item 4e), so the whole inventory + vanished and the verdict could read `resumable`. + * #113 item 3. `git config --get branch..remote` was unguarded, so a read + that failed became the fabricated local upstream `./`. + * #113 item 4. `git worktree list --porcelain` was swallowed, so a checkout + whose registrations could not be read was one with none; a lane root that + cannot be listed was the same silence on disk. + * #114 item 1. `set-lane-tree`'s fence compared the generation and the + operation only, and `SWAPPED` keeps both, so a poll from a FINISHED + operation was filed over the inventory its commit point closed. (Item 2, + the partial observation, is refused with 64 since 8.12.) + * #115. A sidecar passed validation on its `schema:` line alone, so a + truncated `schema: 1` record was read as empty fields AND replaced. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from test_lane_start_claude_current import Sandbox, LANE, pytestmark # noqa: F401 + +US = "\x1f" + +#: A `git` that is the real one, except for the one call FAKE_GIT_FAIL names, +#: which fails the way a read git cannot complete does. The suite's own `git` +#: calls never set it. +FAKE_GIT = """#!/usr/bin/env bash +if [ -n "${FAKE_GIT_FAIL:-}" ]; then + case " $* " in + *" $FAKE_GIT_FAIL "*) + printf 'fatal: injected failure of: git %s\\n' "$*" >&2 + exit 128 ;; + esac +fi +exec "%(real)s" "$@" +""" + + +@pytest.fixture +def box(tmp_path: Path) -> Sandbox: + real_git = shutil.which("git") + assert real_git + box = Sandbox(tmp_path) + shim = box.fakebin / "git" + shim.write_text(FAKE_GIT.replace("%(real)s", real_git), encoding="utf-8") + shim.chmod(0o755) + box.env["LANES_LANE_STATE_ROOT"] = str(tmp_path / "state") + box.resolver() + seeded = box.start("--no-launch") + assert seeded.returncode == 0, seeded.stderr + return box + + +def helper(box: Sandbox, *args: str, **env: str) -> subprocess.CompletedProcess: + full = dict(box.env, LANES_NO_FETCH="1") + full.update(env) + return subprocess.run([str(box.bin / "lanes-edit.sh"), *args], + cwd=box.lane_dir, env=full, text=True, + capture_output=True, timeout=120, check=False) + + +def ok(box: Sandbox, *args: str, **env: str) -> str: + result = helper(box, *args, **env) + assert result.returncode == 0, (args, result.stdout, result.stderr) + return result.stdout + + +def control_root(box: Sandbox) -> Path: + return Path(box.env["LANES_LANE_STATE_ROOT"]) / LANE + + +def snapshot(box: Sandbox) -> Path: + return control_root(box) / "lane-state.yaml" + + +def tabbed(out: str) -> dict: + return dict(line.split("\t", 1) for line in out.splitlines() if "\t" in line) + + +def rows(out: str, tag: str) -> list: + return [line.split(US) for line in out.splitlines() if line.split(US)[0] == tag] + + +def verdict(out: str) -> str: + found = rows(out, "VERDICT") + assert len(found) == 1, out + return found[0][1] + + +def reconcile(box: Sandbox, **env: str) -> str: + return ok(box, "lane-reconcile", LANE, **env) + + +def unprivileged() -> None: + if os.geteuid() == 0: + pytest.skip("a permission fixture needs an unprivileged runner") + + +def swapped(box: Sandbox) -> tuple: + """Take the lane RUNNING -> SWAPPING -> SWAPPED as a handoff does, and + return the generation and operation the swap ran under.""" + took = tabbed(ok(box, "set-lane-state", LANE, "SWAPPING", "--expect", "RUNNING")) + gen, op = took["generation"], took["operation"] + ok(box, "set-lane-state", LANE, "SWAPPED", "--expect", "SWAPPING", + "--expect-generation", gen, "--expect-operation", op) + return gen, op + + +def first_commit(box: Sandbox) -> None: + box.git("-C", str(box.lane_dir), "commit", "-q", "--allow-empty", "-m", "first") + + +# --------------------------------------------------------------- #113 item 1 + +def test_113_1_an_unreadable_register_copy_is_no_holder_nobody_established(box): + # A permission, and not a dangling link: the dispatcher refuses a register + # file that is not there ("registry not found") before any read is made. + unprivileged() + assert tabbed(ok(box, "lane-state", LANE))["state"] == "RUNNING" + before = reconcile(box) + assert rows(before, "HOLDER")[0][1] == "none", before + assert verdict(before) == "ungraceful-stop", before + register = box.wip / "lanes" / "LANES.md" + register.chmod(0) + try: + out = reconcile(box) + finally: + register.chmod(0o644) + holder = rows(out, "HOLDER")[0] + assert holder[1] == "unknown", out + assert "register row could not be read" in holder[2], out + assert verdict(out) == "indeterminate", out + assert "ungraceful-stop" not in out + + +# ------------------------------------------------ #113 item 2 (the remainder) + +@pytest.mark.parametrize("mode", [0o000, 0o300, 0o600], + ids=["no-access", "no-read", "no-search"]) +def test_113_2_an_inventory_nobody_could_list_is_never_a_clearance(box, mode): + unprivileged() + tree = str(box.lane_dir / ".claude" / "worktrees" / "gone") + ok(box, "set-lane-tree", LANE, tree, "--checkout", str(box.lane_dir), + "--branch", "feat/gone", "--head", "1" * 40, "--upstream", "none", + "--dirty", "2", "--unpushed", "1") + swapped(box) + trees = control_root(box) / "trees" + trees.chmod(mode) + try: + out = reconcile(box) + finally: + trees.chmod(0o700) + assert verdict(out) == "indeterminate", out + assert f"{trees} is there and could not be listed" in rows(out, "VERDICT")[0][2] + assert "resumable" not in rows(out, "VERDICT")[0][1] + + +# --------------------------------------------------------------- #113 item 3 + +def test_113_3_a_failed_remote_read_is_never_a_fabricated_local_upstream(box): + first_commit(box) + box.git("-C", str(box.lane_dir), "config", "branch.main.remote", "origin") + box.git("-C", str(box.lane_dir), "config", "branch.main.merge", "refs/heads/main") + fail = {"FAKE_GIT_FAIL": "config --get branch.main.remote"} + # The configured spelling, with no remote-tracking ref here, is an ANSWER. + now = ok(box, "lane-tree-now", str(box.lane_dir)).rstrip("\n").split(US) + assert (now[2], now[4]) == ("origin/main", "unknown"), now + # A remote read that FAILED is a 1 and prints nothing. + result = helper(box, "lane-tree-now", str(box.lane_dir), **fail) + assert result.returncode == 1, (result.stdout, result.stderr) + assert result.stdout == "" + assert "./main" not in result.stdout + # And nothing is filed for a tree this process could not read. + result = helper(box, "set-lane-tree", LANE, str(box.lane_dir), **fail) + assert result.returncode == 1, (result.stdout, result.stderr) + assert not (control_root(box) / "trees").exists() or \ + not any((control_root(box) / "trees").iterdir()) + + +# --------------------------------------------------------------- #113 item 4 + +def test_113_4_registrations_nobody_could_list_are_never_a_clearance(box): + first_commit(box) + stale = box.lane_dir / ".claude" / "worktrees" / "stale" + box.git("-C", str(box.lane_dir), "worktree", "add", "-q", "-b", "feat/stale", str(stale)) + shutil.rmtree(stale) + swapped(box) + readable = reconcile(box) + assert [r[2] for r in rows(readable, "TREE")] == ["stale-registration"], readable + out = reconcile(box, FAKE_GIT_FAIL="worktree list --porcelain") + assert verdict(out) == "indeterminate", out + why = rows(out, "VERDICT")[0][2] + assert f"the worktrees git registers in {box.lane_dir} could not be listed" in why, why + assert "git worktree list exited 128" in why + # A recorded directory that is NO repository registers nothing, and that + # alone is an absence: the lane is resumable over it, as `lane-worktrees` + # reads it (#170 G5). + shutil.rmtree(box.lane_dir / ".git") + out = reconcile(box, FAKE_GIT_FAIL="worktree list --porcelain") + assert verdict(out) == "resumable", out + + +@pytest.mark.parametrize("mode", [0o000, 0o300, 0o600], + ids=["no-access", "no-read", "no-search"]) +def test_113_4_a_lane_root_nobody_could_list_is_never_a_clearance(box, mode): + unprivileged() + swapped(box) + # A checkout git registers nowhere, which only the on-disk sweep finds. + root = box.lane_dir / ".claude" / "worktrees" + box.git("init", "-q", "-b", "main", str(root / "hidden")) + readable = reconcile(box) + assert [(r[2], r[3]) for r in rows(readable, "TREE")] == \ + [("unmanaged", str(root / "hidden"))], readable + root.chmod(mode) + try: + out = reconcile(box) + finally: + root.chmod(0o755) + assert verdict(out) == "indeterminate", out + assert f"the lane's worktree root {root} could not be listed" in rows(out, "VERDICT")[0][2] + + +# --------------------------------------------------------------- #114 item 1 + +@pytest.mark.parametrize("fence", ["generation-and-operation", "generation-only"]) +def test_114_1_a_poll_from_a_finished_operation_is_refused(box, fence): + took = tabbed(ok(box, "set-lane-state", LANE, "SWAPPING", "--expect", "RUNNING")) + gen, op = took["generation"], took["operation"] + named = ["--generation", gen] + (["--operation", op] if fence != "generation-only" else []) + tree = str(box.lane_dir / ".claude" / "worktrees" / "w1") + observed = ["--checkout", str(box.lane_dir), "--branch", "feat/w1", "--head", "a" * 40, + "--upstream", "origin/feat/w1"] + # The operation in flight records its poll. + ok(box, "set-lane-tree", LANE, tree, *observed, "--dirty", "3", "--unpushed", "2", *named) + ok(box, "set-lane-state", LANE, "SWAPPED", "--expect", "SWAPPING", + "--expect-generation", gen, "--expect-operation", op) + sidecar = next((control_root(box) / "trees").iterdir()) + finished = sidecar.read_bytes() + # A poll of the same operation, delayed past its own finalizer. + result = helper(box, "set-lane-tree", LANE, tree, *observed, "--dirty", "0", + "--unpushed", "0", *named) + assert result.returncode == 7, (result.stdout, result.stderr) + assert "the lane is SWAPPED" in result.stderr + assert "superseded reading" in result.stderr + assert sidecar.read_bytes() == finished + # An observation filed under no transition is still the person's to make. + ok(box, "set-lane-tree", LANE, tree, *observed, "--dirty", "0", "--unpushed", "0") + + +# ----------------------------- #114/#115: the shapes a record is read back in + +@pytest.mark.parametrize("flag,value", [("--dirty", "yes"), ("--dirty", "-1"), + ("--unpushed", "some"), ("--unpushed", "2x")]) +def test_114_115_a_count_that_is_not_one_is_refused_before_anything_is_written(box, flag, value): + counts = {"--dirty": "0", "--unpushed": "0", flag: value} + result = helper(box, "set-lane-tree", LANE, str(box.lane_dir / "w"), + "--checkout", str(box.lane_dir), "--branch", "b", "--head", "h", + "--upstream", "none", "--dirty", counts["--dirty"], + "--unpushed", counts["--unpushed"]) + assert result.returncode == 64, (result.stdout, result.stderr) + assert f"'{value}'" in result.stderr + assert helper(box, "lane-trees", LANE).returncode == 8 + + +# --------------------------------------------------------------------- #115 + +WHOLE_SNAPSHOT = ("schema: 1\nlane: {lane}\nstate: SWAPPING\ngeneration: 4\n" + "operation: op-x\nowner: none\nagent: claude\nprofile: none\n" + "workstation: Eagle\nkind: clear\nupdated: 2026-10-18T00:00:00Z\n") + +SNAPSHOTS = { + "schema-only": "schema: 1\n", + "cut-after-state": "schema: 1\nlane: {lane}\nstate: SWAPPING\n", + "cut-mid-line": WHOLE_SNAPSHOT.split("generation: 4")[0] + "generation: ", + "no-updated": WHOLE_SNAPSHOT.rsplit("updated:", 1)[0], + "unknown-state-word": WHOLE_SNAPSHOT.replace("SWAPPING", "WONDERLAND"), + "generation-not-a-number": WHOLE_SNAPSHOT.replace("generation: 4", "generation: four"), +} + + +def plant_snapshot(box: Sandbox, body: str) -> bytes: + snapshot(box).write_text(body.replace("{lane}", LANE), encoding="utf-8") + return snapshot(box).read_bytes() + + +@pytest.mark.parametrize("shape", sorted(SNAPSHOTS)) +def test_115_a_snapshot_that_is_not_whole_is_read_as_none_and_replaced_by_none(box, shape): + planted = plant_snapshot(box, SNAPSHOTS[shape]) + # THE READERS: 10, never fields that came out empty. + result = helper(box, "lane-state", LANE) + assert result.returncode == 10, (shape, result.stdout, result.stderr) + assert result.stdout == "" + assert "nothing can be read out of it as a snapshot" in result.stderr + out = reconcile(box) + assert rows(out, "STATE")[0][1] == "MALFORMED", out + assert verdict(out) == "indeterminate", out + assert "#115" in rows(out, "VERDICT")[0][2] + # THE WRITERS: refused, exactly as an unknown schema is, byte for byte. + result = helper(box, "set-lane-state", LANE, "RUNNING") + assert result.returncode == 1, (shape, result.stdout, result.stderr) + assert "is not a record this helper can read" in result.stderr + result = helper(box, "set-lane-tree", LANE, str(box.lane_dir / "w"), + "--checkout", str(box.lane_dir), "--branch", "b", "--head", "h", + "--upstream", "none", "--dirty", "0", "--unpushed", "0", + "--generation", "0") + assert result.returncode == 1, (shape, result.stdout, result.stderr) + assert snapshot(box).read_bytes() == planted + + +def test_115_the_confirming_act_does_not_replace_a_snapshot_that_is_not_whole(box): + planted = plant_snapshot(box, SNAPSHOTS["cut-after-state"]) + resumed = box.start("--no-launch") + assert resumed.returncode == 0, resumed.stderr + assert "is not a record this helper can read" in resumed.stderr + assert "it is not a whole record" in resumed.stderr + assert snapshot(box).read_bytes() == planted + + +def plant_tree(box: Sandbox, how: str) -> tuple: + """Record a vanished tree that held work, then spoil its sidecar `how`.""" + path = str(box.lane_dir / ".claude" / "worktrees" / "lost") + tid = ok(box, "set-lane-tree", LANE, path, "--checkout", str(box.lane_dir), + "--branch", "feat/lost", "--head", "b" * 40, "--upstream", "origin/feat/lost", + "--dirty", "4", "--unpushed", "2").strip() + sidecar = control_root(box) / "trees" / f"{tid}.yaml" + whole = sidecar.read_text(encoding="utf-8") + if how == "cut-after-path": + body = whole.split("checkout:")[0] + elif how == "schema-only": + body = "schema: 1\n" + elif how == "cut-mid-line": + body = whole.split("dirty: 4")[0] + "dirty: " + elif how == "claims-another-id": + body = whole.replace(f"tree: {tid}", "tree: c1-someone-else") + elif how == "dirty-not-a-count": + body = whole.replace("dirty: 4", "dirty: four") + elif how == "unpushed-not-a-count": + body = whole.replace("unpushed: 2", "unpushed: two") + elif how == "path-not-absolute": + body = whole.replace(f"path: {path}", "path: relative/lost") + else: + raise AssertionError(how) + sidecar.write_text(body, encoding="utf-8") + return path, tid, sidecar + + +TREE_SHAPES = ["cut-after-path", "schema-only", "cut-mid-line", "claims-another-id", + "dirty-not-a-count", "unpushed-not-a-count", "path-not-absolute"] + + +@pytest.mark.parametrize("how", TREE_SHAPES) +def test_115_a_tree_record_that_is_not_whole_is_said_and_never_read(box, how): + path, tid, sidecar = plant_tree(box, how) + listed = [line.split(US) for line in ok(box, "lane-trees", LANE).splitlines()] + assert len(listed) == 1, listed + assert listed[0][0] == tid and listed[0][12] == "", listed + assert listed[0][2:12] == [""] * 10, listed + out = reconcile(box) + trees = rows(out, "TREE") + assert [(t[1], t[2]) for t in trees] == [(tid, "malformed-sidecar")], out + assert f"{sidecar}" in trees[0][4] + # Never `missing`, the clean-and-published word, for a tree that held work. + assert "missing" not in [t[2] for t in trees] + assert rows(out, "TREES")[0][3] == "1 require recovery", out + + +@pytest.mark.parametrize("how", TREE_SHAPES) +def test_115_a_tree_record_that_is_not_whole_is_never_replaced(box, how): + path, tid, sidecar = plant_tree(box, how) + planted = sidecar.read_bytes() + result = helper(box, "set-lane-tree", LANE, path, "--checkout", str(box.lane_dir), + "--branch", "feat/lost", "--head", "c" * 40, "--upstream", "none", + "--dirty", "0", "--unpushed", "0") + assert result.returncode == 1, (result.stdout, result.stderr) + assert "is not a record this helper can read" in result.stderr + assert sidecar.read_bytes() == planted + + +def test_115_a_record_that_is_not_whole_is_never_renamed_into_place(box): + """`lane_sidecar_put` asks the temporary file the same question before the + rename. A blank value is the one a writer can be handed: the reader trims + it to nothing, so the record it would make is not whole.""" + before = snapshot(box).read_bytes() + result = helper(box, "set-lane-state", LANE, "SWAPPING", "--expect", "RUNNING", + "--kind", " ") + assert result.returncode == 1, (result.stdout, result.stderr) + assert "it carries no kind" in result.stderr + assert snapshot(box).read_bytes() == before + assert not list(control_root(box).glob("*.tmp.*")) From 33515f81349797281ca6961ebd764992d73eb782 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 14:19:47 +0000 Subject: [PATCH 05/11] Document #113, #114 and #115 in the manual and in the change's design and tasks docs/README-lanes.md: the verdict table's MALFORMED and failed-read rows, the fence's SWAPPED refusal, the whole-record rule and the counts' shapes, the remote read, `malformed-sidecar`, and the reads whose failure is `indeterminate`. design.md: decisions 26-28. tasks.md section 7: where each remaining item was taken. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 65 +++++++++++++++++-- .../design.md | 46 +++++++++++++ .../tasks.md | 11 +++- 3 files changed, 112 insertions(+), 10 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 6f36e492..8f11bfda 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3124,8 +3124,10 @@ RUNNING --/handoff begins--> SWAPPING --record + row + handoff all landed--> SWA | `SWAPPED` | no | the swap completed; the lane is resumable once its trees are read | | `SWAPPED` | yes | inconsistent — a swapped lane has no holder, and neither side is overwritten | | `CLOSED` | — | the lane is finished; a dirty or unpushed tree under it is a closure inconsistency and no cleanup is made | -| any | **unreadable** | `indeterminate`. A holder that could not be established is NOT "no holder" (`R22`, Amendment 7(d)), and no crash is pronounced on a read nobody got. | +| any | **unreadable** | `indeterminate`. A holder that could not be established is NOT "no holder" (`R22`, Amendment 7(d)), and no crash is pronounced on a read nobody got. That includes the ids the holder is looked up by: a register row that could not be read — the published one, or this checkout's copy — is never a row naming no session (opensoft/openRepoTools#113). | | **unreadable** | — | `indeterminate` again, and for the same rule read one file earlier: a snapshot that IS THERE and cannot be opened is not a lane that has none. `lane-state` exits **10** for it, never the **8** that means *this lane has no snapshot, go on*. | +| **`MALFORMED`** | — | `indeterminate`: a snapshot of this schema that is not a WHOLE record — cut short, or edited by hand (opensoft/openRepoTools#115). `lane-state` exits **10** for it too, and every writer refuses to replace it. | +| any | — and a worktree read **failed** | `indeterminate`, whatever the snapshot and the holder say: an inventory directory, the checkout's registrations or a lane root that could not be listed hides trees the report never saw (opensoft/openRepoTools#113). | | any | — and the lane is **bound elsewhere** | `indeterminate`. Amendment 18(b): liveness is pronounced only from inside the binding's own host and container, and from anywhere else a binding is UNKNOWN, never dead — and this workstation's snapshot is this workstation's alone. The one exception is clause (b)'s own: a binding whose window is gone from this host's tmux is dead, and the local read decides. | | — | — and the lane is **managed-owned** | `managed-owned`, and nothing else is read or pronounced: see *Managed-owned lanes* below. | @@ -3149,6 +3151,16 @@ has recovered and resumed it. A handoff that finds the lane still `SWAPPING` **takes it over** with a new generation and names the operation that never finished; nothing of that operation is undone. +`SWAPPED` keeps the generation and the operation — it is the same operation +reaching its commit point — so for an inventory write the pair alone cannot +tell a poll in flight from one delayed past its own finalizer. A +`set-lane-tree … --generation/--operation` is therefore also refused with **7** +where the lane reads `SWAPPED`: the operation it names is finished and its +inventory is closed (opensoft/openRepoTools#114). Every other transition moves +the generation, so this is the one state in which a matching pair names a +finished operation. An observation filed under no transition is a +`set-lane-tree` with neither flag. + `RUNNING` is written by the act that CONFIRMS the binding and never by the SessionStart hook: `session-start` never writes, never touches the network and always exits 0 (Amendment 8, `R-A8-1`), and a hook that writes is a hook that @@ -3214,7 +3226,10 @@ answers rather than failures and are spelled as such — a branch with no commit yet has `unborn` for its head, and a branch whose upstream is configured but whose remote-tracking ref is not in this checkout (the ordinary state after a merged branch is deleted) records that configured upstream with `unknown` -unpushed, never the `0` that reads as *everything here is published*. +unpushed, never the `0` that reads as *everything here is published*. Both +halves of that configuration are reads with a status: a `branch..merge` or +`branch..remote` that git could not read is a **1**, never an empty remote +spelled as the local upstream `./` (opensoft/openRepoTools#113). **A sidecar this helper cannot read is one it will not replace.** A snapshot or a tree record carrying a schema this version does not write is reported as @@ -3222,8 +3237,28 @@ a tree record carrying a schema this version does not write is reported as than overwritten by a record an older helper can understand — the one act no later reader can undo. And `set-lane-tree --generation/--operation` is COMPARED with the lane's own snapshot under the mutex before the record is filed, so a -poll taken under an operation a recovery has since superseded is refused with -**7** instead of being filed over the current inventory. +poll taken under an operation a recovery has since superseded — or one the +lane has already finalized — is refused with **7** instead of being filed over +the current inventory. + +**And a record of THIS schema is read only when it is whole** +(opensoft/openRepoTools#115). The `schema:` line alone used to pass, so a file +cut short after it was read as empty fields and replaced by every writer. Now +every key its writer writes must be there and not empty — `lane`, `state`, +`generation`, `operation`, `owner`, `agent`, `profile`, `workstation`, `kind` +and `updated` for a snapshot; `lane`, `tree`, `path`, `checkout`, `branch`, +`head`, `upstream`, `dirty`, `unpushed`, `writer`, `generation`, `operation` and +`observed` for a tree — with a snapshot's `state` one of the four words, every +`generation` a number, a tree's `dirty` a number and its `unpushed` a number or +`unknown`, its `path` absolute, and its `tree` the id its file is named for. +Both writers have written exactly these keys since #97 landed. A record that +fails is taken the way an unknown schema is: every writer refuses it (exit +**1**, nothing written), `lane-state` answers **10** and `lane-reconcile` reads +the snapshot as `MALFORMED`, and `lane-trees` carries a tree record as a row of +its own named by its file, every field empty, under the schema ``. +The writers ask the same question of their own temporary file before the +rename, so a record cut short as it was written is removed rather than renamed +over a whole one. **One path, one record.** A tree's record is named from its path and from nothing else: `c-`, a `cksum` of the WHOLE absolute path @@ -3233,7 +3268,9 @@ and then the path with every character outside the file-name alphabet folded to other. And `set-lane-tree` takes a caller's observation **whole** — `--branch`, `--head`, `--upstream`, `--dirty` and `--unpushed` together — or reads the tree itself; a partial one is refused with **64** and nothing is written, because the -fields it lacks would be filed as a `dirty 0, unpushed 0` nobody observed. +fields it lacks would be filed as a `dirty 0, unpushed 0` nobody observed. So is +a `--dirty` that is not a count, or an `--unpushed` that is neither a count nor +`unknown`: a record carrying one could be neither read nor replaced. ### The reconciliation, which resets nothing @@ -3246,8 +3283,15 @@ tree, reads `git worktree list --porcelain` in the lane's checkout and the directories under both lane roots, and prints one `TREE` line per tree with a classification: `ok`, `dirty`, `unpushed`, `unpushed-unknown`, `dirty+unpushed`, `dirty+unpushed-unknown`, `missing`, `possible-loss`, -`not-a-checkout`, `unreadable`, `unreadable-sidecar`, `unknown-schema`, -`unmanaged`, `stale-registration`. A `BINDING` line says where the lane is bound — `here`, +`not-a-checkout`, `unreadable`, `unreadable-sidecar`, `malformed-sidecar`, +`unknown-schema`, `unmanaged`, `stale-registration`. Every one of those reads has +a status, and a read that FAILED is never an empty answer (opensoft/openRepoTools +#113): an inventory directory that cannot be listed (`lane-trees`' **10**), a +`git worktree list --porcelain` that fails in a checkout with a `.git` of its +own, and a lane root that cannot be listed or searched each make the verdict +`indeterminate` and are named in it — a recorded directory that is no +repository at all registers nothing, and that alone is an absence, as +`lane-worktrees` reads it. A `BINDING` line says where the lane is bound — `here`, `free`, `gone` (bound on this host's tmux and its window is gone), `elsewhere` (and where), or `unknown` (its log could not be read) — and `elsewhere` or `unknown` turns every verdict into `indeterminate` (Amendment 18(b)). The last @@ -3305,6 +3349,13 @@ NOTHING (`AGENTS.md` rule 1), so this read runs `git status`, `git log @{u}..`, person reads by hand, and it is never skipped as though the lane owned one tree fewer (`lane-trees` carries it as a row of its own whose schema is ``); +* a **malformed-sidecar** is a tree record of this schema that is not whole — + cut short, a key missing or out of shape, or an id that is not its file's + (opensoft/openRepoTools#115). It is reported as the unreadable one is: no + field of it is read, it counts toward recovery, the line says what is wrong + with it and names the file, and no writer replaces it. Before this, a record + cut after its `path:` came out with an empty dirty and unpushed, and a vanished + tree that held work read `missing`, clean and published; * an **unknown-schema** tree is a sidecar written by a newer tooling: it is named, and not one field of it is read, because a value taken out of a record whose shape this reader is guessing at is worse than no value. diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index 369b9360..c4457ded 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -472,6 +472,52 @@ follow-up this change had filed and deferred: nothing unmanaged or stale, and the report it prints names the binding when it is neither here nor free. +## Decisions taken on the filed findings #113, #114 and #115 + +### 26. Every read the reconciliation rests on has a status, and a failed one is never an empty one + +#113's three remaining reads, and the one beside its second that 8.12 left: +the holder ids (`lane_session_ids_read`: an empty published register, or this +checkout's copy that is there and cannot be opened, is `HOLDER unknown` and so +`indeterminate`, never an empty id set `live_holder` answers 8 for); +`branch..remote` (the `case` its `merge` sibling already had, so a failed +read is `lane_tree_now`'s 3, never the local upstream `./`); `git +worktree list --porcelain` in the lane's checkout; and `lane-trees`' 10 for an +inventory directory that cannot be listed. The last two, and a lane root that +cannot be listed or searched, leave trees nobody read, so each makes the +verdict `indeterminate` and is named in it. A per-record failure stays a row +counted toward recovery, as `unreadable-sidecar` is, and leaves the verdict as +it was — the line T018 draws for an unreadable leg. A recorded directory that is NO +repository registers nothing, and that alone is an absence — `lane-worktrees`' +`no_repository`, #170 G5's rule — so the two readers of one checkout agree. + +### 27. An inventory fence names an operation that must still be open + +#114 offered two exits: require `SWAPPING` while an observation is filed under +an operation, or invalidate the operation id at finalization. The first refuses +a write this change's own suite makes on purpose — a lane `RUNNING` after a +resume records under its current generation and operation (repoRC-5, section +11(c)) — and the second changes what `SWAPPED` writes, which decision 13 and +`lane-handoff`'s finalizer read back. So the fence refuses where the lane reads +`SWAPPED`: every other transition advances the generation, which already +refuses an older pair, and `SWAPPED` is the one state in which a matching pair +names a FINISHED operation. Exit 7, as every fence refusal is; an observation +under no transition is an unfenced `set-lane-tree`. + +### 28. A record of the known schema is valid only when it is whole + +#115. `lane_sidecar_schema_ok` keeps its name and its callers, and now asks of +a record of the current schema every key its writer writes, the shapes the +readers decide with, and the id a tree record's file gives it. Both writers +have written exactly those keys since #97 landed, so no released record fails. +A malformed record takes the unknown schema's path everywhere: refused by every +writer (1), `lane-state` 10 and `MALFORMED` in the reconciliation (no new exit +code — 10 already means *there, and nothing could be read out of it*), and a +`` row of `lane-trees` that `lane-reconcile` reports as +`malformed-sidecar`. `lane_sidecar_put` asks the same of its temporary file +before the rename, and `set-lane-tree` refuses a count that is not one with 64, +so no writer files a record its own readers would refuse. + ## Risks / Trade-offs - **[Risk] Lane-first discovery conflicts with feature-first Speckit paths** → Use a sidecar index over shape-governed paths; do not move governed trees. diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md index 6def619d..eb490845 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md @@ -115,7 +115,9 @@ Nothing is left as a comment thread and nothing is left unnamed. every one of these reads, as it already is at the holder's — a decision for the round that also settles how `lane-trees` carries a row it could not read. *The unreadable sidecar is taken in 8.12* — a row of its own, and the - `unreadable-sidecar` class; the other three reads remain. + `unreadable-sidecar` class; the other three reads remain. *They are taken in + decision 26*, with `lane-trees`' 10 for an inventory directory that cannot be + listed, which the reconciliation also swallowed. - **`opensoft/openRepoTools#114` — the inventory fence, and the partial observation.** `SWAPPING -> SWAPPED` keeps the generation AND the operation by design (decision 13), so `set-lane-tree --generation G --operation O` still @@ -128,7 +130,8 @@ Nothing is left as a comment thread and nothing is left unnamed. compare-and-swap (at minimum `SWAPPING`) or the operation id is invalidated at finalization, and the second is whether a partial observation is a usage refusal or is completed from one `lane_tree_now`. *The second is taken in - 8.12*, as a usage refusal (64); the fence remains. + 8.12*, as a usage refusal (64); the fence remains. *The fence is taken in + decision 27*: a fenced write is refused (7) where the lane reads `SWAPPED`. - **`opensoft/openRepoTools#115` — validation beyond the `schema:` line.** `lane_sidecar_schema_ok` asks one question, so a truncated `schema: 1` file with no state and no generation passes it: readers emit empty fields and @@ -136,7 +139,9 @@ Nothing is left as a comment thread and nothing is left unnamed. performed against a known one whose content is not valid. The cost is the one loss no later reader can undo; what decides it is which keys, types and identity each kind of sidecar requires, and whether a malformed record of a - known schema takes the same refusal path an unknown schema takes. + known schema takes the same refusal path an unknown schema takes. *Taken in + decision 28*: every key its writer writes, their shapes and a tree's id, and + the unknown schema's refusal path on both sides. - **`opensoft/openRepoTools#116` — the tree's identity.** `tree_id_for` folds every character outside the manifest-key set to `-` and squeezes repeats, so `/tmp/a/b` and `/tmp/a-b` share one sidecar and a lane with two valid trees From d727834041c662ccfcdb1d715508ccb98e6f893b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 16:24:05 +0000 Subject: [PATCH 06/11] lane-reconcile reads the holder ids through main's session_ids_checked (#67), not a copy of it (#113) #67 landed on main with `session_ids_checked`: the published-union-local ids, 0 with them, 8 for none and 1 where either copy of the register could not be read, on a `register_text_checked` that reports a failed `git show` or `cat` itself rather than inferring it from an empty render. That is the read #113 item 1 needs, so `lane_reconcile` asks it and `lane_session_ids_read` goes: one implementation. An 8 goes on to `live_holder` as before; a 1 is `HOLDER unknown` and `indeterminate`, as it was. The comments that named two callers of the checked read now name three. Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 57 ++++++------------- .../design.md | 4 +- 2 files changed, 19 insertions(+), 42 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index e4c52f89..cc08318f 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -3247,8 +3247,8 @@ lane_log_exists() { # read exactly as it always did, and it is not an answer. `register_text` is the # same read with the status thrown away, as it always was, because its callers # are pipelines whose `awk` answers for them and rewriting all of them is not -# this change. The two callers for which an empty register is a VERDICT ask the -# checked read through `session_ids_checked` below. +# this change. The three callers for which an empty register is a VERDICT ask +# the checked read through `session_ids_checked` below. # # A FAILED READ IS NEVER CACHED, in the variable or in the file. It used to be: # the empty render went into both, so the first caller to meet a failed read — @@ -5133,11 +5133,12 @@ session_ids_local_of_lane() { # The pair above answer 1 for "this lane has no row" AND for "the register could # not be read" — `row_of_lane` is `register_text | awk`, and `awk` answers 0 # over an empty render — so a caller that unions them with `|| :` holds an -# EMPTY id set either way. For most of them that is harmless. For the two that -# take an empty set as a VERDICT it is fail-open: `holder_is_dead` hands it to -# `live_holder`, whose 8 then calls a lane dead that nobody could check, and -# `duplicate_holder_pids` answers "no duplicate" without looking. Those two ask -# this read instead, and the pair keep their contract for everyone else. +# EMPTY id set either way. For most of them that is harmless. For the three +# that take an empty set as a VERDICT it is fail-open: `holder_is_dead` hands it +# to `live_holder`, whose 8 then calls a lane dead that nobody could check, +# `duplicate_holder_pids` answers "no duplicate" without looking, and +# `lane_reconcile` pronounces a crash on that same 8 (#113 item 1). Those three +# ask this read instead, and the pair keep their contract for everyone else. # # 0 the published-union-local ids, one per line, published first, deduped # 8 BOTH copies were read and neither gives this lane a transcript uuid: no @@ -13220,35 +13221,6 @@ lane_tree_now() { # return 0 } -# THE SESSIONS A LANE'S ROW RECORDS, OR A READ THAT FAILED (opensoft/openRepoTools -# #113 item 1). The published row and THIS checkout's, one id a line, unioned as -# every liveness read here unions them (an id `origin` has not seen yet is one -# more reason a holder may be live). 0 with the ids — none at all is an ANSWER: -# no row, or a row naming no session — and 1 where either copy of the register -# could not be read. Both reads used to end in `|| :`, so a register nobody -# could read came out as an EMPTY id set, `live_holder` answered 8 for it — this -# lane records no session, nothing can be live — and `lane_reconcile` pronounced -# `ungraceful-stop` or `interrupted-swap` on a liveness question nobody answered. -# An EMPTY published register is a failed read, as `lane_is_managed_owned` reads -# it (`register_text` answers empty, not non-zero, where it could not render -# one); this checkout's copy is a failed read where it is there — a dangling -# link included — and awk could not open it. A checkout with no register file -# at all has no row of its own, as `managed_local_hint` reads it. -lane_session_ids_read() { # - lsir_l="${1-}"; lsir_txt=""; lsir_loc=""; lsir_lrc=0 - lsir_txt="$(register_text)" - [ -n "$lsir_txt" ] || return 1 - if [ -e "$LANES_FILE" ] || [ -L "$LANES_FILE" ]; then - lsir_loc="$(row_of_lane_local "$lsir_l" 2>/dev/null)" || lsir_lrc=$? - [ "$lsir_lrc" = 0 ] || return 1 - fi - { lsir_pub="$(printf '%s\n' "$lsir_txt" | awk -v lane="$lsir_l" "$ROW_AWK")" - [ -z "$lsir_pub" ] || row_cell "$lsir_pub" 3 | uuids_in_cell - [ -z "$lsir_loc" ] || row_cell "$lsir_loc" 3 | uuids_in_cell - } | awk 'NF && !seen[$0]++' - return 0 -} - # NO REPOSITORY AT ALL IS AN ABSENCE, and nothing else is: `lane-worktrees`' # `no_repository`, the rule #170 G5 settled, so the two readers of one lane's # checkout cannot disagree about it. A directory with a `.git` of its own IS a @@ -13335,12 +13307,17 @@ lane_reconcile() { # # could not be established fails CLOSED: the verdict is `indeterminate` and # no crash is pronounced on a read nobody got. # AND THE SAME DISTINCTION ONE READ EARLIER (#113 item 1): the ids the row - # records. A register nobody could read is not a row naming no session, so it - # never reaches `live_holder` as an empty set, whose answer to one is 8. + # records. Both reads ended in `|| :`, so a register nobody could read came + # out as an EMPTY id set, `live_holder` answered 8 for it — this lane records + # no session, nothing can be live — and a crash was pronounced on a liveness + # question nobody answered. `session_ids_checked` is #67's read of the same + # published-union-local ids, and it tells the two apart: 8, no row or a row + # naming no session, goes on to `live_holder` exactly as before; 1, either + # copy of the register unread, never reaches it. lrc_ids=""; lrc_irc=0; lrc_hwhy="" - lrc_ids="$(lane_session_ids_read "$lrc_lane")" || lrc_irc=$? + lrc_ids="$(session_ids_checked "$lrc_lane")" || lrc_irc=$? lrc_hold=""; lrc_hrc=0 - if [ "$lrc_irc" != 0 ]; then + if [ "$lrc_irc" != 0 ] && [ "$lrc_irc" != 8 ]; then lrc_hrc=1 lrc_hwhy="lane $lrc_lane's register row could not be read — the published register, or this checkout's copy of it at $LANES_FILE — so which sessions it records is NOT established, and an id set nobody read is never an empty one" else diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index c4457ded..0ef4f587 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -477,8 +477,8 @@ follow-up this change had filed and deferred: ### 26. Every read the reconciliation rests on has a status, and a failed one is never an empty one #113's three remaining reads, and the one beside its second that 8.12 left: -the holder ids (`lane_session_ids_read`: an empty published register, or this -checkout's copy that is there and cannot be opened, is `HOLDER unknown` and so +the holder ids (#67's `session_ids_checked`: a published register or this +checkout's copy that could not be read is `HOLDER unknown` and so `indeterminate`, never an empty id set `live_holder` answers 8 for); `branch..remote` (the `case` its `merge` sibling already had, so a failed read is `lane_tree_now`'s 3, never the local upstream `./`); `git From 36520b969d40c2a12bf8752506c590b43752185f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 16:47:52 +0000 Subject: [PATCH 07/11] lane-reconcile names a failed registrations read in the sweep's words, so lane-end still relays #170 G5's reason (#113) The sweep asks lane-reconcile before its own reads and keeps the first refusal. With #113 item 4 that first refusal is now the reconciliation's, so a lane checkout whose .git is mode 000, or whose .git file points nowhere, refused (2) as before but named "the worktrees git registers in could not be listed" where test_lane_helpers.sh's #170 G5 cases (and lane-end's relay of them) require "the worktree registrations of could not be read". CI run 39141918371 failed those three cases on 33515f8. The reconciliation now says it in lane-worktrees' words, with the exit status after it. Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 2 +- tests/test_lifecycle_fail_open_113_114_115.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index cc08318f..6afc10bc 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -13525,7 +13525,7 @@ EOF lrc_wlo="$(git -C "$lrc_dir" worktree list --porcelain 2>/dev/null Date: Sun, 18 Oct 2026 20:13:50 +0000 Subject: [PATCH 08/11] A snapshot under a directory named trees is read as a snapshot, and set-lane-tree refuses a blank field with 64 (#115, review r1 of #252) lane_sidecar_schema_ok told a snapshot from a tree record by where it is filed, and asked */trees/*.yaml first. A case pattern's * matches /, so under a control root with any directory named trees above it every snapshot was asked a tree's keys and refused: lane-start wrote none, set-lane-state exited 1, and lane-reconcile said no-state, the verdict a launcher goes past. The snapshot's arm now comes first; no tree file is named lane-state.yaml, since a tree id is c-... set-lane-tree refuses a --branch, --head, --upstream, --checkout or --writer that is only blanks with 64, before the lock, as it refuses a count that is not one. It used to fail at the rename with exit 1. lane_no_repository asks git in the C locale, as lane-worktrees' git_env does, so a translated git cannot turn an absence into a failed read. worktree_rows' note no longer says a row's path always begins with /: a row of a record that is not whole keeps the path the record names. Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 6afc10bc..8a7ff498 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -12585,7 +12585,12 @@ lane_sidecar_field() { # # WHICH KIND OF RECORD IT IS COMES FROM WHERE IT IS FILED — `/lane-state.yaml` # for the snapshot, `/trees/.yaml` for a tree — and the second argument # names that place for a file that is not there yet (`lane_sidecar_put`'s -# temporary file). `LSS_WHY` says why a record failed, in words a caller puts +# temporary file). THE SNAPSHOT'S ARM COMES FIRST (review r1 of #252): a `case` +# pattern's `*` matches `/`, so `*/trees/*.yaml` also matches +# `/x/trees/state//lane-state.yaml`, and under a control root with any +# directory named `trees` above it every snapshot was asked a tree's keys, +# refused, and never written. A tree's file is never named `lane-state.yaml`: +# its id is `c-…` (`tree_id_for`). `LSS_WHY` says why a record failed, in words a caller puts # in its own message; it is set in the caller's shell, so call this directly # and never inside `$( )`. The awk reads the id through the ENVIRONMENT, because # `awk -v` processes escapes in a value. @@ -12603,8 +12608,8 @@ lane_sidecar_schema_ok() { # [] fi lss_as="${2:-$1}"; lss_kind=""; lss_id="" case "$lss_as" in - */trees/*.yaml) lss_kind=tree; lss_id="${lss_as##*/}"; lss_id="${lss_id%.yaml}" ;; */lane-state.yaml) lss_kind=snapshot ;; + */trees/*.yaml) lss_kind=tree; lss_id="${lss_as##*/}"; lss_id="${lss_id%.yaml}" ;; esac [ -n "$lss_kind" ] || return 0 lss_rc=0 @@ -13226,11 +13231,14 @@ lane_tree_now() { # # checkout cannot disagree about it. A directory with a `.git` of its own IS a # repository however broken — git answers "not a git repository" for a gitdir # it cannot read too — and only git's own "not a git repository" for a -# directory with none is the answer "there is nothing registered here". +# directory with none is the answer "there is nothing registered here". That +# message is read in the C locale, as `lane-worktrees`' `git_env` reads it: a +# translated git would otherwise say it in words this match never finds, and the +# two readers would disagree (review r1 of #252). lane_no_repository() { # lnr_d="${1-}"; lnr_err="" if [ -e "$lnr_d/.git" ] || [ -L "$lnr_d/.git" ]; then return 1; fi - if lnr_err="$(git -C "$lnr_d" rev-parse --git-dir 2>&1 >/dev/null &1 >/dev/null `; a record that could not be read is `unreadable -# sidecar` and one of another schema `unknown schema `, with the record's -# id where its path would be (a path always begins with `/`). A count that is +# sidecar`, one of another schema `unknown schema `, and one of this schema +# that is not whole `malformed sidecar` (#115). Each keeps the path the record +# names where it names one, else the record's id (its file name) stands +# there. A kept path is the record's own word, so it may be relative or +# not a tree at all; only a whole record's path is known absolute. A count that is # `unknown` is empty — an INTEGER column has no word for it. # # The rows are sorted bytewise, which is the order the index's copy is written @@ -17240,8 +17251,8 @@ EOF # A snapshot of this schema that is not a whole record is 10 as well # (#115): nothing can be read out of it as a snapshot. # 64 a usage error of this subcommand's own — for `set-lane-tree`, a - # partial observation, or a `--dirty`/`--unpushed` that is not a count - # (`unknown` is one for `--unpushed`) + # partial observation, a `--dirty`/`--unpushed` that is not a count + # (`unknown` is one for `--unpushed`), or a field that is only blanks lane-state) lane="${1-}"; [ -n "$lane" ] || die "usage: lane-state " 64 @@ -17471,6 +17482,16 @@ EOF unknown) : ;; ''|*[!0-9]*) die "set-lane-tree's --unpushed takes a count of commits, or 'unknown' where the upstream is configured and its remote-tracking ref is not here; '$slt_n' is neither, and a record carrying it could be neither read nor replaced. Nothing was written." 64 ;; esac + # AND NO FIELD THAT IS ONLY BLANKS (review r1 of #252). A reader trims a + # value, so `--branch ' '` files a record with no branch — one the rename + # below refused only after the lock, with exit 1 and "the shell's own error + # is above". It is the caller's usage, said as one, before anything is taken. + for slt_fv in "branch=$slt_b" "head=$slt_h" "upstream=$slt_u" "checkout=$slt_co" "writer=$slt_w"; do + case "${slt_fv#*=}" in + ''|*[![:space:]]*) : ;; + *) die "set-lane-tree's --${slt_fv%%=*} is only blanks: a reader trims a value, so the record would carry no ${slt_fv%%=*} and could be neither read nor replaced. Nothing was written." 64 ;; + esac + done # THE FENCE, AND IT IS THE LANE'S OWN (Copilot round 5 on #97). A caller # that names the generation and the operation it is recording under is # saying *this observation belongs to that transition* — and until this From f80542e7556927c0df6f7d97adec7dcbc838c47b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 20:13:50 +0000 Subject: [PATCH 09/11] Test a control root under trees, a blank set-lane-tree field and the C-locale read, each red at 36520b9 (#113, #115) Co-Authored-By: Claude Opus 5.5 --- tests/test_lifecycle_fail_open_113_114_115.py | 73 ++++++++++++++++++- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/tests/test_lifecycle_fail_open_113_114_115.py b/tests/test_lifecycle_fail_open_113_114_115.py index 2179817b..268444b6 100644 --- a/tests/test_lifecycle_fail_open_113_114_115.py +++ b/tests/test_lifecycle_fail_open_113_114_115.py @@ -45,6 +45,20 @@ #: which fails the way a read git cannot complete does. The suite's own `git` #: calls never set it. FAKE_GIT = """#!/usr/bin/env bash +# FAKE_GIT_TRANSLATED: a git whose messages are translated, which only the C +# locale turns back into git's English words. +if [ -n "${FAKE_GIT_TRANSLATED:-}" ] && [ "${LC_ALL-}" != C ]; then + case " $* " in + *" rev-parse --git-dir "*) + err="$("%(real)s" "$@" 2>&1 >/dev/null &2 ;; + *) printf '%%s\\n' "$err" >&2 ;; + esac + exit 128 ;; + esac +fi if [ -n "${FAKE_GIT_FAIL:-}" ]; then case " $* " in *" $FAKE_GIT_FAIL "*) @@ -56,21 +70,26 @@ """ -@pytest.fixture -def box(tmp_path: Path) -> Sandbox: +def make_box(tmp_path: Path, state_root: Path) -> Sandbox: real_git = shutil.which("git") assert real_git box = Sandbox(tmp_path) shim = box.fakebin / "git" - shim.write_text(FAKE_GIT.replace("%(real)s", real_git), encoding="utf-8") + shim.write_text(FAKE_GIT.replace("%(real)s", real_git).replace("%%s", "%s"), + encoding="utf-8") shim.chmod(0o755) - box.env["LANES_LANE_STATE_ROOT"] = str(tmp_path / "state") + box.env["LANES_LANE_STATE_ROOT"] = str(state_root) box.resolver() seeded = box.start("--no-launch") assert seeded.returncode == 0, seeded.stderr return box +@pytest.fixture +def box(tmp_path: Path) -> Sandbox: + return make_box(tmp_path, tmp_path / "state") + + def helper(box: Sandbox, *args: str, **env: str) -> subprocess.CompletedProcess: full = dict(box.env, LANES_NO_FETCH="1") full.update(env) @@ -220,6 +239,19 @@ def test_113_4_registrations_nobody_could_list_are_never_a_clearance(box): assert verdict(out) == "resumable", out +def test_113_4_no_repository_is_read_in_the_c_locale(box): + """`lane_no_repository` matches git's English "not a git repository", so it + asks git in the C locale, as `lane-worktrees`' `git_env` does (review r1 of + #252). Under a translated git the words were never found, the absence read + as a failed read, and the two readers of one checkout disagreed.""" + swapped(box) + shutil.rmtree(box.lane_dir / ".git") + out = reconcile(box, FAKE_GIT_FAIL="worktree list --porcelain", + FAKE_GIT_TRANSLATED="1", LC_ALL="de_DE.UTF-8") + assert verdict(out) == "resumable", out + assert "could not be read" not in rows(out, "VERDICT")[0][2] + + @pytest.mark.parametrize("mode", [0o000, 0o300, 0o600], ids=["no-access", "no-read", "no-search"]) def test_113_4_a_lane_root_nobody_could_list_is_never_a_clearance(box, mode): @@ -282,6 +314,39 @@ def test_114_115_a_count_that_is_not_one_is_refused_before_anything_is_written(b assert helper(box, "lane-trees", LANE).returncode == 8 +@pytest.mark.parametrize("flag", ["--branch", "--head", "--upstream", "--checkout", "--writer"]) +def test_115_a_field_that_is_only_blanks_is_refused_before_anything_is_written(box, flag): + """A reader trims a value, so a blank one files a record with no such field + — refused only at the rename, after the lock, with exit 1. It is a usage + error and is said as one, 64, before anything is taken (review r1 of #252).""" + given = {"--checkout": str(box.lane_dir), "--branch": "b", "--head": "h", + "--upstream": "none", "--dirty": "0", "--unpushed": "0", "--writer": "none"} + given[flag] = " " + result = helper(box, "set-lane-tree", LANE, str(box.lane_dir / "w"), + *[x for pair in given.items() for x in pair]) + assert result.returncode == 64, (result.stdout, result.stderr) + assert f"{flag} is only blanks" in result.stderr + assert helper(box, "lane-trees", LANE).returncode == 8 + trees = control_root(box) / "trees" + assert not trees.exists() or not list(trees.iterdir()) + + +def test_115_a_control_root_under_a_directory_named_trees_keeps_its_snapshot(tmp_path): + """The validator tells a snapshot from a tree record by where it is filed, + and a `case` pattern's `*` matches `/`: `*/trees/*.yaml` matched a snapshot + under any directory named `trees`, so it was asked a tree's keys and refused + — `lane-start` wrote none, `set-lane-state` exited 1 and the reconciliation + said `no-state`, the answer a launcher goes past (review r1 of #252).""" + box = make_box(tmp_path, tmp_path / "trees" / "state") + assert snapshot(box).is_file(), "lane-start wrote no snapshot" + assert tabbed(ok(box, "lane-state", LANE))["state"] == "RUNNING" + took = tabbed(ok(box, "set-lane-state", LANE, "SWAPPING", "--expect", "RUNNING")) + assert took["state"] == "SWAPPING", took + out = reconcile(box) + assert rows(out, "STATE")[0][1] == "SWAPPING", out + assert verdict(out) != "no-state", out + + # --------------------------------------------------------------------- #115 WHOLE_SNAPSHOT = ("schema: 1\nlane: {lane}\nstate: SWAPPING\ngeneration: 4\n" From 3ffefce8f9a27fc70fa7b22461f65e7234c0d73f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 20:13:51 +0000 Subject: [PATCH 10/11] Document the malformed snapshot's 10, the lifecycle 64s, the kept path of a malformed row, and why a record's lane: is not compared (#115) The manual's main exit table names the malformed snapshot under 10 and the lifecycle arms' 64s. A lane-trees row keeps the path the record names, which may be relative, and not every field empty. Decision 28 says which arm the validator asks first and why a record's lane: is not compared: rename-lane leaves it naming the old lane until the next write. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 14 +++++++++----- .../design.md | 11 +++++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index db37fe8c..6b38cc09 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1185,9 +1185,9 @@ else. | 6 | `git add` / `commit` / `push` failed | | 7 | another act got there first and this one wrote nothing: `claim`'s `CLAIM-LOST` (another lane's claim landed first), and `set-lane-state` / `set-lane-tree`'s lifecycle fence (openRepoTools#91, below) | | 9 | `CLAIM-LOST` — issue #30's own dead-lane verdict could not be reconfirmed before a `--force` takeover's push landed: the source lane resumed, a live session now backs it up, or that could not be read at all (`claim` only). Never 7 — that code is a RIVAL's claim, and this is the same lane the takeover was granted over | -| 10 | `lane-state`: the lane's lifecycle snapshot is there and could not be read (openRepoTools#91, below) — never the 8 that means it has none. It was 9 until #61 spent 9 on `claim`. `lane-trees`: the lane's `trees` directory is there and could not be listed (#157 item 4e) — never the 8 that means it recorded no tree | +| 10 | `lane-state`: the lane's lifecycle snapshot is there and could not be read, or is of this schema and not a whole record, so nothing can be read out of it as a snapshot (openRepoTools#91 and #115, below) — never the 8 that means it has none. It was 9 until #61 spent 9 on `claim`. `lane-trees`: the lane's `trees` directory is there and could not be listed (#157 item 4e) — never the 8 that means it recorded no tree | | 8 | no record — and no other meaning | -| 64 | `swapped`'s own usage error — never the dispatcher's 2 | +| 64 | `swapped`'s own usage error — never the dispatcher's 2 — and the lifecycle arms' own: `set-lane-tree`'s partial observation, a count that is not one, or a field that is only blanks (below) | 3 to 6 are the codes this helper already used; 7 and 8 were free. @@ -3285,7 +3285,9 @@ Both writers have written exactly these keys since #97 landed. A record that fails is taken the way an unknown schema is: every writer refuses it (exit **1**, nothing written), `lane-state` answers **10** and `lane-reconcile` reads the snapshot as `MALFORMED`, and `lane-trees` carries a tree record as a row of -its own named by its file, every field empty, under the schema ``. +its own named by its file under the schema ``, keeping the path the +record names — so the tree is still named once, though that path is the record's +own word and may be relative — and every other field empty. The writers ask the same question of their own temporary file before the rename, so a record cut short as it was written is removed rather than renamed over a whole one. @@ -3299,8 +3301,10 @@ other. And `set-lane-tree` takes a caller's observation **whole** — `--branch` `--head`, `--upstream`, `--dirty` and `--unpushed` together — or reads the tree itself; a partial one is refused with **64** and nothing is written, because the fields it lacks would be filed as a `dirty 0, unpushed 0` nobody observed. So is -a `--dirty` that is not a count, or an `--unpushed` that is neither a count nor -`unknown`: a record carrying one could be neither read nor replaced. +a `--dirty` that is not a count, an `--unpushed` that is neither a count nor +`unknown`, or a `--branch`, `--head`, `--upstream`, `--checkout` or `--writer` +that is only blanks (a reader trims a value, so that field would be empty): a +record carrying one could be neither read nor replaced. ### The reconciliation, which resets nothing diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index 0ef4f587..1536c52d 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -515,8 +515,15 @@ writer (1), `lane-state` 10 and `MALFORMED` in the reconciliation (no new exit code — 10 already means *there, and nothing could be read out of it*), and a `` row of `lane-trees` that `lane-reconcile` reports as `malformed-sidecar`. `lane_sidecar_put` asks the same of its temporary file -before the rename, and `set-lane-tree` refuses a count that is not one with 64, -so no writer files a record its own readers would refuse. +before the rename, and `set-lane-tree` refuses with 64 a count that is not one +or a field that is only blanks, so no writer files a record its own readers +would refuse. Which kind of record a file is comes from where it is filed, and +the snapshot's `*/lane-state.yaml` is asked first: `*` matches `/`, so a tree's +`*/trees/*.yaml` would also match a snapshot under any directory named `trees`, +and no tree file is named `lane-state.yaml` (its id is `c-…`). A record's +`lane:` is required but not compared with the lane it is filed under, because +`rename-lane` moves the control root and leaves that line naming the old lane +until the next write (`lane_state_rename`); its readers do not key on it. ## Risks / Trade-offs From e6a41b409b61111a590866c7ec288a835cc4f95e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 19 Oct 2026 16:29:25 +0000 Subject: [PATCH 11/11] #219's two path-less sidecar cases read #115's whole-record rule: a record with no path: is a malformed tree, never read and never the marker (#115, #219) Merged with main, CI failed two tests of #219 (lanes-index's reader) in tests/test_lanes_index.py. Both plant a current-schema sidecar with no `path:` and assert its branch appears in `lanes-edit.sh worktrees`. Since #115 such a record is not whole: the listing reads no field of it and names its row `malformed sidecar`, by the path it records or, with none, by its file. The rows were still trees, counted and never NOT READ, so #219's reader and its marker key are unchanged; only the branch text the tests looked for is deliberately no longer read. - test_a_tree_with_no_path_is_a_tree_and_not_an_unread_marker: the path-less record is now asserted as a malformed row named by its file. Its second record keeps the path of the lane's whole tree (it lacks only `upstream:`), so two trees of one lane with one path stay two rows, the clause the source can no longer reach with an empty path. - test_a_tree_whose_branch_reads_like_a_marker_is_a_tree: the forged branch is carried by a whole record (it gains a `path:`), so the branch is read and still makes no marker. Co-Authored-By: Claude Opus 5.5 --- tests/test_lanes_index.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/tests/test_lanes_index.py b/tests/test_lanes_index.py index 4bbe278e..382af62b 100644 --- a/tests/test_lanes_index.py +++ b/tests/test_lanes_index.py @@ -2388,19 +2388,33 @@ def test_a_tree_with_no_path_is_a_tree_and_not_an_unread_marker(inv): no path AND the branch `unreadable inventory (...)`, which no git ref can be; it has a key of its own, so holding the lane never writes it over such a tree; and two trees of one lane with one path are two rows, as they are - two lines of the source read.""" + two lines of the source read. + + SINCE #115 (PR #252) A CURRENT-SCHEMA SIDECAR WITH NO `path:` IS NOT WHOLE: + `lanes-edit.sh worktrees` reads no field of it and lists its row under the + branch `malformed sidecar`, keeping the path it records and naming it by its + file where it records none. So the source no longer gives a tree an empty + path, and this test's branches are never read. The row is still a tree and + never the marker: counted, held over, and exported as its `TREE` line. Two + rows with one path now come from a record that is not whole but keeps a + path: `c2-samepath-y` lacks only its `upstream:` and records the path of + this lane's whole tree.""" trees = inv.lsr / "repoM-1" / "trees" - for tid, branch in (("c1-nopath-x", "feat/x"), ("c2-nopath-y", "feat/y")): + for tid, extra in (("c1-nopath-x", []), + ("c2-samepath-y", ["path: /nowhere/repoM-1/w"])): (trees / f"{tid}.yaml").write_text("\n".join([ - "schema: 1", "lane: repoM-1", f"tree: {tid}", "checkout: unknown", - f"branch: {branch}", "head: " + "3" * 40, "upstream: none", "dirty: 0", + "schema: 1", "lane: repoM-1", f"tree: {tid}", *extra, "checkout: unknown", + "branch: feat/" + tid[-1], "head: " + "3" * 40, + *([] if extra else ["upstream: none"]), "dirty: 0", "unpushed: 0", "writer: none", "generation: 0", "operation: none", "observed: 2026-10-04T00:00:00Z", ""])) res = inv.sync() assert res.returncode == 0, res.stderr src, idx = _wt(inv, "repoM-1"), _wt(inv, "repoM-1", "--index") assert "read: index (sqlite)" in idx.stderr, idx.stderr - assert "feat/x" in src.stdout and "feat/y" in src.stdout, src.stdout + assert "c1-nopath-x" in src.stdout, src.stdout + assert src.stdout.count("malformed sidecar") == 2, src.stdout + assert src.stdout.count("/nowhere/repoM-1/w") == 2, src.stdout assert (idx.returncode, idx.stdout) == (src.returncode, src.stdout), idx.stdout assert "NOT READ" not in idx.stdout st = inv.e.index("status") @@ -2412,7 +2426,8 @@ def test_a_tree_with_no_path_is_a_tree_and_not_an_unread_marker(inv): pytest.skip("this account reads a mode-000 directory (root): nothing to prove here") assert inv.sync().returncode == 0 idx = _wt(inv, "repoM-1", "--index") - assert "feat/x" in idx.stdout and "feat/y" in idx.stdout, idx.stdout + assert "c1-nopath-x" in idx.stdout, idx.stdout + assert idx.stdout.count("/nowhere/repoM-1/w") == 2, idx.stdout assert "the 3 row(s) listed for it are kept from the last sync" in idx.stdout, idx.stdout finally: trees.chmod(0o755) @@ -2427,10 +2442,15 @@ def test_a_tree_whose_branch_reads_like_a_marker_is_a_tree(inv): sidecar with no `path:` and a branch that begins `unreadable inventory (` is a tree no git ref names, and still a tree - exported as its `TREE` line and counted as a worktree - because a marker is the row keyed - `unread_key()`, which no tree's key can be.""" + `unread_key()`, which no tree's key can be. + + SINCE #115 (PR #252) a sidecar with no `path:` is not whole and its branch + is never read, so the forged branch is carried by a WHOLE record: a tree + with a path whose branch alone reads like the marker's.""" tid = "c3-forged-branch" (inv.lsr / "repoM-1" / "trees" / f"{tid}.yaml").write_text("\n".join([ - "schema: 1", "lane: repoM-1", f"tree: {tid}", "checkout: unknown", + "schema: 1", "lane: repoM-1", f"tree: {tid}", "path: /nowhere/repoM-1/forged", + "checkout: unknown", "branch: unreadable inventory (forged by hand)", "head: " + "4" * 40, "upstream: none", "dirty: 0", "unpushed: 0", "writer: none", "generation: 0", "operation: none", "observed: 2026-10-04T00:00:00Z", ""]))