From d4cf2ee847acd45a6e9e00978d6c30b6ff0abfbb Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 18:35:35 +0000 Subject: [PATCH 1/3] duplicate-holder and lane-end --retire read a --resume path whole and act on every verified child, not the first (#76, #86) #76: `ps -o args=` joins a process's argv with spaces and quotes nothing, and all four `--resume` reads (both matches in `duplicate_holder_pids`, both pre-kill rechecks in `lane-end`) cut the value at the first space. A duplicate resumed from a directory with a space in it was never found, and the recheck would have withheld its TERM. The value now runs to the first `.jsonl` that ends an argument, so a directory with a space is read whole while Copilot round 5's `--resume /other.jsonl --label /.jsonl` still reads `/other.jsonl`; a value that would span a later `--option` is refused. One rule, `dhp_resume_value` in lanes-edit.sh and its word-for-word copy `dh_resume_value` in lane-end, with its known limits written beside it. #86: the child scan stopped at the first child that verified and the record had one child slot, so `lane-end --retire` TERMed the parent and one child, said RETIRED, and left a second child holding the transcript. The slot is now a space-separated list of every verified child; lane-end matches its target against each, rechecks and TERMs each, settles each, and refuses as partly retired if any one is still alive. Its dry-run line and its refusal listing name every child too. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 16 ++-- lane-end | 131 ++++++++++++++++++++-------- lanes-edit.sh | 92 +++++++++++++++++--- tests/test_lane_helpers.sh | 174 +++++++++++++++++++++++++++++++++++-- 4 files changed, 353 insertions(+), 60 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 7c0d16ca..b70eb7a5 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1733,7 +1733,10 @@ DOES record — while a second live process now holds the one transcript Amendment 18(h) says exactly one may. `lanes-edit.sh duplicate-holder ` finds it in the PROCESS TABLE instead (`pgrep -f` for a live `--fork-session`, then `ps -o pid=,ppid=,args= -p` per candidate — portable across GNU and -BSD/macOS), matched against every id this lane's row has ever carried, and +BSD/macOS), matched against every id this lane's row has ever carried by the +`--resume` value read whole, a directory with a space in it included +(opensoft/openRepoTools#76: `ps` joins the argv with spaces and quotes nothing, +so the value runs to the first `.jsonl` that ends an argument), and excludes the lane's own live holder (`live_holder`, the same implementation `live-holder` already calls) **and the harness's own companion of it** — Amendment 8 ruling (g)'s `kind: bg` record carrying the same id in the same @@ -1741,11 +1744,12 @@ profile as an interactive record of it, which is one session and not a rival holder, together with any wrapper whose child is that companion. A records tree that cannot be read for that test is a read that failed, never "no companion". `lane-end --retire ` tries `forks` -first and this second, and on a match here it TERMs the pair — the -`bg-pty-host` parent and its child — and reports the pids it signalled; it -refuses, naming the reason, when the pid given is the lane's own live session -rather than the duplicate. Before this the retirement was a bare `kill -TERM`, -outside every tool this estate has. +first and this second, and on a match here it TERMs the `bg-pty-host` parent +and EVERY child of it that resumes the same transcript, not only the first +(opensoft/openRepoTools#86), reports each pid it signalled, and says RETIRED +only once every one of them is gone; it refuses, naming the reason, when the +pid given is the lane's own live session rather than the duplicate. Before this +the retirement was a bare `kill -TERM`, outside every tool this estate has. ### The workstation's name diff --git a/lane-end b/lane-end index cde8c280..671ed0f1 100755 --- a/lane-end +++ b/lane-end @@ -70,10 +70,12 @@ # process under an id the row DOES record, found in # the PROCESS TABLE (opensoft/openRepoTools#39) — is # a background wrapper nobody is answering a prompt -# in, so this act TERMs the pair (the bg-pty-host -# parent and its child, never the lane's own live -# session, which it refuses to touch) and reports -# the pids it signalled. Named by the PARENT or +# in, so this act TERMs the bg-pty-host parent and +# EVERY child of it that resumes this transcript +# (opensoft/openRepoTools#86; never the lane's own +# live session, which it refuses to touch), reports +# each pid it signalled, and says RETIRED only once +# every one is gone. Named by the PARENT or any # CHILD PID and never by the transcript uuid # (Copilot round 9, PR #61) — the same reason a # DUPLICATE below never is: the uuid IS the row's @@ -1102,14 +1104,14 @@ LIVE esac fi - dh_parent=""; dh_child=""; dh_uuid="" + dh_parent=""; dh_children=""; dh_uuid="" if [ -z "$fk_sid" ] && [ "$dh_rc" = 0 ]; then # `IFS=$'\037'`, NEVER A TAB: `duplicate-holder`'s child field is # DOCUMENTED empty for a parent with no confirmable fork-session child # (lanes-edit.sh's own `duplicate_holder_pids`, "the child slot stays # empty"), and a bare tab is IFS-whitespace to `read` — two adjacent tabs # either side of that empty field collapse into one delimiter exactly as - # repeated spaces do, shifting the uuid into `dr_child` and leaving + # repeated spaces do, shifting the uuid into the child field and leaving # `dr_uuid` empty. Measured on e4f9054's CI (`tests-no-submodule`): a # partly-retired duplicate's still-alive child was reported as # `pid (child): gone` and its genuinely-killed parent as @@ -1127,11 +1129,20 @@ LIVE # The uuid form stays reserved for a title-based FORK above, whose id is # one the row does NOT record and so cannot repeat across rows the way a # duplicate holder's row-recorded id can. - while IFS=$'\037' read -r dr_parent dr_child dr_uuid; do + # + # THE CHILD FIELD IS A LIST (opensoft/openRepoTools#86): every child + # `duplicate_holder_pids` verified, space-separated, where it used to name + # only the first. The target is compared with EACH pid in it, by equality, + # never by a substring of the list, so `--retire=` names none. + while IFS=$'\037' read -r dr_parent dr_children dr_uuid; do [ -n "${dr_parent:-}" ] || continue - if [ "$dr_parent" = "$retire_target" ] \ - || { [ -n "$dr_child" ] && [ "$dr_child" = "$retire_target" ]; }; then - dh_parent="$dr_parent"; dh_child="$dr_child"; dh_uuid="$dr_uuid" + dr_hit=0 + if [ "$dr_parent" = "$retire_target" ]; then dr_hit=1; fi + for dr_c in $dr_children; do + if [ "$dr_c" = "$retire_target" ]; then dr_hit=1; fi + done + if [ "$dr_hit" = 1 ]; then + dh_parent="$dr_parent"; dh_children="$dr_children"; dh_uuid="$dr_uuid" break fi done <), or end the lane itself." 2 + # pid would be bizarre, but the fence costs nothing to keep). Every child + # in the slot is asked, not only the first (opensoft/openRepoTools#86). + if [ -n "$lh_pid" ]; then + for dh_c in $dh_parent $dh_children; do + if [ "$lh_pid" = "$dh_c" ]; then + die "pid $lh_pid is lane $LANE's own live session (session $lh_sid, ${lh_verdict:-here}) — Amendment 18(h) refuses to retire the one process a lane's binding actually is. Retire the OTHER pid of the pair ($prog $LANE --retire ), or end the lane itself." 2 + fi + done fi # AMENDMENT 18(h) — A DUPLICATE HOLDER IS TERMED, NOT ONLY NAMED. Unlike @@ -1167,7 +1183,9 @@ DUPES # scoped to this lane's own ids), and never the pid just proved above to # be the lane's own. if (( dry_run )); then - note "--dry-run: would send TERM to pid $dh_parent (bg-pty-host)${dh_child:+ and pid $dh_child (child)} — lane $LANE's duplicate holder of transcript $dh_uuid. Nothing sent." + dh_c_said="" + for dh_c in $dh_children; do dh_c_said="$dh_c_said and pid $dh_c (child)"; done + note "--dry-run: would send TERM to pid $dh_parent (bg-pty-host)$dh_c_said — lane $LANE's duplicate holder of transcript $dh_uuid. Nothing sent." exit 0 fi # RE-VALIDATED IMMEDIATELY BEFORE EACH SIGNAL, not trusted from the read @@ -1188,9 +1206,36 @@ DUPES # THE ACTUAL `--resume` VALUE, NOT A GLOB THAT CAN SPAN OTHER ARGUMENTS # (Copilot round 5, PR #61): `*"--resume"*"/$uuid.jsonl"*` matches # `--resume /other.jsonl --label /.jsonl` too, since `*` between - # them freely crosses argument boundaries. The token immediately after - # `--resume ` — up to the next space or the end of argv — is what the - # harness actually resumed, and that is the only thing compared now. + # them freely crosses argument boundaries. The value right after + # `--resume ` is what the harness actually resumed, and that is the only + # thing compared. + # + # READ WHOLE, SPACES AND ALL (opensoft/openRepoTools#76). `ps -o args=` + # joins the argv with spaces and quotes nothing, so the cut this used to + # make at the first space (`${v%% *}`) turned `--resume /home/Jane + # Doe/…/.jsonl` into `/home/jane`, read a still-matching duplicate + # as "argv no longer matched", and withheld its TERM. The value now ends + # at the first `.jsonl` that ends an argument (followed by a space or by + # the end of the line), which keeps round 5's case above reading + # `/other.jsonl`, and a value with a word opening `--` inside it is + # refused as a later option rather than a directory. This is + # `lanes-edit.sh`'s `dhp_resume_value`, the rule discovery already + # applied to this pid, word for word, and its comment there gives the + # known limits of reading a flattened argv at all; the two must stay the + # same rule. It sets `dh_rv` in this shell rather than printing it. + dh_resume_value() { # — sets dh_rv + dh_rv="" + case "${1-}" in *"--resume "*) : ;; *) return 0 ;; esac + dh_rv_rest="${1#*--resume }" + while :; do + case "$dh_rv_rest" in *.jsonl*) : ;; *) dh_rv=""; return 0 ;; esac + dh_rv="${dh_rv}${dh_rv_rest%%.jsonl*}.jsonl" + dh_rv_rest="${dh_rv_rest#*.jsonl}" + case "$dh_rv_rest" in "" | " "*) break ;; esac + done + case "$dh_rv" in *" --"*) dh_rv="" ;; esac + return 0 + } dh_uuid_lc="$(printf '%s' "$dh_uuid" | tr 'A-F' 'a-f')" dh_p_match=0 dh_p_args="$(ps -o args= -p "$dh_parent" 2>/dev/null | tr 'A-F' 'a-f')" || dh_p_args="" @@ -1202,8 +1247,8 @@ DUPES # false one out the same way `--resume ` already rules out # `--resume-file` beside it. case "$dh_p_args" in *"--fork-session "*"--resume "*) - dh_p_resume="${dh_p_args#*--resume }"; dh_p_resume="${dh_p_resume%% *}" - case "$dh_p_resume" in */"$dh_uuid_lc.jsonl") dh_p_match=1 ;; esac + dh_resume_value "$dh_p_args" + case "$dh_rv" in */"$dh_uuid_lc.jsonl") dh_p_match=1 ;; esac esac # THE SIGNAL'S OWN EXIT STATUS, KEPT APART FROM "DID ARGV MATCH" (Copilot # round 11, PR #61): folding both into one `dh_p_sent` flag and @@ -1217,20 +1262,24 @@ DUPES if [ "$dh_p_match" = 1 ]; then kill -TERM "$dh_parent" 2>/dev/null && dh_p_killed=1 fi - dh_c_match=1 - dh_c_killed=1 - if [ -n "$dh_child" ]; then + # EVERY CHILD IN THE SLOT, NOT ONLY THE FIRST (opensoft/openRepoTools#86): + # each is rechecked and signalled on its own, and its two bits are kept + # beside its pid as `:` for the report below, so one + # child's verdict is never read as another's. + dh_c_state="" + for dh_c in $dh_children; do dh_c_match=0 dh_c_killed=0 - dh_c_args="$(ps -o args= -p "$dh_child" 2>/dev/null | tr 'A-F' 'a-f')" || dh_c_args="" + dh_c_args="$(ps -o args= -p "$dh_c" 2>/dev/null | tr 'A-F' 'a-f')" || dh_c_args="" case "$dh_c_args" in *"--fork-session "*"--resume "*) - dh_c_resume="${dh_c_args#*--resume }"; dh_c_resume="${dh_c_resume%% *}" - case "$dh_c_resume" in */"$dh_uuid_lc.jsonl") dh_c_match=1 ;; esac + dh_resume_value "$dh_c_args" + case "$dh_rv" in */"$dh_uuid_lc.jsonl") dh_c_match=1 ;; esac esac if [ "$dh_c_match" = 1 ]; then - kill -TERM "$dh_child" 2>/dev/null && dh_c_killed=1 + kill -TERM "$dh_c" 2>/dev/null && dh_c_killed=1 fi - fi + dh_c_state="$dh_c_state $dh_c:$dh_c_match$dh_c_killed" + done # VERIFIED GONE, NOT ASSUMED FROM `kill`'S OWN STATUS (Copilot round 4, PR # #61): that status conflates "already gone" (fine — the goal was never # the signal, it was the duplicate not existing) with "no permission" @@ -1283,23 +1332,28 @@ DUPES return 1 } dh_p_gone=1; dh_settle_gone "$dh_parent" || dh_p_gone=0 - dh_c_gone=1 - if [ -n "$dh_child" ]; then - dh_c_gone=1; dh_settle_gone "$dh_child" || dh_c_gone=0 - fi dh_p_msg="gone" [ "$dh_p_gone" = 1 ] || dh_p_msg="STILL ALIVE, TERM withheld: argv no longer matched" [ "$dh_p_gone" = 1 ] || [ "$dh_p_match" = 0 ] || [ "$dh_p_killed" = 1 ] || dh_p_msg="STILL ALIVE, TERM NOT SENT: the signal itself failed (check ownership/permissions) — this is not the process ignoring TERM, it never received one" [ "$dh_p_gone" = 1 ] || [ "$dh_p_match" = 0 ] || [ "$dh_p_killed" = 0 ] || dh_p_msg="STILL ALIVE despite TERM delivered" note "pid $dh_parent (bg-pty-host): $dh_p_msg" - if [ -n "$dh_child" ]; then + # EVERY CHILD MUST BE GONE FOR THIS TO SAY RETIRED (opensoft/openRepoTools#86): + # one child still alive, whichever it is, makes this a partial retirement + # and the refusal below, never a RETIRED over a process still holding the + # transcript. + dh_c_all_gone=1 + for dh_c_tok in $dh_c_state; do + dh_c="${dh_c_tok%%:*}"; dh_c_bits="${dh_c_tok#*:}" + dh_c_match="${dh_c_bits%?}"; dh_c_killed="${dh_c_bits#?}" + dh_c_gone=1; dh_settle_gone "$dh_c" || dh_c_gone=0 + [ "$dh_c_gone" = 1 ] || dh_c_all_gone=0 dh_c_msg="gone" [ "$dh_c_gone" = 1 ] || dh_c_msg="STILL ALIVE, TERM withheld: argv no longer matched" [ "$dh_c_gone" = 1 ] || [ "$dh_c_match" = 0 ] || [ "$dh_c_killed" = 1 ] || dh_c_msg="STILL ALIVE, TERM NOT SENT: the signal itself failed (check ownership/permissions) — this is not the process ignoring TERM, it never received one" [ "$dh_c_gone" = 1 ] || [ "$dh_c_match" = 0 ] || [ "$dh_c_killed" = 0 ] || dh_c_msg="STILL ALIVE despite TERM delivered" - note "pid $dh_child (child): $dh_c_msg" - fi - if [ "$dh_p_gone" = 1 ] && [ "$dh_c_gone" = 1 ]; then + note "pid $dh_c (child): $dh_c_msg" + done + if [ "$dh_p_gone" = 1 ] && [ "$dh_c_all_gone" = 1 ]; then step "RETIRED lane $LANE's duplicate holder of transcript $dh_uuid (Amendment 18(h))" note "The lane itself is untouched: its row, and its own live session if it has one, are unaffected." exit 0 @@ -1517,9 +1571,12 @@ DUPES fi if [ -n "${dh_rows:-}" ]; then note "the live duplicate holders of lane $LANE's transcript, by PROCESS TABLE, are:" - printf '%s\n' "$dh_rows" | while IFS=$'\037' read -r dr_parent dr_child dr_uuid; do + printf '%s\n' "$dh_rows" | while IFS=$'\037' read -r dr_parent dr_children dr_uuid; do [ -n "${dr_parent:-}" ] || continue - note " pid $dr_parent (bg-pty-host)${dr_child:+, pid $dr_child (child)} transcript $dr_uuid" + # Every child in the slot, each named on its own (#86). + dr_c_said="" + for dr_c in $dr_children; do dr_c_said="$dr_c_said, pid $dr_c (child)"; done + note " pid $dr_parent (bg-pty-host)$dr_c_said transcript $dr_uuid" done else note "no live duplicate holder of lane $LANE's transcript is in the process table either (Amendment 18(h), opensoft/openRepoTools#39); \`$LANES_EDIT duplicate-holder $LANE\` is the read that names them." diff --git a/lanes-edit.sh b/lanes-edit.sh index aadd2db1..6429136f 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -6715,7 +6715,7 @@ EOF # usually a no-op is exactly what a fence should be, and `lane-end` asks the # same question again, in words, before it sends a single signal. # -# One line per surviving match: ` +# One line per surviving match: ` # ` — `$US`, never a TAB. A CONFIRMED CHILD OF THE CI RUN ON # PR #61: with a real, honest, DOCUMENTED-empty child slot (the line just # above — "the child slot stays empty" is not a race-only rarity, it is this @@ -6862,10 +6862,12 @@ DHPLINE # THE ACTUAL `--resume` VALUE, NOT A GLOB THAT CAN SPAN OTHER ARGUMENTS # (Copilot round 5, PR #61): `*"--resume"*"/$id.jsonl"*` also matches # `--resume /other.jsonl --label /.jsonl`, since `*` between them - # freely crosses argument boundaries. The token right after `--resume ` — - # up to the next space or the end of argv — is what was actually - # resumed, and that is the only thing compared now. - dhp_resume="${dhp_args_lc#*--resume }"; dhp_resume="${dhp_resume%% *}" + # freely crosses argument boundaries. What was actually resumed is the + # value right after `--resume `, and that is the only thing compared — + # read WHOLE, spaces and all, by `dhp_resume_value` below this function + # (opensoft/openRepoTools#76: cutting it at the first space never matched + # a directory that has one). + dhp_resume_value "$dhp_args_lc"; dhp_resume="$dhp_rv" for dhp_id in $dhp_ids; do dhp_id_lc="$(printf '%s' "$dhp_id" | tr 'A-F' 'a-f')" # AN EXACT PATH COMPONENT, NOT AN ARBITRARY SUBSTRING (Copilot round 4, @@ -6905,9 +6907,10 @@ DHPLINE # which round 9 found true a wrapper with exactly one child for a # DIFFERENT transcript could exploit). Both facts are proven in one # pass, never two: - # * a candidate whose `ps -p` reads and whose `--resume` token - # matches `$dhp_id_lc` exactly is VERIFIED — accepted at once, - # however many other candidates there are; + # * a candidate whose `ps -p` reads and whose `--resume` value + # matches `$dhp_id_lc` exactly is VERIFIED — named, however many + # other candidates there are, and so is EVERY other child that + # verifies (opensoft/openRepoTools#86, below); # * a candidate whose `ps -p` reads but whose `--resume` does NOT # match is a CONFIRMED MISMATCH — a different transcript's fork, # and it is never named, however few other candidates there are; @@ -6924,6 +6927,19 @@ DHPLINE # Two or more unresolved fallbacks are left unnamed rather than # guessed between; the parent is still reported and retirable on # its own in every one of these shapes. + # + # EVERY VERIFIED CHILD, NOT THE FIRST (opensoft/openRepoTools#86, + # Copilot on 11bf0f0, PR #61): this loop used to `break` on the first + # child that verified, and the record had one child slot, so a + # wrapper with two children both resuming this transcript had the + # second left out of the report `lane-end --retire` acts on — which + # then TERMed the parent and the one child it was told about, + # reported RETIRED, and left the other still holding the transcript. + # The loop now runs to the end, and the slot carries every verified + # child, space-separated, in the order `pgrep -P` listed them. A + # pid never contains a space and `$US` is the record's only field + # separator, so `lane-end`'s `IFS=$'\037' read` still takes the + # whole list as one field. dhp_verified=""; dhp_fallback=""; dhp_fallback_n=0 for dhp_c in $dhp_kids; do case "$dhp_cand_fence" in *" $dhp_c "*) : ;; *) continue ;; esac @@ -6939,8 +6955,10 @@ DHPLINE # `--fork-session ` WITH ITS OWN TRAILING SPACE (Copilot round 11, # PR #61) — same fix, same reason as the parent's own check above. case "$dhp_c_args_lc" in *"--fork-session "*"--resume "*) : ;; *) continue ;; esac - dhp_c_resume="${dhp_c_args_lc#*--resume }"; dhp_c_resume="${dhp_c_resume%% *}" - case "$dhp_c_resume" in */"$dhp_id_lc.jsonl") dhp_verified="$dhp_c"; break ;; esac + dhp_resume_value "$dhp_c_args_lc"; dhp_c_resume="$dhp_rv" + case "$dhp_c_resume" in + */"$dhp_id_lc.jsonl") dhp_verified="${dhp_verified}${dhp_verified:+ }${dhp_c}" ;; + esac done if [ -n "$dhp_verified" ]; then dhp_child="$dhp_verified" @@ -6971,6 +6989,60 @@ EOF printf '%s' "$dhp_out" } +# THE `--resume` VALUE, READ WHOLE (opensoft/openRepoTools#76), for both of +# `duplicate_holder_pids`' matches above. It sets `dhp_rv` in the caller's own +# shell, never through `$( … )`, which would fork once per candidate, to the +# value after the FIRST `--resume ` in a flattened, already-lowercased argv, +# or to empty where there is no value it can name. +# +# `ps -o args=` JOINS THE ARGV WITH SPACES AND QUOTES NOTHING, so where one +# argument ends is gone from the line this reads. Measured with procps on +# Linux: `--resume "/home/Jane Doe/.claude/projects/p/.jsonl" +# --output-format stream-json` reads back as `--resume /home/Jane +# Doe/.claude/projects/p/.jsonl --output-format stream-json`. The cut this +# replaced, `${v%% *}`, took `/home/jane` from that and matched nothing, so a +# duplicate resumed from a home, profile or project directory with a space in +# it was never found, and `lane-end`'s pre-kill recheck, which made the same +# cut, would have withheld the TERM it is there to send. +# +# THE VALUE ENDS AT THE FIRST `.jsonl` THAT ENDS AN ARGUMENT, one followed by +# a space or by the end of the line, because the transcript path a harness +# resumes ends exactly there. That keeps both of PR #61's earlier guarantees: +# Copilot round 5's `--resume /other.jsonl --label /.jsonl` still reads +# `/other.jsonl` and never this transcript, and round 4's `.jsonl.bak` +# is still not `.jsonl`, because its `.jsonl` ends no argument. +# +# A WORD OPENING WITH `--` INSIDE IT IS A LATER OPTION, NOT A DIRECTORY, and a +# value that holds one is EMPTY. `--resume --x +# /.jsonl` has no `.jsonl` of its own to stop at, so the first one that +# ends an argument is the later option's, and naming the process from it +# would hand `lane-end --retire` a TERM for something that resumed another +# session. +# +# KNOWN LIMITS, and they are the flattening's rather than this read's. A +# directory whose own name holds `.jsonl ` or ` --` is cut short or refused, +# which only ever misses. A value that does not itself end in `.jsonl`, +# followed by a later plain word (no `--`) ending in `/.jsonl`, reads as +# one value with a space in it; that is the one shape this read can still +# mistake. Linux's `/proc//cmdline` keeps the argument boundaries and +# BSD/macOS has nothing like it, so one portable reading of `ps`, the same on +# every platform and under the suite's own fake `ps`, is kept over two that +# could disagree. `lane-end` carries the same rule for its pre-kill recheck +# (`dh_resume_value`), and the two must stay the same rule. +dhp_resume_value() { # — sets dhp_rv + dhp_rv="" + case "${1-}" in *"--resume "*) : ;; *) return 0 ;; esac + dhp_rv_rest="${1#*--resume }" + while :; do + case "$dhp_rv_rest" in *.jsonl*) : ;; *) dhp_rv=""; return 0 ;; esac + dhp_rv="${dhp_rv}${dhp_rv_rest%%.jsonl*}.jsonl" + dhp_rv_rest="${dhp_rv_rest#*.jsonl}" + case "$dhp_rv_rest" in "" | " "*) break ;; esac + done + case "$dhp_rv" in *" --"*) dhp_rv="" ;; esac + return 0 +} + # ------------------------------- decisions 6 and 7: THE LANE LISTING READ # # ONE READ, TWO LISTINGS. Decision 6 ratified the estate-wide `lanes`; decision diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 53e5d3c1..f6a9ff6f 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -95,6 +95,16 @@ cleanup() { [ -n "${DUP_CHILD:-}" ] && kill "$DUP_CHILD" 2>/dev/null [ -n "${RACE_PARENT:-}" ] && kill "$RACE_PARENT" 2>/dev/null [ -n "${RACE_CHILD:-}" ] && kill "$RACE_CHILD" 2>/dev/null + # opensoft/openRepoTools#76 and #86's fixtures. `PART_CHILD_B` ignores TERM + # on purpose, so it is the one that takes KILL. + [ -n "${SP_PARENT:-}" ] && kill "$SP_PARENT" 2>/dev/null + [ -n "${SP_CHILD:-}" ] && kill "$SP_CHILD" 2>/dev/null + [ -n "${TWO_PARENT:-}" ] && kill "$TWO_PARENT" 2>/dev/null + [ -n "${TWO_CHILD_A:-}" ] && kill "$TWO_CHILD_A" 2>/dev/null + [ -n "${TWO_CHILD_B:-}" ] && kill "$TWO_CHILD_B" 2>/dev/null + [ -n "${PART_PARENT:-}" ] && kill "$PART_PARENT" 2>/dev/null + [ -n "${PART_CHILD_A:-}" ] && kill "$PART_CHILD_A" 2>/dev/null + [ -n "${PART_CHILD_B:-}" ] && kill -KILL "$PART_CHILD_B" 2>/dev/null [ -n "${GD_DUP:-}" ] && kill "$GD_DUP" 2>/dev/null [ -n "${SANDBOX:-}" ] && [ -d "$SANDBOX" ] && rm -rf -- "$SANDBOX" return 0 @@ -5203,25 +5213,36 @@ rm -f "$sessions_dir/$DUP_CHILD-repoM1-companion.json" # absent) must not have the wrong one attached to this parent's row. Pure # discovery, no kill: fixed numbers are fine here, `lane-end --retire` is # never called against them. +# +# AND A SECOND RIGHT CHILD (opensoft/openRepoTools#86): the scan used to stop +# at the first child that verified, and the record had one child slot, so +# $AMBIG_CHILD_RIGHT2, listed by pgrep -P after $AMBIG_CHILD_RIGHT, was left +# out of the report `lane-end --retire` acts on. The slot is a space-separated +# list of every verified child now, in pgrep -P's order. AMBIG_PARENT=900101 AMBIG_CHILD_RIGHT=900102 AMBIG_CHILD_WRONG=900103 +AMBIG_CHILD_RIGHT2=900104 WRONG_ID="aaaa0007-7777-4000-8000-aaaa00077777" -FAKE_PS_AMBIG="$(printf '%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s' \ - "$AMBIG_PARENT" 1 "claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects/$MLIVE_ID.jsonl" \ - "$AMBIG_CHILD_RIGHT" "$AMBIG_PARENT" "claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects/$MLIVE_ID.jsonl" \ - "$AMBIG_CHILD_WRONG" "$AMBIG_PARENT" "claude --session-id $WRONG_ID --fork-session --resume /nonexistent/other-projects/$WRONG_ID.jsonl")" -FAKE_PGREP_F_AMBIG="$(printf '%s\n%s\n%s\n%s' "$LIVE_PID" "$AMBIG_PARENT" "$AMBIG_CHILD_WRONG" "$AMBIG_CHILD_RIGHT")" +FAKE_PS_AMBIG="$(printf '%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s' \ + "$AMBIG_PARENT" 1 "claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects/$MLIVE_ID.jsonl" \ + "$AMBIG_CHILD_RIGHT" "$AMBIG_PARENT" "claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects/$MLIVE_ID.jsonl" \ + "$AMBIG_CHILD_WRONG" "$AMBIG_PARENT" "claude --session-id $WRONG_ID --fork-session --resume /nonexistent/other-projects/$WRONG_ID.jsonl" \ + "$AMBIG_CHILD_RIGHT2" "$AMBIG_PARENT" "claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects/$MLIVE_ID.jsonl")" +FAKE_PGREP_F_AMBIG="$(printf '%s\n%s\n%s\n%s\n%s' "$LIVE_PID" "$AMBIG_PARENT" "$AMBIG_CHILD_WRONG" "$AMBIG_CHILD_RIGHT" "$AMBIG_CHILD_RIGHT2")" # THE WRONG CHILD LISTED FIRST (Copilot round 8), so a fix that merely kept # "whichever `pgrep -P` lists first" would still pass every OTHER assertion # here and only this ordering would catch it. -FAKE_PGREP_CHILDREN_AMBIG="$(printf '%s\t%s\n%s\t%s' "$AMBIG_CHILD_WRONG" "$AMBIG_PARENT" "$AMBIG_CHILD_RIGHT" "$AMBIG_PARENT")" +FAKE_PGREP_CHILDREN_AMBIG="$(printf '%s\t%s\n%s\t%s\n%s\t%s' "$AMBIG_CHILD_WRONG" "$AMBIG_PARENT" "$AMBIG_CHILD_RIGHT" "$AMBIG_PARENT" "$AMBIG_CHILD_RIGHT2" "$AMBIG_PARENT")" run env FAKE_PGREP_F_PIDS="$FAKE_PGREP_F_AMBIG" FAKE_PS_RECORDS="$FAKE_PS_AMBIG" FAKE_PGREP_CHILDREN="$FAKE_PGREP_CHILDREN_AMBIG" \ "$E" duplicate-holder repoM-1 -is "two fork-session children of one wrapper: still one row, and no crash" "$rc" 0 +is "three fork-session children of one wrapper: still one row, and no crash" "$rc" 0 has "…names the parent" "$out" "$AMBIG_PARENT" has "…and the child that actually resumes THIS transcript" "$out" "$AMBIG_CHILD_RIGHT" +has "…and the SECOND child that resumes it too, not only the first (#86)" "$out" "$AMBIG_CHILD_RIGHT2" +is "…the child slot is both, space-separated, in pgrep -P's order" \ + "$(printf '%s\n' "$out" | cut -d "$(printf '\037')" -f2)" "$AMBIG_CHILD_RIGHT $AMBIG_CHILD_RIGHT2" hasnt "…never the sibling resuming a DIFFERENT transcript, though pgrep -P lists it first" "$out" "$AMBIG_CHILD_WRONG" is "…exactly one row" "$(printf '%s\n' "$out" | grep -c .)" 1 @@ -5325,6 +5346,145 @@ is "…and the fixture pair really is gone before its pids are freed" \ "gone,gone" RACE_PARENT=""; RACE_CHILD="" +# ------------- opensoft/openRepoTools#76: a --resume path with a SPACE in it +# +# `ps -o args=` joins the argv with spaces and quotes nothing, so a duplicate +# resumed from a directory with a space in it reads back as `--resume +# /nonexistent/projects with a space/.jsonl`, and the cut both reads made +# at the first space (`${v%% *}`) took `/nonexistent/projects` from that and +# matched nothing: `duplicate-holder` answered 8 over a duplicate that was +# there, and `lane-end --retire`'s pre-kill recheck, the same cut, could only +# have withheld its TERM. Trailing options after the path, as a harness passes +# them, prove the value ends at its `.jsonl` and not at the end of the line. +# Real processes, for the reason $DUP_PARENT's comment gives. +sleep 3000 & SP_PARENT=$! +sleep 3000 & SP_CHILD=$! +SP_ARGS="claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects with a space/$MLIVE_ID.jsonl --output-format stream-json" +FAKE_PS_SP="$(printf '%s\t%s\t%s\n%s\t%s\t%s' \ + "$SP_PARENT" 1 "$SP_ARGS" \ + "$SP_CHILD" "$SP_PARENT" "$SP_ARGS")" +FAKE_PGREP_F_SP="$(printf '%s\n%s\n%s' "$LIVE_PID" "$SP_PARENT" "$SP_CHILD")" +FAKE_PGREP_CHILDREN_SP="$(printf '%s\t%s' "$SP_CHILD" "$SP_PARENT")" +run env FAKE_PGREP_F_PIDS="$FAKE_PGREP_F_SP" FAKE_PS_RECORDS="$FAKE_PS_SP" FAKE_PGREP_CHILDREN="$FAKE_PGREP_CHILDREN_SP" \ + "$E" duplicate-holder repoM-1 +is "a duplicate resumed from a directory with a SPACE in it is found (#76)" "$rc" 0 +has "…naming its parent" "$out" "$SP_PARENT" +has "…and its child, whose own --resume value is read whole too" "$out" "$SP_CHILD" +run env FAKE_PGREP_F_PIDS="$FAKE_PGREP_F_SP" FAKE_PS_RECORDS="$FAKE_PS_SP" FAKE_PGREP_CHILDREN="$FAKE_PGREP_CHILDREN_SP" \ + "$END" repoM-1 --retire "$SP_PARENT" +is "…and lane-end --retire's pre-kill recheck reads it whole as well: retired, 0" "$rc" 0 +has "…reporting RETIRED" "$err" "RETIRED lane repoM-1's duplicate holder" +has "…the parent TERMed, not withheld as an argv that no longer matched" "$err" "pid $SP_PARENT (bg-pty-host): gone" +has "…and the child" "$err" "pid $SP_CHILD (child): gone" +sleep 0.2 +is "…and both really are gone" \ + "$(kill -0 "$SP_PARENT" 2>/dev/null && echo alive || echo gone),$(kill -0 "$SP_CHILD" 2>/dev/null && echo alive || echo gone)" \ + "gone,gone" +# CLEARED ONLY ONCE GONE, the rule $DUP_PARENT's own clearing gives: a pid +# still alive here (a failed case above) stays the trap's to end. +kill -0 "$SP_PARENT" 2>/dev/null || SP_PARENT="" +kill -0 "$SP_CHILD" 2>/dev/null || SP_CHILD="" + +# …AND READING THE VALUE WHOLE REOPENS NEITHER CUT PR #61 ALREADY MADE. +# Copilot round 5's own shape, `--resume /other.jsonl --label /.jsonl`, +# still reads `/other.jsonl`: the value ends at the first `.jsonl` that ends +# an argument. And a `--resume` given a bare session id, with this +# transcript's path in a LATER option, has no `.jsonl` of its own to stop at, +# so the value would run across that option; a value holding a word that +# opens with `--` is refused rather than read across it. Discovery only, so +# fixed numbers, as the AMBIG case above says. +SPAN_PARENT_A=900201 +SPAN_PARENT_B=900202 +FAKE_PS_SPAN="$(printf '%s\t%s\t%s\n%s\t%s\t%s' \ + "$SPAN_PARENT_A" 1 "claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects/other.jsonl --label /nonexistent/projects/$MLIVE_ID.jsonl" \ + "$SPAN_PARENT_B" 1 "claude --session-id $MLIVE_ID --fork-session --resume $WRONG_ID --append-system-prompt-file /nonexistent/projects/$MLIVE_ID.jsonl")" +FAKE_PGREP_F_SPAN="$(printf '%s\n%s\n%s' "$LIVE_PID" "$SPAN_PARENT_A" "$SPAN_PARENT_B")" +run env FAKE_PGREP_F_PIDS="$FAKE_PGREP_F_SPAN" FAKE_PS_RECORDS="$FAKE_PS_SPAN" FAKE_PGREP_CHILDREN="" \ + "$E" duplicate-holder repoM-1 +is "…and a --resume value is never read across into a LATER argument: 8" "$rc" 8 +hasnt "…not round 5's --label shape, which still reads /other.jsonl" "$out" "$SPAN_PARENT_A" +hasnt "…nor a bare session id followed by an option naming this transcript" "$out" "$SPAN_PARENT_B" + +# ------- opensoft/openRepoTools#86: --retire acts on EVERY verified child +# +# A wrapper with TWO children, both resuming this transcript. Before #86 the +# discovery named only the first, `lane-end --retire` TERMed the parent and +# that one child, said RETIRED, and the second went on holding the +# transcript: Amendment 18(h)'s double holder, surviving a retirement +# reported clean. Real processes, so the TERM is proved, not asserted. +sleep 3000 & TWO_PARENT=$! +sleep 3000 & TWO_CHILD_A=$! +sleep 3000 & TWO_CHILD_B=$! +TWO_ARGS="claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects/$MLIVE_ID.jsonl" +FAKE_PS_TWO="$(printf '%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s' \ + "$TWO_PARENT" 1 "$TWO_ARGS" \ + "$TWO_CHILD_A" "$TWO_PARENT" "$TWO_ARGS" \ + "$TWO_CHILD_B" "$TWO_PARENT" "$TWO_ARGS")" +FAKE_PGREP_F_TWO="$(printf '%s\n%s\n%s\n%s' "$LIVE_PID" "$TWO_PARENT" "$TWO_CHILD_A" "$TWO_CHILD_B")" +FAKE_PGREP_CHILDREN_TWO="$(printf '%s\t%s\n%s\t%s' "$TWO_CHILD_A" "$TWO_PARENT" "$TWO_CHILD_B" "$TWO_PARENT")" +run env FAKE_PGREP_F_PIDS="$FAKE_PGREP_F_TWO" FAKE_PS_RECORDS="$FAKE_PS_TWO" FAKE_PGREP_CHILDREN="$FAKE_PGREP_CHILDREN_TWO" \ + "$END" repoM-1 --retire 777777 +is "a pid that is none of them is still refused 2 beside a two-child duplicate" "$rc" 2 +has "…and the listing it prints names BOTH children of the wrapper (#86)" "$err" "pid $TWO_PARENT (bg-pty-host), pid $TWO_CHILD_A (child), pid $TWO_CHILD_B (child)" +run env FAKE_PGREP_F_PIDS="$FAKE_PGREP_F_TWO" FAKE_PS_RECORDS="$FAKE_PS_TWO" FAKE_PGREP_CHILDREN="$FAKE_PGREP_CHILDREN_TWO" \ + "$END" repoM-1 --retire "$TWO_PARENT" --dry-run +is "--retire --dry-run on a wrapper with two verified children exits 0" "$rc" 0 +has "…naming BOTH children it would TERM (#86)" "$err" "and pid $TWO_CHILD_A (child) and pid $TWO_CHILD_B (child)" +run env FAKE_PGREP_F_PIDS="$FAKE_PGREP_F_TWO" FAKE_PS_RECORDS="$FAKE_PS_TWO" FAKE_PGREP_CHILDREN="$FAKE_PGREP_CHILDREN_TWO" \ + "$END" repoM-1 --retire "$TWO_PARENT" +is "lane-end --retire TERMs EVERY verified child of a duplicate holder, not the first (#86)" "$rc" 0 +has "…reporting RETIRED" "$err" "RETIRED lane repoM-1's duplicate holder" +has "…the parent gone" "$err" "pid $TWO_PARENT (bg-pty-host): gone" +has "…the first child gone" "$err" "pid $TWO_CHILD_A (child): gone" +has "…and the SECOND child gone, which it used to leave running" "$err" "pid $TWO_CHILD_B (child): gone" +sleep 0.2 +is "…and all three really are gone" \ + "$(kill -0 "$TWO_PARENT" 2>/dev/null && echo alive || echo gone),$(kill -0 "$TWO_CHILD_A" 2>/dev/null && echo alive || echo gone),$(kill -0 "$TWO_CHILD_B" 2>/dev/null && echo alive || echo gone)" \ + "gone,gone,gone" +kill -0 "$TWO_PARENT" 2>/dev/null || TWO_PARENT="" +kill -0 "$TWO_CHILD_A" 2>/dev/null || TWO_CHILD_A="" +kill -0 "$TWO_CHILD_B" 2>/dev/null || TWO_CHILD_B="" + +# …AND ONE CHILD STILL ALIVE, WHICHEVER IT IS, IS A PARTIAL RETIREMENT AND +# NEVER RETIRED. The SECOND child ignores TERM (an ignored signal survives +# `exec`), and it is named as the target itself, which before #86 matched +# nothing at all, because the child slot held only the first. Its `exec` is +# waited for before the act, so the TERM cannot reach the shell before the +# shell has set the trap; the real `ps` (`command -p`), because the suite's +# fake knows nothing of this process. +sleep 3000 & PART_PARENT=$! +sleep 3000 & PART_CHILD_A=$! +bash -c 'trap "" TERM; exec sleep 3000' & PART_CHILD_B=$! +part_tries=0 +while [ "$part_tries" -lt 50 ]; do + case "$(command -p ps -o comm= -p "$PART_CHILD_B" 2>/dev/null)" in *sleep*) break ;; esac + part_tries=$((part_tries + 1)); sleep 0.1 +done +PART_ARGS="claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects/$MLIVE_ID.jsonl" +FAKE_PS_PART="$(printf '%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s' \ + "$PART_PARENT" 1 "$PART_ARGS" \ + "$PART_CHILD_A" "$PART_PARENT" "$PART_ARGS" \ + "$PART_CHILD_B" "$PART_PARENT" "$PART_ARGS")" +FAKE_PGREP_F_PART="$(printf '%s\n%s\n%s\n%s' "$LIVE_PID" "$PART_PARENT" "$PART_CHILD_A" "$PART_CHILD_B")" +FAKE_PGREP_CHILDREN_PART="$(printf '%s\t%s\n%s\t%s' "$PART_CHILD_A" "$PART_PARENT" "$PART_CHILD_B" "$PART_PARENT")" +run env FAKE_PGREP_F_PIDS="$FAKE_PGREP_F_PART" FAKE_PS_RECORDS="$FAKE_PS_PART" FAKE_PGREP_CHILDREN="$FAKE_PGREP_CHILDREN_PART" \ + "$END" repoM-1 --retire "$PART_CHILD_B" +is "the SECOND child's pid is a --retire target too, and one survivor makes it partial: 1" "$rc" 1 +has "…naming the survivor as alive despite the TERM it was sent" "$err" "pid $PART_CHILD_B (child): STILL ALIVE despite TERM delivered" +has "…and the child that did go" "$err" "pid $PART_CHILD_A (child): gone" +has "…and the parent" "$err" "pid $PART_PARENT (bg-pty-host): gone" +has "…refusing as only partly retired" "$err" "was only partly retired" +hasnt "…never RETIRED over a child still holding the transcript" "$err" "RETIRED lane repoM-1's duplicate holder" +is "…and the survivor really is still running" "$(kill -0 "$PART_CHILD_B" 2>/dev/null && echo alive || echo gone)" "alive" +kill -KILL "$PART_CHILD_B" 2>/dev/null || : +sleep 0.2 +is "…and the fixture trio really is gone before its pids are freed" \ + "$(kill -0 "$PART_PARENT" 2>/dev/null && echo alive || echo gone),$(kill -0 "$PART_CHILD_A" 2>/dev/null && echo alive || echo gone),$(kill -0 "$PART_CHILD_B" 2>/dev/null && echo alive || echo gone)" \ + "gone,gone,gone" +kill -0 "$PART_PARENT" 2>/dev/null || PART_PARENT="" +kill -0 "$PART_CHILD_A" 2>/dev/null || PART_CHILD_A="" +kill -0 "$PART_CHILD_B" 2>/dev/null || PART_CHILD_B="" + # --------------------------- clause (c): lane-start RECORDS dir and profile mkdir -p "$HOME/projects/repoA11b" git init -q -b main "$HOME/projects/repoA11b" From de0809c51c9e18861c0341bb03c9164d0906798b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 14:24:38 +0000 Subject: [PATCH 2/3] A --resume value with a space in it counts only as one absolute path, starting with / and holding no ' /', in both copies of the read Brett Heap's ruling of 2026-10-18 on PR #205, verbatim "Add the condition first (Recommended)". The independent review of d4cf2ee found that reading the --resume value whole made five argv shapes that main ignores MATCH: a bare session id followed by a prompt naming the transcript (two forms), an empty argument before the path, a .jsonl.bak followed by a word naming the transcript, and a shell -c string that resumes $ID and then tails the file. That is the dangerous direction, because lane-end's pre-kill recheck uses the same rule. The condition refuses all five and keeps every real match on the reviewer's 19-case table, spaced home and project directories included; a value with no space in it reads as before. The same four lines go into lanes-edit.sh's dhp_resume_value and lane-end's dh_resume_value, which stay word for word the same. The comment above dhp_resume_value says what the condition refuses and what it still cannot tell apart, and the manual's duplicate-holder paragraph states the rule. Two rows join the SPAN case of tests/test_lane_helpers.sh: a bare session id followed by a prompt, and a .jsonl.bak followed by a word naming the transcript. With 8b16668's code they fail (duplicate-holder names both, exit 0); here they pass, and the SP case's spaced path still matches. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 4 +++- lane-end | 11 ++++++++++- lanes-edit.sh | 39 ++++++++++++++++++++++++++++++-------- tests/test_lane_helpers.sh | 22 ++++++++++++++++++--- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 8eedbc1e..1db6398c 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1742,7 +1742,9 @@ then `ps -o pid=,ppid=,args= -p` per candidate — portable across GNU and BSD/macOS), matched against every id this lane's row has ever carried by the `--resume` value read whole, a directory with a space in it included (opensoft/openRepoTools#76: `ps` joins the argv with spaces and quotes nothing, -so the value runs to the first `.jsonl` that ends an argument), and +so the value runs to the first `.jsonl` that ends an argument, and a value with +a space in it counts only as one absolute path, starting with `/` and holding +no ` /`, by Brett Heap's ruling of 2026-10-18 on PR #205), and excludes the lane's own live holder (`live_holder`, the same implementation `live-holder` already calls) **and the harness's own companion of it** — Amendment 8 ruling (g)'s `kind: bg` record carrying the same id in the same diff --git a/lane-end b/lane-end index 671ed0f1..64394c1a 100755 --- a/lane-end +++ b/lane-end @@ -1218,7 +1218,12 @@ DUPES # at the first `.jsonl` that ends an argument (followed by a space or by # the end of the line), which keeps round 5's case above reading # `/other.jsonl`, and a value with a word opening `--` inside it is - # refused as a later option rather than a directory. This is + # refused as a later option rather than a directory. A value with a + # space in it must be one absolute path, starting with `/` and holding no + # ` /`, or it is refused too (Brett Heap's ruling of 2026-10-18 on PR + # #205): a bare session id followed by a prompt naming this transcript, + # or a `.jsonl.bak` followed by a word naming it, would otherwise be + # read as one value ending in `/.jsonl` and TERMed here. This is # `lanes-edit.sh`'s `dhp_resume_value`, the rule discovery already # applied to this pid, word for word, and its comment there gives the # known limits of reading a flattened argv at all; the two must stay the @@ -1234,6 +1239,10 @@ DUPES case "$dh_rv_rest" in "" | " "*) break ;; esac done case "$dh_rv" in *" --"*) dh_rv="" ;; esac + case "$dh_rv" in *" "*) + case "$dh_rv" in /*) : ;; *) dh_rv="" ;; esac + case "$dh_rv" in *" /"*) dh_rv="" ;; esac + esac return 0 } dh_uuid_lc="$(printf '%s' "$dh_uuid" | tr 'A-F' 'a-f')" diff --git a/lanes-edit.sh b/lanes-edit.sh index 29d9440f..0df7d75c 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -7035,15 +7035,34 @@ EOF # would hand `lane-end --retire` a TERM for something that resumed another # session. # +# A VALUE WITH A SPACE IN IT MUST BE ONE ABSOLUTE PATH, starting with `/` and +# holding no ` /`, or it is EMPTY (Brett Heap's ruling of 2026-10-18 on PR +# #205, "Add the condition first (Recommended)"). A value that does not itself +# end in `.jsonl` runs on to the next `.jsonl` that ends an argument, so a +# later plain word ending in `/.jsonl` was read as its tail, where the cut +# at the first space never read it at all. Five shapes did that: a bare +# session id followed by a prompt that names the transcript (`--resume +# read /p/.jsonl and summarise`, and `claude -p … --resume summarise +# /p/.jsonl`), an empty argument before the path (`--resume '' +# /p/.jsonl`, which `ps` prints with two spaces), a `.jsonl.bak` +# followed by a word that names the transcript (`--resume /p/.jsonl.bak +# note /q/.jsonl`), and a shell whose `-c` string resumes `$ID` and then +# tails the transcript. Each of them starts with something other than `/`, or +# reaches the later path through a space and a `/`, and a transcript path does +# neither, whatever spaces its home, profile or project directory holds. A +# value with no space in it is untouched by this, and reads as the cut read it. +# # KNOWN LIMITS, and they are the flattening's rather than this read's. A -# directory whose own name holds `.jsonl ` or ` --` is cut short or refused, -# which only ever misses. A value that does not itself end in `.jsonl`, -# followed by a later plain word (no `--`) ending in `/.jsonl`, reads as -# one value with a space in it; that is the one shape this read can still -# mistake. Linux's `/proc//cmdline` keeps the argument boundaries and -# BSD/macOS has nothing like it, so one portable reading of `ps`, the same on -# every platform and under the suite's own fake `ps`, is kept over two that -# could disagree. `lane-end` carries the same rule for its pre-kill recheck +# directory whose own name holds `.jsonl ` or ` --`, or ends in a space before +# its `/`, is cut short or refused, which only ever misses. The one shape this +# read can still mistake is narrower now: a value that starts with `/` but +# does not itself end in `.jsonl`, followed by a later plain word that names +# `.jsonl` by a RELATIVE path (`--resume /p/.jsonl.bak note +# q/.jsonl`), which reaches it through no ` /`. Linux's +# `/proc//cmdline` keeps the argument boundaries and BSD/macOS has +# nothing like it, so one portable reading of `ps`, the same on every +# platform and under the suite's own fake `ps`, is kept over two that could +# disagree. `lane-end` carries the same rule for its pre-kill recheck # (`dh_resume_value`), and the two must stay the same rule. dhp_resume_value() { # — sets dhp_rv dhp_rv="" @@ -7056,6 +7075,10 @@ dhp_resume_value() { # — sets dhp_rv case "$dhp_rv_rest" in "" | " "*) break ;; esac done case "$dhp_rv" in *" --"*) dhp_rv="" ;; esac + case "$dhp_rv" in *" "*) + case "$dhp_rv" in /*) : ;; *) dhp_rv="" ;; esac + case "$dhp_rv" in *" /"*) dhp_rv="" ;; esac + esac return 0 } diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 36a91e6e..d8c8b083 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -5437,17 +5437,33 @@ kill -0 "$SP_CHILD" 2>/dev/null || SP_CHILD="" # so the value would run across that option; a value holding a word that # opens with `--` is refused rather than read across it. Discovery only, so # fixed numbers, as the AMBIG case above says. +# +# …NOR ACROSS A LATER PLAIN WORD THAT NAMES THIS TRANSCRIPT (Brett Heap's +# ruling of 2026-10-18 on PR #205, "Add the condition first (Recommended)"). +# A bare session id followed by a prompt, and a `.jsonl.bak` followed by +# a word naming the transcript, hold no `--` to refuse, so the value ran on to +# the later `/.jsonl` and matched, where the cut at the first space never +# had. A value with a space in it must be one absolute path, starting with `/` +# and holding no ` /`: $SPAN_PARENT_C's starts with the id, and +# $SPAN_PARENT_D's reaches the later path through ` /`. The SP case above is +# the spaced path that must still match, and does. SPAN_PARENT_A=900201 SPAN_PARENT_B=900202 -FAKE_PS_SPAN="$(printf '%s\t%s\t%s\n%s\t%s\t%s' \ +SPAN_PARENT_C=900203 +SPAN_PARENT_D=900204 +FAKE_PS_SPAN="$(printf '%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s' \ "$SPAN_PARENT_A" 1 "claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects/other.jsonl --label /nonexistent/projects/$MLIVE_ID.jsonl" \ - "$SPAN_PARENT_B" 1 "claude --session-id $MLIVE_ID --fork-session --resume $WRONG_ID --append-system-prompt-file /nonexistent/projects/$MLIVE_ID.jsonl")" -FAKE_PGREP_F_SPAN="$(printf '%s\n%s\n%s' "$LIVE_PID" "$SPAN_PARENT_A" "$SPAN_PARENT_B")" + "$SPAN_PARENT_B" 1 "claude --session-id $MLIVE_ID --fork-session --resume $WRONG_ID --append-system-prompt-file /nonexistent/projects/$MLIVE_ID.jsonl" \ + "$SPAN_PARENT_C" 1 "claude --session-id $MLIVE_ID --fork-session --resume $WRONG_ID read /nonexistent/projects/$MLIVE_ID.jsonl and summarise" \ + "$SPAN_PARENT_D" 1 "claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects/$MLIVE_ID.jsonl.bak note /nonexistent/other-projects/$MLIVE_ID.jsonl")" +FAKE_PGREP_F_SPAN="$(printf '%s\n%s\n%s\n%s\n%s' "$LIVE_PID" "$SPAN_PARENT_A" "$SPAN_PARENT_B" "$SPAN_PARENT_C" "$SPAN_PARENT_D")" run env FAKE_PGREP_F_PIDS="$FAKE_PGREP_F_SPAN" FAKE_PS_RECORDS="$FAKE_PS_SPAN" FAKE_PGREP_CHILDREN="" \ "$E" duplicate-holder repoM-1 is "…and a --resume value is never read across into a LATER argument: 8" "$rc" 8 hasnt "…not round 5's --label shape, which still reads /other.jsonl" "$out" "$SPAN_PARENT_A" hasnt "…nor a bare session id followed by an option naming this transcript" "$out" "$SPAN_PARENT_B" +hasnt "…nor a bare session id followed by a PROMPT naming it, with no -- to refuse (#205 ruling)" "$out" "$SPAN_PARENT_C" +hasnt "…nor a .jsonl.bak followed by a later word naming it (#205 ruling)" "$out" "$SPAN_PARENT_D" # ------- opensoft/openRepoTools#86: --retire acts on EVERY verified child # From 0cf74176e74577c69b0300611092e700dd6e806a Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 14:56:26 +0000 Subject: [PATCH 3/3] Pin the start-with-/ half of the --resume condition with its own SPAN row, test that lane-end's copy of the read stays identical to lanes-edit.sh's, and say exactly which shapes the rule still leaves open The independent delta review of de0809c gave LAND with these non-blocking points, all done here. A third SPAN row, 900205 (`--resume summarise nonexistent/projects/.jsonl`), holds no ' /', so only the start-with-/ line of the condition refuses it. Rows 900203 and 900204 are both refused by the ' /' half alone, so deleting lanes-edit.sh's start-with-/ line still gave 75 passed. lane-end's dh_resume_value had no test: removing it alone still gave 75 passed. A case in the repoM-1 block now extracts dhp_resume_value from lanes-edit.sh and dh_resume_value from lane-end by awk, maps the dhp_/dh_ prefix, takes lane-end's indent off, checks that each extraction is a whole function and compares the two with diff. Three comments now say what the rule does: - lanes-edit.sh's known limits no longer call the open shape "a RELATIVE path": it is a later word that does not begin with `/`, which also covers `~/`, `$HOME/`, `file:///`, `path=/` and `(/`. - lane-end no longer implies that every .jsonl.bak form is refused: only one whose later path is reached through ' /' is. - lanes-edit.sh's "a transcript path does neither" now excepts a directory whose name ends in a space before its `/`, which the known limits cover. Co-Authored-By: Claude Sonnet 5.5 --- lane-end | 17 +++++++----- lanes-edit.sh | 13 +++++---- tests/test_lane_helpers.sh | 54 +++++++++++++++++++++++++++++++++----- 3 files changed, 66 insertions(+), 18 deletions(-) diff --git a/lane-end b/lane-end index 64394c1a..7eccde15 100755 --- a/lane-end +++ b/lane-end @@ -1221,13 +1221,16 @@ DUPES # refused as a later option rather than a directory. A value with a # space in it must be one absolute path, starting with `/` and holding no # ` /`, or it is refused too (Brett Heap's ruling of 2026-10-18 on PR - # #205): a bare session id followed by a prompt naming this transcript, - # or a `.jsonl.bak` followed by a word naming it, would otherwise be - # read as one value ending in `/.jsonl` and TERMed here. This is - # `lanes-edit.sh`'s `dhp_resume_value`, the rule discovery already - # applied to this pid, word for word, and its comment there gives the - # known limits of reading a flattened argv at all; the two must stay the - # same rule. It sets `dh_rv` in this shell rather than printing it. + # #205): a bare session id followed by a prompt naming this transcript, or + # a `.jsonl.bak` followed by a word that reaches it through ` /`, + # would otherwise be read as one value ending in `/.jsonl` and TERMed + # here. Not every `.jsonl.bak` form is refused: one followed by a + # later word that does not begin with `/` still reads as that value. This + # is `lanes-edit.sh`'s `dhp_resume_value`, the rule discovery already + # applied to this pid, word for word, and its comment there gives that + # limit and the others of reading a flattened argv at all; the two must + # stay the same rule. It sets `dh_rv` in this shell rather than printing + # it. dh_resume_value() { # — sets dh_rv dh_rv="" case "${1-}" in *"--resume "*) : ;; *) return 0 ;; esac diff --git a/lanes-edit.sh b/lanes-edit.sh index 0df7d75c..b31e7c19 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -7049,16 +7049,19 @@ EOF # note /q/.jsonl`), and a shell whose `-c` string resumes `$ID` and then # tails the transcript. Each of them starts with something other than `/`, or # reaches the later path through a space and a `/`, and a transcript path does -# neither, whatever spaces its home, profile or project directory holds. A -# value with no space in it is untouched by this, and reads as the cut read it. +# neither, whatever spaces its home, profile or project directory holds, save +# a directory whose name ENDS in a space before its `/`, which puts a ` /` +# inside the path, so it is refused (see the known limits below). A value with +# no space in it is untouched by this, and reads as the cut read it. # # KNOWN LIMITS, and they are the flattening's rather than this read's. A # directory whose own name holds `.jsonl ` or ` --`, or ends in a space before # its `/`, is cut short or refused, which only ever misses. The one shape this # read can still mistake is narrower now: a value that starts with `/` but -# does not itself end in `.jsonl`, followed by a later plain word that names -# `.jsonl` by a RELATIVE path (`--resume /p/.jsonl.bak note -# q/.jsonl`), which reaches it through no ` /`. Linux's +# does not itself end in `.jsonl`, followed by a later word that names +# `.jsonl` and does not begin with `/` (`--resume /p/.jsonl.bak note +# q/.jsonl`, and likewise a word opening `~/`, `$HOME/`, `file:///`, +# `path=/` or `(/`), which reaches it through no ` /`. Linux's # `/proc//cmdline` keeps the argument boundaries and BSD/macOS has # nothing like it, so one portable reading of `ps`, the same on every # platform and under the suite's own fake `ps`, is kept over two that could diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index d8c8b083..5bbccefa 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -5444,19 +5444,26 @@ kill -0 "$SP_CHILD" 2>/dev/null || SP_CHILD="" # a word naming the transcript, hold no `--` to refuse, so the value ran on to # the later `/.jsonl` and matched, where the cut at the first space never # had. A value with a space in it must be one absolute path, starting with `/` -# and holding no ` /`: $SPAN_PARENT_C's starts with the id, and -# $SPAN_PARENT_D's reaches the later path through ` /`. The SP case above is -# the spaced path that must still match, and does. +# and holding no ` /`, and each half needs a row that only IT refuses: +# $SPAN_PARENT_C's starts with the id AND reaches its path through ` /`, +# $SPAN_PARENT_D's starts with `/` and reaches the later path through ` /` +# alone, and $SPAN_PARENT_E's starts with the id and holds no ` /` at all, so +# only the start-with-`/` half refuses it (deleting that line from +# `dhp_resume_value` left every row above passing, measured on de0809c, since +# C and D are both refused by the ` /` half). The SP case above is the spaced +# path that must still match, and does. SPAN_PARENT_A=900201 SPAN_PARENT_B=900202 SPAN_PARENT_C=900203 SPAN_PARENT_D=900204 -FAKE_PS_SPAN="$(printf '%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s' \ +SPAN_PARENT_E=900205 +FAKE_PS_SPAN="$(printf '%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s' \ "$SPAN_PARENT_A" 1 "claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects/other.jsonl --label /nonexistent/projects/$MLIVE_ID.jsonl" \ "$SPAN_PARENT_B" 1 "claude --session-id $MLIVE_ID --fork-session --resume $WRONG_ID --append-system-prompt-file /nonexistent/projects/$MLIVE_ID.jsonl" \ "$SPAN_PARENT_C" 1 "claude --session-id $MLIVE_ID --fork-session --resume $WRONG_ID read /nonexistent/projects/$MLIVE_ID.jsonl and summarise" \ - "$SPAN_PARENT_D" 1 "claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects/$MLIVE_ID.jsonl.bak note /nonexistent/other-projects/$MLIVE_ID.jsonl")" -FAKE_PGREP_F_SPAN="$(printf '%s\n%s\n%s\n%s\n%s' "$LIVE_PID" "$SPAN_PARENT_A" "$SPAN_PARENT_B" "$SPAN_PARENT_C" "$SPAN_PARENT_D")" + "$SPAN_PARENT_D" 1 "claude --session-id $MLIVE_ID --fork-session --resume /nonexistent/projects/$MLIVE_ID.jsonl.bak note /nonexistent/other-projects/$MLIVE_ID.jsonl" \ + "$SPAN_PARENT_E" 1 "claude --session-id $MLIVE_ID --fork-session --resume $WRONG_ID summarise nonexistent/projects/$MLIVE_ID.jsonl")" +FAKE_PGREP_F_SPAN="$(printf '%s\n%s\n%s\n%s\n%s\n%s' "$LIVE_PID" "$SPAN_PARENT_A" "$SPAN_PARENT_B" "$SPAN_PARENT_C" "$SPAN_PARENT_D" "$SPAN_PARENT_E")" run env FAKE_PGREP_F_PIDS="$FAKE_PGREP_F_SPAN" FAKE_PS_RECORDS="$FAKE_PS_SPAN" FAKE_PGREP_CHILDREN="" \ "$E" duplicate-holder repoM-1 is "…and a --resume value is never read across into a LATER argument: 8" "$rc" 8 @@ -5464,6 +5471,41 @@ hasnt "…not round 5's --label shape, which still reads /other.jsonl" "$out" "$ hasnt "…nor a bare session id followed by an option naming this transcript" "$out" "$SPAN_PARENT_B" hasnt "…nor a bare session id followed by a PROMPT naming it, with no -- to refuse (#205 ruling)" "$out" "$SPAN_PARENT_C" hasnt "…nor a .jsonl.bak followed by a later word naming it (#205 ruling)" "$out" "$SPAN_PARENT_D" +hasnt "…nor a bare session id followed by a RELATIVE path naming it, which only the start-with-/ half refuses (#205 ruling)" "$out" "$SPAN_PARENT_E" + +# …AND THE TWO COPIES OF THAT READ ARE ONE TEXT. The rows above drive +# discovery only, so `lane-end`'s own copy, `dh_resume_value` (its pre-kill +# recheck), could lose its condition and every case here would still pass +# (measured on de0809c: removing that copy alone left 75 passed). Its comment +# says the two "must stay the same rule", and a rule held by two copies is held +# by nothing until a case compares them. Each body is EXTRACTED FROM ITS FILE, +# like `/restart`'s rung 4 and `cut_to_line` beside it, and not copied here: a +# copy is what would go on passing after the file drifted. Only the name prefix +# (`dhp_` in `lanes-edit.sh`, `dh_` in `lane-end`) may differ, and `lane-end`'s +# copy sits inside a function, so its four-space indent is taken off. The +# address is awk's own match on the function's line and its closing brace, not +# `sed -n '/…/,/^}/p'`: `lane-end`'s closing brace is indented, and an +# unescaped `{` in a BSD `sed` BRE is undefined (the `cut_to_line` note). +resume_body() { # — the function's own lines, indent off + awk -v name="$2" -v ind="$3" ' + !on && index($0, ind name "() {") == 1 { on = 1 } + on { print substr($0, length(ind) + 1) } + on && $0 == ind "}" { exit }' "$1" +} +resume_body "$E" dhp_resume_value "" | sed 's/dhp_/dh_/g' > "$SANDBOX/resume_body.lanes-edit" +resume_body "$END" dh_resume_value " " > "$SANDBOX/resume_body.lane-end" +resume_body_whole() { # — "whole" when it opens at the function and ends at its brace + local rbw_t + rbw_t="$(cat "$1")" + case "$rbw_t" in + "dh_resume_value() {"*$'\n'"}") printf 'whole' ;; + *) printf 'EMPTY-OR-CUT' ;; + esac +} +is "both copies of the --resume read were found in their files, opening at the function and ending at its closing brace" \ + "$(resume_body_whole "$SANDBOX/resume_body.lanes-edit") $(resume_body_whole "$SANDBOX/resume_body.lane-end")" "whole whole" +is "lane-end's dh_resume_value is lanes-edit.sh's dhp_resume_value, word for word, name prefix aside (#205 review)" \ + "$(diff "$SANDBOX/resume_body.lane-end" "$SANDBOX/resume_body.lanes-edit" 2>&1)" "" # ------- opensoft/openRepoTools#86: --retire acts on EVERY verified child #