diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 6a2e266..fd5adde 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1755,7 +1755,12 @@ 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 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 @@ -1765,11 +1770,12 @@ tree that cannot be read for that test is a read that failed, never "no companion", and a register that cannot be read is never "no duplicate": the row's ids are the whole search, so it exits 1 and names the read (opensoft/openRepoTools#67). `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 cde8c28..7eccde1 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,48 @@ 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. 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 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 + 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 + 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')" 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 +1259,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 +1274,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 +1344,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 +1583,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 acbd2a1..61b7889 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -6874,7 +6874,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 @@ -7030,10 +7030,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, @@ -7073,9 +7075,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; @@ -7092,6 +7095,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 @@ -7107,8 +7123,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" @@ -7139,6 +7157,86 @@ 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. +# +# 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, 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 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 +# 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 + case "$dhp_rv" in *" "*) + case "$dhp_rv" in /*) : ;; *) dhp_rv="" ;; esac + case "$dhp_rv" in *" /"*) dhp_rv="" ;; esac + 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 dc935f5..1491626 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -97,6 +97,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 # #180's short checkout root for the clause (c) TAIL case, which is outside # `$SANDBOX` wherever the run root is long. @@ -5420,25 +5430,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 @@ -5542,6 +5563,203 @@ 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. +# +# …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 ` /`, 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 +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" \ + "$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 +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" +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 +# +# 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"