From fc061fe3177fe4fe078af76d45c657abc3d79d3b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 19 Oct 2026 22:55:23 +0000 Subject: [PATCH 1/2] lane-handoff reads the lane lifecycle whole and parses it afterwards, so a filter that exits early can no longer turn the producer's SIGPIPE into a lifecycle that could not be read (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lane-handoff read `lanes-edit.sh lane-state` as `lane-state | awk '… { print $2; exit }'` under `set -o pipefail`. That awk exits at the line it wants, so a lane-state that wrote once more after it - the builtin printf hands a multi-line snapshot to the pipe in pieces - died of SIGPIPE, the substitution's status was 141, and the case on it took that for a read that failed: "the lane lifecycle could not be read", no fence, no inventory and no RUNNING -> SWAPPING -> SWAPPED, on timing alone (CI run 39322387035). The reader had stopped listening; the read had not failed. Both lifecycle reads - the state, and the operation of a lane still SWAPPING - now capture lane-state's whole output first, take the status from that command alone, and parse the variable with the same awk filter. A lane-state that exits 0 gives the values the old pipeline gave whenever it did not race, 8 and every other status keep their meanings, and the note texts are unchanged. The other 32 early-exit pipelines in lane-handoff (30 `awk … exit`, 2 `head -n 1`) were audited and left: none has a command producer that can write after its consumer stops and a status that is read (a printf of one variable, one short line, a status never read, or a consumer that reads to the end). Two tests next to T018's hold the race every time: a lane-state that prints its state line, waits 0.3 s and prints the rest, in front of the old read shape (premise: `RUNNING 141`) and in front of the handoff. mawk, this workstation's awk, hands its first record over only at EOF or after hundreds of KB, so the old pipeline cannot race on a snapshot this small there; the command under test gets an awk that does not wait (`mawk -W interactive`, or the system's own where it is not mawk). Lane: openRepoTools-3 Co-Authored-By: Claude Sonnet 5.5 --- lane-handoff | 22 +++- tests/test_triad_lane_tooling_handoff.py | 146 ++++++++++++++++++++++- 2 files changed, 161 insertions(+), 7 deletions(-) diff --git a/lane-handoff b/lane-handoff index 3c09e0b1..3505e726 100755 --- a/lane-handoff +++ b/lane-handoff @@ -1486,15 +1486,25 @@ lc_on=""; lc_gen=""; lc_op=""; lc_state=""; lc_inventory_missed="" lifecycle_begin() { lc_on=""; lc_gen=""; lc_op=""; lc_state="" - lcb_now=""; lcb_rc=0 - lcb_now="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-state "$lane" 2>/dev/null \ - | awk -F"\t" '$1 == "state" { print $2; exit }')" || lcb_rc=$? + lcb_now=""; lcb_raw=""; lcb_rc=0 + # READ WHOLE, THEN PARSE (opensoft/openRepoTools#280). This read was + # `lane-state | awk '… { print $2; exit }'` under `pipefail`, and that `awk` + # exits at the line it wants: a `lane-state` that wrote once more after it + # — its builtin `printf` hands a multi-line snapshot to the pipe in pieces — + # died of SIGPIPE, the substitution's status was 141, and the `case` below + # took that for a read that FAILED: "could not be read", no fence, no + # inventory and no RUNNING -> SWAPPING -> SWAPPED, on timing alone (CI run + # 39322387035). The reader had stopped listening; the read had not failed. + # So the status below is `lane-state`'s own, from a command nothing can end + # early, and the state is parsed out of what it printed afterwards. + lcb_raw="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-state "$lane" 2>/dev/null)" || lcb_rc=$? case "$lcb_rc" in 0 | 8) : ;; *) note "the lane lifecycle could not be read (\`$LANES_EDIT lane-state $lane\` exited $lcb_rc), so this handoff records no RUNNING -> SWAPPING -> SWAPPED transition. The swap itself goes on (\`R-A11-11\`) and the PAUSED record below is unaffected." return 0 ;; esac + lcb_now="$(printf '%s\n' "$lcb_raw" | awk -F"\t" '$1 == "state" { print $2; exit }')" [ -n "$lcb_now" ] || lcb_now=none # AN INTERRUPTED SWAP IS TAKEN OVER, NOT COMPETED WITH. A lane still # reading `SWAPPING` when a new handoff starts is one whose previous @@ -1502,8 +1512,10 @@ lifecycle_begin() { # which is precisely what refuses the old finalizer if it ever wakes up. # The old operation id is named rather than lost. if [ "$lcb_now" = SWAPPING ]; then - lcb_old="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-state "$lane" 2>/dev/null \ - | awk -F"\t" '$1 == "operation" { print $2; exit }')" + # Read whole, then parsed, for the reason given above (#280); its status + # was never read, and the operation is only named in the note below. + lcb_old_raw="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-state "$lane" 2>/dev/null)" + lcb_old="$(printf '%s\n' "$lcb_old_raw" | awk -F"\t" '$1 == "operation" { print $2; exit }')" note "lane $lane is still recorded SWAPPING from operation ${lcb_old:-unknown}, which never finished: this handoff SUPERSEDES it with a new generation, and that is what refuses the old finalizer if it ever returns. Nothing of that operation is undone here." fi lcb_out=""; lcb_src=0 diff --git a/tests/test_triad_lane_tooling_handoff.py b/tests/test_triad_lane_tooling_handoff.py index 0dbdfad0..7b009b97 100644 --- a/tests/test_triad_lane_tooling_handoff.py +++ b/tests/test_triad_lane_tooling_handoff.py @@ -96,9 +96,9 @@ def field(out: str, key: str) -> list: def handoff(b: Bench, lane: str, cwd: Path, why: str = "t018 handoff", - *flags: str) -> subprocess.CompletedProcess: + *flags: str, env: dict | None = None) -> subprocess.CompletedProcess: return b.tool("lane-handoff", "--lane", lane, "--transcript", "none", "--agent", "claude", - *flags, why, cwd=cwd) + *flags, why, cwd=cwd, env=env) def writers_file(b: Bench, lane: str, rows: dict) -> Path: @@ -789,6 +789,148 @@ def test_t018_a_leg_tree_add_made_is_withheld_where_the_legs_git_is_an_empty_dir assert inventory(b, "triad-1") == want +# ===================================================================== #280 + +#: A `lanes-edit.sh` that is the bench's own for every verb but `lane-state`, and +#: answers that one in TWO PIECES - the `state` line, a pause, then the rest of +#: the snapshot - as the real one's builtin `printf` can when its reader is quick +#: enough to see the first piece before the second is written. `real` is the +#: bench's copy, `calls` the file each `lane-state` call is logged to, and `path` +#: the bench's own PATH, which it puts back before it runs anything: the command +#: it serves has an `awk` of its own in front (see `quick_awk`). +SLOW_LANE_STATE = """#!/bin/sh +PATH='{path}' +real='{real}' +if [ "$1" = lane-state ]; then + printf '%s\\n' "$*" >> '{calls}' + out="$("$real" "$@")" + rc=$? + if [ "$rc" != 0 ]; then + [ -z "$out" ] || printf '%s\\n' "$out" + exit "$rc" + fi + first="$(printf '%s\\n' "$out" | sed -n 1p)" + rest="$(printf '%s\\n' "$out" | sed 1d)" + printf '%s\\n' "$first" + sleep 0.3 + printf '%s\\n' "$rest" + exit 0 +fi +exec "$real" "$@" +""" + +#: `lane-handoff`'s lifecycle read as it was written before #280: the producer +#: piped into an `awk` that exits at its first match, under `pipefail`. +OLD_LIFECYCLE_READ = r'''set -uo pipefail +rc=0 +now="$("$1" lane-state "$2" 2>/dev/null | awk -F"\t" '$1 == "state" { print $2; exit }')" || rc=$? +printf '%s %s\n' "$now" "$rc" +''' + + +def quick_awk(b: Bench) -> str: + """A PATH, for ONE command, whose `awk` hands each record to its program as + it arrives. mawk - the awk of Debian and Ubuntu - reads until its buffer is + full or its input ends before it hands over a first record (more than 300 KB + and at most 1 MB, measured with mawk 1.3.4 20240123), so where it is the awk + the old pipeline cannot race on a snapshot of 600 bytes, whatever the + producer does; gawk and the one-true-awk hand a record over at once, and so + does `mawk -W interactive`. + The shim is `mawk -W interactive` where the system's awk is mawk, and the + system's own otherwise.""" + real = shutil.which("awk", path=b.env["PATH"]) + assert real, "no awk on the bench's PATH" + said = subprocess.run([real, "-W", "version"], capture_output=True, text=True, + stdin=subprocess.DEVNULL) + flags = "-W interactive " if (said.stdout + said.stderr).startswith("mawk") else "" + shim = b.root / "quick-awk" / "awk" + shim.parent.mkdir() + shim.write_text(f"#!/bin/sh\nexec '{real}' {flags}\"$@\"\n") + shim.chmod(0o755) + return os.pathsep.join([str(shim.parent), b.env["PATH"]]) + + +def slow_lane_state(b: Bench) -> tuple: + """THE PRODUCER THAT OUTLIVES ITS READER, as the environment of ONE command + (the quick `awk` in front, `$LANES_EDIT` the two-piece wrapper), and the file + the wrapper logs each `lane-state` call to.""" + wrapper = b.root / "slow-lane-state" / "lanes-edit.sh" + wrapper.parent.mkdir() + calls = wrapper.parent / "calls" + wrapper.write_text(SLOW_LANE_STATE.format(path=b.env["PATH"], real=b.bin / "lanes-edit.sh", + calls=calls)) + wrapper.chmod(0o755) + return {"LANES_EDIT": str(wrapper), "PATH": quick_awk(b), "LANES_NO_FETCH": "1"}, calls + + +def lane_state(b: Bench, lane: str) -> dict: + proc = b.tool("lanes-edit.sh", "lane-state", lane) + assert proc.returncode == 0, proc.stderr + return dict(ln.split("\t", 1) for ln in proc.stdout.splitlines() if "\t" in ln) + + +@NEEDS_UPSTREAM +def test_280_a_lifecycle_read_whose_producer_writes_after_the_filter_exits_is_still_a_read(triad): + """#280: `lane-handoff` read the lane's lifecycle as `lane-state | awk '$1 == + "state" { print $2; exit }'` under `set -o pipefail`, and that `awk` exits at + the first line. A producer that writes once more after that dies of SIGPIPE - + the real one's builtin `printf` hands a multi-line snapshot to the pipe in + pieces, and a reader quick enough sees the first before the last is written - + so the substitution's status was 141, the handoff said "the lane lifecycle + could not be read" and recorded no RUNNING -> SWAPPING -> SWAPPED transition + (CI run 39322387035, which passed one prefix down and in every run before + it). The race is held here EVERY time, by a `lane-state` that prints its + `state` line, waits 0.3 s and prints the rest. THE PREMISE FIRST: the old + shape of the read, run on that producer, gives `RUNNING 141`. Then the + handoff reads the snapshot whole and takes the status from `lane-state` + alone, so it records the transition as it does when the producer is quick.""" + b, t = triad + a = t["assembly"] + env, calls = slow_lane_state(b) + old = b.run("bash", "-c", OLD_LIFECYCLE_READ, "_", env["LANES_EDIT"], "triad-1", env=env) + assert (old.returncode, old.stdout) == (0, "RUNNING 141\n"), (old.stdout, old.stderr) + calls.write_text("") # the premise logged a call of its own + + proc = handoff(b, "triad-1", a, "t280 handoff", env=env) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "lane-state triad-1" in calls.read_text().splitlines(), \ + "the handoff never read the lifecycle through the wrapper" + assert "the lane lifecycle could not be read" not in proc.stderr, proc.stderr + assert "exited 141" not in proc.stderr, proc.stderr + assert "lifecycle: RUNNING -> SWAPPING (generation " in proc.stderr, proc.stderr + assert "lifecycle: SWAPPING -> SWAPPED (generation " in proc.stderr, proc.stderr + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + assert lane_state(b, "triad-1")["state"] == "SWAPPED" + + +@NEEDS_UPSTREAM +def test_280_a_swapping_lane_read_the_same_way_still_names_the_operation_it_supersedes(triad): + """#280, the second read: a lane still recorded SWAPPING is read once more + for the operation that never finished, through the same pipeline, and the + handoff names it in the note that says it supersedes it. Read whole and + parsed afterwards, that operation comes out of a producer that writes in two + pieces too, and the lane ends SWAPPED under a new operation.""" + b, t = triad + a = t["assembly"] + env, calls = slow_lane_state(b) + took = b.tool("lanes-edit.sh", "set-lane-state", "triad-1", "SWAPPING", "--expect", "RUNNING", + "--operation", "op-stuck-280") + assert took.returncode == 0, took.stderr + assert lane_state(b, "triad-1")["operation"] == "op-stuck-280" + + proc = handoff(b, "triad-1", a, "t280 handoff", env=env) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "lane-state triad-1" in calls.read_text().splitlines(), \ + "the handoff never read the lifecycle through the wrapper" + assert "the lane lifecycle could not be read" not in proc.stderr, proc.stderr + assert ("lane triad-1 is still recorded SWAPPING from operation op-stuck-280, which never " + "finished") in proc.stderr, proc.stderr + assert "lifecycle: SWAPPING -> SWAPPING (generation " in proc.stderr, proc.stderr + assert "lifecycle: SWAPPING -> SWAPPED (generation " in proc.stderr, proc.stderr + after = lane_state(b, "triad-1") + assert after["state"] == "SWAPPED" and after["operation"] != "op-stuck-280", after + + # ===================================================== the single repository def single_repository_decoys(bench: Bench) -> list: From ae752a73550ed27709310d68a6f72222f781a3c7 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 00:46:50 +0000 Subject: [PATCH 2/2] #280 review: the test's fake lane-state pads the pipe instead of sleeping, so the old read's 141 no longer depends on timing, and the awk shim goes (#280) The review of fc061fe came back LAND with one SHOULD and three NITs; this takes them all, in the test file only. fc061fe is not touched. SHOULD - the premise was a race of its own. The fake lane-state wrote its state line, slept 0.3 s and wrote the rest, so an awk that stalled for longer than the pause read it all, the old read gave `RUNNING 0`, and the premise assertion failed on correct code. It now writes the state line, 1.5 MiB of padding and then the rest of the record. The padding's key, `pad`, is no field lane-state prints, so no filter in lane-handoff can take it for one. A pipe holds 64 KiB, so that producer is still writing when a reader that stops at the first line has closed: its next write gets SIGPIPE, the wrapper dies of it, and the old read gives 141 whatever the timing and whatever the awk. Plain mawk is included - it reads ahead until its buffer is full (the end of input at 255 KiB or less, an early stop at 320 KiB or more), and the padding is far above that. The quick-awk shim, its PATH entry and the wrapper's PATH reset are gone, and the premise assertion (the old read shape gives `RUNNING 141`) stays and is now deterministic. NIT 2 - the tests no longer set LANES_NO_FETCH=1 for the whole handoff; lane-handoff sets it on its own reads. The premise script, which reproduces the old line, carries it as that line did. NIT 3 - the second test's docstring says what it pins: the note's content and the first read on a SWAPPING lane. It cannot pin the second read's own fix, whose status was never read, so reverting that site alone still passes. NIT 4 - quick_awk's docstring went with the shim. Lane: openRepoTools-3 Co-Authored-By: Claude Sonnet 5.5 --- tests/test_triad_lane_tooling_handoff.py | 88 +++++++++++------------- 1 file changed, 39 insertions(+), 49 deletions(-) diff --git a/tests/test_triad_lane_tooling_handoff.py b/tests/test_triad_lane_tooling_handoff.py index 7b009b97..eee65abd 100644 --- a/tests/test_triad_lane_tooling_handoff.py +++ b/tests/test_triad_lane_tooling_handoff.py @@ -792,14 +792,21 @@ def test_t018_a_leg_tree_add_made_is_withheld_where_the_legs_git_is_an_empty_dir # ===================================================================== #280 #: A `lanes-edit.sh` that is the bench's own for every verb but `lane-state`, and -#: answers that one in TWO PIECES - the `state` line, a pause, then the rest of -#: the snapshot - as the real one's builtin `printf` can when its reader is quick -#: enough to see the first piece before the second is written. `real` is the -#: bench's copy, `calls` the file each `lane-state` call is logged to, and `path` -#: the bench's own PATH, which it puts back before it runs anything: the command -#: it serves has an `awk` of its own in front (see `quick_awk`). +#: answers that one in PIECES - the `state` line, 1.5 MiB of padding, then the +#: rest of the snapshot - so that its reader has gone before it has finished, +#: WHATEVER THE TIMING AND WHATEVER THE AWK. A pipe holds 64 KiB, so a producer +#: with 1.5 MiB to write is still writing when a reader that stops at the first +#: line closes; its next write gets SIGPIPE and the wrapper dies of it, as the +#: real one can when a reader is quick enough to leave between its writes (its +#: builtin `printf` writes a multi-line snapshot a line at a time). A pause +#: instead of the padding was a race of its own: an awk that stalled for longer +#: than the pause read it all. mawk reads ahead until its buffer is full - the +#: review of fc061fe measured the end of input at 255 KiB or less and an early +#: stop at 320 KiB or more - so the padding stays far above that. Its key, `pad`, +#: is no field `lane-state` prints, so nothing in `lane-handoff` takes it for +#: one. `real` is the bench's copy, `calls` the file each `lane-state` call is +#: logged to, `pad` one padding line (a real TAB) and `pad_lines` how many. SLOW_LANE_STATE = """#!/bin/sh -PATH='{path}' real='{real}' if [ "$1" = lane-state ]; then printf '%s\\n' "$*" >> '{calls}' @@ -812,55 +819,35 @@ def test_t018_a_leg_tree_add_made_is_withheld_where_the_legs_git_is_an_empty_dir first="$(printf '%s\\n' "$out" | sed -n 1p)" rest="$(printf '%s\\n' "$out" | sed 1d)" printf '%s\\n' "$first" - sleep 0.3 + yes '{pad}' | head -n {pad_lines} printf '%s\\n' "$rest" exit 0 fi exec "$real" "$@" """ +#: One line of padding, and how many: 32768 lines of 48 bytes are 1.5 MiB. +PAD_LINE = "pad\tno lifecycle field is spelled like this one" +PAD_LINES = 32768 #: `lane-handoff`'s lifecycle read as it was written before #280: the producer #: piped into an `awk` that exits at its first match, under `pipefail`. OLD_LIFECYCLE_READ = r'''set -uo pipefail rc=0 -now="$("$1" lane-state "$2" 2>/dev/null | awk -F"\t" '$1 == "state" { print $2; exit }')" || rc=$? +now="$(LANES_NO_FETCH=1 "$1" lane-state "$2" 2>/dev/null | awk -F"\t" '$1 == "state" { print $2; exit }')" || rc=$? printf '%s %s\n' "$now" "$rc" ''' -def quick_awk(b: Bench) -> str: - """A PATH, for ONE command, whose `awk` hands each record to its program as - it arrives. mawk - the awk of Debian and Ubuntu - reads until its buffer is - full or its input ends before it hands over a first record (more than 300 KB - and at most 1 MB, measured with mawk 1.3.4 20240123), so where it is the awk - the old pipeline cannot race on a snapshot of 600 bytes, whatever the - producer does; gawk and the one-true-awk hand a record over at once, and so - does `mawk -W interactive`. - The shim is `mawk -W interactive` where the system's awk is mawk, and the - system's own otherwise.""" - real = shutil.which("awk", path=b.env["PATH"]) - assert real, "no awk on the bench's PATH" - said = subprocess.run([real, "-W", "version"], capture_output=True, text=True, - stdin=subprocess.DEVNULL) - flags = "-W interactive " if (said.stdout + said.stderr).startswith("mawk") else "" - shim = b.root / "quick-awk" / "awk" - shim.parent.mkdir() - shim.write_text(f"#!/bin/sh\nexec '{real}' {flags}\"$@\"\n") - shim.chmod(0o755) - return os.pathsep.join([str(shim.parent), b.env["PATH"]]) - - def slow_lane_state(b: Bench) -> tuple: - """THE PRODUCER THAT OUTLIVES ITS READER, as the environment of ONE command - (the quick `awk` in front, `$LANES_EDIT` the two-piece wrapper), and the file - the wrapper logs each `lane-state` call to.""" + """THE PRODUCER THAT OUTLIVES ITS READER: the wrapper (a `$LANES_EDIT`), and + the file it logs each `lane-state` call to.""" wrapper = b.root / "slow-lane-state" / "lanes-edit.sh" wrapper.parent.mkdir() calls = wrapper.parent / "calls" - wrapper.write_text(SLOW_LANE_STATE.format(path=b.env["PATH"], real=b.bin / "lanes-edit.sh", - calls=calls)) + wrapper.write_text(SLOW_LANE_STATE.format(real=b.bin / "lanes-edit.sh", calls=calls, + pad=PAD_LINE, pad_lines=PAD_LINES)) wrapper.chmod(0o755) - return {"LANES_EDIT": str(wrapper), "PATH": quick_awk(b), "LANES_NO_FETCH": "1"}, calls + return wrapper, calls def lane_state(b: Bench, lane: str) -> dict: @@ -880,18 +867,20 @@ def test_280_a_lifecycle_read_whose_producer_writes_after_the_filter_exits_is_st could not be read" and recorded no RUNNING -> SWAPPING -> SWAPPED transition (CI run 39322387035, which passed one prefix down and in every run before it). The race is held here EVERY time, by a `lane-state` that prints its - `state` line, waits 0.3 s and prints the rest. THE PREMISE FIRST: the old - shape of the read, run on that producer, gives `RUNNING 141`. Then the + `state` line, 1.5 MiB of padding and then the rest: a pipe holds 64 KiB, so + that producer is still writing when a reader that stops at the first line + has closed, whatever the timing and whatever the awk. THE PREMISE FIRST: the + old shape of the read, run on that producer, gives `RUNNING 141`. Then the handoff reads the snapshot whole and takes the status from `lane-state` alone, so it records the transition as it does when the producer is quick.""" b, t = triad a = t["assembly"] - env, calls = slow_lane_state(b) - old = b.run("bash", "-c", OLD_LIFECYCLE_READ, "_", env["LANES_EDIT"], "triad-1", env=env) + wrapper, calls = slow_lane_state(b) + old = b.run("bash", "-c", OLD_LIFECYCLE_READ, "_", wrapper, "triad-1") assert (old.returncode, old.stdout) == (0, "RUNNING 141\n"), (old.stdout, old.stderr) calls.write_text("") # the premise logged a call of its own - proc = handoff(b, "triad-1", a, "t280 handoff", env=env) + proc = handoff(b, "triad-1", a, "t280 handoff", env={"LANES_EDIT": str(wrapper)}) assert proc.returncode == 0, proc.stdout + proc.stderr assert "lane-state triad-1" in calls.read_text().splitlines(), \ "the handoff never read the lifecycle through the wrapper" @@ -905,20 +894,21 @@ def test_280_a_lifecycle_read_whose_producer_writes_after_the_filter_exits_is_st @NEEDS_UPSTREAM def test_280_a_swapping_lane_read_the_same_way_still_names_the_operation_it_supersedes(triad): - """#280, the second read: a lane still recorded SWAPPING is read once more - for the operation that never finished, through the same pipeline, and the - handoff names it in the note that says it supersedes it. Read whole and - parsed afterwards, that operation comes out of a producer that writes in two - pieces too, and the lane ends SWAPPED under a new operation.""" + """#280, a lane left SWAPPING: the FIRST read - the state - goes through the + same producer and must still see SWAPPING, so the handoff takes the lane + over, and its note names the operation that never finished. THAT IS WHAT + THIS PINS: the note's content, and the first read on a SWAPPING lane. It + cannot pin the SECOND read's own fix, the operation read once more for that + note: its status was never read, so reverting that site alone still passes.""" b, t = triad a = t["assembly"] - env, calls = slow_lane_state(b) + wrapper, calls = slow_lane_state(b) took = b.tool("lanes-edit.sh", "set-lane-state", "triad-1", "SWAPPING", "--expect", "RUNNING", "--operation", "op-stuck-280") assert took.returncode == 0, took.stderr assert lane_state(b, "triad-1")["operation"] == "op-stuck-280" - proc = handoff(b, "triad-1", a, "t280 handoff", env=env) + proc = handoff(b, "triad-1", a, "t280 handoff", env={"LANES_EDIT": str(wrapper)}) assert proc.returncode == 0, proc.stdout + proc.stderr assert "lane-state triad-1" in calls.read_text().splitlines(), \ "the handoff never read the lifecycle through the wrapper"