From 4420b6c043c54f147fca0b3c7dabe8e8f35c5396 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 16 Sep 2026 00:15:38 +0000 Subject: [PATCH 01/25] WIP: rescue the supervised-restart work whose writer died before it could commit The previous writer proved #94's cause and wrote most of the implementation in the working tree, then lost its process with nothing committed. This commit is that working tree verbatim, so the work survives the next crash; it is not a reviewed state and the acts are finished in the commits that follow. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r --- commands/ctx.md | 43 +- docs/README-lanes.md | 151 +++- lane-handoff | 820 ++++++++++++++++-- lane-start | 205 ++++- lanes-edit.sh | 441 +++++++++- .../.openspec.yaml | 2 + .../add-supervised-context-restart/design.md | 203 +++++ .../proposal.md | 41 + .../specs/supervised-context-restart/spec.md | 179 ++++ .../add-supervised-context-restart/tasks.md | 80 ++ skills/handoff/SKILL.md | 70 +- tests/test_lane_helpers.sh | 465 ++++++++++ 12 files changed, 2553 insertions(+), 147 deletions(-) create mode 100644 openspec/changes/add-supervised-context-restart/.openspec.yaml create mode 100644 openspec/changes/add-supervised-context-restart/design.md create mode 100644 openspec/changes/add-supervised-context-restart/proposal.md create mode 100644 openspec/changes/add-supervised-context-restart/specs/supervised-context-restart/spec.md create mode 100644 openspec/changes/add-supervised-context-restart/tasks.md diff --git a/commands/ctx.md b/commands/ctx.md index 88224ae6..8abfc82d 100644 --- a/commands/ctx.md +++ b/commands/ctx.md @@ -17,14 +17,32 @@ What that means in order, and the order is the rule: every writer this lane has running** (its worktree, its branch, its brief, what it had committed), the writers are polled, and `PAUSED` is written with Amendment 11(c)'s sub-fields, Amendment 17(b)'s `agent` and `transcript`, and `clear` as its why; -2. **then** the lane's own pane is respawned through the launcher — `tmux respawn-pane -k`, so the act - survives the death of the session that started it — with a NEW session of the same agent whose FIRST - PROMPT is that top block, run without anyone typing it; -3. that session stamps `RESUMED by …` first, as Rule 3 requires, then **COUNTS the live writers** — +2. **then** a **RESTART INTENT** is written under the lane's own control root — the lane, a generation, an + operation id, the transcript being paused, the agent, the profile, the checkout, the pane, the handoff's + path and its **digest**, and the launch mode `fresh-from-handoff` — and read back; +3. **then** the lane's own pane is respawned — `tmux respawn-pane -k`, so the act survives the death of the + session that started it — with the **pane supervisor** (`lane-handoff --supervise`, by absolute path). The + supervisor claims that intent, runs the launch as its **child**, and stays in the pane whatever happens; +4. the launch is a NEW session of the same agent whose FIRST PROMPT is the top block written in step 1, run + without anyone typing it; +5. that session stamps `RESUMED by …` first, as Rule 3 requires, then **COUNTS the live writers** — `ListAgents`, the agent's equivalent elsewhere — because the block's `WRITERS` section is a list to COUNT and not a list to relaunch (Amendment 17 Addendum 1 (i), in force 2026-09-14T20:59:31Z). A writer still live OWNS its worktree and is told, not relaunched; only a writer that is NOT live is relaunched, from - where it stood. + where it stood; +6. only when the replacement is **positively confirmed** — a live child, exactly one holder of this lane, its + transcript the new one and not the paused one, in the pane the intent names — is the restart marked + `ready`. Until then it is not. + +**Steps 2, 3 and 6 are new, and they are `opensoft/openRepoTools#94`.** On 2026-09-15 this command wrote its +record, respawned the pane with `LANE_START_FRESH=1 … lane `, and what came up was +`claude --name --resume ` — the same conversation, not a fresh one, with +no supervisor to notice. The seam was an **environment variable**, and the launcher re-creates its child +through `tmux new-session`, which hands that child the tmux **server's** environment and not the caller's. +An environment cannot cross that boundary; a **file** can, so the intent on disk is now the authority and +`LANE_START_FRESH` is a compatibility detail. And what tmux starts is a supervisor rather than the launch +itself, because a launch that ends takes the pane with it — Amendment 11's own invariant is *"No path the +launcher opened exits the pane"*, and this one did. **`/ctx` says which it did** (Addendum 1 (j)). This one respawns the pane, so the process every writer was a child of is gone, the record says `kind respawn`, and the count then finds none — which is what makes @@ -33,8 +51,17 @@ one is `--in-process`, `kind in-process`, and its block says to EXPECT every wri the kind says what to expect and never what to do. **A `/ctx` whose record could not be written REFUSES before it kills anything.** A pane is never respawned -over an unrecorded lane — and the record is three writes, not one: if the `PAUSED` line did not land, or the -row was not flipped, or the handoff's top block (which is the new session's first prompt) could not be -refreshed, this pane stays exactly as it is and the reason is printed. +over an unrecorded lane — and the record is now FOUR writes, not three: if the `PAUSED` line did not land, or +the row was not flipped, or the handoff's top block (which is the new session's first prompt) could not be +refreshed, **or the restart intent could not be written and read back**, this pane stays exactly as it is and +the reason is printed. A pane respawned with no intent is a pane whose supervisor has nothing to launch from, +which is the same unrecorded restart one step along. + +**A restart already in flight is not superseded.** A second `/ctx` on a lane whose intent is `pending` or +`starting` refuses — before the kill — and names `lane-handoff --restart-status --lane `. + +**When the launch fails, the pane is still yours.** The supervisor marks the intent `failed`, prints the +stage that failed and offers one line to retry the same operation. Nothing of the lane is lost: the handoff +it wrote is intact and its top block is still the first prompt the next session gets. No picker, no title fallback, no second command: the one word is the whole act. diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 50968732..94d8d029 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2043,28 +2043,135 @@ each one right. A `/ctx` that clears IN PLACE is the other kind and must say so (`--in-process`, `kind in-process`): the process survives, its writers survive with it, and the block says to EXPECT every writer below live. -**The record comes first, always, and the record is THREE writes.** A `/ctx` -**refuses before it kills anything** where the `PAUSED` line did not land, where -the row was not flipped (the register would say RUNNING about a session that has -just been replaced), or where the handoff could not be refreshed (its top block -is literally the new session's first prompt, and a stale one hands over the -instructions of another act). A pane is never respawned over an unrecorded lane. - -**The respawn line is `lane `**, and never `restart ` — Amendment 18 -Addendum 2 (i-8): the respawn *"relaunches the lane's own pane with `lane ` -(its parked branch is exactly Amendment 11(i)'s act), or through the launcher -directly"*, and `restart` leaves the person's `PATH` with `openRepoTools#43`. -`lane ` needs no profile argument: its parked branch reads the record the -handoff has just written — the lane's recorded directory and profile — and asks -nothing. **Where `lane` is not on `PATH`** (it arrives with #43, and this act -shipped first) the line is `pclaude --lane `, the same act one -door along, and a `lane` on `PATH` that is not this estate's word is passed over -for it with a line saying so: a respawn is the one act no later refusal can undo, -so the word is used only where it can be SEEN. `LANE_START_FRESH=1` is the one -seam that says *a new session, primed by the top block* — which is what a -context clear is, and why `/ctx` does not resume the transcript it has just -paused. It rides in the ENVIRONMENT, so it survives `lane` handing the launch on -to `lane-start` exactly as it survives the launcher doing so. +**The record comes first, always, and the record is FOUR writes** (it was three +until `openRepoTools#94`). A `/ctx` **refuses before it kills anything** where the +`PAUSED` line did not land, where the row was not flipped (the register would say +RUNNING about a session that has just been replaced), where the handoff could not +be refreshed (its top block is literally the new session's first prompt, and a +stale one hands over the instructions of another act), **or where the restart +intent could not be written and read back**. A pane is never respawned over an +unrecorded lane, and a pane respawned with no intent is a pane whose supervisor +has nothing to launch from — the same unrecorded restart one step along. + +#### The restart intent — what makes the new session a FRESH one + +`opensoft/openRepoTools#94`, measured on Eagle at 2026-09-15T20:59Z: `/ctx` wrote +its record, respawned its own pane with `LANE_START_FRESH=1 … lane `, and +what came up was `claude --name --resume ` — +the same conversation, not a fresh one. `lane-start` was right (both of its +`(( ! fresh ))` gates were in the installed copy), `lane` was right (its available +branch ends in a plain `exec`), and the variable simply never arrived. The +launcher re-creates its child through tmux, and **a command tmux starts gets the +tmux SERVER's environment, not the caller's** — `claude-profile`'s own code says +so, which is why it writes six values into the command string explicitly. The +seventh was never written, so it was never there. + +An environment cannot cross a boundary another repository owns. **A file can.** +Before anything is killed, `/ctx` writes a RESTART INTENT under the lane's own +control root — the same root `openRepoTools#91`'s lifecycle snapshot uses, +resolved the same three ways (`$LANES_LANE_STATE_ROOT`, the checkout's parent +`.lane-state/`, `$PROJECTS_ROOT/.lane-state/`): + +``` +$ lanes-edit.sh restart-intent openRepoTools-3 +state pending +generation 7 +operation ctx-20260915T210412Z-41233-1187 +mode fresh-from-handoff +agent claude +profile max-001 +dir /…/openRepoTools +pane claude-…:@6.%6 +handoff /…/handoffs/openRepoTools/…md +digest 9f2c… +old_transcript 4135b2c9-… +new_transcript none +attempt 0 +``` + +It carries no credential and there is no field for one. `lane-start` reads it back +and **the intent is the authority**: with a `pending` or `starting` intent whose +mode is `fresh-from-handoff`, the launch is a new session named for the lane and +primed by the handoff's top block, whatever the row's last uuid or the lane's last +`PAUSED` record name and whether or not any environment variable survived. A +`ready` intent is history and authorises nothing, which is what makes the next +`lane ` an ordinary resume. `LANE_START_FRESH=1` still works and is no longer +the authority; `lane-start --fresh` is the same thing as an argument, for a caller +with no launcher between it and there. + +Two refusals rather than substitutions: an intent naming another checkout, or an +`--operation` that is not the lane's current one, is a refusal that renames +nothing and starts nothing — and a handoff whose **digest** has changed since the +intent was written blocks the launch, because that file's top block IS the first +prompt. + +#### The supervisor — what tmux actually starts + +The pane is respawned with `lane-handoff --supervise --lane --operation +`, **by absolute path**, because a respawned pane's `PATH` is whatever the +person's shell profile makes of it. It is a MODE of a command that is already +installed and not a thirteenth word: Amendment 18 Addendum 2 (i-8) is explicit +that *"no word is kept on `PATH` for it alone"*, which is the ground on which +`restart` was taken off `PATH` in the first place. + +What the supervisor does, in order: + +1. **claims** the intent — `pending`/`failed` → `starting`, compare-and-swap on + the operation and the generation, so a stale supervisor writes nothing; +2. refuses before launching anything on a stale operation, a `starting` or + `ready` one, a pane the intent does not name, a changed handoff digest, or a + live holder of the lane (Amendment 18(h): a second session of one lane is the + collision the whole protocol is about — and the act it prints is + `lane-end --retire `, never a kill); +3. **runs the launch as its CHILD** — `pclaude --lane `, which is + (i-8)'s second door, falling to `lane-start --fresh --operation ` where + there is no launcher or the record names no profile. Never `lane `: that + is the human dispatcher, and attaching to a live session is one of its valid + outcomes; +4. **confirms readiness** from a read-only predicate — the child alive, exactly + one live holder of this lane, its transcript the new one and not the paused + one, in the pane the intent names — and only then marks the intent `ready` and + moves `SWAPPED → RUNNING` where this workstation has `openRepoTools#91`'s + lifecycle. Tmux accepting a command is **not** a started session, and nothing + here treats it as one; +5. **stays in the pane**, whatever happens. Amendment 11's own invariant is *"No + path the launcher opened exits the pane"*, and `/ctx` was the one path that + did: what tmux used to start was the launch itself, so a launch that failed + took the pane with it. + +#### The retry surface + +A launch that never reaches readiness leaves the intent `failed` with a bounded +reason, and the supervisor prints the stage, the fact that the handoff is intact, +and three lines — retry, read, by hand: + +``` +RESTART FAILED — lane openRepoTools-3, operation ctx-20260915T210412Z-41233-1187 + the launch ended with status 127 before readiness was confirmed + + retry: lane-handoff --supervise --lane openRepoTools-3 --operation ctx-… + read it: lane-handoff --restart-status --lane openRepoTools-3 + by hand: lane openRepoTools-3 +``` + +Where there is a terminal it then asks once — `[r = retry, q = leave it]` — and a +retry is **the same operation**: the same generation, digest, directory, profile +and launch mode, with the attempt count incremented. Where there is no terminal +it exits 3 and says so. A deadline that expires with the child still ALIVE is a +third answer, `INDETERMINATE`: nothing is killed (Amendment 8(f) — ending +somebody's process is not a boundary script's act), nothing is retried, the lane +is not marked running, and the reason goes into the record, because an interactive +child owns the pane's screen. + +`lane-handoff --restart-status --lane ` reads all of it from any window — +the lane, the intent and its attempt, the operation and generation, the mode, the +agent and profile, the checkout, the pane, the handoff and its digest, both +transcripts, the bounded failure reason, and what act is open on it. + +**A second `/ctx` never supersedes a restart in flight.** With the intent +`pending` or `starting`, an ordinary `/ctx` refuses *before* the kill and names +the status command. Taking over an abandoned operation is an explicit act and not +an automatic one. `/handoff --exit requested by @/` is the other end (Amendment 18(d)): after the record, `/exit` is typed into this lane's own pane diff --git a/lane-handoff b/lane-handoff index acc66887..54cab36d 100755 --- a/lane-handoff +++ b/lane-handoff @@ -188,6 +188,19 @@ lane-handoff [--restart | --exit | --in-process] [--late --at ] instruction (Addendum 1 (j)). Refused with --restart, --exit and --late, which each mean the process ended. With none of the four the kind is `unknown`. + --supervise THE PANE SUPERVISOR, started by --restart's respawn + and not typed by a person: it claims the lane's + restart intent, launches the replacement as its own + CHILD, confirms readiness before anything is called + running, and STAYS IN THE PANE with the diagnostic and + a retry when the launch fails. Needs --lane and + --operation. + --restart-status read the lane's restart intent — its operation, + generation, attempt, state, handoff, digest, + transcripts and bounded failure reason — and print the + act that is open on it. Reads only. Needs --lane. + --operation the restart operation --supervise was started for. An + id that is not the lane's current one is a refusal. --late --at write the record a swap never left, dated by the moment the old session ended. Run it from a shell BEFORE the relaunch; see --help's LATE RULE. @@ -202,13 +215,16 @@ lane-handoff [--restart | --exit | --in-process] [--late --at ] requested by @/. Exit: 0 written · 2 refused · 1 a read failed. + --supervise adds: 3 the launch failed and there was no terminal here to + offer the retry on · 4 indeterminate (a live child, readiness unconfirmed). USAGE } # ------------------------------------------------------------------ arguments -do_restart=0; do_exit=0; do_late=0; do_in_process=0 +do_restart=0; do_exit=0; do_late=0; do_in_process=0; do_supervise=0; do_status=0 late_at=""; lane=""; agent=""; transcript=""; writers_file=""; dir_flag="" +operation="" why="" # A FLAG WHERE A VALUE BELONGS IS REFUSED, in this command as in `park`, @@ -236,6 +252,19 @@ while [ $# -gt 0 ]; do # says *relaunch every writer* puts a second writer on a worktree the first # one still holds. --in-process) do_in_process=1; shift ;; + # THE OTHER HALF OF `--restart`, AND THE SAME COMMAND ON PURPOSE + # (openRepoTools#94). `--supervise` is what tmux starts in the respawned + # pane: it claims the restart intent `--restart` wrote, launches the + # replacement as a CHILD, confirms readiness and stays in the pane when + # anything goes wrong. It is a MODE and not a thirteenth installed word, + # because Amendment 18 Addendum 2 (i-8) is explicit that *"no word is kept + # on `PATH` for it alone"* — which is the ground on which `restart` was + # taken off it. `--restart-status` is the read-only surface over the same + # record. + --supervise) do_supervise=1; shift ;; + --restart-status) do_status=1; shift ;; + --operation) operation="$(flag_value --operation "${2-}")"; shift 2 ;; + --operation=*) operation="${1#--operation=}"; shift ;; --at) late_at="$(flag_value --at "${2-}")"; shift 2 ;; --at=*) late_at="${1#--at=}"; shift ;; --lane) lane="$(flag_value --lane "${2-}")"; shift 2 ;; @@ -259,6 +288,28 @@ done # (Amendment 18(d)); asking for both is asking the same pane to do two opposite # things, and whichever ran second would run against a pane the first had # already replaced. +# AND THE TWO MODES THAT ARE NOT HANDOFFS AT ALL ARE ASKED FOR ALONE +# (openRepoTools#94). `--supervise` is the pane's own root process after a +# `/ctx` and `--restart-status` is a read; neither writes a PAUSED record, and a +# caller that asked for one of them beside `--restart` is asking this command to +# hand off a lane and to be the thing that catches the handoff, in one process. +for lh_mode_a in supervise:$do_supervise status:$do_status; do + [ "${lh_mode_a#*:}" = 1 ] || continue + for lh_mode_b in restart:$do_restart exit:$do_exit late:$do_late in-process:$do_in_process; do + [ "${lh_mode_b#*:}" = 1 ] || continue + die "--${lh_mode_a%%:*} is not a handoff and --${lh_mode_b%%:*} is: the first is the pane's own process after a /ctx (or a read of what that /ctx left), and the second writes the record. Ask for one." 2 + done +done +[ "$do_supervise" = 1 ] && [ "$do_status" = 1 ] && + die "--supervise launches and --restart-status reads; ask for one." 2 +# AND `--operation` NAMES THE ACT `--supervise` WAS STARTED FOR, so it is +# meaningless anywhere else — and worse than meaningless on `--restart`, whose +# own tail MINTS an operation: a value given there would be silently discarded, +# which is the shape of thing a person only finds out about from a record that +# does not say what they typed. +[ -n "$operation" ] && [ "$do_supervise" != 1 ] && + die "--operation names the restart operation a supervisor was started for, and only --supervise reads it. --restart MINTS its own operation and would discard this one silently; every other mode has none. Drop it, or run: $prog --supervise --lane --operation $operation" 2 + [ "$do_restart" = 1 ] && [ "$do_exit" = 1 ] && die "--restart and --exit are two different ends for one act: --restart respawns this lane's own pane (Amendment 17(f), the lane stays here) and --exit ends the session (Amendment 18(d), the lane goes elsewhere). Pick one." 2 # AND `--late` IS NEITHER OF THEM. It is written BEFORE the relaunch by a shell @@ -350,6 +401,593 @@ have_tmux() { command -v tmux >/dev/null 2>&1 } +# THIS COMMAND'S OWN ABSOLUTE PATH, because tmux starts the supervisor and a +# respawned pane's `PATH` is whatever the person's shell profile makes of it — +# which is one of the ways a respawn comes up with a word that is not this +# estate's. +SELF_ABS="$selfdir/${SELF##*/}" + +# THE HANDOFF'S DIGEST, spelled as `lanes-edit.sh` and `lane-start` spell it: +# all three compare the same number, so there is ONE implementation of it and +# not three. `none` is an ANSWER — this workstation has neither tool — and it +# means *not checked here*, never *unchanged*. +handoff_digest() { # + [ -r "${1-}" ] || { printf 'none\n'; return 0; } + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -- "$1" 2>/dev/null | awk '{print $1; exit}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 -- "$1" 2>/dev/null | awk '{print $1; exit}' + else + printf 'none\n' + fi +} + +# A PANE REF IS COMPARED COMPONENT-WISE, NEVER AS A SUFFIX OR A PREFIX. The +# three spellings are `:<@id>`, `:<@id>.%` and, in +# `$TMUX_PANE`, the bare `%` — and a `case` on `*"$x"` accepts `%16` for +# `%6` and a `case` on `"$x"*` accepts `@331` for `@33`. Both are the wrong +# window, and this comparison decides whether a second session of a lane gets +# started in one. Empty on either side is NOT a disagreement: a comparison that +# cannot be made is said rather than failed shut, which is what the callers do +# with it. +pane_agrees() { # + pa_a="${1-}"; pa_b="${2-}" + [ -n "$pa_a" ] && [ -n "$pa_b" ] || return 0 + [ "$pa_a" != none ] && [ "$pa_b" != none ] || return 0 + [ "$pa_a" = "$pa_b" ] && return 0 + # A BARE `%` on either side is compared against the other's pane + # component and against nothing else. + case "$pa_a" in + %*) case "$pa_b" in *.%*) [ "$pa_a" = "${pa_b##*.}" ] && return 0 ;; esac; return 1 ;; + esac + case "$pa_b" in + %*) case "$pa_a" in *.%*) [ "$pa_b" = "${pa_a##*.}" ] && return 0 ;; esac; return 1 ;; + esac + # Otherwise both name a window; one may also name a pane, and a record that + # names only the window agrees with an intent that names the pane inside it. + case "$pa_a" in *.%*) pa_aw="${pa_a%.*}" ;; *) pa_aw="$pa_a" ;; esac + case "$pa_b" in *.%*) pa_bw="${pa_b%.*}" ;; *) pa_bw="$pa_b" ;; esac + [ "$pa_aw" = "$pa_bw" ] +} + +# ---------------------------------------------------- the readiness predicate +# +# READ-ONLY, AND POSITIVE. It asks five things and every one of them must +# answer, because the failure this exists to stop is a launch that was ACCEPTED +# and never happened: +# +# 1. the child this supervisor started is still alive; +# 2. this workstation's session records name a LIVE HOLDER of the lane; +# 3. exactly ONE of them — a second is Amendment 18(h)'s collision, and a +# restart that confirmed itself while two processes answered for one lane +# would be the tooling making the very thing the protocol refuses; +# 4. that holder's transcript is the one `lane-start` said it was about to +# make — and, where it said nothing, at least one that is NOT the +# transcript the `/ctx` paused, which is the spec's *a transcript distinct +# from the old transcript*; +# 5. it is in the pane the intent was written for, where both are knowable. +# +# IT WRITES NOTHING, AND IT NEEDS NOTHING FROM THE `SessionStart` HOOK, whose +# read-only, network-free, always-zero contract is load-bearing (`R-A8-1`, +# ratified) and is untouched by any of this. +# +# THE CHILD'S PID COMES OUT OF A FILE and not out of a variable, because the +# child is started in the FOREGROUND (below) and this predicate runs in a +# background observer that has no way to be told it otherwise. An empty file is +# *the child has not got as far as recording itself*, which is not the same as +# *the child is gone*. +# +# 0 ready · 1 not yet (the reason is on `READY_WHY`) · 2 the child is gone. +ready_check() { # + rc_lane="$1"; rc_pidf="$2"; rc_new="$3"; rc_old="$4"; rc_pane="$5" + READY_WHY=""; READY_SID="" + rc_pid="" + [ -r "$rc_pidf" ] && rc_pid="$(head -n 1 -- "$rc_pidf" 2>/dev/null | tr -dc '0-9')" + if [ -z "$rc_pid" ]; then + READY_WHY="the child has not recorded its pid yet" + return 1 + fi + kill -0 "$rc_pid" 2>/dev/null || { READY_WHY="the child (pid $rc_pid) is gone"; return 2; } + rc_out=""; rc_rc=0 + rc_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" live-holder "$rc_lane" 2>/dev/null)" || rc_rc=$? + case "$rc_rc" in + 0) : ;; + 8) READY_WHY="no live session record names lane $rc_lane yet"; return 1 ;; + *) READY_WHY="this workstation's session records could not be read (live-holder exited $rc_rc), which is NOT 'nothing is live'"; return 1 ;; + esac + rc_n="$(printf '%s\n' "$rc_out" | grep -c . || :)" + if [ "${rc_n:-0}" != 1 ]; then + READY_WHY="$rc_n live holders answer for lane $rc_lane and readiness needs exactly one (Amendment 18(h))" + return 1 + fi + rc_sid="$(printf '%s\n' "$rc_out" | awk -F'\037' '{print $1; exit}')" + rc_tgt="$(printf '%s\n' "$rc_out" | awk -F'\037' '{print $2; exit}')" + READY_SID="$rc_sid" + if [ -n "$rc_new" ] && [ "$rc_new" != none ]; then + if [ "$rc_sid" != "$rc_new" ]; then + READY_WHY="the live holder's transcript is $rc_sid and this launch was preparing $rc_new" + return 1 + fi + elif [ -n "$rc_old" ] && [ "$rc_old" != none ] && [ "$rc_sid" = "$rc_old" ]; then + READY_WHY="the live holder's transcript is $rc_sid, which is the one this /ctx paused — that is a resume and not a fresh session" + return 1 + fi + if ! pane_agrees "$rc_pane" "$rc_tgt"; then + READY_WHY="the live holder is in $rc_tgt and the intent names pane $rc_pane" + return 1 + fi + return 0 +} + +# ------------------------------------------------- the observer, and the wait +# +# THE CHILD RUNS IN THE FOREGROUND AND THIS PROCESS WAITS ON IT. That order is +# not a preference: the child is an interactive terminal program, and a +# background child in a shell with no job control cannot read the terminal at +# all. So the OBSERVATION goes into the background instead — one subshell that +# polls the read-only predicate above and, the moment it answers, makes the one +# fenced write that turns this restart into a confirmed one. That is the +# design's *separate observation path*, and it is why nothing is printed over a +# running Claude's screen: the observer WRITES its verdict, and +# `--restart-status` from any other window is where it is read. +# +# EVERY TWO SECONDS AND NOT EVERY ONE: the predicate's middle question goes +# through `lanes-edit.sh live-holder`, which reads the register, and sixty of +# those in a minute is sixty git processes behind a session that is trying to +# start. +supervise_watch() { # + sw_lane="$1"; sw_op="$2"; sw_gen="$3"; sw_pidf="$4"; sw_new="$5"; sw_old="$6"; sw_pane="$7" + sw_waited=0 + while [ "$sw_waited" -lt "$SUPERVISE_READY_SECONDS" ]; do + sleep 2 + sw_waited=$((sw_waited + 2)) + sw_rc=0 + ready_check "$sw_lane" "$sw_pidf" "$sw_new" "$sw_old" "$sw_pane" || sw_rc=$? + case "$sw_rc" in + 2) return 3 ;; # the child is gone; the supervisor has its status + 0) + # THE FINALIZER, FENCED ON THIS OPERATION AND THIS GENERATION. A + # stale observer of a superseded operation writes nothing: exit 7 + # is the intent refusing it, and that refusal is the whole point of + # the fence. + sw_frc=0 + LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$sw_lane" ready \ + --expect starting --expect-operation "$sw_op" --expect-generation "$sw_gen" \ + --new-transcript "${READY_SID:-$sw_new}" \ + --reason "readiness confirmed after ${sw_waited}s" >/dev/null 2>&1 || sw_frc=$? + if [ "$sw_frc" != 0 ]; then + note "lane $sw_lane came up and the restart intent could NOT be marked ready (exit $sw_frc): the session is running and the record does not say so. Read it: $prog --restart-status --lane $sw_lane" + return 5 + fi + # AND THE LANE'S OWN LIFECYCLE WHERE THIS WORKSTATION HAS ONE + # (openRepoTools#91): `SWAPPED -> RUNNING`, on the same act. A + # `lanes-edit.sh` without `lane-state` answers 2 from its + # unknown-subcommand arm and this is SILENT — the lane simply has + # no lifecycle snapshot to move, which is the pre-cutover case and + # not a failure. + sw_lrc=0 + LANES_NO_FETCH=1 "$LANES_EDIT" set-lane-state "$sw_lane" RUNNING \ + --expect SWAPPED >/dev/null 2>&1 || sw_lrc=$? + case "$sw_lrc" in + 0 | 1 | 2) : ;; + 7) note "lane $sw_lane's lifecycle moved under this restart, so the SWAPPED -> RUNNING transition was refused: the restart intent is ready and the lifecycle is somebody else's now. Read it: $LANES_EDIT lane-reconcile $sw_lane" ;; + *) note "lane $sw_lane came up and its lifecycle could not be moved to RUNNING (set-lane-state exited $sw_lrc). The restart intent IS ready; the lifecycle snapshot is behind it." ;; + esac + return 0 ;; + esac + done + # THE DEADLINE, WITH THE CHILD STILL ALIVE — INDETERMINATE, which is a THIRD + # answer and not a failure. Nothing is killed (Amendment 8(f): ending + # somebody's process is not a boundary script's act), nothing is retried, + # and the lane is NOT marked running. + LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$sw_lane" starting \ + --expect starting --expect-operation "$sw_op" \ + --reason "INDETERMINATE: ${SUPERVISE_READY_SECONDS}s elapsed with the child alive and readiness unconfirmed; ${READY_WHY:-no evidence}" >/dev/null 2>&1 || : + return 4 +} + +# THE CHILD'S ARGV, out of the intent and out of nothing else — the spec's *no +# picker, attach, select, current-directory, title, or default-profile +# resolution*, and never the human-facing `lane` dispatcher, whose valid +# outcomes include attaching to something instead of launching. +# +# THE LAUNCHER FIRST, because it is what configures the ACCOUNT: `pclaude +# --lane ` is the same act `lane`'s available branch makes, one +# door along, and `lane-start` is what it runs. Where this workstation has no +# launcher, or the intent records no profile to hand one, the supervisor runs +# `lane-start` itself with the fresh mode as an ARGUMENT — the narrow operation +# launch, and the only path on which `--operation` can be passed at all, since +# the launcher forwards no argument of its own. +# +# EITHER WAY THE FRESH SESSION IS THE INTENT'S TO DECIDE AND NOT THIS ARGV'S: +# `lane-start` reads the same file and goes fresh with no flag and no variable +# set. The flag and the variable are set here anyway, because a launch that +# says what it is in three places is a launch whose defect capture can be read +# — and because on the launcher path they are the only two that even reach it. +supervise_argv() { # + sa_lane="$1"; sa_op="$2" + SUPERVISE_CMD=(); SUPERVISE_VIA="" + sa_launcher="${PCLAUDE:-}" + if [ -z "$sa_launcher" ]; then + sa_launcher="$(command -v pclaude 2>/dev/null || command -v claude-profile 2>/dev/null || printf '')" + fi + if [ -n "$sa_launcher" ] && [ -n "$RI_PROFILE" ] && [ "$RI_PROFILE" != none ]; then + SUPERVISE_CMD=("$sa_launcher" --lane "$sa_lane" "$RI_PROFILE") + SUPERVISE_VIA="the launcher ($sa_launcher), which configures the account and runs lane-start" + return 0 + fi + sa_start="" + if [ -x "$selfdir/lane-start" ]; then sa_start="$selfdir/lane-start" + else sa_start="$(command -v lane-start 2>/dev/null || printf '')" + fi + [ -n "$sa_start" ] || return 1 + SUPERVISE_CMD=("$sa_start" --fresh --operation "$sa_op") + [ -n "$RI_DIR" ] && [ "$RI_DIR" != none ] && SUPERVISE_CMD+=(--dir "$RI_DIR") + SUPERVISE_CMD+=("$sa_lane") + if [ -n "$sa_launcher" ]; then + SUPERVISE_VIA="lane-start directly ($sa_start) — this lane's record names no profile, so there is nothing to tell the launcher which account to come up in" + else + SUPERVISE_VIA="lane-start directly ($sa_start) — there is no launcher on PATH here" + fi + return 0 +} + +# THE FAILURE SURFACE. The supervisor is STILL IN THE PANE, which is the whole +# of what this change is for, and what it offers is one line to retry and one +# to walk away with. A pane with no terminal — the suite, a `--supervise` run +# from a script — is told the same thing and gets exit 3 rather than a read +# nobody will answer. +# +# 3 = reported, no terminal · 10 = reported, ask the person. +supervise_failed() { # + sf_lane="$1"; sf_op="$2"; sf_why="$3" + LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$sf_lane" failed \ + --expect 'starting|pending' --expect-operation "$sf_op" \ + --reason "$sf_why" >/dev/null 2>&1 || + note "the restart intent could not be marked failed (another operation may hold this lane now). Read it: $prog --restart-status --lane $sf_lane" + say "" + say "RESTART FAILED — lane $sf_lane, operation $sf_op" + say " $sf_why" + say "" + say " The old session is gone (this pane was respawned for the restart) and the" + say " handoff it wrote is intact: its top block is still the first prompt the next" + say " session gets, and nothing of the lane has been lost." + say "" + say " retry: $(quoted "$SELF_ABS") --supervise --lane $sf_lane --operation $sf_op" + say " read it: $(quoted "$SELF_ABS") --restart-status --lane $sf_lane" + say " by hand: lane $sf_lane" + say "" + if [ "${LANE_SUPERVISOR_NO_PROMPT:-0}" = 1 ] || [ ! -t 0 ]; then + note "there is no terminal here to offer the retry on, so this supervisor exits 3 rather than blocking on a read nobody will answer. The intent is \`failed\` and carries the reason; the three lines above are the whole of what to do next." + return 3 + fi + return 10 +} + +# THE ONE QUESTION THIS COMMAND EVER ASKS, and it asks it only where a person is +# looking at the pane. `r` retries the SAME operation; anything else leaves the +# pane to the person, with the intent `failed` and every line above still on +# screen. +supervise_prompt() { # + printf '%s: retry this restart? [r = retry, q = leave it] ' "$prog" >&2 + sp_ans="" + IFS= read -r sp_ans || { printf '\n' >&2; return 3; } + case "$sp_ans" in + r | R | retry) return 0 ;; + *) + say "" + say "left as it is. The intent stays \`failed\` and this pane is yours:" + say " retry: $(quoted "$SELF_ABS") --supervise --lane $1 --operation $2" + return 3 ;; + esac +} + +# THE SUPERVISOR PROPER. It loops only because a RETRY is the same launch made +# again under the same operation, generation, digest, directory and mode — the +# spec's idempotent retry — and never because a child that ended is relaunched +# on its own. +supervise_run() { # + sr_lane="$1"; sr_op="$2" + sr_pidf="$(mktemp "${TMPDIR:-/tmp}/lane-supervisor.XXXXXX" 2>/dev/null || printf '')" + [ -n "$sr_pidf" ] || die "no temporary file could be made to record the launch's pid, and without it this supervisor cannot tell a child that died from one that is starting. Nothing was started." 2 + SV_PIDFILE="$sr_pidf" + while : ; do + # ---- the claim: `pending`/`failed` -> `starting`, fenced on the + # operation AND the generation. It is INSIDE the loop because a retry + # is a second claim of the same operation and must take the fence + # again: between the failure and the `r` a person may have started the + # lane by hand, and the retry must lose that race rather than win it. + ri_load "$sr_lane" >/dev/null 2>&1 || : + case "$RI_STATE" in + pending) sr_att=(--attempt=1) ;; + failed) sr_att=(--bump-attempt) ;; + *) die "lane $sr_lane's restart intent is '${RI_STATE:-none}' and this supervisor can only launch from \`pending\` or \`failed\`. Nothing was started. Read it: $prog --restart-status --lane $sr_lane" 2 ;; + esac + sr_crc=0 + LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$sr_lane" starting \ + --expect "$RI_STATE" --expect-operation "$sr_op" --expect-generation "${RI_GEN:-0}" \ + "${sr_att[@]}" --reason "supervisor claimed the operation" >/dev/null 2>&1 || sr_crc=$? + case "$sr_crc" in + 0) : ;; + 7) die "lane $sr_lane's restart intent moved between reading it and claiming it: another act owns operation $sr_op now. Nothing was started. Read it: $prog --restart-status --lane $sr_lane" 2 ;; + *) die "lane $sr_lane's restart intent could not be claimed (\`set-restart-intent\` exited $sr_crc), and this supervisor will not launch without a fenced hold on operation $sr_op. Nothing was started." 2 ;; + esac + ri_load "$sr_lane" >/dev/null 2>&1 || : + + if ! supervise_argv "$sr_lane" "$sr_op"; then + supervise_failed "$sr_lane" "$sr_op" "there is no launcher and no lane-start on PATH here, so nothing could be started" + sr_fs=$? + [ "$sr_fs" = 10 ] || return "$sr_fs" + supervise_prompt "$sr_lane" "$sr_op" || return $? + continue + fi + sr_dir="$RI_DIR" + if [ -n "$sr_dir" ] && [ "$sr_dir" != none ] && [ ! -d "$sr_dir" ]; then + supervise_failed "$sr_lane" "$sr_op" "the intent's checkout $sr_dir does not exist here, so the launch was not made" + sr_fs=$? + [ "$sr_fs" = 10 ] || return "$sr_fs" + supervise_prompt "$sr_lane" "$sr_op" || return $? + continue + fi + say "lane $sr_lane — restarting (operation $sr_op, attempt ${RI_ATTEMPT:-1})" + say " through $SUPERVISE_VIA" + say " first prompt: the top block of ${RI_HANDOFF:-the handoff this lane names}" + say " this supervisor stays in the pane: whatever the launch does, it will say so here." + say "" + : > "$sr_pidf" + supervise_watch "$sr_lane" "$sr_op" "${RI_GEN:-0}" "$sr_pidf" "$RI_NEW" "$RI_OLD" "$RI_PANE" & + sr_watch=$! + sr_status=0 + # THE PID IS WRITTEN BY THE PROCESS THAT BECOMES THE CHILD, and `sh -c` + # is how, on bash 3.2 as well as on a modern one: `$$` inside it is + # that shell's pid, and the `exec` on the next word makes that pid the + # launch's own. `$BASHPID` would be the one-liner and macOS ships bash + # 3.2, which does not have it. + if [ -n "$sr_dir" ] && [ "$sr_dir" != none ]; then + ( cd -- "$sr_dir" && LANE_START_FRESH=1 LANE_RESTART_OPERATION="$sr_op" \ + exec sh -c 'printf "%s\n" "$$" > "$1"; shift; exec "$@"' _ "$sr_pidf" "${SUPERVISE_CMD[@]}" ) || sr_status=$? + else + LANE_START_FRESH=1 LANE_RESTART_OPERATION="$sr_op" \ + sh -c 'printf "%s\n" "$$" > "$1"; shift; exec "$@"' _ "$sr_pidf" "${SUPERVISE_CMD[@]}" || sr_status=$? + fi + kill "$sr_watch" 2>/dev/null + wait "$sr_watch" 2>/dev/null + # WHAT THE RECORD SAYS NOW IS WHAT DECIDES, and never this process's + # memory of what it started: the observer is the only thing that ever + # had positive evidence, and it wrote that evidence down. + ri_load "$sr_lane" >/dev/null 2>&1 || : + if [ "$RI_STATE" = ready ]; then + say "" + say "lane $sr_lane: the replacement session was CONFIRMED (operation $sr_op, transcript ${RI_NEW:-unrecorded}) and has now exited." + say " A session that exited on purpose is not relaunched here — that is \`lane $sr_lane\`." + return 0 + fi + sr_why="the launch ended with status $sr_status before readiness was confirmed" + case "$RI_REASON" in + INDETERMINATE:*) sr_why="the launch ran without readiness evidence and then ended with status $sr_status; $RI_REASON" ;; + esac + supervise_failed "$sr_lane" "$sr_op" "$sr_why" + sr_fs=$? + [ "$sr_fs" = 10 ] || return "$sr_fs" + supervise_prompt "$sr_lane" "$sr_op" || return $? + done +} + +# ============ THE PANE SUPERVISOR AND THE RESTART STATUS (openRepoTools#94) +# +# WHY A PROCESS STAYS IN THE PANE. `/ctx` respawned its own pane and treated +# tmux ACCEPTING the command as proof a session had started. Tmux accepting a +# command says nothing about whether the child resolved a profile, executed +# Claude, stayed alive, or consumed the handoff it was supposed to. On +# 2026-09-15 the preservation side completed, the old process was killed, and +# what came back was not the act the record says — with nothing in the pane to +# notice, because the thing tmux started was the launch itself and a launch +# that ends takes the pane with it. +# +# THE PROTOCOL ALREADY ASKS FOR THIS IN SO MANY WORDS. Amendment 11's own +# invariant is **"No path the launcher opened exits the pane"** (clause (b), +# with its width, closed under `R-A11-16`); `/ctx` opened a path that did. The +# supervisor is that invariant kept for the one act that had no one keeping it: +# it is the pane's root process, it launches the real thing as a CHILD, and +# whatever the child does the pane still has a process in it that can say what +# happened and offer the retry. +# +# AND IT IS NOT A NEW WORD ON `PATH` (Amendment 18 Addendum 2 (i-8): *"no word +# is kept on `PATH` for it alone"*, which is the ground on which `restart` was +# removed from the installed set). It is a MODE of the command that already +# owns the `/ctx` transaction, invoked by absolute path — tmux must not depend +# on the respawned pane's `PATH` — and a person still has exactly one word, +# `lane`. +# +# WHAT IT NEVER DOES: it never kills the child (Amendment 8(f): ending +# somebody's process is not a boundary script's act), it never relaunches a +# child that exited AFTER readiness, it never runs a picker, it never +# rediscovers a launch choice from the current directory, the window title or a +# rotating default, and it never marks a lane running on anything but positive +# evidence. +# +# SUPERVISOR EXIT CODES, and they are contract: +# 0 the child became ready and has since exited normally, or the operator +# ended the supervisor after a reported outcome +# 2 REFUSED before launching anything — a stale operation, a mismatched +# pane, an intent that is not this lane's, an ambiguous live holder +# 3 the launch FAILED before readiness and there was no terminal here to +# offer the retry on (the intent is `failed` and carries the reason) +# 4 the launch was INDETERMINATE: the child is alive and readiness could not +# be confirmed inside the deadline. Nothing was killed and nothing was +# marked running + +# HOW LONG READINESS IS WAITED FOR. Sixty seconds is the design's number and a +# slow workstation's transcript can take a few of them; the seam is for the +# suite, which must not spend a minute proving a timeout. +SUPERVISE_READY_SECONDS="${LANE_SUPERVISOR_READY_SECONDS:-60}" +case "$SUPERVISE_READY_SECONDS" in '' | *[!0-9]*) SUPERVISE_READY_SECONDS=60 ;; esac + +# ONE FIELD OF THE LANE'S RESTART INTENT. Empty where there is none, which +# every caller below tests for rather than guessing at. +ri_field() { # + LANES_NO_FETCH=1 "$LANES_EDIT" restart-intent "$1" 2>/dev/null \ + | awk -F'\t' -v k="$2" '$1 == k { print $2; exit }' +} + +# THE WHOLE INTENT ONCE, into `RI_*`. 0 where there is one, 8 where there is +# not, 1 where the read FAILED — which is never the same as "there is none" +# (Amendment 7(d)). +ri_load() { # + RI_STATE=""; RI_OP=""; RI_GEN=""; RI_MODE=""; RI_AGENT=""; RI_PROFILE="" + RI_DIR=""; RI_PANE=""; RI_WINDOW=""; RI_HANDOFF=""; RI_DIGEST="" + RI_OLD=""; RI_NEW=""; RI_ATTEMPT=""; RI_REASON=""; RI_FILE="" + ril_out=""; ril_rc=0 + ril_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" restart-intent "$1" 2>/dev/null)" || ril_rc=$? + case "$ril_rc" in + 0) : ;; + 8) return 8 ;; + *) return 1 ;; + esac + RI_STATE="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "state" { print $2; exit }')" + RI_OP="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "operation" { print $2; exit }')" + RI_GEN="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "generation" { print $2; exit }')" + RI_MODE="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "mode" { print $2; exit }')" + RI_AGENT="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "agent" { print $2; exit }')" + RI_PROFILE="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "profile" { print $2; exit }')" + RI_DIR="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "dir" { print $2; exit }')" + RI_PANE="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "pane" { print $2; exit }')" + RI_WINDOW="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "window" { print $2; exit }')" + RI_HANDOFF="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "handoff" { print $2; exit }')" + RI_DIGEST="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "digest" { print $2; exit }')" + RI_OLD="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "old_transcript" { print $2; exit }')" + RI_NEW="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "new_transcript" { print $2; exit }')" + RI_ATTEMPT="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "attempt" { print $2; exit }')" + RI_REASON="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "reason" { print $2; exit }')" + RI_FILE="$(printf '%s\n' "$ril_out" | awk -F'\t' '$1 == "file" { print $2; exit }')" + return 0 +} + +# THE STATUS SURFACE — read-only, and the whole of what an operator needs to +# tell a preservation failure, a tmux refusal, a launcher failure, an immediate +# child exit, an indeterminate readiness and a confirmed run apart. +if [ "$do_status" = 1 ]; then + [ -n "$lane" ] || die "--restart-status needs the lane: $prog --restart-status --lane " 2 + rst_rc=0 + ri_load "$lane" || rst_rc=$? + case "$rst_rc" in + 8) say "lane $lane has no restart intent: no /ctx is in flight for it."; exit 0 ;; + 1) die "lane $lane's restart intent could not be read (\`$LANES_EDIT restart-intent $lane\` failed). That is NOT 'there is none' — a read that failed is never an answer (Amendment 7(d))." 1 ;; + esac + say "lane $lane" + say "intent $RI_STATE (attempt ${RI_ATTEMPT:-0})" + say "operation ${RI_OP:-none} generation ${RI_GEN:-0}" + say "mode ${RI_MODE:-none}" + say "agent ${RI_AGENT:-none} profile ${RI_PROFILE:-none}" + say "dir ${RI_DIR:-none}" + say "pane ${RI_PANE:-none} window ${RI_WINDOW:-none}" + say "handoff ${RI_HANDOFF:-none}" + say "digest ${RI_DIGEST:-none}" + say "transcript old ${RI_OLD:-none} → new ${RI_NEW:-none}" + say "reason ${RI_REASON:-none}" + say "file ${RI_FILE:-none}" + # THE LANE'S OWN LIFECYCLE BESIDE IT WHERE THE HELPER HAS ONE + # (openRepoTools#91). Where it does not, the line says so rather than + # leaving a reader to wonder which of the two they are looking at. + rst_ls=""; rst_lrc=0 + rst_ls="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-state "$lane" 2>/dev/null \ + | awk -F'\t' '$1 == "state" { print $2; exit }')" || rst_lrc=$? + case "$rst_lrc" in + 0) say "lifecycle ${rst_ls:-none} (lanes-edit.sh lane-state $lane)" ;; + *) say "lifecycle not recorded here — this lanes-edit.sh has no \`lane-state\` (opensoft/openRepoTools#91 has not landed on this workstation)" ;; + esac + case "$RI_STATE" in + failed) say ""; say "RETRY: $(quoted "$SELF_ABS") --supervise --lane $lane --operation ${RI_OP:-}" ;; + pending) say ""; say "NOT YET CLAIMED: no supervisor has taken this operation. The pane it was written for may never have been respawned." ;; + starting) say ""; say "IN FLIGHT: a supervisor holds this operation. Do not start a second one — read the pane it is in (${RI_PANE:-pane not recorded})." ;; + ready) say ""; say "DONE: this restart was confirmed. It authorises no further launch; an ordinary \`lane $lane\` resumes the lane." ;; + esac + exit 0 +fi + +# THE PANE IS HELD OPEN ON EVERY NON-ZERO EXIT OF THIS MODE, refusals +# included. Amendment 11's invariant is *"No path the launcher opened exits the +# pane"*, and a supervisor that refused before launching and then returned would +# leave tmux printing `[exited]` over the one message that says why — which is +# the shape of the defect this whole mode exists to close, reached through its +# own front door. Where there is no terminal there is nobody to hold it for, and +# the status is simply returned. +SV_PIDFILE="" +sv_on_exit() { + sv_hs=$? + [ -z "$SV_PIDFILE" ] || rm -f -- "$SV_PIDFILE" 2>/dev/null + [ "$sv_hs" = 0 ] && return 0 + [ "${LANE_SUPERVISOR_NO_PROMPT:-0}" = 1 ] && return 0 + [ -t 0 ] || return 0 + printf '\n%s: this pane is held open so the lines above can be read (exit %s). Press Enter to close it. ' "$prog" "$sv_hs" >&2 + IFS= read -r sv_ignored || : + return 0 +} + +if [ "$do_supervise" = 1 ]; then + trap sv_on_exit EXIT + [ -n "$lane" ] || die "--supervise needs the lane: $prog --supervise --lane --operation " 2 + [ -n "$operation" ] || die "--supervise needs the operation it was started for: $prog --supervise --lane $lane --operation . A supervisor that took whatever intent it found would be a supervisor that launches a lane for an act somebody else is in the middle of." 2 + + # ---- the operation this supervisor was started for, and no other + sv_rc=0 + ri_load "$lane" || sv_rc=$? + case "$sv_rc" in + 8) die "lane $lane has no restart intent, so there is nothing for operation $operation to launch. Nothing was started. A /ctx whose intent could not be written should have refused before it killed anything — read the lane: $prog --restart-status --lane $lane" 2 ;; + 1) die "lane $lane's restart intent could not be read (\`$LANES_EDIT restart-intent $lane\` failed). That is NOT 'there is none', and this supervisor will not launch a session for an operation it could not verify (Amendment 7(d)). Nothing was started." 1 ;; + esac + [ "$RI_OP" = "$operation" ] || + die "operation $operation is not lane $lane's current restart operation, which is ${RI_OP:-none} (intent ${RI_STATE:-none}, generation ${RI_GEN:-0}). A stale supervisor never launches against a newer operation: nothing was started. Read it: $prog --restart-status --lane $lane" 2 + case "$RI_STATE" in + pending | failed) : ;; + starting) die "lane $lane's restart operation $operation is already \`starting\` — a supervisor holds it, in ${RI_PANE:-a pane this record does not name}. A second one would launch a second session of one lane, which is the collision this whole protocol is about (Amendment 18(h)). Nothing was started." 2 ;; + ready) die "lane $lane's restart operation $operation is already \`ready\`: that restart was confirmed and this record authorises no further launch. Nothing was started. To open the lane: lane $lane" 2 ;; + *) die "lane $lane's restart intent is in state '${RI_STATE:-none}', which this supervisor does not know how to launch from. Nothing was started." 2 ;; + esac + [ "$RI_MODE" = fresh-from-handoff ] || + die "lane $lane's restart intent names launch mode '${RI_MODE:-none}' and this supervisor makes only 'fresh-from-handoff' launches. Nothing was started." 2 + + # ---- the pane: this supervisor is IN the pane the intent was written for + # + # A SUPERVISOR IN THE WRONG PANE WOULD PUT A SECOND SESSION OF THIS LANE + # SOMEWHERE THE LANE IS NOT. The intent records the pane `/ctx` respawned; + # `$TMUX_PANE` is where this process actually is. They are compared where + # both are knowable, and a comparison that CANNOT be made is said rather than + # assumed — this runs under a fake tmux in the suite and under none at all + # where a person invoked it by hand. + sv_pane_here="${TMUX_PANE:-}" + pane_agrees "$RI_PANE" "$sv_pane_here" || + die "lane $lane's restart intent was written for pane $RI_PANE and this supervisor is in $sv_pane_here. Nothing was started: a launch here would put this lane's session in a pane the lane's own record does not name, and that record is what every other reader goes by." 2 + + # ---- the handoff's digest: the first prompt is that file, and only that file + if [ -n "$RI_HANDOFF" ] && [ "$RI_HANDOFF" != none ] && [ -n "$RI_DIGEST" ] && [ "$RI_DIGEST" != none ]; then + sv_now="$(handoff_digest "$RI_HANDOFF")" + [ "$sv_now" = "$RI_DIGEST" ] || + die "lane $lane's handoff has changed since operation $operation was written: the intent records $RI_DIGEST for $RI_HANDOFF and it now digests $sv_now. The top block of that file is the new session's FIRST PROMPT, so launching now would deliver a block this /ctx did not write. Nothing was started. Take the handoff again: $prog --restart" 2 + fi + + # ---- a live holder makes this ambiguous, and ambiguity refuses + # + # `live-holder` answers who is running this lane NOW. A supervisor that + # launched behind one would be Amendment 18(h)'s second binding, made by the + # tooling itself. A read that FAILED is not "nobody is": it refuses too, + # because this is the one moment a second process gets created. + sv_hold=""; sv_hrc=0 + sv_hold="$(LANES_NO_FETCH=1 "$LANES_EDIT" live-holder "$lane" 2>/dev/null)" || sv_hrc=$? + case "$sv_hrc" in + 0) [ -z "$sv_hold" ] || + die "lane $lane already has a live holder, so this supervisor will not start a second session of it (Amendment 18(h)): +$sv_hold +Nothing was started. If that process is a leftover of an earlier attempt, retire it first — \`lane-end $lane --retire \` — then retry: $prog --supervise --lane $lane --operation $operation" 2 ;; + 8 | 2) : ;; + *) die "lane $lane's live holders could not be read (\`$LANES_EDIT live-holder $lane\` exited $sv_hrc), so whether this lane is already running is UNKNOWN from here — and a read that failed is never an answer (Amendment 7(d)). This supervisor will not create a second process on a lane that may be live. Nothing was started." 1 ;; + esac + + supervise_run "$lane" "$operation" + exit $? +fi + + # EVERY READ OF THE REGISTER GOES THROUGH ONE FENCE, and Amendment 7(d) is the # whole of it: `0` an answer, `8` NO ANSWER and `2` a helper predating the read # — both of those fall to the next rung — and ANYTHING ELSE is a read that @@ -1090,79 +1728,121 @@ if [ "$do_restart" = 1 ]; then # session running this command is killed BY the command it just wrote the # record for. # - # THE RESPAWN LINE IS `lane `, AND NEVER `restart ` (Amendment 18 - # Addendum 2, in force 2026-09-14T16:50:32Z, clause (i-8): *"The respawn - # after `/ctx` (Amendment 17(f)) relaunches the lane's own pane with `lane - # ` (its parked branch is exactly Amendment 11(i)'s act), or through - # the launcher directly"*, and its adoption line: *"opensoft/openRepoTools#36 - # (`/ctx`) respawns with `lane `"*). `lane ` needs no profile - # argument from here: its parked branch reads the record THIS COMMAND JUST - # WROTE — the lane's recorded directory and profile — and asks nothing. + # WHAT THE RESPAWN LINE USED TO BE, AND WHY IT IS NOT THAT ANY MORE. + # Amendment 18 Addendum 2 (i-8) names two doors for this act: *"relaunches + # the lane's own pane with `lane ` (its parked branch is exactly + # Amendment 11(i)'s act), or through the launcher directly"*. This command + # respawned with the first of them, and openRepoTools#94 is what that cost: + # `lane` is a HUMAN DISPATCHER whose valid outcomes include attaching to an + # existing session without launching anything, and the fresh-session + # requirement it was asked to carry was an environment variable that the + # launcher's own `tmux new-session` dropped on the way through. # - # AND THE LAUNCHER IS THE DOOR THE SAME CLAUSE LEAVES OPEN, for the - # workstation where that word is not installed yet: `lane` arrives with - # opensoft/openRepoTools#43 and this command ships before it. A respawn is - # the one act no later refusal can undo — the pane's process is already dead - # when the new command fails — so the word is used only where it can be SEEN - # on `PATH` here, and everywhere else the line is `pclaude --lane - # `, the same act one door along. `--lane` is a LEADING option, read - # before the action and the profile, so it goes BEFORE the profile and never - # after it. - lane_word="" - lane_word_path="$(command -v lane 2>/dev/null || printf '')" - if [ -n "$lane_word_path" ] && [ -f "$lane_word_path" ] && [ -r "$lane_word_path" ]; then - # THE WORD THIS ESTATE PLACES, AND NOT EVERY `lane` ON A PATH. `pclaude` - # is nobody else's name; `lane` is an ordinary English word, and a pane - # respawned over somebody else's `lane` is a pane the person loses with - # no way back. So the file is read for the string every bash file this - # toolset ships carries, and a `lane` that is not this estate's takes - # the launcher — which is the SAFE side of the two: the same act, one - # door along, rather than a killed pane. - # READ THROUGH A REDIRECT and not `head -c … -- `: the macOS job - # runs this suite under BSD `head`, whose option handling is its own, - # and a portability question is not one to answer inside the act that - # kills a pane. - # `grep` READS ALL OF IT, AND `-q` IS WHY IT MUST (Copilot round 3 on - # openRepoTools#47). Under `set -o pipefail` the pipeline's status is - # the last non-zero one, and `grep -q` exits the instant it matches — - # so `head` writing the rest of a word larger than 8192 bytes takes - # SIGPIPE, 141 wins over grep's 0, and the estate's OWN `lane` is - # classified as somebody else's. The redirect costs nothing and the - # input is bounded by `head` already. - if head -c 8192 < "$lane_word_path" 2>/dev/null | grep 'lane-collision-protocol' >/dev/null 2>&1; then - lane_word="$lane_word_path" - else - note "there is a \`lane\` on PATH ($lane_word_path) that is not this estate's word (its head names no lane-collision-protocol), so the respawn goes through the launcher instead — the door Amendment 18 Addendum 2 (i-8) leaves open." - fi - fi - # THE FIRST PROMPT IS THE HANDOFF'S TOP BLOCK, and it is delivered by - # `lane-start` rather than carried through this argv. `LANE_START_FRESH=1` - # is the one seam: it says *a NEW session of this lane's agent, started with - # the top block of the handoff the row names as its first prompt* — which is - # exactly what a context clear is, and it is why `/ctx` does not simply - # resume the transcript it just paused. One mechanism, read in one place - # (`lane-start`'s 5a), so the prompt a respawn delivers and the prompt an - # `--agent` launch delivers are the same bytes from the same file. It is an - # ENVIRONMENT seam and not an argument, so it survives `lane` handing the - # launch on to `lane-start` exactly as it survives the launcher doing so. - if [ -n "$lane_word" ]; then - launch_line="LANE_START_FRESH=1 $(quoted "$lane_word" "$lane")" - else - launcher="${PCLAUDE:-}" - if [ -z "$launcher" ]; then - launcher="$(command -v pclaude 2>/dev/null || command -v claude-profile 2>/dev/null || printf '')" - fi - [ -n "$launcher" ] || - die "the record is written and there is neither \`lane\` nor a launcher on PATH (pclaude, claude-profile), so this pane was NOT respawned. Start the lane yourself: $restart_cmd" 2 - [ -n "$profile_name" ] || - die "the record is written and this lane's record names no profile, so the launcher cannot be told which account to come up in and the pane was NOT respawned. Start it yourself, naming the profile: pclaude --lane $lane " 2 - launch_line="LANE_START_FRESH=1 $(quoted "$launcher" --lane "$lane" "$profile_name")" - fi + # BOTH DOORS ARE KEPT, ONE LEVEL DOWN. What tmux starts now is this + # command's `--supervise` mode, and what the SUPERVISOR starts is (i-8)'s + # second door — `pclaude --lane `, the launcher directly — + # falling to `lane-start` itself where this workstation has no launcher or + # the lane's record names no profile. The ground of (i-8) is honoured + # exactly: *"no word is kept on `PATH` for it alone"*, and none is — the + # supervisor is a mode of a command that is already installed, and a person + # still has one word, `lane`. + # + # THE DEVIATION IS WRITTEN DOWN rather than taken quietly: it is decision 5 + # of `openspec/changes/add-supervised-context-restart/design.md`, and the + # protocol text that would ratify it is task 7.4's, unwritten as this + # lands. + + # ---- THE PRE-KILL COMMIT POINT: THE RESTART INTENT (openRepoTools#94) + # + # THE FRESH SESSION IS WRITTEN DOWN BEFORE ANYTHING IS KILLED, and that is + # the whole of this change. `LANE_START_FRESH=1` in the respawn's command + # string was the seam and it was measured NOT ARRIVING: `claude-profile` + # re-creates the child through `tmux new-session`, and tmux hands a command + # the SERVER's environment and not the client's — the launcher's own code + # says so, which is why it threads six values into the command string + # explicitly and why a seventh nobody thought to thread simply vanished. + # The pane came back resuming the transcript this act had just paused. + # + # SO THE REQUIREMENT BECOMES A FILE. The intent is written under the lane's + # own control root, carries the lane, generation, operation, old + # transcript, agent, profile, checkout, pane, handoff path and DIGEST and + # the launch mode, and `lane-start` reads it back whatever the environment + # did. It is the one channel that crosses a boundary another repository + # owns. + # + # AND IT IS A GATE, LIKE THE THREE ABOVE IT. Amendment 17(f) is an order — + # *a `/ctx` whose record could not be written REFUSES before it kills + # anything* — and after this change the intent IS part of that record: a + # pane respawned with no intent is a pane whose supervisor has nothing to + # launch from, which is the same unrecorded restart one step along. It + # therefore refuses here, with the old process still alive, rather than + # after it is dead. + # + # THE GENERATION COMES FROM THE LANE'S LIFECYCLE WHERE THIS WORKSTATION HAS + # ONE (openRepoTools#91's `lane-state`), so the two fences are one number; + # where it has none the intent carries 0 and fences on its own operation + # alone, which is what a workstation before that change can honestly say. + ri_gen=0 + ri_g_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-state "$lane" 2>/dev/null \ + | awk -F'\t' '$1 == "generation" { print $2; exit }')" || : + case "${ri_g_out:-}" in '' | *[!0-9]*) ri_gen=0 ;; *) ri_gen="$ri_g_out" ;; esac + operation="$(printf 'ctx-%s-%s-%s' "$(date -u +%Y%m%dT%H%M%SZ)" "$$" "${RANDOM:-0}")" + ri_args=(set-restart-intent "$lane" pending + --expect 'none|ready|failed' + --operation "$operation" --generation "$ri_gen" + --mode fresh-from-handoff + --agent "$agent" --pane "$target" + --old-transcript "${transcript:-none}") + [ -n "$profile_name" ] && ri_args+=(--profile "$profile_name") + case "$dir" in /*) ri_args+=(--dir "$dir") ;; esac + case "$handoff_file" in /*) ri_args+=(--handoff "$handoff_file") ;; esac + [ -n "$window_ref" ] && ri_args+=(--window "$window_ref") + ri_wrc=0 + LANES_NO_FETCH=1 "$LANES_EDIT" "${ri_args[@]}" >/dev/null 2>&1 || ri_wrc=$? + case "$ri_wrc" in + 0) : ;; + 7) die "lane $lane already has a restart in flight (its intent is \`pending\` or \`starting\`), so this /ctx did NOT replace the pane: an ordinary context clear never supersedes an operation somebody else is in the middle of. The record above IS written. Read what is open: $prog --restart-status --lane $lane" 2 ;; + *) die "the lane's PAUSED record, row and handoff are all written and the RESTART INTENT could not be (\`set-restart-intent\` exited $ri_wrc), so this pane is left exactly as it is. The intent is what tells the new session it is a FRESH one primed by the handoff's top block, and a pane respawned without it comes up resuming the transcript this act just paused — which is openRepoTools#94, measured. Restart by hand once it can be written: $restart_cmd" 2 ;; + esac + # READ BACK BEFORE THE KILL, because a write that reported success and a + # record a second process can read are not the same fact, and the process + # that could fix the difference is the one about to die. + ri_back="$(LANES_NO_FETCH=1 "$LANES_EDIT" restart-intent "$lane" 2>/dev/null \ + | awk -F'\t' '$1 == "operation" { print $2; exit }')" || : + [ "$ri_back" = "$operation" ] || + die "the restart intent was written and reads back as operation '${ri_back:-nothing}' rather than $operation, so this pane is left exactly as it is: the supervisor would launch for an operation this act does not own. The record above IS written. Restart by hand: $restart_cmd" 2 + say " restart intent $operation written (generation $ri_gen, mode fresh-from-handoff)" + + # ---- THE RESPAWN, INTO A SUPERVISOR AND NOT INTO THE LAUNCH ITSELF + # + # WHAT TMUX STARTS IS A PROCESS THAT STAYS. The old line respawned the pane + # with the launch, so a launch that failed took the pane with it and left + # an empty dead window where a person's lane had been — against Amendment + # 11's own invariant, *"No path the launcher opened exits the pane"*. The + # supervisor is that invariant kept for the one act that had nobody + # keeping it: it claims the intent, runs the launch as its CHILD, confirms + # readiness, and on any failure stays in the pane with the diagnostic and + # the retry. + # + # BY ABSOLUTE PATH, because a respawned pane's `PATH` is whatever the + # person's shell profile makes of it, and a respawn is the one act no later + # refusal can undo. It is checked for existence HERE, while this process is + # still alive to say so. + [ -x "$SELF_ABS" ] || + die "the record and the restart intent are written and this command cannot find itself at $SELF_ABS to hand the pane to, so nothing was respawned: tmux starts the supervisor by absolute path because a respawned pane's PATH is not this one's. Restart by hand: $restart_cmd" 2 + # AND THE ENVIRONMENT THE SUPERVISOR NEEDS IS WRITTEN INTO THE COMMAND + # STRING, for exactly the reason #94 exists: the workstation name is what + # every register write from the new session is filed under (`R-A11-14`), + # and tmux would otherwise hand the pane whatever the SERVER has. + launch_line="$(quoted "$SELF_ABS" --supervise --lane "$lane" --operation "$operation")" + [ -n "$ws" ] && launch_line="LANES_WORKSTATION=$(quoted "$ws") $launch_line" say " respawning $target: $launch_line" if tmux respawn-pane -k -t "$target" "$launch_line" 2>/dev/null; then - say " respawned (the new session's first prompt is this handoff's top block)" + say " respawned into the supervisor — it launches the fresh session, confirms it came up, and stays in this pane if it did not" else - die "the record is written and \`tmux respawn-pane -k -t $target\` was refused, so this pane is exactly as it was. Start the lane yourself: $restart_cmd" 2 + die "the record and the restart intent are written and \`tmux respawn-pane -k -t $target\` was refused, so this pane is exactly as it was and the lane's restart intent is \`pending\` for operation $operation. Nothing was killed. Start it yourself: $restart_cmd + or hand this pane to the supervisor once tmux will take it: + $launch_line" 2 fi exit 0 fi diff --git a/lane-start b/lane-start index a92ea124..d675b5fe 100755 --- a/lane-start +++ b/lane-start @@ -91,6 +91,16 @@ # as its first prompt. The table is `claude` and `codex`; a # name it does not carry is a refusal naming the two, never a # guessed argv +# --fresh A NEW SESSION of this lane's agent, primed by the handoff's +# top block, whatever transcript the row names — the +# fresh-session mode as an ARGUMENT rather than as an +# environment variable (openRepoTools#94). The lane's own +# pending restart intent outranks it; a conflicting one is a +# refusal and never a substitution +# --operation the restart operation this launch belongs to, passed by the +# pane supervisor. The intent named by it is the one this run +# reads; an id that is not the lane's current operation is a +# refusal # -h, --help # -- everything after it is passed through to claude # @@ -271,11 +281,13 @@ # LANES_NO_GIT=1 write the handoff's stamp but commit nothing # LANE_START_FRESH=1 a NEW session of this lane's agent, started with the # top block of the handoff the row names as its first -# prompt — which is what a context clear IS. It is the -# one seam `/ctx` uses (`lane-handoff --restart` respawns -# the lane's own pane with it set, Amendment 17(f)), and -# with it unset this command behaves exactly as it did -# before Amendment 17 +# prompt — which is what a context clear IS. It was +# `/ctx`'s one seam under Amendment 17(f) and it still +# works; it is NO LONGER THE AUTHORITY (openRepoTools#94): +# the lane's own restart intent outranks it and `--fresh` +# stands beside it, because an environment variable does +# not survive the launcher's `tmux new-session` and this +# command was measured being reached without it # # It spells no absolute path of its own. Since Amendment 9(a) the register is # found through `$AGENT_PROTOCOL_ROOT/workspace.yaml` rather than beside this @@ -612,7 +624,7 @@ quoted() { # ------------------------------------------------------------------ arguments dry_run=0; no_launch=0; estate=""; dir_override=""; confirm=0; take_yes=0 -agent_flag="" +agent_flag=""; fresh_flag=0; op_flag="" positional=(); pass=() while [ $# -gt 0 ]; do @@ -629,6 +641,24 @@ while [ $# -gt 0 ]; do # empty value, for the reason `--dir` states above. --agent) agent_flag="${2-}"; [ -n "$agent_flag" ] || die "--agent needs a name" 2; shift 2 ;; --agent=*) agent_flag="${1#--agent=}"; [ -n "$agent_flag" ] || die "--agent needs a name" 2; shift ;; + # `--fresh` — THE FRESH-SESSION MODE AS AN ARGUMENT, and it exists because + # the environment seam it stands beside cannot be relied on to arrive + # (openRepoTools#94, measured on Eagle 2026-09-15T20:59Z). `/ctx` set + # `LANE_START_FRESH=1` in the command it respawned the pane with, the + # launcher re-created the child through `tmux new-session`, and tmux hands + # a command the SERVER's environment and not the client's — so this + # command was reached with the variable gone and resumed the very + # transcript the context clear had just paused. An ARGUMENT survives an + # `exec`; it does not survive a re-creation either, which is why the + # DURABLE RESTART INTENT below outranks both. This flag is for the caller + # that reaches this command directly. + --fresh) fresh_flag=1; shift ;; + # THE OPERATION THIS LAUNCH BELONGS TO (openRepoTools#94). The supervisor + # passes it so that this run reads the intent it was started for and no + # other, and so that a launch arriving with a STALE operation refuses + # rather than consuming a newer lane's intent. + --operation) op_flag="${2-}"; [ -n "$op_flag" ] || die "--operation needs an operation id" 2; shift 2 ;; + --operation=*) op_flag="${1#--operation=}"; [ -n "$op_flag" ] || die "--operation needs an operation id" 2; shift ;; # THE TWO SPELLINGS OF ONE FLAG GIVE ONE ANSWER, and these two did not # (#26, the review of `c3ebcfe`, `lane-start:707`). `--dir ""` refuses; # `--dir=` set the variable to nothing and fell through as if the operator @@ -1918,6 +1948,21 @@ profile_projects_dirs() { # parses this file. A path that is not a directory comes back as it was given, # which is what the `readlink -f` fallback did. real_of() { ( CDPATH=''; cd -P -- "$1" 2>/dev/null && pwd -P ) || printf '%s\n' "$1"; } +# THE HANDOFF'S DIGEST, spelled exactly as `lanes-edit.sh` spells it, because +# the two are compared: this file is what the intent recorded and what a launch +# verifies, and two implementations of one digest is one implementation too +# many. `none` where the workstation has neither tool — an ANSWER, meaning *not +# checked here*, never *unchanged*. +handoff_digest_of() { # + [ -r "${1-}" ] || { printf 'none\n'; return 0; } + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -- "$1" 2>/dev/null | awk '{print $1; exit}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 -- "$1" 2>/dev/null | awk '{print $1; exit}' + else + printf 'none\n' + fi +} profile_of_projects_dir() { # <…//projects> -> the profile's name popd_d="${1%/projects}"; popd_n="${popd_d##*/}" @@ -2232,16 +2277,112 @@ if [ "$AGENT" != claude ]; then esac fi fi -# `LANE_START_FRESH=1` IS THE `/ctx` SEAM, and it is one sentence: *a NEW -# session of this lane's agent, started with the top block of the handoff the -# row names as its first prompt*. That is what a context clear IS, and it is why -# `/ctx` does not simply resume the transcript it has just paused. -# `lane-handoff --restart` respawns the lane's own pane with it set (Amendment -# 17(f)); nothing else sets it, and with it unset this command behaves exactly -# as it did before this amendment. +# ------------- THE FRESH SESSION, AND WHICH OF ITS THREE SOURCES IS AUTHORITY +# +# A CONTEXT CLEAR IS ONE SENTENCE — *a NEW session of this lane's agent, +# started with the top block of the handoff the row names as its first prompt* — +# and it is why `/ctx` does not simply resume the transcript it has just paused. +# What changed on 2026-09-15 is not the sentence but WHO SAYS IT. +# +# `LANE_START_FRESH=1` WAS THE SEAM AND IS NO LONGER THE AUTHORITY +# (openRepoTools#94, measured on Eagle 2026-09-15T20:59Z). `/ctx` respawned the +# lane's own pane with the variable set, and this command was reached WITHOUT +# it: `claude-profile` re-creates the child through tmux, and its own code says +# the rule — *"`tmux new-session` hands the command the SERVER's environment, +# not this client's, so a variable the operator exported in their own shell +# reaches the child only if it is written into the command string"*. It threads +# six values explicitly and this was never one of them. The gates below were +# correct and were simply never reached with `fresh` set; the lane resumed the +# uuid it had just paused, and the record said an act had happened that had not. +# +# SO THE AUTHORITY IS A FILE, and the three sources are read in this order: +# +# 1. THE LANE'S OWN RESTART INTENT — `lanes-edit.sh restart-intent `, +# written under the lane's control root BEFORE the old process was killed +# and read back here whatever the environment did. It is the only channel +# that crosses a boundary another repository owns, because it is on disk +# rather than in an environment. Only `pending` and `starting` authorise a +# launch: a `ready` intent is HISTORY (which is what makes an ordinary +# `lane ` after a successful `/ctx` an ordinary resume), and a +# `failed` one is the supervisor's to retry, which moves it back to +# `starting` first. +# 2. `--fresh`, for the caller that reaches this command directly and has no +# launcher between it and here. +# 3. `LANE_START_FRESH=1`, kept exactly as it was so that nothing written +# about it stops working, and no longer trusted to arrive. +# +# AND AN INTENT THAT DISAGREES WITH THIS RUN IS A REFUSAL, NEVER A SUBSTITUTION +# (spec: *the operation launch refuses without substituting the conflicting +# value*). An intent naming another directory is an intent for a lane in +# another checkout, and launching this one in it is Evidence 3's silent loss of +# the repository's CLAUDE.md and the lane's memory; an `--operation` that is not +# the lane's current one is a supervisor acting for an operation that has been +# superseded. fresh=0 -[ "${LANE_START_FRESH:-0}" = 1 ] && fresh=1 +fresh_why="" first_prompt=0 +ri_state=""; ri_op=""; ri_gen=""; ri_mode=""; ri_dir=""; ri_hf=""; ri_dig=""; ri_rc=0 +ri_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" restart-intent "$LANE" 2>/dev/null)" || ri_rc=$? +case "$ri_rc" in + 0) + ri_state="$(printf '%s\n' "$ri_out" | awk -F'\t' '$1 == "state" { print $2; exit }')" + ri_op="$(printf '%s\n' "$ri_out" | awk -F'\t' '$1 == "operation" { print $2; exit }')" + ri_gen="$(printf '%s\n' "$ri_out" | awk -F'\t' '$1 == "generation" { print $2; exit }')" + ri_mode="$(printf '%s\n' "$ri_out" | awk -F'\t' '$1 == "mode" { print $2; exit }')" + ri_dir="$(printf '%s\n' "$ri_out" | awk -F'\t' '$1 == "dir" { print $2; exit }')" + ri_hf="$(printf '%s\n' "$ri_out" | awk -F'\t' '$1 == "handoff" { print $2; exit }')" + ri_dig="$(printf '%s\n' "$ri_out" | awk -F'\t' '$1 == "digest" { print $2; exit }')" ;; + # 8 IS AN ANSWER — this lane has no restart in flight, which is every + # ordinary launch — and 1 is a lane with no control root, which is the + # pre-cutover lane and not a failure either. Anything else is a read that + # FAILED, and a read that failed is never an answer (Amendment 7(d)): it is + # said, and the other two sources still decide, because refusing every launch + # on a helper that could not answer is not a safety property. + 8|1) : ;; + *) note "$LANES_EDIT restart-intent $LANE failed (exit $ri_rc) — this launch reads no restart intent, so a /ctx in flight for this lane is NOT visible here. If a context clear was just taken, run it again once the read works." ;; +esac +# AN `--operation` NAMES THE INTENT IT BELONGS TO, AND ONLY THAT ONE. +if [ -n "$op_flag" ]; then + [ -n "$ri_state" ] || + die "--operation $op_flag was given and lane $LANE has no restart intent to match it against (the read above says what answered). A supervised launch is made for ONE operation and this one cannot be found: nothing was renamed, nothing was written and no session was started. Read it with: lanes-edit.sh restart-intent $LANE" 2 + [ "$op_flag" = "$ri_op" ] || + die "--operation $op_flag is not lane $LANE's current restart operation, which is ${ri_op:-none} (state ${ri_state:-none}, generation ${ri_gen:-0}). A stale supervisor must never launch against a newer operation — nothing was renamed, written or started. Read it with: lanes-edit.sh restart-intent $LANE" 2 +fi +case "$ri_state" in + pending|starting) + if [ "$ri_mode" = fresh-from-handoff ]; then + # THE DIRECTORY IS COMPARED BEFORE ANYTHING IS TAKEN. `real_of` is this + # file's own resolver, so a symlinked projects root does not read as a + # disagreement it is not. + if [ -n "$ri_dir" ] && [ "$ri_dir" != none ] && [ "$(real_of "$ri_dir")" != "$(real_of "$DIR")" ]; then + die "lane $LANE's restart intent (operation ${ri_op:-none}, state $ri_state) names checkout $ri_dir and this launch stands in $DIR. That is a conflict and not a preference: a launch that lands in the right transcript and the wrong directory loses the repository's CLAUDE.md and the lane's memory, silently (Evidence 3). Nothing was renamed, written or started. Launch it where the intent says, or read the intent: lanes-edit.sh restart-intent $LANE" 2 + fi + # THE HANDOFF'S DIGEST IS VERIFIED ON EVERY LAUNCH (spec: *a changed or + # missing handoff blocks launch and retry*), because the top block of + # that file is literally this launch's first prompt and a file that has + # moved under the intent is a different instruction. A `none` digest is + # *not checked here* — a workstation with neither `sha256sum` nor + # `shasum` — and says so rather than passing silently. + if [ -n "$ri_hf" ] && [ "$ri_hf" != none ] && [ -n "$ri_dig" ] && [ "$ri_dig" != none ]; then + ri_now="$(handoff_digest_of "$ri_hf")" + if [ "$ri_now" != "$ri_dig" ]; then + die "lane $LANE's handoff has changed since its restart intent was written: the intent records digest $ri_dig for $ri_hf and that file now digests $ri_now. The top block of that file is this launch's FIRST PROMPT, so launching now would hand the new session the block of a handoff the /ctx did not write. Nothing was renamed, written or started (operation ${ri_op:-none}). Take the handoff again: lane-handoff --restart" 2 + fi + fi + fresh=1 + fresh_why=" (restart intent ${ri_op:-none}, state $ri_state: the /ctx respawn)" + else + note "lane $LANE has a restart intent in state $ri_state whose mode is '${ri_mode:-none}' and not 'fresh-from-handoff', so it authorises no fresh session here; this launch resolves its own resume source." + fi ;; +esac +if (( ! fresh )) && [ "$fresh_flag" = 1 ]; then + fresh=1 + fresh_why=" (--fresh)" +fi +if (( ! fresh )) && [ "${LANE_START_FRESH:-0}" = 1 ]; then + fresh=1 + fresh_why=" (LANE_START_FRESH=1: the /ctx respawn)" +fi # AMENDMENT 11, EVIDENCE 4 — `--name "$LANE"` IS ON EVERY LAUNCH BRANCH, NOT # ONLY THE NEW-SESSION ONE. The session NAME is the lane's messaging address @@ -2350,8 +2491,6 @@ elif (( fresh )) || (( AGENT_EXPLICIT )); then # title fallback deliberately — a caller that named an agent, or a context # clear that has just written a fresh top block, is asking for a new session # of this lane and not for a picker filtered by a title. - fresh_why="" - (( fresh )) && fresh_why=" (LANE_START_FRESH=1: the /ctx respawn)" step "agent claude — a new session named for the lane, with this lane's handoff top block as its first prompt$fresh_why" cmd=("$CLAUDE_BIN" --name "$LANE") lane_verb=RESUMED; lane_sid=unknown; first_prompt=1 @@ -3188,6 +3327,40 @@ if (( first_prompt )); then fi fi +# --------- 5c. the transcript this launch is about to make, INTO THE INTENT +# +# THE SUPERVISOR CANNOT CONFIRM WHAT IT WAS NEVER TOLD (openRepoTools#94). Its +# readiness predicate asks whether the EXPECTED new transcript exists and is +# bound to this lane; the only process that knows which uuid that is, is this +# one, and only after 5a minted it. So it is written back into the intent +# before the `exec` — the last act this command takes that the supervisor can +# read — and the state goes with it: `starting` is *a launch was prepared for +# this operation*, and the lane stays wherever its lifecycle already had it +# until readiness is CONFIRMED. +# +# THE WRITE IS FENCED ON THE OPERATION AND ON NOTHING ELSE. A launch that has +# been superseded between 5a and here writes nothing, and says so: the exit 7 +# is the intent telling this run that a newer operation owns the lane. +# +# IT NEVER FAILS THE LAUNCH. A supervisor that cannot read the expected uuid +# falls back to its other evidence and to its timeout, which is an +# INDETERMINATE and not a false RUNNING; refusing the launch here would trade a +# confirmable start for no start at all. +if (( fresh )) && [ -n "$ri_op" ] && (( ! no_launch )) && (( ! dry_run )); then + ri_new="$lane_sid" + [ -n "$ri_new" ] && [ "$ri_new" != unknown ] || ri_new="${new_sid:-}" + ri_wrc=0 + LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$LANE" starting \ + --expect 'pending|starting' --expect-operation "$ri_op" \ + ${ri_new:+--new-transcript "$ri_new"} \ + --reason "lane-start prepared the launch" >/dev/null 2>&1 || ri_wrc=$? + case "$ri_wrc" in + 0) step "restart intent $ri_op: starting${ri_new:+, expected transcript $ri_new}" ;; + 7) note "the restart intent moved under this launch (another operation owns lane $LANE now). The launch goes on — it is the one this pane was respawned for — but its supervisor will read the newer operation and will not mark this one ready." ;; + *) note "the restart intent could not be updated (set-restart-intent exited $ri_wrc), so the supervisor has no expected transcript to confirm against and will fall to its timeout. The launch is unaffected." ;; + esac +fi + # ---------------------------------------------------------------- 6. launch # # `cmd` was decided in 5a, before the row was written. diff --git a/lanes-edit.sh b/lanes-edit.sh index d1c6cfca..0ff94a7f 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -7502,6 +7502,224 @@ EOF return "$msc_rc" } +# ------------------------------------- the restart intent (openRepoTools#94) +# +# WHY THIS IS A FILE AND NOT AN ENVIRONMENT VARIABLE, measured on Eagle +# 2026-09-15T20:59Z and filed as `opensoft/openRepoTools#94`. `/ctx` respawned +# its own pane with `LANE_START_FRESH=1 … lane openRepoTools-3` and what came +# up was `claude --name openRepoTools-3 --resume `. +# `lane-start` was not at fault — its `(( ! fresh ))` gates were in the +# installed copy and are in this tree — and neither was `lane`, which `exec`s. +# The launcher is: `claude-profile` re-creates the child through tmux, and its +# own comment states the rule this estate now has to build around — +# +# "`tmux new-session` hands the command the SERVER's environment, not this +# client's, so a variable the operator exported in their own shell reaches +# the child only if it is written into the command string" +# +# — which is why it threads six values explicitly and why a seventh nobody +# thought to thread is simply GONE. The defect capture's session name, +# `claude-max-001-20260915205926-456327`, is that launcher's own +# `claude---` pattern, so this is what happened and not +# what might have. An ENVIRONMENT seam cannot cross a boundary another +# repository owns. A FILE can: the intent is written under the lane's own +# control root before the old process is killed, and every launch after it +# reads the same bytes whatever the environment did. +# +# IT IS THE SAME CONTROL ROOT `opensoft/openRepoTools#91` DEFINES, and the four +# helpers below are ITS four, spelled the same on purpose: when that change +# lands, the resolution of the textual conflict is *keep either*, and the +# restart intent then sits beside `lane-state.yaml` in one directory rather +# than in a second scheme of its own. + +LANE_RESTART_SCHEMA=1 + +# THE CONTROL ROOT — three rungs, in order. 0 with the path (which need not +# exist yet), 8 where no rung answers. +lane_control_root() { # [] + lcr_lane="${1-}"; lcr_pay="${2-}"; lcr_dir=""; lcr_par="" + [ -n "$lcr_lane" ] || return 8 + if [ -n "${LANES_LANE_STATE_ROOT:-}" ]; then + printf '%s/%s\n' "${LANES_LANE_STATE_ROOT%/}" "$lcr_lane" + return 0 + fi + [ -n "$lcr_pay" ] && lcr_dir="$(payload_subfield "$lcr_pay" dir)" + if [ -z "$lcr_dir" ]; then + lcr_dir="$(lane_payload_field "$lcr_lane" dir 2>/dev/null || :)" + fi + case "$lcr_dir" in + /*) lcr_par="${lcr_dir%/*}" ;; + *) lcr_par="" ;; + esac + if [ -n "$lcr_par" ] && [ -d "$lcr_par" ]; then + printf '%s/.lane-state/%s\n' "$lcr_par" "$lcr_lane" + return 0 + fi + if [ -n "${PROJECTS_ROOT:-}" ] && [ -d "$PROJECTS_ROOT" ]; then + printf '%s/.lane-state/%s\n' "${PROJECTS_ROOT%/}" "$lcr_lane" + return 0 + fi + return 8 +} + +# ONE FIELD OUT OF ONE OF THESE FILES. They are flat `key: value` lines and +# nothing else — no nesting, no lists — so one `awk` reads every one of them +# and a malformed file answers empty rather than half a value. +lane_sidecar_field() { # + [ -r "${1-}" ] || return 1 + awk -v k="${2-}" ' + BEGIN { k = k ": " } + substr($0, 1, length(k)) == k { + v = substr($0, length(k) + 1) + sub(/^[ \t]+/, "", v); sub(/[ \t]+$/, "", v) + print v; exit }' "$1" +} + +# A VALUE IS ONE LINE OF `key: value`, so a newline or a leading space in one +# would make the file unreadable by the reader above. Both are flattened here +# rather than refused, because a value this can spoil is a branch name or a +# path and losing the WHOLE record over one is the worse trade. +lane_sidecar_value() { # + lsv="${1-}" + lsv="${lsv//$'\r'/ }" + lsv="${lsv//$'\n'/ }" + printf '%s' "$lsv" +} + +# THE ATOMIC REPLACE. Written beside the target and renamed over it, so a +# reader never sees half a snapshot and a full disk leaves the old one intact. +lane_sidecar_put() { # ; the whole body on stdin + lsp_f="${1-}"; lsp_d="${lsp_f%/*}" + [ -n "$lsp_f" ] || return 1 + mkdir -p -- "$lsp_d" 2>/dev/null || return 1 + lsp_t="$lsp_f.tmp.$$" + cat > "$lsp_t" 2>/dev/null || { rm -f -- "$lsp_t" 2>/dev/null; return 1; } + mv -- "$lsp_t" "$lsp_f" 2>/dev/null || { rm -f -- "$lsp_t" 2>/dev/null; return 1; } + return 0 +} + +# AN OPERATION ID: unique to one transition, and shaped like the manifest key +# every other sub-field of this estate is — letters, digits, `.`, `_`, `-` — so +# that it can be carried in a payload, a filename and a commit subject without +# a quoting rule of its own. There is no `uuidgen` on every workstation this +# runs on, and a timestamp with the pid and two `$RANDOM`s behind it is unique +# among the handfuls of transitions one lane takes in a day. +lane_op_id() { + printf 'op-%s-%s-%s%s\n' "$(date -u +%Y%m%dT%H%M%SZ)" "$$" "$RANDOM" "$RANDOM" +} + +# THE FOUR STATES OF A RESTART, AND NOTHING ELSE IS ONE. +# pending the intent is written and the old process may still be alive; the +# pane has not been replaced, or has been and the supervisor has +# not claimed it yet. +# starting a supervisor has claimed this exact operation and launched. +# failed the launch did not reach readiness; the supervisor is still in +# the pane with the diagnostic and the retry. +# ready readiness was positively confirmed. A `ready` intent is HISTORY +# and authorises no launch, which is what makes an ordinary +# `lane ` after a successful `/ctx` an ordinary resume. +lane_restart_word_ok() { # + case "${1-}" in pending|starting|failed|ready) return 0 ;; esac + return 1 +} + +# THE HANDOFF'S DIGEST. `shasum -a 256` is on macOS, `sha256sum` on Linux, and +# a workstation with neither answers `none` — which is an ANSWER and not a gap: +# the digest then cannot be COMPARED, and every reader below treats a `none` +# digest as *not checked here* rather than as *unchanged*. Never `cksum`: a +# 32-bit checksum is not a change detector for a file a person edits. +lane_handoff_digest() { # + [ -r "${1-}" ] || { printf 'none\n'; return 0; } + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -- "$1" 2>/dev/null | awk '{print $1; exit}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 -- "$1" 2>/dev/null | awk '{print $1; exit}' + else + printf 'none\n' + fi +} + +# THE INTENT, READ. `` lines, which is what every caller here +# parses with one `awk`. 0 with the fields, 8 where the lane has no intent at +# all (the ordinary lane, and not a failure), 1 where the control root could +# not be derived. +lane_restart_read() { # + lrr_lane="${1-}"; lrr_root=""; lrr_rc=0 + lrr_root="$(lane_control_root "$lrr_lane")" || lrr_rc=$? + [ "$lrr_rc" = 0 ] || return 1 + lrr_f="$lrr_root/restart-intent.yaml" + [ -r "$lrr_f" ] || return 8 + lrr_schema="$(lane_sidecar_field "$lrr_f" schema)" + # AN UNKNOWN SCHEMA FAILS CLOSED, exactly as `lane-state` does for the same + # reason: a newer tooling's intent read by an older reader must not be + # reported as a launch this reader knows how to make. + case "$lrr_schema" in + "$LANE_RESTART_SCHEMA") : ;; + *) printf 'state\tUNKNOWN-SCHEMA\n'; printf 'schema\t%s\n' "${lrr_schema:-}" + printf 'file\t%s\n' "$lrr_f"; return 0 ;; + esac + for lrr_k in state generation operation mode lane agent profile dir pane window \ + handoff digest old_transcript new_transcript attempt workstation \ + created updated reason; do + printf '%s\t%s\n' "$lrr_k" "$(lane_sidecar_field "$lrr_f" "$lrr_k")" + done + printf 'file\t%s\n' "$lrr_f" + return 0 +} + +# One field of it, for the callers that want exactly one. +lane_restart_of() { # + lro_out=""; lro_rc=0 + lro_out="$(lane_restart_read "${1-}")" || lro_rc=$? + [ "$lro_rc" = 0 ] || return "$lro_rc" + printf '%s\n' "$lro_out" | awk -F'\t' -v k="${2-}" '$1 == k { print $2; exit }' +} + +# THE INTENT, WRITTEN — the ONE writer, and every caller reaches it through +# `set-restart-intent`. An empty field is written as `none`, which is an ANSWER +# and not a gap, on the same argument Amendment 17(b) gives its `transcript` +# sub-field. +# +# THERE IS NO CREDENTIAL FIELD AND THERE IS NO PLACE FOR ONE. The fields are a +# lane, a generation, an operation, two transcript ids, an agent, a PROFILE +# NAME (a manifest key, never a token), a directory, a pane, a handoff path, +# its digest, an attempt count and a bounded reason. A caller that had a secret +# in hand would have nowhere here to put it. +lane_restart_put() { # + lrw_root="$1"; lrw_lane="$2"; lrw_state="$3"; lrw_gen="$4"; lrw_op="$5" + lrw_mode="$6"; lrw_agent="$7"; lrw_prof="$8"; lrw_dir="$9"; shift 9 + lrw_pane="$1"; lrw_win="$2"; lrw_hf="$3"; lrw_dig="$4"; lrw_old="$5" + lrw_new="$6"; lrw_att="$7"; lrw_reason="$8"; lrw_created="$9" + # BOUNDED DIAGNOSTICS (design decision 3). A launcher that died printing a + # megabyte of stderr must not turn the lane's own control file into that + # megabyte, and the field is one LINE by construction. + lrw_reason="$(lane_sidecar_value "$lrw_reason")" + if [ "${#lrw_reason}" -gt 400 ]; then + lrw_reason="$(printf '%s' "$lrw_reason" | cut -c1-400)… (truncated)" + fi + { printf 'schema: %s\n' "$LANE_RESTART_SCHEMA" + printf 'lane: %s\n' "$(lane_sidecar_value "$lrw_lane")" + printf 'state: %s\n' "$lrw_state" + printf 'generation: %s\n' "${lrw_gen:-0}" + printf 'operation: %s\n' "${lrw_op:-none}" + printf 'mode: %s\n' "${lrw_mode:-fresh-from-handoff}" + printf 'agent: %s\n' "${lrw_agent:-none}" + printf 'profile: %s\n' "${lrw_prof:-none}" + printf 'dir: %s\n' "$(lane_sidecar_value "${lrw_dir:-none}")" + printf 'pane: %s\n' "$(lane_sidecar_value "${lrw_pane:-none}")" + printf 'window: %s\n' "$(lane_sidecar_value "${lrw_win:-none}")" + printf 'handoff: %s\n' "$(lane_sidecar_value "${lrw_hf:-none}")" + printf 'digest: %s\n' "${lrw_dig:-none}" + printf 'old_transcript: %s\n' "${lrw_old:-none}" + printf 'new_transcript: %s\n' "${lrw_new:-none}" + printf 'attempt: %s\n' "${lrw_att:-0}" + printf 'workstation: %s\n' "${WS:-none}" + printf 'created: %s\n' "${lrw_created:-$(utc_now)}" + printf 'updated: %s\n' "$(utc_now)" + printf 'reason: %s\n' "${lrw_reason:-none}" + } | lane_sidecar_put "$lrw_root/restart-intent.yaml" +} + # ---------------------------------------------------------------- subcommands cmd="${1-}" @@ -9004,7 +9222,228 @@ EOF printf '%s\n' "$rh_out" ;; + # ---------- openRepoTools#94: the restart intent the supervisor launches from + # + # BOTH ANSWER OUT OF THE LOCAL CONTROL ROOT and neither touches the register, + # the object log or the network. The WRITER is deliberately absent from the + # dispatcher's workstation guard above — `R-A11-14` is about a record FILED + # UNDER A WORKSTATION, and this one is filed under nothing and never leaves + # the machine that wrote it — and it takes the same 64-for-usage contract + # every read Amendment 11 added takes, because it sits in front of a launch + # exactly as those do. + # + # 0 done + # 1 no control root could be derived, or it could not be written + # 2 a refusal of the arguments + # 7 THE FENCE DID NOT MATCH — another act got there first, which is what + # this file already spends 7 on (`claim`'s CLAIM-LOST, `set-lane-state`). + # Nothing was written and the current intent is printed. + # 8 there is no intent for this lane + # 64 a usage error of this subcommand's own + + restart-intent) + lane="${1-}"; [ -n "$lane" ] || die "usage: restart-intent " 64 + [ "$#" -le 1 ] || die "restart-intent takes one lane: restart-intent " 64 + check_lane_name "$lane" + log_sync + lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 + lri_out=""; lri_rc=0 + lri_out="$(lane_restart_read "$lane")" || lri_rc=$? + case "$lri_rc" in + 0) : ;; + 8) exit 8 ;; + *) die "lane $lane has no lifecycle control root, so it can carry no restart intent: its record names no directory (Amendment 11(c)) and \$PROJECTS_ROOT is not a directory here. That is NOT 'this lane has no restart in flight' — a read that could not be made is never an answer (Amendment 7(d))." 1 ;; + esac + printf '%s\n' "$lri_out" + ;; + + set-restart-intent) + lane="${1-}"; [ -n "$lane" ] || die "usage: set-restart-intent [--expect ] [--expect-generation ] [--expect-operation ] [--operation ] [--generation ] [--mode ] [--agent ] [--profile ] [--dir ] [--pane ] [--window ] [--handoff ] [--digest ] [--old-transcript ] [--new-transcript ] [--attempt ] [--bump-attempt] [--reason ]" 64 + shift + sri_state="${1-}"; [ -n "$sri_state" ] || die "set-restart-intent needs the state to move to: pending, starting, failed or ready" 64 + shift + sri_exp=""; sri_expg=""; sri_expo=""; sri_op=""; sri_gen=""; sri_mode="" + sri_agent=""; sri_prof=""; sri_dir=""; sri_pane=""; sri_win=""; sri_hf="" + sri_dig=""; sri_old=""; sri_new=""; sri_att=""; sri_bump=0; sri_reason="" + while [ $# -gt 0 ]; do + case "$1" in + --expect) sri_exp="${2-}"; [ -n "$sri_exp" ] || die "--expect needs a state word, 'none', or a '|'-separated set of them" 64; shift 2 ;; + --expect=*) sri_exp="${1#--expect=}"; shift ;; + --expect-generation) sri_expg="${2-}"; [ -n "$sri_expg" ] || die "--expect-generation needs a number" 64; shift 2 ;; + --expect-generation=*) sri_expg="${1#--expect-generation=}"; shift ;; + --expect-operation) sri_expo="${2-}"; [ -n "$sri_expo" ] || die "--expect-operation needs an operation id" 64; shift 2 ;; + --expect-operation=*) sri_expo="${1#--expect-operation=}"; shift ;; + --operation) sri_op="${2-}"; [ -n "$sri_op" ] || die "--operation needs an operation id" 64; shift 2 ;; + --operation=*) sri_op="${1#--operation=}"; shift ;; + --generation) sri_gen="${2-}"; [ -n "$sri_gen" ] || die "--generation needs a number" 64; shift 2 ;; + --generation=*) sri_gen="${1#--generation=}"; shift ;; + --mode) sri_mode="${2-}"; [ "$#" -ge 2 ] || die "--mode needs a value" 64; shift 2 ;; + --mode=*) sri_mode="${1#--mode=}"; shift ;; + --agent) sri_agent="${2-}"; [ "$#" -ge 2 ] || die "--agent needs a value" 64; shift 2 ;; + --agent=*) sri_agent="${1#--agent=}"; shift ;; + --profile) sri_prof="${2-}"; [ "$#" -ge 2 ] || die "--profile needs a value" 64; shift 2 ;; + --profile=*) sri_prof="${1#--profile=}"; shift ;; + --dir) sri_dir="${2-}"; [ "$#" -ge 2 ] || die "--dir needs a value" 64; shift 2 ;; + --dir=*) sri_dir="${1#--dir=}"; shift ;; + --pane) sri_pane="${2-}"; [ "$#" -ge 2 ] || die "--pane needs a value" 64; shift 2 ;; + --pane=*) sri_pane="${1#--pane=}"; shift ;; + --window) sri_win="${2-}"; [ "$#" -ge 2 ] || die "--window needs a value" 64; shift 2 ;; + --window=*) sri_win="${1#--window=}"; shift ;; + --handoff) sri_hf="${2-}"; [ "$#" -ge 2 ] || die "--handoff needs a value" 64; shift 2 ;; + --handoff=*) sri_hf="${1#--handoff=}"; shift ;; + --digest) sri_dig="${2-}"; [ "$#" -ge 2 ] || die "--digest needs a value" 64; shift 2 ;; + --digest=*) sri_dig="${1#--digest=}"; shift ;; + --old-transcript) sri_old="${2-}"; [ "$#" -ge 2 ] || die "--old-transcript needs a value" 64; shift 2 ;; + --old-transcript=*) sri_old="${1#--old-transcript=}"; shift ;; + --new-transcript) sri_new="${2-}"; [ "$#" -ge 2 ] || die "--new-transcript needs a value" 64; shift 2 ;; + --new-transcript=*) sri_new="${1#--new-transcript=}"; shift ;; + --attempt) sri_att="${2-}"; [ -n "$sri_att" ] || die "--attempt needs a number" 64; shift 2 ;; + --attempt=*) sri_att="${1#--attempt=}"; shift ;; + --bump-attempt) sri_bump=1; shift ;; + --reason) sri_reason="${2-}"; [ "$#" -ge 2 ] || die "--reason needs a value" 64; shift 2 ;; + --reason=*) sri_reason="${1#--reason=}"; shift ;; + --) shift ;; + *) die "unknown option '$1' for set-restart-intent" 64 ;; + esac + done + lane_restart_word_ok "$sri_state" || + die "'$sri_state' is not a restart-intent state: pending, starting, failed and ready are the four, and a fifth word in this file would be a state no reader of it knows" 64 + # THE FOUR VALUES THAT ARE KEYS AND NOT PROSE. An operation id, a launch + # mode, an agent and a profile are MANIFEST KEYS everywhere else in this + # estate — letters, digits, `.`, `_`, `-` — and each of them is compared + # for equality by a later reader (`--expect-operation`, the supervisor's + # own fence, `lane-start --agent`). A value carrying a space, a `;` or a + # newline is a value no such comparison can be trusted with, and this file + # is written one `key: value` line at a time. + for sri_k in "operation=$sri_op" "mode=$sri_mode" "agent=$sri_agent" "profile=$sri_prof" \ + "expect-operation=$sri_expo"; do + sri_kv="${sri_k#*=}" + [ -n "$sri_kv" ] || continue + case "$sri_kv" in + *[!A-Za-z0-9._-]*) die "--${sri_k%%=*} takes a manifest key — letters, digits, '.', '_' and '-' — because a later reader compares it for equality and this record is one line per field. Got '$sri_kv'." 64 ;; + esac + done + case "$sri_gen$sri_att" in *[!0-9]*) die "--generation and --attempt take numbers" 64 ;; esac + case "$sri_expg" in + '') : ;; + *[!0-9]*) die "--expect-generation takes a number; '$sri_expg' is not one" 64 ;; + esac + # A DIRECTORY IN THIS RECORD IS ABSOLUTE OR IT IS NOTHING, and the same for + # the handoff: the reader of this file is a DIFFERENT PROCESS IN A + # DIFFERENT DIRECTORY — the supervisor in a respawned pane — and a relative + # path resolved against its cwd is a path to somewhere else. `~` is the + # writing shell's and is never expanded here. + case "$sri_dir" in ''|/*) : ;; *) die "--dir takes an ABSOLUTE path: this record is read by the supervisor in a pane whose working directory is not this one, and a relative path there names a different place. Got '$sri_dir'." 64 ;; esac + case "$sri_hf" in ''|/*) : ;; *) die "--handoff takes an ABSOLUTE path, for the same reason --dir does: the launch that reads it back stands somewhere else. Got '$sri_hf'." 64 ;; esac + check_lane_name "$lane" + log_sync + lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 + sri_root=""; sri_rrc=0 + sri_root="$(lane_control_root "$lane")" || sri_rrc=$? + [ "$sri_rrc" = 0 ] || + die "lane $lane has no lifecycle control root, so there is nowhere to record that its restart is $sri_state: its record names no directory (Amendment 11(c)) and \$PROJECTS_ROOT is not a directory here. Set \$LANES_LANE_STATE_ROOT, or start the lane through lane-start so that its record carries a dir." 1 + sri_f="$sri_root/restart-intent.yaml" + # THE FENCE, read under the same mutex the write takes, so that two + # transitions cannot both read the intent before either writes it. + acquire_lock + sri_now=""; sri_nowg=""; sri_nowo=""; sri_nowa="" + if [ -r "$sri_f" ]; then + sri_now="$(lane_sidecar_field "$sri_f" state)" + sri_nowg="$(lane_sidecar_field "$sri_f" generation)" + sri_nowo="$(lane_sidecar_field "$sri_f" operation)" + sri_nowa="$(lane_sidecar_field "$sri_f" attempt)" + fi + sri_fence="" + # `--expect` TAKES A SET, because the states a caller is entitled to write + # over are usually more than one and asking for them in two calls is a + # second read-then-write with a window between. `pending` is created over + # `none|ready|failed` and never over `pending|starting`, which is the + # spec's *an ordinary `/ctx` does not supersede an operation in flight* + # expressed as one argument. + if [ -n "$sri_exp" ]; then + sri_match=0 + sri_rest="$sri_exp" + while [ -n "$sri_rest" ]; do + case "$sri_rest" in + *'|'*) sri_one="${sri_rest%%|*}"; sri_rest="${sri_rest#*|}" ;; + *) sri_one="$sri_rest"; sri_rest="" ;; + esac + [ "$sri_one" = none ] || lane_restart_word_ok "$sri_one" || + { release_lock; die "--expect takes restart-intent states or 'none'; '$sri_one' is neither" 64; } + [ "$sri_one" = "${sri_now:-none}" ] && sri_match=1 + done + [ "$sri_match" = 1 ] || sri_fence="the intent is ${sri_now:-none} and --expect named $sri_exp" + fi + [ -z "$sri_expg" ] || [ "$sri_expg" = "${sri_nowg:-0}" ] || sri_fence="${sri_fence:+$sri_fence; }generation is ${sri_nowg:-0} and --expect-generation named $sri_expg" + [ -z "$sri_expo" ] || [ "$sri_expo" = "${sri_nowo:-none}" ] || sri_fence="${sri_fence:+$sri_fence; }operation is ${sri_nowo:-none} and --expect-operation named $sri_expo" + if [ -n "$sri_fence" ]; then + release_lock + printf 'state\t%s\ngeneration\t%s\noperation\t%s\nattempt\t%s\n' \ + "${sri_now:-none}" "${sri_nowg:-0}" "${sri_nowo:-none}" "${sri_nowa:-0}" + die "lane $lane's restart intent did not move to $sri_state: $sri_fence. Another act got there first — a second /ctx, a supervisor that claimed this operation, or a resume that finished it — and nothing was written. Re-read it (lanes-edit.sh restart-intent $lane) before deciding what this process should do; a stale supervisor must never overwrite a newer operation." 7 + fi + case "$sri_nowg" in ''|*[!0-9]*) sri_nowg=0 ;; esac + case "$sri_nowa" in ''|*[!0-9]*) sri_nowa=0 ;; esac + # A TRANSITION THAT DOES NOT NAME A FIELD KEEPS IT, and does not blank it — + # the same rule `set-lane-state` takes, and for the same reason: a + # `starting` written by the supervisor is about the STATE, and blanking the + # handoff digest out of that call would lose the one fact every later + # launch and retry is fenced on. + sri_keep() { # + [ -n "${2-}" ] && { printf '%s' "$2"; return 0; } + lane_sidecar_field "$sri_f" "$1" 2>/dev/null || : + } + sri_gen="$(sri_keep generation "$sri_gen")"; [ -n "$sri_gen" ] || sri_gen="$sri_nowg" + sri_op="$(sri_keep operation "$sri_op")"; [ -n "$sri_op" ] || sri_op="$(lane_op_id)" + sri_mode="$(sri_keep mode "$sri_mode")" + sri_agent="$(sri_keep agent "$sri_agent")" + sri_prof="$(sri_keep profile "$sri_prof")" + sri_dir="$(sri_keep dir "$sri_dir")" + sri_pane="$(sri_keep pane "$sri_pane")" + sri_win="$(sri_keep window "$sri_win")" + sri_hf="$(sri_keep handoff "$sri_hf")" + sri_old="$(sri_keep old_transcript "$sri_old")" + sri_new="$(sri_keep new_transcript "$sri_new")" + sri_reason="$(sri_keep reason "$sri_reason")" + # THE DIGEST IS COMPUTED HERE WHERE THE CALLER NAMED A HANDOFF AND NO + # DIGEST, so that there is ONE implementation of *what this handoff was + # when the intent was made* and a caller cannot accidentally record a + # digest of something else. + if [ -z "$sri_dig" ]; then + case "$sri_hf" in + /*) if [ "$sri_state" = pending ] && [ -r "$sri_hf" ]; then + sri_dig="$(lane_handoff_digest "$sri_hf")" + else + sri_dig="$(lane_sidecar_field "$sri_f" digest 2>/dev/null || :)" + fi ;; + *) sri_dig="$(lane_sidecar_field "$sri_f" digest 2>/dev/null || :)" ;; + esac + fi + # THE ATTEMPT COUNT. `--bump-attempt` is the retry's own act and is the one + # thing that may not be inferred: a supervisor claiming a NEW operation + # starts at 1, and a retry of the SAME one increments. + if [ -n "$sri_att" ]; then + : + elif [ "$sri_bump" = 1 ]; then + sri_att=$((sri_nowa + 1)) + else + sri_att="$sri_nowa" + fi + sri_created="$(lane_sidecar_field "$sri_f" created 2>/dev/null || :)" + if lane_restart_put "$sri_root" "$lane" "$sri_state" "$sri_gen" "$sri_op" \ + "$sri_mode" "$sri_agent" "$sri_prof" "$sri_dir" "$sri_pane" "$sri_win" \ + "$sri_hf" "$sri_dig" "$sri_old" "$sri_new" "$sri_att" "$sri_reason" \ + "$sri_created"; then + release_lock + printf 'state\t%s\ngeneration\t%s\noperation\t%s\nattempt\t%s\ndigest\t%s\nfile\t%s\n' \ + "$sri_state" "$sri_gen" "$sri_op" "$sri_att" "${sri_dig:-none}" "$sri_f" + else + release_lock + die "lane $lane's restart intent could not be written under $sri_root (the shell's own error is above). Nothing was changed: the intent is replaced atomically, so the one that was there is the one that is there." 1 + fi + ;; + *) - die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|migrate-state-cells|commit|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|canon-lane|resolve-home)" 2 + die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|migrate-state-cells|commit|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|canon-lane|resolve-home|restart-intent|set-restart-intent)" 2 ;; esac diff --git a/openspec/changes/add-supervised-context-restart/.openspec.yaml b/openspec/changes/add-supervised-context-restart/.openspec.yaml new file mode 100644 index 00000000..96db9a43 --- /dev/null +++ b/openspec/changes/add-supervised-context-restart/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-15 diff --git a/openspec/changes/add-supervised-context-restart/design.md b/openspec/changes/add-supervised-context-restart/design.md new file mode 100644 index 00000000..0fc81a92 --- /dev/null +++ b/openspec/changes/add-supervised-context-restart/design.md @@ -0,0 +1,203 @@ +## Context + +`/ctx` is a semantic context rollover: the current Claude coordinator captures its reasoning, polls writers, refreshes the lane handoff, records the lane as paused, and starts a new Claude transcript whose first prompt is the handoff's top block. The shipped tail calls `tmux respawn-pane -k` with `lane ` or `pclaude` and exits successfully when tmux accepts that command. Tmux acceptance says nothing about whether the child selected an attach-only path, resolved a profile, executed Claude, remained alive, or consumed the intended handoff. + +A measured run completed the preservation side and killed the old process but produced no replacement session. The valid handoff made manual recovery possible, but the pane and launch error disappeared. Three related facts make this more than a missing error check: + +- the fresh-session requirement currently exists only as `LANE_START_FRESH=1` in the attempted command; +- `lane-start` records the lane `LIVE` before its final `exec`; +- `lane` is a human dispatcher whose valid outcomes include selecting or attaching to an existing session without launching anything. + +**Measured before implementation (`opensoft/openRepoTools#94`, Eagle 2026-09-15T20:59Z).** The first of those three is not merely weak, it is *broken across the launcher boundary*. `claude-profile` re-creates its child through tmux, and its own `act1_compose_child_command` says why that matters: *"`tmux new-session` hands the command the SERVER's environment, not this client's, so a variable the operator exported in their own shell reaches the child only if it is written into the command string"*. It threads six values in explicitly; `LANE_START_FRESH` is not among them, so the respawn's fresh-session requirement was dropped at a boundary this repository does not own, and the pane came up resuming the transcript the `/ctx` had just paused. `lane-start`'s own `(( ! fresh ))` gates were correct and were simply never reached with `fresh` set. This is why decision 3's intent file is the *authority* and not a convenience, and why decision 6's environment seam is documented as a compatibility detail from the start rather than as a mechanism. + +This change depends on the generation and operation fencing introduced by `add-crash-consistent-lane-worktree-recovery`. It supplies the reliable execution primitive required before `add-automatic-context-rollover` may act at 65% context. Bash 3.2, Linux, macOS, WSL2, existing lane records, the read-only SessionStart hook contract, and non-destructive worktree behavior remain constraints. + +## Goals / Non-Goals + +**Goals:** + +- Preserve the one-command `/ctx` experience on success. +- Refuse before killing the old process when semantic preparation, lifecycle ownership, or any mandatory preservation write fails. +- Persist the exact fresh launch as operation-scoped data before process replacement. +- Keep a process in the pane that can display and retry every post-handoff launch failure. +- Launch through a narrow operation consumer rather than the human-facing `lane` dispatcher. +- Mark a lane `RUNNING` only after positive replacement identity and liveness evidence. +- Make restart retries idempotent and reject stale operations and competing owners. +- Make the end-to-end behavior testable without a real GitHub repository or real Claude account. + +**Non-Goals:** + +- Replacing Claude's semantic handoff with a shell-generated summary. +- Interrupting an active turn or implementing the 65% threshold controller. +- Adding a continuously running machine-wide daemon. +- Rebuilding or modifying Git worktrees outside the existing estate and recovery contracts. +- Making profile part of stable lane identity. +- Changing the generic SessionStart hook into a blocking or networked writer. + +## Decisions + +### 1. Split semantic preparation from the mechanical restart transaction + +The handoff skill SHALL produce the human narrative, writer replies, and a machine-readable staged payload. It SHALL then call one backend operation. The backend SHALL own identity validation, generation acquisition, lifecycle transitions, canonical handoff replacement, protocol records, restart intent, tmux replacement, and result classification. + +The staged payload is not yet the canonical handoff and cannot authorize a restart. This allows the model to finish semantic work before the backend acquires the lane lock while preventing a model-authored shell recipe from becoming a second implementation of the destructive tail. + +Alternative considered: retain executable tmux recipes in the skill. That preserves flexibility but caused the current implementation to have overlapping orchestration paths and makes exact failure behavior dependent on how the model follows prose. + +### 2. Treat lifecycle ownership as a hard precondition + +The backend SHALL compare-and-swap the current lane from `RUNNING` to `SWAPPING` under the prerequisite change's lane lock. Failure to read state, acquire the lock, match the expected generation, or establish the current owner SHALL refuse the restart while the old process remains alive. + +An ordinary `/ctx` SHALL NOT supersede an existing `SWAPPING` operation. Takeover requires a separately named recovery action that first proves there is no matching live holder, retains the interrupted operation in history, and advances generation under lock. + +Alternative considered: allow the semantic handoff and pause records to continue when lifecycle state cannot be written. That retains a best-effort handoff but permits competing operations to kill each other's coordinators, defeating generation fencing. + +### 3. Preserve the existing swap states and add an operation-scoped restart intent + +The lane lifecycle remains: + +```text +RUNNING -> SWAPPING -> SWAPPED -> RUNNING +``` + +The launch interval is represented by a restart-intent sidecar beneath the lane control root rather than a new top-level `STARTING` state. The intent has states `pending`, `starting`, `failed`, and `ready`. While it is `pending`, `starting`, or `failed`, the lane remains `SWAPPED`; only positive readiness moves the lane to `RUNNING`. + +The intent includes schema version, lane, generation, operation ID, old transcript, agent, profile, canonical directory, handoff path and digest, launch mode `fresh-from-handoff`, pane binding, timestamps, attempt count, and bounded failure diagnostics. It contains no credentials. All writes use atomic replacement under the lane lock. Callers use `lanes-edit.sh` operations rather than parsing or rewriting the YAML themselves. + +This makes the fresh-session requirement durable and retryable without expanding the top-level state machine needed by other lane operations. + +### 4. Make `SWAPPED` and restart intent one pre-kill commit point + +The guarded backend performs these acts in order: + +1. validate the current lane/transcript/pane/directory identity; +2. acquire `RUNNING -> SWAPPING`, minting generation and operation ID; +3. verify the staged semantic payload and snapshot the current writer/worktree evidence; +4. refresh the canonical handoff and complete all mandatory protocol and lifecycle writes; +5. atomically create `pending` restart intent for the same generation and operation; +6. finalize the lifecycle to `SWAPPED` only when both preservation and intent are readable and consistent; +7. preflight the absolute supervisor executable and tmux target; +8. ask tmux to replace the pane with the supervisor for that exact operation. + +Any failure through step 7 refuses without intentionally killing the old process. A tmux refusal leaves the lane `SWAPPED` with pending intent and the old process still present; the command reports the explicit terminal recovery command. Once tmux accepts step 8, the supervisor—not the old Claude—owns recovery. + +Alternative considered: create restart intent after respawn. The originating process may already be dead, recreating the unrecorded launch gap this change exists to remove. + +### 5. Use a restart-only pane supervisor owned by openRepoTools + +The first implementation adds an openRepoTools command such as `lane-restart-supervisor`. Tmux starts it by absolute installed path with only lane and operation identifiers. The supervisor validates the pending intent and changes it to `starting` with compare-and-swap semantics before launching anything. + +The supervisor invokes the installed profile launcher as a child, supplying the recorded lane, profile, directory, and fresh-from-handoff operation. It never invokes `lane`, never runs a picker, and never rediscovers launch choices from current directory, window title, or rotating defaults. `pclaude` may still perform account configuration and execute Claude inside the child process; because the supervisor is the parent, child exit does not close the pane. + +The supervisor remains for the child's lifetime. Before readiness, child failure changes the intent to `failed` and leaves a diagnostic prompt in the pane. After readiness, a later normal child exit is reported and the supervisor exits according to the ordinary lane-end contract; it does not automatically relaunch an intentionally exited session. + +Alternative considered: modify workBenches so every `pclaude` invocation has a permanent supervisor. That may be valuable later but broadens this fix across repositories. A restart-only wrapper can be delivered and tested entirely by openRepoTools while treating `pclaude` as a child dependency. + +### 6. Launch by exact operation, not by human lane dispatch + +The supervisor passes a new explicit operation input through the launcher to `lane-start`, for example `LANE_RESTART_OPERATION=` during the compatibility phase and ultimately an explicit launcher option where workBenches can expose one. `lane-start` reads all launch facts from the fenced restart intent and rejects command-line or environment values that conflict. + +`LANE_START_FRESH=1` may remain temporarily as an internal compatibility detail, but it is no longer the authority for deciding fresh launch. The intent's `mode: fresh-from-handoff` is authoritative. Normal `lane ` behavior remains unchanged for people. + +### 7. Confirm readiness without changing the generic SessionStart contract + +`lane-start` SHALL stop writing `LIVE`/`RUNNING` before `exec` for supervised operations. It may write launch-prepared identity and the intended new transcript while the lifecycle remains `SWAPPED`. + +The supervisor waits up to 60 seconds for a local readiness predicate that combines: + +- the launched child remains alive; +- the expected new transcript has been created by the selected profile; +- the canonical lane, agent, directory, pane, and transcript binding match the restart intent; +- no competing live holder exists. + +The predicate is implemented as a read-only helper over process, transcript, register, and local sidecar evidence. It does not require the generic SessionStart hook to write or touch the network. Once the predicate succeeds, a separate supervisor-owned, generation-fenced writer atomically moves `SWAPPED` to `RUNNING`, marks the intent `ready`, and appends the normal resumed evidence. + +If the child exits first, readiness is `failed`. If the 60-second deadline expires while the child remains alive, the result is `indeterminate`: the supervisor does not kill the child, does not retry automatically, and does not mark `RUNNING`; it displays inspection and explicit finalize-or-stop instructions. + +Alternative considered: regard successful `exec` or a short grace period as readiness. Neither proves transcript creation, correct binding, or survival. A write from the generic SessionStart hook was also rejected because its read-only, always-zero contract is load-bearing. + +### 8. Retry the same operation and make recovery explicit + +When intent is `failed` and no matching child or competing holder is live, the supervisor offers a retry that compare-and-swaps the same intent back to `starting` and increments its attempt count. The retry uses the same handoff digest, generation, operation, directory, and launch mode. + +Stale generation, changed handoff digest, mismatched pane, or live-holder ambiguity refuses retry. Replacing or superseding the operation requires the explicit lifecycle recovery command, not an automatic fallback. No path resets, deletes, stashes, or reconstructs a worktree. + +### 9. Test the process boundary, not only the generated tmux string + +Tests SHALL run the real supervisor against fake launch children and isolated tmux-compatible harnesses. They SHALL assert process survival, state transitions, transcript/binding readiness, exact first-prompt handoff content, retries, and stale-operation refusal. Existing string-construction tests remain useful but are insufficient as the end-to-end gate. + +### 10. The supervisor is a MODE of `lane-handoff`, not a new word on `PATH` + +Decision 5 proposes "an openRepoTools command such as `lane-restart-supervisor`", installed by absolute path (tasks 4.1 and 4.6). **That contradicts Amendment 18 Addendum 2 (i-8) as ratified**, which removed `restart` from the installed set on exactly this ground: *"no word is kept on `PATH` for it alone — 'if we need it for ctx, keep it for that' is met by `lane`, which does the same thing under the one name"*, over Brett Heap's own ruling *"i do not see any reason to expose this to the user"*. A thirteenth installed word whose only caller is `/ctx` is the thing that clause refuses. + +The supervisor is therefore `lane-handoff --supervise --operation ` — a mode of the command that already owns the `/ctx` transaction and is already installed. Everything the decision actually requires is kept: it is still a separate process from the launch it supervises, it is still what tmux starts, and it is still resolved **by absolute path** into the respawn line, because a respawned pane's `PATH` is whatever the person's shell profile makes of it. What is dropped is the new installed word, the twelve-file installer count it would have moved, and task 4.6's installer work, which becomes nothing to install. A person still has exactly one word, `lane`. + +`--restart-status ` is the read-only surface of task 6.1 on the same command, for the same reason. + +### 11. (i-8)'s two doors are kept one level down, and `lane ` is not one of them for `/ctx` + +Clause (i-8) names two doors for the respawn: *"relaunches the lane's own pane with `lane ` … or through the launcher directly"*. This change respawns into the supervisor and the supervisor takes **the second door** — `pclaude --lane `, the launcher directly — falling to `lane-start` itself where the workstation has no launcher or the lane's record names no profile. + +The first door is not used for `/ctx` any more, and the reason is measured rather than argued. `lane ` is the human dispatcher: its valid outcomes include attaching to a live session and refusing, and the spec's requirement *Internal restart bypasses human lane dispatch* is incompatible with it. It was also the door through which #94 happened, because the fresh-session requirement it was asked to carry was an environment variable it could not protect. + +**This is a deviation from in-force protocol text and it is not treated as ratified by this proposal.** Task 7.4 owes an amendment (an addendum to Amendment 18 Addendum 2, or to Amendment 17(f)) that authorises a supervised respawn and a durable restart intent. Until that is ratified the implementation is what it is and the divergence is named here, in the pull request, and in `lane-handoff`'s own comment at the respawn. + +### 12. `RUNNING` before `exec` is scoped to #91's lifecycle, never to Amendment 8(d)'s `RESUMED` + +Decision 7 says `lane-start` "SHALL stop writing `LIVE`/`RUNNING` before `exec` for supervised operations". Read widely that would repeal **Amendment 8(d)**, which makes `lane-start` the writer of the row stamp, the object log's `RESUMED`/`STARTED` line and the handoff's Rule 3 stamp, *before* the launch and deliberately — *"on a restart the session makes no register write at all"*. Those three are the append-only record of an act that did happen (a launch was prepared and made), every other lane reads them, and removing them would break `swapped`, the launcher's own resolution and Rule 3's stamp in one move. + +So the decision is scoped: what must not be written before readiness is **#91's lifecycle snapshot** — the `RUNNING` word in `lane-state.yaml`, which is the thing that means *this lane has a confirmed holder*. On a workstation where #91 has landed, `lane_state_follow` moves the snapshot to `RUNNING` on any `STARTED`/`RESUMED` line, and reconciling that with this change is work #91's own branch must take when the two merge; this change's supervisor writes `SWAPPED → RUNNING` itself, fenced, only after readiness, and its finalizer's `--expect SWAPPED` is what refuses a snapshot something else already moved. + +**Honest residual, stated rather than hidden:** on `main` today there is no lifecycle snapshot at all, so between `lane-start`'s pre-`exec` `RESUMED` and the supervisor's readiness confirmation the register row reads as running while the restart intent still reads `starting`. The spec's *Swapped state remains non-running until readiness* is therefore met **in the restart intent, which this change owns**, and only met in the lane's lifecycle once #91 lands. Nothing in this change marks a lane ready on anything but positive evidence, which is the property the requirement exists for. + +### 13. The lifecycle is fail-closed on the KILL and fail-open on the RECORD + +Decision 2 and task 1.3 ask for fail-closed lifecycle ownership: refuse when state cannot be read, locked or compare-and-swapped. #91's landed integration is explicitly the opposite — *"THE LIFECYCLE NEVER REFUSES THE SWAP"* — and it supersedes an interrupted `SWAPPING` rather than refusing it, citing that a swap is never left unwritten (Amendment 8(a) step 4; the rule is `R-A11-5`, which this repository's own comments mis-cite as `R-A11-11`). + +Both are right about different acts, and the reconciliation is the line Amendment 17(f) already draws: + +- **The record is never refused.** The handoff, the `PAUSED` line and the row's state cell are written on every path, with any gap named in the handoff. #91's fail-open lifecycle stands. +- **The kill is always refused** when the record is incomplete: *"a `/ctx` whose record could not be written REFUSES before it kills anything (a pane is never respawned over an unrecorded lane)"*. This change adds the restart intent to that record — a pane respawned with no intent has a supervisor with nothing to launch from, which is the same unrecorded restart one step along — so the intent is the fourth gate beside the `PAUSED` line, the row and the handoff, and it is checked, written, and **read back**, before tmux is asked for anything. + +A second `/ctx` over an operation that is still `pending` or `starting` is refused by the intent's own compare-and-swap (`--expect 'none|ready|failed'`), which is the spec's *ordinary `/ctx` does not supersede an existing operation* expressed as one argument, and it refuses **before** the kill rather than after it. + +### 14. The restart intent lands without waiting for #91, under #91's control root + +Task 1.1 assumes the prerequisite has landed. It has not: `add-crash-consistent-lane-worktree-recovery` is in flight on its own branch with its own writer. Blocking a measured, reproducible defect on another change's merge is the worse trade, so the restart intent is implemented now as a **sibling sidecar** — `restart-intent.yaml` beside `lane-state.yaml` — under a `lane_control_root` written byte-identical to #91's three rungs (`$LANES_LANE_STATE_ROOT`, the lane checkout's parent `.lane-state/`, `$PROJECTS_ROOT/.lane-state/`), with the same flat `key: value` grammar, the same atomic replace, the same `lane_op_id` shape and the same exit-code contract (0/1/2/**7 fence lost**/8/64). + +The merge of the two branches is therefore a textual conflict whose resolution is *keep either copy of the four shared helpers*, and no second scheme is created. The generation the intent carries is read from #91's `lane-state` where the workstation has it and is `0` where it does not, which is what a workstation before that change can honestly say. + +### 15. Readiness is proved from evidence `main` actually has + +Decision 7's predicate names "the expected new transcript has been created by the selected profile". On `main` the readable, positive form of that is the workstation's own live-session records, read through `lanes-edit.sh live-holder `, which is the same read every other collision check in this estate makes. The predicate is therefore: the child is alive; exactly one live holder answers for the lane (a second is Amendment 18(h)'s collision, and a restart that confirmed itself with two processes answering would be the tooling making the very thing the protocol refuses); that holder's transcript is the one `lane-start` recorded it was about to make, or at minimum is **not** the transcript the `/ctx` paused; and it is in the pane the intent names. + +Decision 7 also has the supervisor "display inspection and finalize-or-stop instructions" on an indeterminate. It **writes** them instead, into the intent's bounded `reason`, because the child is an interactive terminal program that owns the pane's screen and a line printed over a running Claude is a line nobody reads. `lane-handoff --restart-status --lane ` from any other window is where it is read, and the supervisor prints the same thing in the pane the moment the child exits. + +## Risks / Trade-offs + +- **[Risk] The supervisor and interactive child contend for the terminal** → Launch the child in the foreground with the supervisor blocked in `wait`; print only before launch and after child exit or timeout handling through a separate observation path. +- **[Risk] Transcript creation is delayed on a slow machine** → Use a 60-second readiness deadline, preserve an alive child on timeout, and classify the result as indeterminate rather than failed or running. +- **[Risk] pclaude does not yet expose an operation option** → Thread an operation environment variable through its existing environment-preservation seam first, then add an explicit option in coordinated workBenches work without making the environment value authoritative by itself. +- **[Risk] Local sidecar and shared lane records disagree** → Fence all finalization on generation and operation, report disagreement, and retain the intent and handoff for recovery. +- **[Risk] Strict refusal leaves an interrupted `SWAPPING` lane requiring attention** → Provide a read-only reconciliation report and separately named takeover action; never let ordinary `/ctx` silently seize it. +- **[Risk] The handoff changes after intent creation** → Store and verify its digest on every launch and retry. +- **[Risk] Automatic rollover mistakes tmux acceptance for completion** → Expose completion only from the readiness-gated `RUNNING` transition and make the automatic controller consume that outcome. + +## Migration Plan + +1. Land the prerequisite lifecycle operations with fail-closed compare-and-swap behavior; do not merge the current fail-open handoff integration. +2. Add restart-intent read/write/CAS operations and read-only status reporting without changing current `/ctx`. +3. Add the supervisor, exact-operation launch path, and fake-child tests behind an opt-in environment gate. +4. Refactor supervised `lane-start` so pre-exec work cannot write `RUNNING`; add readiness and finalization tests. +5. Switch manual `/ctx` to stage semantic input and invoke the guarded supervised backend. Retain a documented `/handoff` plus manual terminal launch as rollback. +6. Install the supervisor alongside the other openRepoTools commands and verify absolute-path resolution on Linux, macOS, and WSL2. +7. Run a manual canary that proves both successful continuation and visible child-launch failure before enabling the behavior globally. +8. Update `add-automatic-context-rollover` to require supervised readiness completion, then canary automatic rollover separately. + +Rollback disables supervised respawn and returns `/ctx` to warning plus non-restarting `/handoff`; it does not restore the unsafe direct self-respawn. Pending intents and lifecycle history remain for diagnosis and can be closed only through explicit recovery. + +## Open Questions + +- Should successful readiness archive old restart intents immediately or retain a bounded per-lane history outside the append-only event log? +- What explicit operator command names best distinguish retry, finalize an indeterminate-but-valid child, cancel, and generation-advancing recovery? +- When workBenches adds a first-class operation option, should the compatibility environment seam be removed immediately or after one release overlap? diff --git a/openspec/changes/add-supervised-context-restart/proposal.md b/openspec/changes/add-supervised-context-restart/proposal.md new file mode 100644 index 00000000..b7bbbf73 --- /dev/null +++ b/openspec/changes/add-supervised-context-restart/proposal.md @@ -0,0 +1,41 @@ +## Why + +`/ctx` currently kills its own pane after writing a handoff and treats `tmux respawn-pane` accepting a command as proof that a fresh Claude session started. A measured failure left the lane `PAUSED` with a valid handoff but no replacement process or visible error, and the planned automatic 65% rollover would reproduce that unsafe boundary without a stronger restart primitive. + +**The measured failure is `opensoft/openRepoTools#94`** (Eagle, 2026-09-15T20:59Z, lane `openRepoTools-3`), and the cause was established before this change was implemented rather than inferred from it. `/ctx` respawned its own pane with `LANE_START_FRESH=1 … lane openRepoTools-3` and what came up was `claude --name openRepoTools-3 --resume `. `lane-start` was not at fault — both of its `(( ! fresh ))` gates are present in the installed copy and in PR #87's branch version — and neither was `lane`, whose available branch ends in a plain `exec`. The variable never arrived: the installed launcher re-creates its child through tmux, and `claude-profile`'s own `act1_compose_child_command` states the rule it has to obey — *"`tmux new-session` hands the command the SERVER's environment, not this client's, so a variable the operator exported in their own shell reaches the child only if it is written into the command string"* — which is why it threads six values explicitly, and `LANE_START_FRESH` is not one of them. The defect capture's window, `claude-max-001-20260915205926-456327:@6`, is that launcher's own `claude---` pattern, so this is what happened rather than what might have. + +Two consequences follow, and they are what this change is: + +- **An environment seam cannot carry the fresh-session requirement**, because the boundary it must cross belongs to another repository; a seventh threaded variable would move the same fragility one release along. The requirement becomes a durable restart intent on disk, written before the old process is killed and read back whatever the environment did. +- **Nothing noticed**, because what tmux started *was* the launch: a launch that ends takes the pane with it. Amendment 11's own invariant is *"No path the launcher opened exits the pane"*, and `/ctx` opened a path that did. + +## What Changes + +- Make context restart a generation-fenced transaction that preserves exact fresh-session launch intent before replacing the old process. +- Replace direct respawn into the human-facing `lane` dispatcher with a pane-resident supervisor and a narrow internal launch operation. +- Keep a diagnostic and retry surface alive when profile resolution, launcher execution, Claude startup, or readiness confirmation fails. +- Keep the lane non-running until the replacement's transcript, agent, directory, binding, and liveness are positively confirmed. +- Require lifecycle ownership and mandatory preservation writes to succeed before `/ctx` can kill the current process. +- Separate model-authored semantic handoff preparation from deterministic state, tmux, launch, and retry mechanics. +- Add real process-lifecycle tests that distinguish tmux command acceptance from a confirmed replacement session. + +## Capabilities + +### New Capabilities + +- `supervised-context-restart`: Defines the guarded `/ctx` transaction, durable restart intent, surviving pane supervisor, internal fresh-session launch, readiness confirmation, and idempotent failure recovery. + +### Modified Capabilities + +None. This repository currently has no baseline OpenSpec capabilities; relationships to the in-progress crash-recovery and automatic-rollover changes are expressed as dependencies rather than fictional baseline modifications. + +## Dependencies and cross-links + +- **`opensoft/openRepoTools#94`** — the measured failure this change answers. Its root cause is quoted above and recorded as a comment on the issue. +- **`opensoft/openRepoTools#91`** (`add-crash-consistent-lane-worktree-recovery`) — the lane control root, the `RUNNING → SWAPPING → SWAPPED → CLOSED` lifecycle and the generation/operation fence the restart intent is meant to sit beside. That change is in flight on `feat/crash-consistent-lane-worktree-recovery` and is **not on `main`**, so the restart intent is implemented as a sibling sidecar under the *same* control root, resolved by a `lane_control_root` written to be byte-identical to #91's, and the `SWAPPED → RUNNING` finalization is made through #91's `set-lane-state` **where the workstation has it** and skipped in silence where it does not. Nothing here blocks on #91 landing, and nothing here re-implements it. +- **`add-automatic-context-rollover`** — consumes this change's readiness-confirmed completion rather than raw `respawn-pane` acceptance. +- **Lane collision protocol** — Amendment 17(f) (the record before the kill), Amendment 17 Addendum 1 (h)–(k) (the kind, the writer count, one worktree one writer), Amendment 18(h) (one binding), Amendment 18 Addendum 2 (i-8) (the respawn's two doors, and *"no word is kept on `PATH` for it alone"*), Amendment 8(f) (ending somebody's process is not a boundary script's act), Amendment 8(a) step 4 / `R-A11-5` (a swap is never left unwritten) and `R-A8-1` (the `SessionStart` hook never writes, never touches the network, always exits 0). + +## Impact + +The change affects `commands/ctx.md`, the handoff skill, `lane-handoff`, `lane-start`, `lanes-edit.sh`, lane lifecycle sidecars, tmux process management, and lane helper tests. It introduces an integration boundary with the installed `pclaude` launcher owned by workBenches: either the new supervisor invokes that launcher as a child, or coordinated work there supplies the equivalent non-`exec` supervision seam. The `add-crash-consistent-lane-worktree-recovery` change must expose fail-closed transition ownership, and `add-automatic-context-rollover` must depend on confirmed supervised restart rather than raw respawn acceptance. diff --git a/openspec/changes/add-supervised-context-restart/specs/supervised-context-restart/spec.md b/openspec/changes/add-supervised-context-restart/specs/supervised-context-restart/spec.md new file mode 100644 index 00000000..b1c93ff5 --- /dev/null +++ b/openspec/changes/add-supervised-context-restart/specs/supervised-context-restart/spec.md @@ -0,0 +1,179 @@ +## ADDED Requirements + +### Requirement: Semantic preparation precedes mechanical restart +The system SHALL obtain a current semantic handoff payload from the lane coordinator before beginning a normal `/ctx` restart, and SHALL execute lifecycle, record, tmux, launch, and retry mechanics through one deterministic backend. + +#### Scenario: Current coordinator prepares a context restart +- **WHEN** the user invokes `/ctx` in a live lane coordinator +- **THEN** the coordinator captures current reasoning and writer outcomes in a staged handoff payload before invoking the mechanical backend +- **AND** the coordinator does not directly execute a tmux respawn recipe + +#### Scenario: Semantic preparation cannot complete +- **WHEN** required semantic handoff or writer-accounting information cannot be produced +- **THEN** the system refuses the restart without killing the current pane process + +### Requirement: Transition ownership is fail-closed +The mechanical backend MUST acquire the current lane generation and exclusive transition operation before changing canonical handoff or process state, and any failed or ambiguous acquisition MUST leave the current process alive. + +#### Scenario: Current running owner acquires transition +- **WHEN** the requesting transcript and pane exclusively own a lane in `RUNNING` +- **THEN** the backend atomically moves that generation to `SWAPPING` and records a unique operation ID + +#### Scenario: Generation changes during acquisition +- **WHEN** the lane generation changes between validation and compare-and-swap +- **THEN** the backend refuses without writing pause completion or replacing the pane + +#### Scenario: Lane is already swapping +- **WHEN** ordinary `/ctx` finds the lane in `SWAPPING` +- **THEN** it refuses and directs the operator to reconciliation +- **AND** it does not automatically supersede the existing operation + +### Requirement: Context restart intent is durable and operation-scoped +Before replacing the old process, the system SHALL atomically persist a restart intent containing the exact lane, generation, operation, old transcript, agent, profile, directory, pane binding, handoff path and digest, and `fresh-from-handoff` launch mode. The intent SHALL be the authority for the launch mode, and no environment variable SHALL be relied on to carry it (`opensoft/openRepoTools#94`: the installed profile launcher re-creates its child through `tmux new-session`, which hands that child the tmux server's environment and not the caller's, so an environment-only seam is lost at a boundary this system does not own). The intent SHALL live beneath the lane control root defined by `opensoft/openRepoTools#91`. + +#### Scenario: Preservation transaction completes +- **WHEN** all mandatory handoff, protocol, row, worktree-snapshot, and lifecycle writes succeed +- **THEN** the system records a `pending` restart intent for the same generation and operation before attempting pane replacement +- **AND** it reads that intent back and confirms it names this operation before asking tmux for anything + +#### Scenario: A restart is already in flight for this lane +- **WHEN** an ordinary `/ctx` finds a restart intent in `pending` or `starting` +- **THEN** it refuses before replacing the pane and leaves the existing operation untouched + +#### Scenario: Intent cannot be persisted +- **WHEN** the restart intent cannot be atomically written or read back consistently +- **THEN** the system leaves the lane in an incomplete pre-restart state and does not intentionally kill the current process + +#### Scenario: Handoff changes after intent creation +- **WHEN** the current handoff digest differs from the digest in a pending or failed intent +- **THEN** launch and retry refuse as stale rather than consuming changed instructions + +### Requirement: Swapped state remains non-running until readiness +The lane SHALL remain `SWAPPED` while restart intent is `pending`, `starting`, or `failed`, and SHALL transition to `RUNNING` only through generation- and operation-fenced readiness finalization. + +#### Scenario: Tmux accepts supervisor command +- **WHEN** tmux accepts replacement of the old pane process with the supervisor +- **THEN** the system records only that process replacement was accepted +- **AND** it does not mark the lane `RUNNING` + +#### Scenario: Launcher is about to execute Claude +- **WHEN** launch preparation and pre-exec validation succeed +- **THEN** the lifecycle remains `SWAPPED` and the intent remains `starting` until readiness evidence succeeds + +### Requirement: Pane supervisor survives replacement launch failures +Tmux SHALL replace the old coordinator with a pane-resident supervisor that launches the profile launcher as its child and remains present until the child becomes ready or its failure is reported. No path this supervisor opens SHALL exit the pane silently, including its own refusals before any launch (lane-collision-protocol Amendment 11: *No path the launcher opened exits the pane*). The supervisor SHALL never terminate the child (Amendment 8(f): ending somebody's process is not a boundary script's act). + +#### Scenario: Profile launcher exits nonzero +- **WHEN** profile resolution or the launcher exits nonzero before readiness +- **THEN** the supervisor remains in the pane, marks the intent `failed`, and displays the exact failed stage and retry command + +#### Scenario: Claude exits immediately +- **WHEN** the launcher succeeds but the Claude child exits before readiness +- **THEN** the supervisor remains visible and the lane remains non-running with a retryable failed intent + +#### Scenario: Replacement becomes interactive +- **WHEN** the child is running normally +- **THEN** it owns the pane terminal in the foreground while the supervisor remains its waiting parent + +### Requirement: Internal restart bypasses human lane dispatch +The supervisor MUST launch the exact pending operation without invoking the human-facing `lane` command or performing picker, attach, select, current-directory, title, or default-profile resolution. The supervisor MUST NOT be a separate installed command reachable on a user's `PATH` (lane-collision-protocol Amendment 18 Addendum 2 (i-8): *no word is kept on `PATH` for it alone*), and MUST be started by absolute path so that the respawned pane's `PATH` cannot decide what runs. + +#### Scenario: Existing holder evidence is stale during restart +- **WHEN** stale records could cause `lane ` to choose an attach-only path +- **THEN** the supervisor ignores that dispatcher path and invokes the narrow operation launch using recorded intent + +#### Scenario: Runtime arguments conflict with intent +- **WHEN** command-line or environment directory, profile, agent, pane, or lane values conflict with the restart intent +- **THEN** the operation launch refuses without substituting the conflicting value + +### Requirement: Replacement readiness is positively verified +The system SHALL confirm child liveness, new transcript creation, canonical lane, agent, directory, pane and transcript binding, and absence of a competing live holder before finalizing `RUNNING`. + +#### Scenario: All readiness evidence agrees +- **WHEN** the expected child is alive and every identity and binding check matches the restart intent +- **THEN** the system atomically marks the intent `ready`, transitions the same operation to `RUNNING`, and appends resumed evidence + +#### Scenario: Child exits during readiness wait +- **WHEN** the child exits before all readiness evidence agrees +- **THEN** readiness fails, the lifecycle remains `SWAPPED`, and no successful resumed state is recorded + +#### Scenario: Readiness deadline expires with a live child +- **WHEN** 60 seconds elapse while the child remains alive but readiness evidence is incomplete +- **THEN** the supervisor reports an indeterminate launch, leaves the child and lane state untouched, and offers explicit inspection and finalize-or-stop actions + +#### Scenario: Generic SessionStart hook executes +- **WHEN** the replacement triggers the generic SessionStart hook +- **THEN** that hook remains read-only, network-free, and non-blocking +- **AND** supervisor-owned finalization supplies the state write + +### Requirement: Fresh handoff is the replacement's first prompt +The operation launch SHALL create a new transcript and supply the exact current handoff top block as its initial user prompt. This is the requirement `opensoft/openRepoTools#94` measured being violated, and it SHALL hold with no fresh-session environment variable present at all. + +#### Scenario: Pending context restart launches +- **WHEN** the supervisor consumes a valid `fresh-from-handoff` intent +- **THEN** it creates a transcript distinct from the old transcript and passes the digest-matched handoff top block as the first prompt + +#### Scenario: Old transcript remains resumable +- **WHEN** a context restart intent names an old transcript that still exists +- **THEN** the operation launch does not use the normal transcript-resume path + +#### Scenario: Every resume source is available and the environment carries nothing +- **WHEN** a pending `fresh-from-handoff` intent exists, the register row's last session id has a transcript in this directory, the lane's last `PAUSED` record names a second transcript that also exists, and no fresh-session environment variable is set +- **THEN** the launch is a new session named for the lane, primed by the handoff's top block +- **AND** it passes no transcript-resume argument for either existing transcript + +#### Scenario: A confirmed restart authorises no further fresh launch +- **WHEN** a restart intent has reached `ready` +- **THEN** a later ordinary launch of that lane resolves its own resume source and does not treat the intent as authorising a fresh session + +### Requirement: Failed restart is idempotently retryable +A failed restart SHALL be retryable under the same generation, operation, handoff digest, and launch facts when no matching child or competing holder is live. + +#### Scenario: Operator retries a failed launch +- **WHEN** the intent is `failed`, its generation and handoff still match, and no live holder exists +- **THEN** the supervisor atomically increments the attempt count, returns the same intent to `starting`, and retries the exact launch + +#### Scenario: Stale operation attempts retry +- **WHEN** a retry names an operation or generation that is no longer current +- **THEN** the system refuses without launching a child or modifying newer state + +#### Scenario: Possible live child makes retry ambiguous +- **WHEN** process or binding evidence cannot exclude a live child from the previous attempt +- **THEN** retry refuses and directs the operator to reconciliation + +### Requirement: Interrupted operations require explicit recovery +The system MUST separate ordinary context restart from generation-advancing recovery of abandoned `SWAPPING` or inconsistent restart operations. + +#### Scenario: Recovery finds no live owner +- **WHEN** an explicit recovery action verifies that the recorded owner and launch child are absent +- **THEN** it may advance generation while retaining the interrupted operation in history + +#### Scenario: Recovery finds a matching live owner +- **WHEN** reconciliation finds a live process matching the current operation +- **THEN** takeover refuses and reports that owner without killing it + +### Requirement: Restart outcomes are observable and testable +The system SHALL expose operation state and diagnostics sufficient to distinguish preservation failure, tmux refusal, launcher failure, immediate child exit, indeterminate readiness, and confirmed running state. + +#### Scenario: Operator inspects a failed restart +- **WHEN** the operator requests restart status after a failure +- **THEN** the output names the lane, generation, operation, attempt, lifecycle state, intent state, handoff, and bounded failure reason + +#### Scenario: End-to-end success test runs +- **WHEN** the isolated test harness executes a supervised context restart with a fake child that emits valid transcript and binding evidence +- **THEN** it observes `RUNNING`, a ready intent, a distinct transcript, and the exact handoff top block as first prompt + +#### Scenario: End-to-end failure test runs +- **WHEN** the isolated test harness executes a child that exits or never produces readiness evidence +- **THEN** it proves the supervisor remains, the lane is not `RUNNING`, and the same operation has a visible recovery path + +### Requirement: Automatic rollover waits for supervised completion +Any automatic context controller SHALL treat a rollover as successful only after the supervised restart reaches readiness-confirmed `RUNNING`. + +#### Scenario: Automatic request reaches swapped state only +- **WHEN** an automatic rollover completes its handoff but the replacement is pending, failed, or indeterminate +- **THEN** the controller reports the rollover incomplete and does not clear its operation latch as successful + +#### Scenario: Automatic replacement is confirmed +- **WHEN** the supervisor finalizes the intended replacement as `RUNNING` +- **THEN** the automatic controller may mark its request completed diff --git a/openspec/changes/add-supervised-context-restart/tasks.md b/openspec/changes/add-supervised-context-restart/tasks.md new file mode 100644 index 00000000..0cb4c124 --- /dev/null +++ b/openspec/changes/add-supervised-context-restart/tasks.md @@ -0,0 +1,80 @@ +## 0. What this slice landed, and what it did not + +The first implementation pass landed the transaction, the supervisor, the exact fresh launch and the retry surface — the path `opensoft/openRepoTools#94` measured breaking — and deliberately did **not** land the prerequisite lifecycle work, the machine-readable staged payload, the crash-point matrix or the protocol amendment. Ticked boxes below are implemented AND covered by cases in `tests/test_lane_helpers.sh`; unticked ones are open, and the three that were rewritten rather than ticked-as-written say so in their own line. + +**Deferred, with the reason:** + +- **§1 entirely** — it consumes `add-crash-consistent-lane-worktree-recovery`'s lane lock, generation and operation, which are on `feat/crash-consistent-lane-worktree-recovery` and not on `main`. The restart intent is a sibling sidecar under the *same* control root (design decision 14) so nothing here blocks on that merge and nothing re-implements it; §1's fail-closed work belongs in that change's own pass. +- **§3.1–3.3** — the staged semantic payload. `/ctx`'s skill step now calls `lane-handoff --restart` and carries no executable tmux recipe, which is decision 1's *one backend operation* and removes the second implementation of the destructive tail. The machine-readable payload file and its binding checks are a further step and are not in this pass. +- **§6.4–6.5, §8.4, §8.7–8.8** — explicit finalize-or-stop actions, generation-advancing recovery, the crash-point matrix and the manual canary. +- **§7.4** — the amendment that would authorise a supervised respawn and a durable restart intent. Design decisions 10 and 11 record where this implementation diverges from Amendment 18 Addendum 2 (i-8) as ratified; **this proposal is not treated as ratification of that divergence.** + +## 1. Prerequisite Lifecycle Contract + +- [ ] 1.1 Reconcile this change with the landed `add-crash-consistent-lane-worktree-recovery` state schema and document the exact lane lock, generation, operation, and exit-code interfaces consumed by supervised restart. +- [ ] 1.2 Add red-first tests proving ordinary handoff and `/ctx` refuse when lifecycle state cannot be read, locked, or compare-and-swapped. +- [ ] 1.3 Remove the fail-open path that continues a restart without lifecycle ownership and satisfy the refusal tests while preserving non-restarting late-handoff behavior where explicitly allowed. +- [ ] 1.4 Add red-first tests proving ordinary `/ctx` cannot supersede `SWAPPING`, and implement a separately named recovery/takeover operation that requires absence of a matching live owner. +- [ ] 1.5 Add stale-finalizer and concurrent-operation tests proving generation and operation fencing protect newer lane state. + +## 2. Restart Intent Storage + +- [x] 2.1 Define the versioned restart-intent schema, field validation, bounded diagnostics, and `pending`, `starting`, `failed`, and `ready` transitions. +- [x] 2.2 Add red-first tests for atomic intent creation, readback, compare-and-swap, attempt increments, malformed or unknown schemas, and missing control roots. +- [x] 2.3 Implement restart-intent create, read, transition, and status operations under the existing per-lane lock using Bash 3.2-compatible code and atomic replacement. +- [x] 2.4 Add tests proving restart intents contain no credentials and reject absolute/relative handoff or directory values that conflict with the canonical lane record. +- [x] 2.5 Add handoff-digest tests proving a changed or missing handoff blocks launch and retry. + +## 3. Semantic-to-Mechanical Boundary + +- [ ] 3.1 Define and test the operation-scoped staged semantic payload containing coordinator narrative, writer inventory, writer outcomes, and intended handoff top block. +- [ ] 3.2 Refactor the handoff skill so `/ctx` produces the staged payload and invokes one backend operation instead of containing an executable tmux respawn recipe. +- [ ] 3.3 Add backend validation that binds staged payload, current transcript, canonical lane, pane, directory, generation, and operation before canonical writes begin. +- [x] 3.4 Refactor `lane-handoff --restart` to perform the ordered `RUNNING -> SWAPPING`, preservation writes, restart-intent creation, `SWAPPED`, supervisor preflight, and tmux handoff transaction. +- [x] 3.5 Add failure-injection tests at every pre-kill stage proving the current pane process remains alive and no stage reports a completed restart. + +## 4. Pane Restart Supervisor + +- [x] 4.1 Add a Bash 3.2-compatible pane supervisor that accepts only canonical lane and operation identifiers and resolves all launch facts from restart intent. **Rewritten from `lane-restart-supervisor`:** it is `lane-handoff --supervise --lane --operation `, a mode of an already-installed command, because Amendment 18 Addendum 2 (i-8) keeps *no word on `PATH` for `/ctx` alone* (design decision 10). It is still a separate process, still what tmux starts, and still resolved by absolute path. +- [x] 4.2 Add red-first tests proving the supervisor refuses stale generations, mismatched panes, non-pending operations, conflicting runtime values, and ambiguous live holders. +- [x] 4.3 Implement the `pending -> starting` claim and launch the installed profile launcher as a foreground child without invoking the human-facing `lane` dispatcher. +- [x] 4.4 Preserve the supervisor as the pane root and add tests proving profile failure, launcher failure, and immediate child exit leave a visible process, `SWAPPED` lifecycle, failed intent, and retry instructions. +- [x] 4.5 Define and test normal signal propagation and post-readiness child exit behavior so intentional Claude exit does not trigger an automatic relaunch loop. +- [x] 4.6 ~~Install the supervisor by absolute path alongside existing lane commands~~ — **nothing to install** (decision 10). The supervisor is a mode of `lane-handoff`, which `openRepoTools --install` already places; the installed-file count is unchanged at twelve. It is *invoked* by absolute path, computed from the command's own location, which is asserted by the respawn case. + +## 5. Exact Fresh Launch and Readiness + +- [x] 5.1 Add an explicit restart-operation input. **Rewritten by measurement (#94):** the pclaude-to-lane-start *environment* seam cannot carry it at all — `tmux new-session` hands the child the server's environment — so the authority is the restart intent on disk, `lane-start --operation ` is the explicit argument on the direct path, and `LANE_RESTART_OPERATION`/`LANE_START_FRESH` are exported by the supervisor as compatibility values that are never relied on. +- [x] 5.2 Refactor supervised `lane-start` preparation so it creates a distinct transcript and exact handoff first prompt. **Scoped (decision 12):** what must not be written before readiness is #91's lifecycle snapshot, never Amendment 8(d)'s `RESUMED` row stamp, object-log line and Rule 3 handoff stamp, which are the append-only record of a launch that was made and which every other reader depends on. `lane-start` writes the prepared transcript id back into the intent, fenced on the operation, so the supervisor has something to confirm against. +- [x] 5.3 Add conflict tests proving lane, profile, agent, directory, pane, transcript, and launch-mode arguments cannot override the restart intent. +- [x] 5.4 Implement a read-only readiness predicate over child liveness, transcript creation, canonical binding, directory, pane, and competing-holder evidence. +- [x] 5.5 Add a supervisor-owned fenced finalizer that marks the intent ready, moves `SWAPPED` to `RUNNING`, and appends resumed evidence only after the readiness predicate succeeds. +- [x] 5.6 Add tests for child exit before readiness, all-evidence success, conflicting holder, and a 60-second indeterminate timeout that preserves a live child without marking the lane running. +- [x] 5.7 Verify the generic SessionStart hook remains read-only, network-free, always non-blocking. **Verified by construction:** nothing in this slice writes from the hook or reads the network in it; readiness is a supervisor-owned predicate over process, register and sidecar evidence (`R-A8-1` untouched). Making the hook *report* the pending supervised state is not in this slice. + +## 6. Retry, Inspection, and Recovery + +- [x] 6.1 Add an operator status surface that reports lane generation, operation, attempt, lifecycle state, intent state, handoff, child evidence, and bounded failure reason. +- [x] 6.2 Implement idempotent retry from `failed` using the same operation, generation, handoff digest, directory, profile, and fresh launch mode. +- [x] 6.3 Add tests proving retry refuses stale state, changed handoff, mismatched pane, or any possible live child and never launches a competing process. +- [ ] 6.4 Implement explicit inspection and finalize-or-stop actions for an indeterminate but still-live child, each fenced on the original operation. +- [ ] 6.5 Add explicit cancellation and generation-advancing recovery behavior without deleting handoffs, lifecycle history, worktrees, or unpublished Git state. + +## 7. Command and Protocol Integration + +- [x] 7.1 Update `commands/ctx.md` and the handoff skill to describe the supervised transaction, visible failure behavior, and exact boundary between semantic and mechanical work. +- [x] 7.2 Remove direct `/ctx` respawn through `lane ` and retain normal human `lane` attach/select/launch behavior unchanged. +- [x] 7.3 Update lane documentation and command help with restart status, retry, indeterminate, cancel, and explicit recovery instructions. +- [ ] 7.4 Update the lane collision protocol proposal/amendment material required to authorize durable restart intent and supervised readiness, without silently treating this OpenSpec proposal as ratification. +- [ ] 7.5 Amend `add-automatic-context-rollover` artifacts so its success condition is readiness-confirmed `RUNNING` and its implementation depends on this change. + +## 8. End-to-End Verification and Delivery + +- [x] 8.1 Replace the fake-tmux string-only `/ctx` gate with an isolated end-to-end harness that runs the real supervisor and fake launcher children. +- [ ] 8.2 Prove successful rollover creates a distinct transcript, delivers the digest-matched handoff top block as the first prompt, reaches ready intent and `RUNNING`, and leaves no duplicate holder. +- [ ] 8.3 Prove tmux refusal, unknown profile, launcher nonzero exit, immediate child death, missing transcript, binding mismatch, readiness timeout, stale generation, and duplicate retry each produce their specified recoverable state. +- [ ] 8.4 Add crash-point tests between every `SWAPPING`, preservation, intent, `SWAPPED`, supervisor claim, child launch, and readiness-finalization write and verify deterministic reconciliation. +- [x] 8.5 Run targeted lane helper and installer tests, then run the complete serialized `tests/run.sh` suite and resolve all regressions. +- [x] 8.6 Verify all changed shell scripts parse and behavior tests pass under macOS Bash 3.2 constraints. +- [ ] 8.7 Canary one manual `/ctx` success and one deliberate launcher failure, recording evidence that success resumes from the handoff and failure leaves the supervisor and retry path visible. +- [ ] 8.8 Keep automatic rollover disabled until the manual canary and readiness-confirmed end-to-end gate pass; document rollback to non-restarting `/handoff` rather than unsafe direct self-respawn. diff --git a/skills/handoff/SKILL.md b/skills/handoff/SKILL.md index 94982f7e..7eabafda 100644 --- a/skills/handoff/SKILL.md +++ b/skills/handoff/SKILL.md @@ -634,40 +634,50 @@ Steps 1–5 are the whole act for a plain `/handoff`. The two flags add ONE act and both refuse where step 4's `PAUSED` line did not land: *"a `/ctx` whose record could not be written REFUSES before it kills anything (a pane is never respawned over an unrecorded lane)"* (Amendment 17(f)). -**`/ctx` (`/handoff --restart`) — the context clear, and the person types nothing else.** The lane's own -pane is respawned through the launcher with a NEW session of the same agent, and that session's first -prompt is the top block step 2 just wrote: +**`/ctx` (`/handoff --restart`) — the context clear, and the person types nothing else.** IT IS ONE COMMAND +AND THIS STEP DOES NOT SPELL IT OUT IN SHELL: ```sh -# the pane is the LIVE RECORD'S own `tmux` field — `:<@id>.%` — -# because that is the pane the session is really in. -# AND IT IS ASKED WITH `:<@id>`, NOT `@id` ALONE: that is the HARNESS's -# own `tmux` field, which is what `window-session` matches a record on, and the -# three spellings of one window are never derived from each other -# (`lane-start:975-984`, and `lane-handoff` reads it the same way). -pane="$("$L" window-session "$(tmux display-message -p '#{session_name}:#{window_id}')" | awk -F'\037' '{print $2}')" -# `lane ` where this workstation has that word, and the launcher where it -# does not: `lane-handoff --restart` makes exactly this choice, in code. -if command -v lane >/dev/null 2>&1; then - tmux respawn-pane -k -t "$pane" "LANE_START_FRESH=1 lane $lane" -else - tmux respawn-pane -k -t "$pane" "LANE_START_FRESH=1 pclaude --lane $lane ${CLAUDE_PROFILE_NAME:-}" -fi +lane-handoff --restart "$why" ``` -`respawn-pane -k` replaces the pane's process, so the act survives the death of the session that started it. -`LANE_START_FRESH=1` is the one seam: it tells `lane-start` *a NEW session of this lane's agent, started with -the top block of the handoff the row names as its first prompt* — which is what a context clear IS, and it is -why `/ctx` does not simply resume the transcript it has just paused. It is an ENVIRONMENT seam and not an -argument, so it survives `lane` handing the launch on to `lane-start` exactly as it survives the launcher -doing so. **The respawn line is `lane ` and never `restart `** (Amendment 18 Addendum 2, in force -2026-09-14T16:50:32Z, clause (i-8): the respawn *"relaunches the lane's own pane with `lane ` (its -parked branch is exactly Amendment 11(i)'s act), or through the launcher directly"*, and `restart` leaves the -person's `PATH` with `opensoft/openRepoTools#43`). `lane ` needs no profile argument: its parked branch -reads the record step 4 just wrote — the lane's recorded directory and profile — and asks nothing. **Where -`lane` is not on `PATH`** (it arrives with #43, and this act shipped first) the line is `pclaude --lane - `, the same act one door along: a respawn is the one act no later refusal can undo, so the -word is used only where it can be seen on `PATH`. +**THE TMUX RECIPE THAT USED TO BE HERE IS GONE, AND `opensoft/openRepoTools#94` IS WHY.** This step used to +carry an executable respawn — `tmux respawn-pane -k -t "$pane" "LANE_START_FRESH=1 lane $lane"` — for a +session to run itself. On 2026-09-15T20:59Z that is what ran, with two extra environment assignments the +model added on the spot, and what came up in the pane was `claude --name --resume `: the same conversation resumed by id, not a fresh session primed by the block +step 2 had just written, and no process left in the pane to notice. Two things were wrong and only one of +them was the recipe: + +- **`LANE_START_FRESH=1` is an ENVIRONMENT seam and it does not cross the launcher.** `claude-profile` + re-creates its child through tmux, and a command tmux starts gets the tmux SERVER's environment, not the + caller's — the launcher's own code says so and threads six values into the command string for exactly + that reason. The fresh-session requirement was not one of them. It is now a RESTART INTENT written to + disk under the lane's own control root before anything is killed, and `lane-start` reads it back whatever + the environment did. +- **A prose recipe is a second implementation of the destructive tail.** Two copies of one procedure that + must stay byte-equal is the rejected alternative everywhere else in this file, and the one place it was + allowed is the one place a session kills its own pane. + +So the whole of `/ctx`'s end is the one command above, and `lane-handoff` owns it: the restart intent +written and read back as a FOURTH gate beside the `PAUSED` line, the row and the handoff; the pane +respawned with `lane-handoff --supervise --lane --operation ` **by absolute path**; the +supervisor claiming that intent, running the launch as its CHILD through the launcher, confirming the +replacement came up, and STAYING IN THE PANE with a diagnostic and a retry when it did not. + +**WHAT TO DO WITH WHAT IT PRINTS.** Exit 0 is the pane handed over — this session is about to be killed by +the command it just ran, and there is nothing after it. Exit 2 is a REFUSAL and this pane is exactly as it +was: read the line, it names which of the four writes did not land, or says a restart is already in flight +(`lane-handoff --restart-status --lane ` reads that one), or that tmux would not take the pane. In +every refusal the record is already written, so the fallback is the printed restart line and never a second +`--restart`. + +**`lane ` IS STILL A PERSON'S DOOR AND IS NO LONGER THIS ONE** (Amendment 18 Addendum 2 (i-8) names +two doors: `lane `, *"or through the launcher directly"*). The supervisor takes the second, because +`lane` is a HUMAN DISPATCHER whose valid outcomes include attaching to a live session without launching +anything — and because it was the door #94 came through. The clause's own ground is kept: *"no word is +kept on `PATH` for it alone"*, and none is — the supervisor is a mode of `lane-handoff`, which is already +installed. **`/ctx` SAYS WHICH IT DID** (Addendum 1 (j)). The respawn above replaces the pane's process, so every writer of this lane dies with it: the kind is `respawn`, and the count the next session makes first then finds none, diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 37153efa..c3e76258 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -9477,6 +9477,471 @@ is "…and no pane was respawned for a lane no read of it is unambiguous abou is "…and no log was written for either spelling" \ "$(ls "$LOGD" | grep -ci '^repohf-13\.md$' || :)" 0 + +# ===================================================================== +# openRepoTools#94 — THE SUPERVISED CONTEXT RESTART +# +# WHAT WAS MEASURED, AND WHAT THESE CASES HOLD. On 2026-09-15T20:59Z `/ctx` +# wrote its record, respawned its own pane with `LANE_START_FRESH=1 … lane +# ` — and the pane came up `claude --name --resume `. The seam was an ENVIRONMENT variable and the launcher +# re-creates its child through `tmux new-session`, which hands a command the +# SERVER's environment and not the client's. Nothing noticed, because what tmux +# had started WAS the launch: a launch that ends takes the pane with it. +# +# So the cases below are about three things and not one: +# * the FRESH SESSION is decided by a FILE and not by an environment — the +# row's last uuid may have a transcript, the lane's last PAUSED may name +# another that also has one, and neither is resumed; +# * the intent is written BEFORE the pane is killed, and a pane is never +# respawned without one; +# * TMUX ACCEPTING A COMMAND IS NOT A STARTED SESSION — the thing tmux starts +# is a supervisor, it runs the launch as a child, and a child that dies +# leaves a process in the pane saying so. +# +# EVERYTHING HERE IS ITS OWN — its own bin, its own fakes, its own lanes, its +# own control root — so it can be read, moved or merged in one piece. + +SV_BIN="$SANDBOX/svbin" +mkdir -p "$SV_BIN" +SV_STATE="$SANDBOX/lane-state" +mkdir -p "$SV_STATE" +export LANES_LANE_STATE_ROOT="$SV_STATE" + +SV_DIR="$HOME/projects/repoSV" +mkdir -p "$SV_DIR" +git init -q -b main "$SV_DIR" +git -C "$SV_DIR" remote add origin "https://github.com/opensoft/repoSV.git" + +SV_OLD="5f000001-1111-4000-8000-5f0000011111" +SV_PAUSED="5f000002-2222-4000-8000-5f0000022222" +SV_NEW="5f000003-3333-4000-8000-5f0000033333" + +# BOTH RESUME SOURCES EXIST ON DISK, which is the whole point of the #94 case: +# the row's last uuid has a transcript here, AND the lane's last PAUSED names a +# second one that also has a transcript here (PR #87's recovery source). A +# fresh launch must take neither. +sv_tdir="$HOME/.claude/projects/$(sanitize "$SV_DIR")" +mkdir -p "$sv_tdir" +printf '{"type":"user"}\n' > "$sv_tdir/$SV_OLD.jsonl" +printf '{"type":"user"}\n' > "$sv_tdir/$SV_PAUSED.jsonl" + +mkdir -p "$WIP/handoffs/repoSV" +SV_HANDOFF="$WIP/handoffs/repoSV/repoSV-1.md" +{ printf 'Lane: repoSV-1 (team-05a, session %s) — single-use resume prompt: stamp RESUMED-by before acting (lane-collision-protocol rule 3)\n' "$SV_OLD" + printf '\n' + printf '## RESUME PROMPT — the top block of repoSV-1\n' +} > "$SV_HANDOFF" +for sv_n in 2 3 4; do + { printf 'Lane: repoSV-%s (team-05a, session %s) — single-use resume prompt: stamp RESUMED-by before acting (lane-collision-protocol rule 3)\n' "$sv_n" "$SV_OLD" + printf '\n' + printf '## RESUME PROMPT — the top block of repoSV-%s\n' "$sv_n" + } > "$WIP/handoffs/repoSV/repoSV-$sv_n.md" +done +git -C "$WIP" add -- handoffs/repoSV >/dev/null 2>&1 +git -C "$WIP" commit -q -m "seed the repoSV handoffs" +git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : +git -C "$WIP" push -q origin main + +for sv_l in repoSV-1 repoSV-2 repoSV-3 repoSV-4; do + "$E" add-row "| \`$sv_l\` | harness \`$SV_OLD\` | Eagle / test / brett | 2026-09-15T00:00Z | none | handoffs/repoSV/$sv_l.md | ACTIVE |" >/dev/null 2>&1 +done +# Row 2 and 3 name the handoff of lane 1, which is fine: only lane 1's launch +# reads a top block here. +sv_seed_log() { # + { printf '# lane %s — object log (lane-collision-protocol Amendment 7)\n' "$1" + printf 'STARTED — lane %s, session %s@Eagle, 2026-09-15T09:00:00Z, lane:%s → home opensoft/repoSV; estate repoSV; dir %s\n' \ + "$1" "$SV_OLD" "$1" "$SV_DIR" + printf 'PAUSED — lane %s, session %s@Eagle, 2026-09-15T10:00:00Z, lane:%s → swap; dir %s; profile team-05a; workstation Eagle; agent claude; transcript %s; kind respawn\n' \ + "$1" "$SV_OLD" "$1" "$SV_DIR" "$SV_PAUSED" + } > "$LOGD/$1.md" + git -C "$WIP" add -- "lanes/log/$1.md" + git -C "$WIP" commit -q -m "LOG($1@Eagle): seed" + git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : + git -C "$WIP" push -q origin main + return 0 +} +for sv_l in repoSV-1 repoSV-2 repoSV-3; do sv_seed_log "$sv_l"; done + +# ---------------------------------------- 1. the intent store, on its own + +run "$E" restart-intent repoSV-1 +is "a lane with no /ctx in flight has no restart intent" "$rc" 8 + +run "$E" set-restart-intent repoSV-1 flying +is "a fifth state word is refused" "$rc" 64 +has "…naming the four there are" "$err" "pending, starting, failed and ready" + +run "$E" set-restart-intent repoSV-1 pending --dir relative/path +is "a relative --dir is refused" "$rc" 64 +has "…because the reader of this record stands somewhere else" "$err" "ABSOLUTE path" + +run "$E" set-restart-intent repoSV-1 pending --operation 'op one; two' +is "an operation id that is not a manifest key is refused" "$rc" 64 +has "…because a later reader compares it for equality" "$err" "manifest key" +run "$E" set-restart-intent repoSV-1 pending --profile "$(printf 'a\nb')" +is "…and so is a profile carrying a newline, in a file of one line per field" "$rc" 64 + +run "$E" set-restart-intent repoSV-1 pending --expect 'none|ready|failed' \ + --operation op-test-1 --generation 3 --mode fresh-from-handoff \ + --agent claude --profile team-05a --dir "$SV_DIR" --pane 'svsess:@31.%31' \ + --handoff "$SV_HANDOFF" --old-transcript "$SV_OLD" +is "a pending restart intent is written over 'none'" "$rc" 0 +has "…and reports the operation it minted" "$out" "op-test-1" +run "$E" restart-intent repoSV-1 +is "…and reads back" "$rc" 0 +has "…as pending" "$out" "$(printf 'state\tpending')" +has "…carrying the generation it was fenced on" "$out" "$(printf 'generation\t3')" +has "…and the launch mode that authorises a fresh session" "$out" "$(printf 'mode\tfresh-from-handoff')" +is "…with a digest of the handoff, computed by the writer" \ + "$( [ "$(printf '%s\n' "$out" | awk -F'\t' '$1=="digest"{print length($2)}')" -gt 16 ] && echo yes || echo no )" yes +is "…and no field that could carry a credential" \ + "$(printf '%s\n' "$out" | grep -ci 'token\|secret\|password\|key' || :)" 0 + +run "$E" set-restart-intent repoSV-1 pending --expect 'none|ready|failed' --operation op-test-2 +is "a second /ctx does not supersede an operation in flight" "$rc" 7 +has "…saying which state refused it" "$err" "the intent is pending and --expect named" +run "$E" restart-intent repoSV-1 +has "…and the first operation still owns the lane" "$out" "op-test-1" + +run "$E" set-restart-intent repoSV-1 starting --expect starting --expect-operation op-test-1 +is "a compare-and-swap whose expected state is wrong writes nothing" "$rc" 7 +run "$E" set-restart-intent repoSV-1 starting --expect pending --expect-operation op-elsewhere +is "…and so does one whose expected OPERATION is wrong" "$rc" 7 +has "…naming the operation that really holds it" "$err" "operation is op-test-1" + +run "$E" set-restart-intent repoSV-1 starting --expect pending --expect-operation op-test-1 --expect-generation 3 --attempt 1 +is "the supervisor's claim takes pending -> starting under the fence" "$rc" 0 +run "$E" restart-intent repoSV-1 +has "…and the fields it did not name are KEPT, not blanked" "$out" "$(printf 'handoff\t%s' "$SV_HANDOFF")" +has "…including the transcript the /ctx paused" "$out" "$(printf 'old_transcript\t%s' "$SV_OLD")" + +run "$E" set-restart-intent repoSV-1 failed --expect starting --expect-operation op-test-1 --reason "the launcher exited 127" +is "a failed launch is recorded against the same operation" "$rc" 0 +run "$E" set-restart-intent repoSV-1 starting --expect failed --expect-operation op-test-1 --bump-attempt +is "…and the retry increments the attempt count rather than minting a new operation" "$rc" 0 +has "…still the same operation" "$out" "op-test-1" +has "…attempt 2" "$out" "$(printf 'attempt\t2')" + +# A SCHEMA THIS READER DOES NOT KNOW FAILS CLOSED. A newer tooling's intent read +# by an older reader must not be reported as a launch this reader knows how to +# make — the same rule `lane-state` takes, for the same reason. +sed -i.bak 's/^schema: .*/schema: 99/' "$SV_STATE/repoSV-1/restart-intent.yaml" 2>/dev/null || \ + sed -i '' 's/^schema: .*/schema: 99/' "$SV_STATE/repoSV-1/restart-intent.yaml" 2>/dev/null || : +rm -f "$SV_STATE/repoSV-1/restart-intent.yaml.bak" +run "$E" restart-intent repoSV-1 +is "an intent whose schema this reader does not know still reads" "$rc" 0 +has "…and says so rather than reporting a state it does not understand" "$out" "UNKNOWN-SCHEMA" +: > "$FAKE_CLAUDE_LOG" +run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ + "$START" --dir "$SV_DIR" repoSV-1 --no-launch +is "…and no launch treats it as authorising a fresh session" "$rc" 0 +has "…so the ordinary resume source decides" "$out" "--resume" +# PUT IT BACK, because the supervisor cases below are asked on this same lane: +# a `starting` intent is what proves a second supervisor refuses rather than +# launching a second session of one lane. +sed -i.bak 's/^schema: .*/schema: 1/' "$SV_STATE/repoSV-1/restart-intent.yaml" 2>/dev/null || \ + sed -i '' 's/^schema: .*/schema: 1/' "$SV_STATE/repoSV-1/restart-intent.yaml" 2>/dev/null || : +rm -f "$SV_STATE/repoSV-1/restart-intent.yaml.bak" + +# A LANE WITH NO CONTROL ROOT IS A READ THAT COULD NOT BE MADE, and never +# 'this lane has no restart in flight' (Amendment 7(d)). +run env LANES_LANE_STATE_ROOT= PROJECTS_ROOT=/nonexistent-projects-root "$E" restart-intent repoSV-4 +is "a lane with no control root is a failed read and not an empty answer" "$rc" 1 +has "…saying which two rungs answered nothing" "$err" "names no directory" +run env LANES_LANE_STATE_ROOT= PROJECTS_ROOT=/nonexistent-projects-root \ + "$E" set-restart-intent repoSV-4 pending --expect none +is "…and there is nowhere to write one either" "$rc" 1 +has "…naming the seam that gives it one" "$err" "LANES_LANE_STATE_ROOT" + +# ------------------------- 2. openRepoTools#94: the fresh launch, from the FILE + +SV_LANE_HANDOFF="$OPENREPOTOOLS_BIN_DIR/lane-handoff" +: > "$FAKE_CLAUDE_LOG" +run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ + "$START" --dir "$SV_DIR" repoSV-2 --no-launch +is "without a restart intent, a lane whose row uuid has a transcript resumes it" "$rc" 0 +has "…exactly as Amendment 6 has always done" "$out" "--resume $SV_OLD" + +# THE CASE openRepoTools#94 ASKS FOR, and it is stronger than the issue's own +# wording: the environment carries NOTHING — no LANE_START_FRESH — the row's +# last uuid has a transcript here, and the lane's last PAUSED names a SECOND +# transcript that also has one (PR #87's recovery source, so this case holds +# when that lands too). The only thing saying "fresh" is the file. +run "$E" set-restart-intent repoSV-2 pending --expect 'none|ready|failed' \ + --operation op-94 --generation 1 --mode fresh-from-handoff \ + --agent claude --profile team-05a --dir "$SV_DIR" \ + --handoff "$WIP/handoffs/repoSV/repoSV-2.md" --old-transcript "$SV_OLD" +is "a /ctx writes its intent before anything is killed" "$rc" 0 +: > "$FAKE_CLAUDE_LOG" +run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ + "$START" --dir "$SV_DIR" repoSV-2 --no-launch +is "with a pending restart intent and NO LANE_START_FRESH, the launch is a new session" "$rc" 0 +hasnt "…never a resume of the transcript the /ctx paused" "$out" "--resume $SV_OLD" +hasnt "…never a resume of the transcript its last PAUSED names either" "$out" "--resume $SV_PAUSED" +hasnt "…never a resume of anything at all" "$out" "--resume" +has "…named for the lane" "$out" "--name repoSV-2" +has "…and primed by the handoff's top block" "$out" "the top block of repoSV-2" +has "…saying which authority decided it" "$err" "restart intent op-94" + +# AND THE INTENT OUTRANKS A CONFLICT RATHER THAN SUBSTITUTING FOR IT. +run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ + "$START" --dir "$SV_DIR" --operation op-not-this-one repoSV-2 --no-launch +is "a launch naming another operation is refused" "$rc" 2 +has "…naming the one that really holds the lane" "$err" "op-94" + +SV_OTHER="$HOME/projects/repoSVX" +mkdir -p "$SV_OTHER" +git init -q -b main "$SV_OTHER" +git -C "$SV_OTHER" remote add origin "https://github.com/opensoft/repoSV.git" +run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ + "$START" --dir "$SV_OTHER" repoSV-2 --no-launch +is "a launch in a checkout the intent does not name is refused" "$rc" 2 +has "…citing the silent loss a wrong directory is" "$err" "Evidence 3" + +# A CHANGED HANDOFF BLOCKS THE LAUNCH, because its top block IS the first prompt. +printf 'appended after the intent was written\n' >> "$WIP/handoffs/repoSV/repoSV-2.md" +run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ + "$START" --dir "$SV_DIR" repoSV-2 --no-launch +is "a handoff that changed since the intent was written blocks the launch" "$rc" 2 +has "…saying the top block is the first prompt" "$err" "FIRST PROMPT" +git -C "$WIP" checkout -- handoffs/repoSV/repoSV-2.md 2>/dev/null || : + +# A `ready` INTENT IS HISTORY AND AUTHORISES NOTHING. +run "$E" set-restart-intent repoSV-2 ready --expect pending --expect-operation op-94 +is "readiness is recorded against the operation" "$rc" 0 +: > "$FAKE_CLAUDE_LOG" +run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ + "$START" --dir "$SV_DIR" repoSV-2 --no-launch +is "a confirmed restart authorises no further fresh launch" "$rc" 0 +has "…so the next lane is an ordinary resume" "$out" "--resume" + +# `--fresh` IS THE ARGUMENT BESIDE THE VARIABLE, for a caller with no launcher +# between it and here. +: > "$FAKE_CLAUDE_LOG" +run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ + "$START" --dir "$SV_DIR" --fresh repoSV-2 --no-launch +is "--fresh takes a new session with no environment and no intent" "$rc" 0 +hasnt "…resuming nothing" "$out" "--resume" +has "…and saying which of the three said so" "$err" "(--fresh)" + +# --------------- 3. the transaction: the intent is written BEFORE the kill + +# ITS OWN FAKE TMUX, logging the respawn and recording how many restart intents +# the lane's control root held at the instant tmux was asked. Amendment 17(f) is +# an ORDER, and an order is proved by what the second act could see of the first. +cat > "$SV_BIN/tmux" <<'FAKE' +#!/usr/bin/env bash +case "${1-}" in + send-keys|respawn-pane) + printf '%s' "$*" >> "${FAKE_TMUX_SV_LOG:-/dev/null}" + printf ' | intents=%s\n' \ + "$(ls "${FAKE_TMUX_SV_WATCH:-/nonexistent}" 2>/dev/null | grep -c '^restart-intent.yaml$' || printf 0)" \ + >> "${FAKE_TMUX_SV_LOG:-/dev/null}" + exit "${FAKE_TMUX_SV_RC:-0}" ;; +esac +exec "${FAKE_TMUX_REAL:?}" "$@" +FAKE +chmod +x "$SV_BIN/tmux" +export FAKE_TMUX_SV_LOG="$SANDBOX/tmux-sv.log" +export FAKE_TMUX_SV_WATCH="$SV_STATE/repoSV-3" +: > "$FAKE_TMUX_SV_LOG" +SVPATH="$SV_BIN:$A17PATH" + +write_record_ns "$sessions_dir/live-sv.json" "$SV_OLD" "$LIVE_PID" "$live_start" "svsess:@33.%33" "repoSV-3" "user" "busy" +run env PATH="$SVPATH" FAKE_TMUX_WINDOW="svsess:@33" CLAUDE_CODE_SESSION_ID="$SV_OLD" \ + CLAUDE_PROFILE_NAME=team-05a "$SV_LANE_HANDOFF" --lane repoSV-3 --restart clear +is "lane-handoff --restart exits 0" "$rc" 0 +sv_tmux="$(cat "$FAKE_TMUX_SV_LOG")" +has "…respawning the lane's own pane" "$sv_tmux" "respawn-pane -k -t svsess:@33.%33" +has "…into this command's own supervisor, by absolute path" "$sv_tmux" "lane-handoff --supervise --lane repoSV-3 --operation" +hasnt "…and never into the human dispatcher, whose valid outcomes include attaching" "$sv_tmux" " lane repoSV-3" +has "…threading the workstation into the command string, because tmux hands the SERVER's environment" "$sv_tmux" "LANES_WORKSTATION=Eagle" +is "…and the restart intent was on disk BEFORE tmux was asked to kill anything" \ + "$(printf '%s' "$sv_tmux" | sed -n 's/.*intents=\([0-9]*\).*/\1/p' | head -n 1)" 1 +run "$E" restart-intent repoSV-3 +is "…the intent reads back" "$rc" 0 +has "…as pending, claimed by nobody yet" "$out" "$(printf 'state\tpending')" +has "…naming the pane the /ctx respawned" "$out" "$(printf 'pane\tsvsess:@33.%%33')" +has "…the transcript it paused" "$out" "$(printf 'old_transcript\t%s' "$SV_OLD")" +has "…and the launch mode that authorises a fresh session" "$out" "$(printf 'mode\tfresh-from-handoff')" +SV_OP3="$(printf '%s\n' "$out" | awk -F'\t' '$1=="operation"{print $2}')" +is "…with an operation of its own" "$( [ -n "$SV_OP3" ] && echo yes || echo no )" yes + +# A SECOND /ctx OVER AN OPERATION IN FLIGHT REFUSES, AND KILLS NOTHING. +: > "$FAKE_TMUX_SV_LOG" +run env PATH="$SVPATH" FAKE_TMUX_WINDOW="svsess:@33" CLAUDE_CODE_SESSION_ID="$SV_OLD" \ + CLAUDE_PROFILE_NAME=team-05a "$SV_LANE_HANDOFF" --lane repoSV-3 --restart clear +is "a second /ctx over an operation in flight is refused" "$rc" 2 +has "…saying an ordinary context clear never supersedes one" "$err" "never supersedes an operation" +is "…and the pane was NOT respawned" "$(grep -c 'respawn-pane' "$FAKE_TMUX_SV_LOG")" 0 + +# A TMUX THAT REFUSES LEAVES THE OLD PROCESS ALIVE AND THE INTENT PENDING. +"$E" set-restart-intent repoSV-3 ready --expect pending >/dev/null 2>&1 +: > "$FAKE_TMUX_SV_LOG" +run env PATH="$SVPATH" FAKE_TMUX_SV_RC=1 FAKE_TMUX_WINDOW="svsess:@33" \ + CLAUDE_CODE_SESSION_ID="$SV_OLD" CLAUDE_PROFILE_NAME=team-05a \ + "$SV_LANE_HANDOFF" --lane repoSV-3 --restart clear +is "a tmux that refuses the respawn is a refusal and not a silent success" "$rc" 2 +has "…saying nothing was killed" "$err" "Nothing was killed" +has "…and naming the line that hands the pane over once tmux will take it" "$err" "--supervise --lane repoSV-3" +run "$E" restart-intent repoSV-3 +has "…with the intent left pending for the supervisor that never arrived" "$out" "$(printf 'state\tpending')" + +# --------------------------------- 4. the supervisor: refusals before a launch + +SV_OP="$(printf '%s\n' "$out" | awk -F'\t' '$1=="operation"{print $2}')" +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 "$SV_LANE_HANDOFF" \ + --supervise --lane repoSV-3 --operation op-stale +is "a supervisor started for a stale operation refuses" "$rc" 2 +has "…naming the operation that really holds the lane" "$err" "$SV_OP" +hasnt "…and launches nothing" "$err" "restarting (operation" + +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 "$SV_LANE_HANDOFF" \ + --supervise --lane repoSV-1 --operation op-test-1 +is "a supervisor whose intent is already \`starting\` refuses rather than starting a second session" "$rc" 2 +has "…citing the one-transcript-one-process rule" "$err" "Amendment 18(h)" + +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE='%99' "$SV_LANE_HANDOFF" \ + --supervise --lane repoSV-3 --operation "$SV_OP" +is "a supervisor in a pane the intent does not name refuses" "$rc" 2 +has "…naming both panes" "$err" "svsess:@33.%33" +# AND THE COMPARISON IS COMPONENT-WISE, NOT A SUFFIX. `%3` is a SUFFIX of +# `%33`, so a `case ... in *"$TMUX_PANE")` accepted a supervisor in pane 3 for +# an intent written for pane 33 — the wrong window, decided by string tail. +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE='%3' "$SV_LANE_HANDOFF" \ + --supervise --lane repoSV-3 --operation "$SV_OP" +is "…and a pane whose id is a SUFFIX of the intent's is still another pane" "$rc" 2 +has "…named as the disagreement it is" "$err" "and this supervisor is in %3" + +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 "$SV_LANE_HANDOFF" \ + --supervise --lane repoSV-3 --operation "$SV_OP" +is "a supervisor that finds a live holder refuses to make a second one" "$rc" 2 +has "…naming the retire act and never a kill" "$err" "lane-end repoSV-3 --retire" + +rm -f "$sessions_dir/live-sv.json" +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 "$SV_LANE_HANDOFF" \ + --supervise --lane repoSV-3 +is "a supervisor with no operation refuses" "$rc" 2 +has "…because one that took whatever it found would launch into somebody else's act" "$err" "--operation " + +# ------------------------------- 5. the supervisor: a launch that FAILS + +cat > "$SV_BIN/pclaude-fail" <<'FAKE' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FAKE_SVLAUNCH_LOG:-/dev/null}" +exit 127 +FAKE +chmod +x "$SV_BIN/pclaude-fail" +export FAKE_SVLAUNCH_LOG="$SANDBOX/svlaunch.log" +: > "$FAKE_SVLAUNCH_LOG" +run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-fail" LANE_SUPERVISOR_NO_PROMPT=1 \ + LANE_SUPERVISOR_READY_SECONDS=4 "$SV_LANE_HANDOFF" \ + --supervise --lane repoSV-3 --operation "$SV_OP" +is "a launcher that exits nonzero is a FAILED restart and not a started one" "$rc" 3 +has "…and the supervisor was still there to say so" "$out" "RESTART FAILED" +has "…naming the status the launch ended with" "$out" "status 127" +has "…offering the retry, in one line" "$out" "--supervise --lane repoSV-3 --operation $SV_OP" +has "…and saying the handoff is intact" "$out" "handoff it wrote is intact" +is "…the launch really was attempted" "$(grep -c -- "--lane repoSV-3" "$FAKE_SVLAUNCH_LOG")" 1 +run "$E" restart-intent repoSV-3 +has "…the intent is failed" "$out" "$(printf 'state\tfailed')" +has "…still the same operation" "$out" "$SV_OP" +has "…the attempt counted" "$out" "$(printf 'attempt\t1')" +has "…and the reason bounded and recorded" "$out" "before readiness was confirmed" + +# THE RETRY IS THE SAME OPERATION, NOT A NEW ONE. +run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-fail" LANE_SUPERVISOR_NO_PROMPT=1 \ + LANE_SUPERVISOR_READY_SECONDS=4 "$SV_LANE_HANDOFF" \ + --supervise --lane repoSV-3 --operation "$SV_OP" +is "a retry of a failed restart runs" "$rc" 3 +run "$E" restart-intent repoSV-3 +has "…under the same operation" "$out" "$SV_OP" +has "…with the attempt incremented" "$out" "$(printf 'attempt\t2')" + +# ------------------------- 6. the supervisor: a launch that BECOMES READY + +SV_READY_SRC="$SANDBOX/sv-ready-record.json" +write_record_ns "$SV_READY_SRC" "$SV_NEW" "$LIVE_PID" "$live_start" "svsess:@33.%33" "repoSV-3" "user" "busy" +"$E" append-session-id repoSV-3 "$SV_OLD" "→ harness \`$SV_NEW\`" >/dev/null 2>&1 +cat > "$SV_BIN/pclaude-ok" <<'FAKE' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FAKE_SVLAUNCH_LOG:-/dev/null}" +cp "$SV_READY_SRC" "$SV_READY_DST" +sleep "${FAKE_SVLAUNCH_SLEEP:-8}" +exit 0 +FAKE +chmod +x "$SV_BIN/pclaude-ok" +export SV_READY_SRC SV_READY_DST="$sessions_dir/live-sv-new.json" +"$E" set-restart-intent repoSV-3 pending --expect failed --new-transcript "$SV_NEW" >/dev/null 2>&1 +: > "$FAKE_SVLAUNCH_LOG" +run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-ok" LANE_SUPERVISOR_NO_PROMPT=1 \ + LANE_SUPERVISOR_READY_SECONDS=20 FAKE_SVLAUNCH_SLEEP=8 "$SV_LANE_HANDOFF" \ + --supervise --lane repoSV-3 --operation "$SV_OP" +is "a launch that comes up and is CONFIRMED exits 0 when its session later ends" "$rc" 0 +has "…saying the replacement was confirmed" "$out" "was CONFIRMED" +has "…and refusing to relaunch a session that exited on purpose" "$out" "is not relaunched here" +run "$E" restart-intent repoSV-3 +has "…the intent is ready" "$out" "$(printf 'state\tready')" +has "…naming the transcript readiness was proved on" "$out" "$SV_NEW" +has "…which is NOT the one the /ctx paused" "$out" "$(printf 'old_transcript\t%s' "$SV_OLD")" +rm -f "$SV_READY_DST" + +# A `ready` OPERATION IS HISTORY: a supervisor started for it again refuses. +run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-ok" LANE_SUPERVISOR_NO_PROMPT=1 \ + "$SV_LANE_HANDOFF" --supervise --lane repoSV-3 --operation "$SV_OP" +is "a confirmed restart is not launched a second time" "$rc" 2 +has "…and the door to the lane is the one word a person has" "$err" "lane repoSV-3" + +# ------------------------------------------- 7. the status surface + +run env PATH="$SVPATH" "$SV_LANE_HANDOFF" --restart-status --lane repoSV-3 +is "the status surface reads" "$rc" 0 +has "…the lane" "$out" "lane repoSV-3" +has "…the intent and its attempt" "$out" "intent ready" +has "…the operation and generation" "$out" "operation $SV_OP" +has "…the handoff" "$out" "handoff " +has "…both transcripts" "$out" "transcript old $SV_OLD" +has "…and what is open on it" "$out" "DONE: this restart was confirmed" +run env PATH="$SVPATH" "$SV_LANE_HANDOFF" --restart-status --lane repoSV-2 +has "…and a lane whose lanes-edit.sh has no lifecycle says so rather than leaving it blank" \ + "$out" "lifecycle " + +run env PATH="$SVPATH" "$SV_LANE_HANDOFF" --restart-status --lane repoSV-3 --restart +is "--restart-status and --restart together are refused" "$rc" 2 +has "…because one reads and the other writes the record" "$err" "Ask for one." +run env PATH="$SVPATH" "$SV_LANE_HANDOFF" --supervise --restart-status --lane repoSV-3 +is "--supervise and --restart-status together are refused" "$rc" 2 +run env PATH="$SVPATH" "$SV_LANE_HANDOFF" --restart --lane repoSV-3 --operation "$SV_OP" clear +is "--operation outside --supervise is refused rather than silently discarded" "$rc" 2 +has "…because --restart mints its own" "$err" "MINTS its own operation" + +# ------------------- 6a. the third answer: INDETERMINATE, and nothing is killed +# +# A CHILD THAT IS ALIVE AND UNCONFIRMED IS NOT A FAILURE AND NOT A SUCCESS. The +# deadline expires, the observer writes what it could not prove, and nothing is +# killed — Amendment 8(f): ending somebody's process is not a boundary script's +# act. Here the child then exits on its own, which is the only way a supervisor +# blocked on it ever gets to report. +cat > "$SV_BIN/pclaude-quiet" <<'FAKE' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FAKE_SVLAUNCH_LOG:-/dev/null}" +sleep "${FAKE_SVLAUNCH_SLEEP:-7}" +exit 0 +FAKE +chmod +x "$SV_BIN/pclaude-quiet" +"$E" set-restart-intent repoSV-3 pending --expect ready >/dev/null 2>&1 +: > "$FAKE_SVLAUNCH_LOG" +run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-quiet" LANE_SUPERVISOR_NO_PROMPT=1 \ + LANE_SUPERVISOR_READY_SECONDS=4 FAKE_SVLAUNCH_SLEEP=8 "$SV_LANE_HANDOFF" \ + --supervise --lane repoSV-3 --operation "$SV_OP" +is "a child that stays alive without readiness evidence is INDETERMINATE, not running" "$rc" 3 +has "…and the supervisor says which it was" "$out" "INDETERMINATE" +run "$E" restart-intent repoSV-3 +hasnt "…the lane was never marked ready on a launch nothing proved" "$out" "$(printf 'state\tready')" +has "…the deadline is in the record for a reader in another window" "$out" "INDETERMINATE" + + # ------------------------------------------------------- nothing real touched # # The check is that the suite CREATED nothing here, which is not the same as From 378c0b77fd4ae0153347dae18d65547c4b028b53 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 16 Sep 2026 00:28:08 +0000 Subject: [PATCH 02/25] A restart that inherits the last restart's transcript cannot catch #94 with the check written to catch it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review pass over the rescued work found three defects, and the first is the one that matters: `set-restart-intent` keeps every field a transition does not name — which is what lets the supervisor write `starting` without blanking the digest every later launch is fenced on — but five of those fields belong to ONE OPERATION, and the readiness predicate is built on two of them. A second `/ctx` inherited the last one's `new_transcript`, a uuid the new launch will never mint, so readiness could only ever run to its deadline; and it inherited the last one's `old_transcript`, which is the uuid the replacement must be DISTINCT FROM. Being distinct from it is the check that catches openRepoTools#94 — a pane that came back resuming the transcript the context clear had just paused — so an inherited one let the measured defect through the predicate written for it. `old_transcript`, `new_transcript`, `attempt`, `reason` and `created` now fall back to nothing when the operation changes; a caller that names one still wins, and the fields that belong to the LANE are kept exactly as before. The other two are diagnostics that lie. `lane-start` treated a `2` from `restart-intent` as a read that FAILED and printed a note, so every launch of every lane on a workstation whose `lanes-edit.sh` predates this change would nag about a state that is simply the old one — the estate's fence is `0` an answer, `8` no answer and `2` a helper predating the read, and the last two fall to the next rung. And `--restart-status` told a reader that `lanes-edit.sh` has no `lane-state` when the lane merely has no snapshot, which would send somebody hunting an installation problem the day #97 lands. Brett Heap's brainstorm packet for this change is committed beside the implementation, as #97 committed its own, and the proposal now carries a verdict for each of its documents and for each OpenSpec file: what matched, what deviated, and which numbered decision answers it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r --- ideation/README.md | 9 ++ ...pervised-context-restart-durable-intent.md | 65 +++++++++++++ .../supervised-context-restart-overview.md | 94 +++++++++++++++++++ ...ervised-context-restart-pane-supervisor.md | 60 ++++++++++++ ...xt-restart-semantic-mechanical-boundary.md | 51 ++++++++++ ...ed-context-restart-startup-confirmation.md | 56 +++++++++++ ...ext-restart-synthesis-reliable-rollover.md | 69 ++++++++++++++ lane-handoff | 8 +- lane-start | 19 ++-- lanes-edit.sh | 43 ++++++++- .../proposal.md | 22 ++++- .../add-supervised-context-restart/tasks.md | 6 ++ tests/test_lane_helpers.sh | 38 ++++++++ 13 files changed, 528 insertions(+), 12 deletions(-) create mode 100644 ideation/README.md create mode 100644 ideation/brainstorm/supervised-context-restart-durable-intent.md create mode 100644 ideation/brainstorm/supervised-context-restart-overview.md create mode 100644 ideation/brainstorm/supervised-context-restart-pane-supervisor.md create mode 100644 ideation/brainstorm/supervised-context-restart-semantic-mechanical-boundary.md create mode 100644 ideation/brainstorm/supervised-context-restart-startup-confirmation.md create mode 100644 ideation/brainstorm/supervised-context-restart-synthesis-reliable-rollover.md diff --git a/ideation/README.md b/ideation/README.md new file mode 100644 index 00000000..6adc7d85 --- /dev/null +++ b/ideation/README.md @@ -0,0 +1,9 @@ +# openRepoTools Ideation + +This directory holds non-normative brainstorm material. Documents under +`brainstorm/` describe possibilities for later specification and implementation; +they do not amend the lane collision protocol or the shipped command contracts. + +## Brainstorm packets + +- [Supervised Context Restart](brainstorm/supervised-context-restart-overview.md) — a durable `/ctx` restart transaction with a surviving pane supervisor, explicit fresh-launch intent, and positive replacement readiness. diff --git a/ideation/brainstorm/supervised-context-restart-durable-intent.md b/ideation/brainstorm/supervised-context-restart-durable-intent.md new file mode 100644 index 00000000..db083649 --- /dev/null +++ b/ideation/brainstorm/supervised-context-restart-durable-intent.md @@ -0,0 +1,65 @@ +# Durable Context-Restart Intent — Brainstorm + +Status: brainstorm +Kind: architecture +Summary: Persist the exact fresh-session launch intent before `/ctx` replaces its current process so a failed launch remains diagnosable and idempotently retryable. +Topics: supervised-context-restart, durable-restart-intent, ctx, crash-consistent-lifecycle +Repository context: openRepoTools lane handoff, lifecycle state, and fresh-session launch behavior +Captured: 2026-09-15 + +## Possible feats + +- **Restart intent record** — Store the lane, directory, profile, agent, handoff, generation, operation, and fresh-session requirement as one guarded launch request. +- **Idempotent context restart** — Retry the same unfinished launch without resuming the old transcript or minting competing operations. + +## Focus + +This document isolates the information that must survive between completing a semantic handoff and successfully starting its replacement session. Today `LANE_START_FRESH=1` exists only in the command passed to `tmux respawn-pane`; when that command exits, the durable lane records retain a pause but not the exact launch that was intended. + +## Proposed model + +After the handoff operation reaches its preservation commit point, write an atomic restart-intent sidecar under the lane's local control root: + +```yaml +schema: 1 +lane: openRepoTools-3 +generation: 42 +operation_id: +state: pending +agent: claude +profile: team-01l +directory: /workspace/projects/openRepoTools +handoff: handoffs/openRepoTools/session-handoff-2026-09-15-lane-openRepoTools-3.md +mode: fresh-from-handoff +old_transcript: +requested_at: +``` + +The record is written before process replacement and compare-and-swap fenced by lane generation and operation ID. Launch attempts update bounded status such as `pending`, `starting`, `ready`, or `failed`, plus diagnostic details. The intended mode is data, not a one-use environment variable. + +A retry consumes the same pending operation. It does not silently mint a second generation, resume the old transcript, or reinterpret a normal lane launch as a context clear. + +## Interfaces and boundaries + +The semantic handoff supplies the handoff path and old-session facts. The crash-consistent lifecycle supplies generation and operation ownership. The restart supervisor consumes the intent and records launch outcome. + +The intent does not prove that a replacement is running. It records what must be attempted and preserves failure evidence. Positive startup confirmation remains a separate concern. + +The local sidecar is sufficient for same-workstation pane replacement. Cross-workstation continuation still depends on the workspace repository and estate records; whether restart intent itself should be replicated remains open. + +## Alternatives and tensions + +- An environment variable is simple but disappears with the failed process and cannot support safe retry. +- Encoding launch intent only in prose inside the handoff is human-readable but makes exact, fenced consumption difficult. +- Reconstructing intent from the latest `PAUSED` event risks confusing a profile switch, ordinary handoff, and context clear. +- Retaining failed intents improves diagnosis but requires explicit cleanup rules after success, cancellation, or lane retirement. + +## Open questions + +- Should the intent use a distinct `STARTING` lifecycle state or remain a child record beneath `SWAPPED`? +- Which failure details are safe and useful to persist without capturing credentials or command-line secrets? +- Can an operator cancel a pending restart while keeping the lane cleanly `SWAPPED`? + +## Relationships + +The [Pane-Resident Restart Supervisor](supervised-context-restart-pane-supervisor.md) consumes this record, and [Positive Replacement Startup Confirmation](supervised-context-restart-startup-confirmation.md) decides when it is complete. Generation ownership comes from [Crash-Consistent Lane Lifecycle](lane-worktree-recovery-state-crash-consistent-lifecycle.md). diff --git a/ideation/brainstorm/supervised-context-restart-overview.md b/ideation/brainstorm/supervised-context-restart-overview.md new file mode 100644 index 00000000..0f35e24e --- /dev/null +++ b/ideation/brainstorm/supervised-context-restart-overview.md @@ -0,0 +1,94 @@ +# Supervised Context Restart Overview — Brainstorm + +Status: brainstorm +Kind: reference +Summary: Make `/ctx` a durable, generation-fenced restart transaction whose replacement launcher survives failure and marks the lane running only after confirmation. +Topics: supervised-context-restart, ctx, semantic-handoff, crash-consistent-lifecycle, automatic-context-rollover +Repository context: openRepoTools with an integration boundary at the workBenches-owned pclaude launcher +Captured: 2026-09-15 + +## Possible feats + +- **Supervised `/ctx`** — Preserve the semantic handoff, replace the old context, start a fresh session automatically, and retain a visible recovery surface on every launch failure. +- **Safe automatic context execution** — Supply the confirmed restart primitive required by threshold-triggered rollover. + +## Motivation + +`/ctx` is intended to clear context and immediately continue from a handoff without another operator command. The current implementation successfully preserves the handoff before replacing the pane, but it passes `lane ` or `pclaude` directly to `tmux respawn-pane` and treats tmux's acceptance as success. The old Claude is then gone before anyone knows whether the replacement launched. + +One observed run wrote its handoff and `PAUSED` record, killed the old process, and left no replacement session. The design has no surviving process to report a launcher error, and its fresh-session intent exists only in the lost command environment. The human-facing `lane` dispatcher can also legally choose an attach/select path in which nothing is launched, making it unsuitable as an internal restart primitive. + +## Goals + +- Preserve the one-command `/ctx` experience when all components succeed. +- Keep the old session alive until semantic preservation and exclusive lifecycle ownership are complete. +- Preserve exact fresh-session launch intent across child failure. +- Leave an observable process and safe retry path whenever replacement fails. +- Mark the lane running only after the intended replacement is positively identified and alive. +- Give manual and automatic context rollover one authoritative backend. + +## Non-goals + +- Replacing semantic handoff generation with a shell-only summary. +- Making the human-facing `lane` dispatcher a narrower internal API. +- Reconstructing work that was never written to a surviving worktree or handoff. +- Adding a continuously polling system daemon merely to supervise context restart. +- Changing profile rotation into lane identity. + +## What the system delivers + +The proposed system separates semantic preparation from deterministic mechanics. Claude captures reasoning and writer state, then calls one guarded backend. That backend acquires generation ownership, performs mandatory writes, and creates a durable restart intent. Tmux starts a pane-resident supervisor, which directly launches the exact fresh session as a child and remains visible on failure. A readiness check finally moves the lane to `RUNNING`. + +For operators, success still looks like typing `/ctx` once. Failure changes substantially: the pane remains, explains the failed stage, and offers an idempotent retry rather than silently exiting. + +## System model + +```text +RUNNING + | semantic handoff + exclusive compare-and-swap + v +SWAPPING + | writer inventory + handoff + protocol records + v +SWAPPED + durable restart intent + | tmux starts surviving supervisor + v +STARTING (or fenced attempt beneath SWAPPED) + | replacement identity and liveness confirmed + v +RUNNING + +Any pre-commit failure: old session remains alive. +Any launch failure: supervisor and restart intent remain available. +``` + +## Cluster map + +- [Synthesis: Reliable Context Rollover](supervised-context-restart-synthesis-reliable-rollover.md) — joins semantic authority, durable intent, process supervision, and readiness into one transaction. + +## How it fits + +The proposal refines the replacement edge of [Crash-Consistent Lane Worktree Recovery](lane-worktree-recovery-state-overview.md). That work owns lane generations, worktree inventory, and interrupted-swap recovery. Supervised context restart owns the narrower interval from a completed handoff to a confirmed fresh process. + +It also becomes a prerequisite for [Automatic Context Rollover](automatic-context-rollover-overview.md). The threshold controller may request the semantic workflow, but it should report completion only after supervised startup confirmation. + +The pclaude launcher remains responsible for profile/account selection. Whether the supervisor belongs in openRepoTools or workBenches is an ownership decision; either way, the internal launch surface must be narrower than the operator-facing `lane` command. + +## Key decisions and open questions + +Load-bearing proposed decisions are to fail closed when transition ownership cannot be acquired, persist fresh launch semantics as data, never call `lane` from the internal restart path, and retain a pane process through launch failure. + +The main unresolved decisions are whether to add a first-class `STARTING` state, where the supervisor is owned, how readiness is confirmed without violating the generic SessionStart hook contract, and whether restart-only supervision should later expand to every lane launch. + +## Document map + +### Synthesis + +- [Reliable Context Rollover](supervised-context-restart-synthesis-reliable-rollover.md) + +### Atomic documents + +- [Semantic and Mechanical Restart Boundary](supervised-context-restart-semantic-mechanical-boundary.md) +- [Durable Context-Restart Intent](supervised-context-restart-durable-intent.md) +- [Pane-Resident Restart Supervisor](supervised-context-restart-pane-supervisor.md) +- [Positive Replacement Startup Confirmation](supervised-context-restart-startup-confirmation.md) diff --git a/ideation/brainstorm/supervised-context-restart-pane-supervisor.md b/ideation/brainstorm/supervised-context-restart-pane-supervisor.md new file mode 100644 index 00000000..5357f788 --- /dev/null +++ b/ideation/brainstorm/supervised-context-restart-pane-supervisor.md @@ -0,0 +1,60 @@ +# Pane-Resident Restart Supervisor — Brainstorm + +Status: brainstorm +Kind: architecture +Summary: Respawn `/ctx` into a small supervisor that launches the replacement as a child and remains visible with recovery instructions if that child cannot start. +Topics: supervised-context-restart, pane-supervisor, tmux, ctx +Repository context: openRepoTools pane replacement integrated with the installed pclaude launcher +Captured: 2026-09-15 + +## Possible feats + +- **Surviving restart controller** — Keep one process alive in the lane pane while the old Claude exits and a fresh Claude is launched. +- **Visible launch failure** — Leave the exact launcher error and an idempotent retry command on screen instead of closing the pane. + +## Focus + +This document isolates which process owns the dangerous interval after `/ctx` kills the old Claude but before a fresh Claude is known to be running. `tmux respawn-pane` currently starts `lane ` or `pclaude` directly and treats tmux's acceptance as success. If the command returns or takes a no-launch route, the pane disappears with no surviving controller. + +## Proposed model + +Respawn the pane into a dedicated command such as: + +```text +lane-restart-supervisor --lane --operation +``` + +The supervisor becomes the pane's root process. It reads and fences the durable restart intent, invokes a private launch primitive as a child, and waits for positive startup evidence. It never calls the human-facing `lane` dispatcher, whose job includes attach/select behavior and therefore permits successful outcomes in which nothing is launched. + +If preparation or launch fails, the supervisor remains in the pane and prints: + +- the lane, generation, and operation it attempted; +- the exact failed stage and exit status; +- the state and location of the handoff and restart intent; +- a safe retry command that consumes the same operation; +- a cancellation or inspection command where supported. + +On success, the interactive child owns the terminal while the supervisor remains its parent. The implementation may either keep supervising for the life of the child or exit only after a tmux-safe handoff that preserves a failure surface for future restarts. + +## Interfaces and boundaries + +The supervisor owns process lifetime, diagnostics, retry, and launch-attempt status. It does not generate semantic handoff content, choose a lane from a picker, modify worktrees, or infer identity from the pane title. + +The dedicated launch primitive owns exact directory/profile/agent argument construction. Existing `pclaude` may remain the account launcher, but it runs beneath the supervisor rather than replacing the only process capable of reporting failure. + +## Alternatives and tensions + +- A permanently resident supervisor around every Claude launch gives the cleanest lifecycle but changes the installed launcher architecture more broadly. +- A restart-only supervisor is smaller and fixes `/ctx`, although ordinary lane launches retain their existing process shape. +- A detached external daemon could supervise every lane but adds discovery, authorization, and service-lifecycle complexity that the pane itself already solves. +- A diagnostic shell on failure is convenient but must not accidentally imply the lane is `RUNNING` or permit an unfenced second launch. + +## Open questions + +- Should the supervisor live in openRepoTools or in the workBenches project that owns `pclaude`? +- Should the supervisor remain for the full Claude lifetime or only through confirmed startup? +- How should terminal signals and exit status propagate between tmux, the supervisor, `pclaude`, and Claude? + +## Relationships + +The supervisor consumes [Durable Context-Restart Intent](supervised-context-restart-durable-intent.md) and waits for [Positive Replacement Startup Confirmation](supervised-context-restart-startup-confirmation.md). Its launch request is produced only after the [Semantic and Mechanical Restart Boundary](supervised-context-restart-semantic-mechanical-boundary.md) completes preservation. diff --git a/ideation/brainstorm/supervised-context-restart-semantic-mechanical-boundary.md b/ideation/brainstorm/supervised-context-restart-semantic-mechanical-boundary.md new file mode 100644 index 00000000..a796df7b --- /dev/null +++ b/ideation/brainstorm/supervised-context-restart-semantic-mechanical-boundary.md @@ -0,0 +1,51 @@ +# Semantic and Mechanical Restart Boundary — Brainstorm + +Status: brainstorm +Kind: architecture +Summary: Let Claude produce semantic handoff content while one deterministic backend exclusively owns lifecycle writes, process replacement, and retry behavior. +Topics: supervised-context-restart, semantic-mechanical-boundary, semantic-handoff, ctx +Repository context: openRepoTools `/ctx` command, handoff skill, lane-handoff backend, and lane launch commands +Captured: 2026-09-15 + +## Possible feats + +- **Single restart transaction API** — Accept model-produced handoff material and execute every mechanical state transition through one testable backend. +- **Thin `/ctx` command** — Reduce the model instruction to semantic capture followed by one guarded backend invocation. + +## Focus + +This document isolates the authority boundary between reasoning that requires the current model and mechanics that must behave identically every time. The current handoff skill includes extensive shell recipes, including direct `tmux respawn-pane` instructions, while `lane-handoff` contains overlapping mechanics. That duplication allows a session to reconstruct the destructive tail instead of calling one authoritative transaction. + +## Proposed model + +Split `/ctx` into two explicit phases: + +1. **Semantic preparation:** Claude summarizes current reasoning, discovers and polls writers, and produces a structured handoff payload plus human-readable narrative. +2. **Mechanical commit and restart:** one backend validates identity, acquires the lane generation, snapshots trees, writes the handoff and protocol records, commits the transition, persists restart intent, and delegates replacement to the supervisor. + +The backend accepts an operation-scoped input rather than asking the model to reproduce shell commands. It either completes the guarded pre-kill transaction or refuses with the old session intact. Once the restart commit point is crossed, the supervisor owns all subsequent process behavior. + +The human-facing `lane` command remains responsible for lookup, attach, and operator-selected launch. It is not an internal primitive for a restart transaction. A private launch command has narrower semantics: launch this exact pending operation or refuse. + +## Interfaces and boundaries + +The model owns meaning, unresolved intent, and communication with live writers. It does not own compare-and-swap, tmux quoting, launcher selection, or success determination. + +The backend owns state and process orchestration. It must not invent semantic handoff content when the old session is still available; late recovery after a crash remains a distinct degraded path. + +## Alternatives and tensions + +- Keeping all steps in a skill maximizes model flexibility but weakens reproducibility at the most destructive boundary. +- Making the entire handoff shell-only is deterministic but loses current reasoning and agent communication. +- A structured intermediate payload adds schema/versioning work but makes replay, validation, and tests substantially clearer. +- Direct in-process `/clear` could avoid process replacement if the harness offered a supported clear-and-seed operation; terminal keystroke injection is too race-prone to emulate that interface. + +## Open questions + +- What is the smallest structured payload that preserves semantic value without coupling the model to backend state schema? +- Does the backend write the final Markdown handoff, or splice a validated model-authored section into it? +- How should a partially prepared semantic handoff be exposed if lifecycle acquisition subsequently refuses? + +## Relationships + +This extends [Semantic Handoff Before Restart](automatic-context-rollover-semantic-handoff.md) by defining its implementation boundary. It produces [Durable Context-Restart Intent](supervised-context-restart-durable-intent.md) and hands process control to the [Pane-Resident Restart Supervisor](supervised-context-restart-pane-supervisor.md). diff --git a/ideation/brainstorm/supervised-context-restart-startup-confirmation.md b/ideation/brainstorm/supervised-context-restart-startup-confirmation.md new file mode 100644 index 00000000..11551223 --- /dev/null +++ b/ideation/brainstorm/supervised-context-restart-startup-confirmation.md @@ -0,0 +1,56 @@ +# Positive Replacement Startup Confirmation — Brainstorm + +Status: brainstorm +Kind: architecture +Summary: Keep a lane non-running until evidence confirms that the intended replacement process, transcript, directory, and binding are alive. +Topics: supervised-context-restart, startup-confirmation, session-binding, crash-consistent-lifecycle +Repository context: openRepoTools lane-start, SessionStart reporting, and lifecycle transition behavior +Captured: 2026-09-15 + +## Possible feats + +- **Confirmed `RUNNING` transition** — Move from a completed handoff to `RUNNING` only after the intended replacement proves its identity and liveness. +- **Launch-time recovery classification** — Distinguish a pending restart, an active startup attempt, an immediate child failure, and a ready replacement. + +## Focus + +This document isolates what evidence is required before the lane may claim `RUNNING`. Current `lane-start` writes `LIVE` before its final `exec`, so a launcher or Claude failure can leave a positive state for a process that never existed. + +## Proposed model + +Keep the lifecycle at `SWAPPED`, or move it to a fenced `STARTING`, while the supervisor launches the child. Confirm readiness only when evidence agrees on: + +- canonical lane name; +- generation and restart operation; +- new transcript/session identifier; +- agent and selected profile; +- expected coordinator directory; +- exclusive pane/session binding; +- a live replacement process associated with that binding. + +The confirming mechanism must be local, bounded, and idempotent. If the generic SessionStart hook must remain read-only and always exit zero, confirmation can come from a separate launcher-owned acknowledgment or from a supervisor check combining child liveness with newly created transcript and binding evidence. It must not be inferred merely from `exec` being attempted. + +Only confirmation may write `RUNNING` and mark the restart intent `ready`. A timeout or child exit leaves the intent retryable and the lifecycle non-running; it does not fabricate `RESUMED` success. + +## Interfaces and boundaries + +`lane-start` may prepare and validate a launch, but pre-exec validation is not runtime readiness. The supervisor observes the child. The lane-state writer owns the fenced transition. SessionStart may report identity without becoming a blocking networked hook. + +This confirmation does not certify that all writers were relaunched or that the model correctly understood the handoff. Those are post-start semantic responsibilities. + +## Alternatives and tensions + +- A SessionStart write is semantically direct but conflicts with the current rule that the generic hook is read-only, network-free, and non-blocking. +- A short process-liveness grace period is easy but can mistake a hung or misbound client for a ready session. +- Transcript-file creation plus process liveness is stronger, but profile storage paths and timing must be handled without races. +- Letting the new model stamp itself `RESUMED` proves semantic execution but is too late and too nondeterministic to be the only process-level readiness signal. + +## Open questions + +- What is the minimum sufficient local readiness evidence on Claude, Codex, and future agents? +- How long may `STARTING` remain before the supervisor reports failure without killing a possibly slow but valid launch? +- Which component writes the append-only `RESUMED` event, and how is it ordered against local `RUNNING`? + +## Relationships + +This closes the operation recorded by [Durable Context-Restart Intent](supervised-context-restart-durable-intent.md) and observed by the [Pane-Resident Restart Supervisor](supervised-context-restart-pane-supervisor.md). It refines the replacement edge in [Crash-Consistent Lane Lifecycle](lane-worktree-recovery-state-crash-consistent-lifecycle.md). diff --git a/ideation/brainstorm/supervised-context-restart-synthesis-reliable-rollover.md b/ideation/brainstorm/supervised-context-restart-synthesis-reliable-rollover.md new file mode 100644 index 00000000..f7892b7f --- /dev/null +++ b/ideation/brainstorm/supervised-context-restart-synthesis-reliable-rollover.md @@ -0,0 +1,69 @@ +# Synthesis: Reliable Context Rollover — Brainstorm + +Status: brainstorm +Kind: architecture +Summary: A strict semantic boundary, durable launch intent, surviving supervisor, and positive readiness check turn `/ctx` into an observable and retryable transaction. +Topics: supervised-context-restart, reliable-rollover, durable-restart-intent, pane-supervisor, startup-confirmation, synthesis +Repository context: openRepoTools context clearing from handoff preparation through confirmed replacement +Captured: 2026-09-15 + +## Possible feats + +- **Failure-visible `/ctx`** — Clear context automatically while ensuring every failed replacement leaves a pane, durable intent, and safe retry path. +- **Readiness-gated automatic rollover** — Give the 65% controller a completion condition stronger than tmux accepting a command. + +## Members and their joints + +Atomic members: [Semantic and Mechanical Restart Boundary](supervised-context-restart-semantic-mechanical-boundary.md), [Durable Context-Restart Intent](supervised-context-restart-durable-intent.md), [Pane-Resident Restart Supervisor](supervised-context-restart-pane-supervisor.md), and [Positive Replacement Startup Confirmation](supervised-context-restart-startup-confirmation.md). + +```text +Claude semantic preparation + | + v +guarded SWAPPING -- mandatory writes --> SWAPPED + restart intent + | + v + pane supervisor + | | + child ready child fails + | | + v v + RUNNING visible retry +``` + +### Authority and commit points + +The [semantic boundary](supervised-context-restart-semantic-mechanical-boundary.md) prevents model instructions from becoming an alternate mechanical implementation. The backend must own the lane generation before preservation begins. Failure to acquire ownership is a refusal, not a warning followed by process replacement. + +`SWAPPED` is the preservation commit point, not proof of a running replacement. The [durable intent](supervised-context-restart-durable-intent.md) records exactly what must happen next and prevents a failed one-use environment variable from erasing fresh-session semantics. + +### Process lifetime and feedback + +The [supervisor](supervised-context-restart-pane-supervisor.md) is the first process started after the old session is replaced. Because it launches the agent as a child, launcher refusal or immediate exit cannot close the only diagnostic surface. + +The [startup confirmation](supervised-context-restart-startup-confirmation.md) separates "tmux accepted a command" from "the intended new session owns the lane." That distinction controls when `RUNNING` and completed intent may be written. + +### Failure and recovery flow + +Failures before `SWAPPED` preserve the old session wherever it is still alive. Failures after `SWAPPED` preserve the completed handoff, restart intent, and supervisor. Retrying uses the same fenced operation unless an explicit recovery act proves it abandoned and advances the generation. + +## Emergent behavior + +Together these mechanisms make a destructive context reset recoverable at every boundary. The user can type one command in the successful case, while launch failures remain visible and machine-classifiable. Automatic rollover can wait for true completion rather than equating process replacement with success. + +## Tensions to hold + +- The SessionStart hook's read-only contract conflicts with using it as the writer of `RUNNING`; another bounded confirmation path may be required. +- A restart-only supervisor is easier to ship, while a permanent lane supervisor offers a cleaner general process model. +- Local restart intent enables immediate recovery but does not by itself provide cross-workstation continuation. +- Strict fail-closed generation handling can require explicit operator recovery for stale `SWAPPING` operations. + +## Recombination opportunities + +This cluster can become the execution prerequisite for [Automatic Context Rollover](automatic-context-rollover-overview.md). Its durable operation and error states can also strengthen ordinary profile swaps without making profile part of lane identity. + +## Open questions + +- Is a distinct persisted `STARTING` state needed, or is `SWAPPED` plus attempt status sufficient? +- Which repository owns the supervisor when `pclaude` is supplied by workBenches but lane state is supplied by openRepoTools? +- What positive evidence is portable enough for every supported agent? diff --git a/lane-handoff b/lane-handoff index 54cab36d..4ee5ac32 100755 --- a/lane-handoff +++ b/lane-handoff @@ -894,7 +894,13 @@ if [ "$do_status" = 1 ]; then | awk -F'\t' '$1 == "state" { print $2; exit }')" || rst_lrc=$? case "$rst_lrc" in 0) say "lifecycle ${rst_ls:-none} (lanes-edit.sh lane-state $lane)" ;; - *) say "lifecycle not recorded here — this lanes-edit.sh has no \`lane-state\` (opensoft/openRepoTools#91 has not landed on this workstation)" ;; + # 8 IS THIS LANE HAVING NO SNAPSHOT and 2 IS THE HELPER HAVING NO WORD FOR + # ONE, and telling the two apart matters the day #91 lands: a reader shown + # "this lanes-edit.sh has no lane-state" about a helper that has one would + # go looking for an installation problem that is not there. + 8) say "lifecycle no snapshot for this lane yet (\`$LANES_EDIT lane-state $lane\` answers 8)" ;; + 2) say "lifecycle not recorded here — this lanes-edit.sh has no \`lane-state\` (opensoft/openRepoTools#91 has not landed on this workstation)" ;; + *) say "lifecycle could not be read (\`$LANES_EDIT lane-state $lane\` exited $rst_lrc), which is NOT 'there is none' (Amendment 7(d))" ;; esac case "$RI_STATE" in failed) say ""; say "RETRY: $(quoted "$SELF_ABS") --supervise --lane $lane --operation ${RI_OP:-}" ;; diff --git a/lane-start b/lane-start index 76440d9b..ce66acfa 100755 --- a/lane-start +++ b/lane-start @@ -2408,12 +2408,19 @@ case "$ri_rc" in ri_hf="$(printf '%s\n' "$ri_out" | awk -F'\t' '$1 == "handoff" { print $2; exit }')" ri_dig="$(printf '%s\n' "$ri_out" | awk -F'\t' '$1 == "digest" { print $2; exit }')" ;; # 8 IS AN ANSWER — this lane has no restart in flight, which is every - # ordinary launch — and 1 is a lane with no control root, which is the - # pre-cutover lane and not a failure either. Anything else is a read that - # FAILED, and a read that failed is never an answer (Amendment 7(d)): it is - # said, and the other two sources still decide, because refusing every launch - # on a helper that could not answer is not a safety property. - 8|1) : ;; + # ordinary launch — 1 is a lane with no control root, which is the + # pre-cutover lane and not a failure either, and 2 IS A HELPER THAT PREDATES + # THIS READ: the estate's fence is one sentence and `lane-handoff` spells it + # out at its own register reads — *"`0` an answer, `8` NO ANSWER and `2` a + # helper predating the read — both of those fall to the next rung"*. An + # installed `lanes-edit.sh` from before openRepoTools#94 answers 2 from its + # unknown-subcommand arm, and a note printed on EVERY launch of EVERY lane on + # such a workstation is noise about a state that is simply the old one. + # Anything else is a read that FAILED, and a read that failed is never an + # answer (Amendment 7(d)): it is said, and the other two sources still + # decide, because refusing every launch on a helper that could not answer is + # not a safety property. + 8|1|2) : ;; *) note "$LANES_EDIT restart-intent $LANE failed (exit $ri_rc) — this launch reads no restart intent, so a /ctx in flight for this lane is NOT visible here. If a context clear was just taken, run it again once the read works." ;; esac # AN `--operation` NAMES THE INTENT IT BELONGS TO, AND ONLY THAT ONE. diff --git a/lanes-edit.sh b/lanes-edit.sh index e43aefc7..fc3a62d0 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -9520,9 +9520,35 @@ EOF sri_pane="$(sri_keep pane "$sri_pane")" sri_win="$(sri_keep window "$sri_win")" sri_hf="$(sri_keep handoff "$sri_hf")" - sri_old="$(sri_keep old_transcript "$sri_old")" - sri_new="$(sri_keep new_transcript "$sri_new")" - sri_reason="$(sri_keep reason "$sri_reason")" + # …EXCEPT WHERE THE OPERATION ITSELF HAS CHANGED, and then five of these + # fields are not this act's facts at all. `old_transcript`, + # `new_transcript`, `attempt`, `reason` and `created` are scoped to ONE + # operation: the transcript that restart paused, the transcript its launch + # was preparing, how many times THAT launch was tried, why THAT one failed, + # and when THAT intent was made. Carrying them into the next operation is + # how a brand-new `/ctx` comes out reading `attempt 3` and `reason "the + # launcher exited 127"` from a restart that is over — and two of them are + # worse than cosmetic, because the supervisor's readiness predicate is + # built on exactly this pair: + # + # * an inherited `new_transcript` is a uuid THIS launch will never mint, + # so readiness can only run to its deadline and report indeterminate; + # * an inherited `old_transcript` is the wrong uuid to be distinct FROM, + # and that check is the one that catches openRepoTools#94 itself — a + # replacement that came up resuming the transcript this `/ctx` paused. + # Comparing it against some earlier restart's transcript would let the + # measured defect through the very predicate written to catch it. + # + # A caller that names one of the five still wins; this only decides what an + # UNNAMED field falls back to, and `none` is an answer where another + # operation's value is a lie. + sri_newop=0 + [ "$sri_op" = "${sri_nowo:-none}" ] || sri_newop=1 + if [ "$sri_newop" != 1 ]; then + sri_old="$(sri_keep old_transcript "$sri_old")" + sri_new="$(sri_keep new_transcript "$sri_new")" + sri_reason="$(sri_keep reason "$sri_reason")" + fi # THE DIGEST IS COMPUTED HERE WHERE THE CALLER NAMED A HANDOFF AND NO # DIGEST, so that there is ONE implementation of *what this handoff was # when the intent was made* and a caller cannot accidentally record a @@ -9544,10 +9570,19 @@ EOF : elif [ "$sri_bump" = 1 ]; then sri_att=$((sri_nowa + 1)) + elif [ "$sri_newop" = 1 ]; then + sri_att=0 else sri_att="$sri_nowa" fi - sri_created="$(lane_sidecar_field "$sri_f" created 2>/dev/null || :)" + # AND `created` IS THIS OPERATION'S OWN, for the same reason: it is read as + # *when this restart was taken*, and a new operation inheriting the last + # one's timestamp says a `/ctx` happened at a moment it did not. + if [ "$sri_newop" = 1 ]; then + sri_created="" + else + sri_created="$(lane_sidecar_field "$sri_f" created 2>/dev/null || :)" + fi if lane_restart_put "$sri_root" "$lane" "$sri_state" "$sri_gen" "$sri_op" \ "$sri_mode" "$sri_agent" "$sri_prof" "$sri_dir" "$sri_pane" "$sri_win" \ "$sri_hf" "$sri_dig" "$sri_old" "$sri_new" "$sri_att" "$sri_reason" \ diff --git a/openspec/changes/add-supervised-context-restart/proposal.md b/openspec/changes/add-supervised-context-restart/proposal.md index b7bbbf73..eeeb066a 100644 --- a/openspec/changes/add-supervised-context-restart/proposal.md +++ b/openspec/changes/add-supervised-context-restart/proposal.md @@ -32,10 +32,30 @@ None. This repository currently has no baseline OpenSpec capabilities; relations ## Dependencies and cross-links - **`opensoft/openRepoTools#94`** — the measured failure this change answers. Its root cause is quoted above and recorded as a comment on the issue. -- **`opensoft/openRepoTools#91`** (`add-crash-consistent-lane-worktree-recovery`) — the lane control root, the `RUNNING → SWAPPING → SWAPPED → CLOSED` lifecycle and the generation/operation fence the restart intent is meant to sit beside. That change is in flight on `feat/crash-consistent-lane-worktree-recovery` and is **not on `main`**, so the restart intent is implemented as a sibling sidecar under the *same* control root, resolved by a `lane_control_root` written to be byte-identical to #91's, and the `SWAPPED → RUNNING` finalization is made through #91's `set-lane-state` **where the workstation has it** and skipped in silence where it does not. Nothing here blocks on #91 landing, and nothing here re-implements it. +- **`opensoft/openRepoTools#91`** (`add-crash-consistent-lane-worktree-recovery`) — the lane control root, the `RUNNING → SWAPPING → SWAPPED → CLOSED` lifecycle and the generation/operation fence the restart intent is meant to sit beside. That change is in flight as **pull request `opensoft/openRepoTools#97`** on `feat/crash-consistent-lane-worktree-recovery` and is **not on `main`**, so the restart intent is implemented as a sibling sidecar under the *same* control root, resolved by a `lane_control_root` written to be byte-identical to #91's, and the `SWAPPED → RUNNING` finalization is made through #91's `set-lane-state` **where the workstation has it** and skipped in silence where it does not. Nothing here blocks on #91 landing, and nothing here re-implements it. - **`add-automatic-context-rollover`** — consumes this change's readiness-confirmed completion rather than raw `respawn-pane` acceptance. - **Lane collision protocol** — Amendment 17(f) (the record before the kill), Amendment 17 Addendum 1 (h)–(k) (the kind, the writer count, one worktree one writer), Amendment 18(h) (one binding), Amendment 18 Addendum 2 (i-8) (the respawn's two doors, and *"no word is kept on `PATH` for it alone"*), Amendment 8(f) (ending somebody's process is not a boundary script's act), Amendment 8(a) step 4 / `R-A11-5` (a swap is never left unwritten) and `R-A8-1` (the `SessionStart` hook never writes, never touches the network, always exits 0). +## Review verdicts — the implementation against the documents it came from + +Brett Heap's brainstorm packet (`ideation/brainstorm/supervised-context-restart-*.md`, captured 2026-09-15) is committed with this change, as `opensoft/openRepoTools#97` committed its own. Each document was read against what shipped, and where the two differ the difference is a numbered decision in `design.md` rather than a silence. + +| document | verdict | +|---|---| +| `ideation/README.md` | **matches**, carried with the one bullet this branch's files answer to. Brett's working copy lists three packets; the other two are committed by their own changes (#97 carries the crash-recovery packet), and a link to a document no branch has is worse than a list that grows one packet at a time. The merge with #97's copy is a union of the bullets. | +| `…-overview.md` | **matches.** Every goal and every non-goal is honoured, and two of the non-goals are honoured by refusal rather than by omission: *"Making the human-facing `lane` dispatcher a narrower internal API"* — `lane` is not touched at all, the restart simply stops calling it (decision 11); and *"Adding a continuously polling system daemon"* — the supervisor exists for ONE operation and ends with its child. Its system model's `STARTING` box is answered as the alternative the document itself offers: no fifth lifecycle word, a fenced attempt beneath `SWAPPED` (decision 3). Its open question *"whether the supervisor belongs in openRepoTools or workBenches"* is answered openRepoTools, and narrowed by decision 10 to not being a new word anywhere. | +| `…-durable-intent.md` | **matches, with its field spellings changed and six fields added.** Its YAML sample writes `operation_id`, `directory` and `requested_at`; the record writes `operation`, `dir` and `created`, because it shares its grammar, its reader, its atomic replace and its three-rung control root with #91's `lane-state.yaml` byte for byte (decision 14), and a second spelling of one estate's fields is a second scheme. Six fields are added, each because a requirement of this change cannot be checked without it: `digest` (*a changed handoff blocks launch*), `pane` and `window` (the supervisor must prove it stands in the pane the `/ctx` respawned), `new_transcript` (readiness has nothing to confirm against otherwise), `attempt`, and `workstation`. Its open question *"which failure details are safe to persist without capturing credentials"* is answered by construction — there is no credential field and the profile is a manifest key, asserted by a test. Its *"can an operator cancel a pending restart"* is **not** answered: task 6.5, open. | +| `…-pane-supervisor.md` | **deviates, recorded as decision 10.** It proposes a new command, `lane-restart-supervisor --lane --operation `. Amendment 18 Addendum 2 (i-8) took `restart` off `PATH` on the ground that *"no word is kept on `PATH` for it alone"*, so the supervisor is `lane-handoff --supervise --lane --operation ` — everything the document requires (a separate process, the thing tmux starts, resolved by absolute path, the launcher as its child, never `lane`, never a picker) and nothing added to a person's `PATH`. Its *"a cancellation or inspection command where supported"* is half-delivered: retry, read and by-hand are printed; cancel is task 6.5. Its open question *"remain for the full Claude lifetime or only through confirmed startup"* is answered — the full lifetime, and a child that exits AFTER readiness is reported and never relaunched. | +| `…-semantic-mechanical-boundary.md` | **partly delivered, and the missing half is named.** Its phase 2 lands whole: `/ctx`'s skill step is one command and the executable `tmux respawn-pane` recipe is gone from it, which is the document's own *"allows a session to reconstruct the destructive tail instead of calling one authoritative transaction"*. Its phase 1 **structured staged payload does not** — tasks 3.1–3.3, open, listed as deferred in `tasks.md` §0. The model's semantic work still reaches the backend as the handoff file it wrote and the writers it polled, not as an operation-scoped payload with binding checks. | +| `…-startup-confirmation.md` | **matches, with the evidence narrowed to what `main` can actually prove** (decision 15). It asks for nine facts; the predicate asks four — the child alive, exactly one live holder of the lane, that holder's transcript the one `lane-start` recorded it was preparing (or at minimum NOT the one the `/ctx` paused), and the pane. Agent, profile and directory are fenced *earlier*, where a conflict refuses the launch before it is made, rather than re-checked after. Its *"Only confirmation may write `RUNNING`"* is scoped by decision 12 — it is #91's lifecycle snapshot that waits, never Amendment 8(d)'s append-only `RESUMED` line, whose removal would break three other readers — and the residual that follows on a `main` with no snapshot is stated there rather than hidden. Its *"how long may `STARTING` remain"* is answered: 60 seconds, then INDETERMINATE, which kills nothing. | +| `…-synthesis-reliable-rollover.md` | **matches**, with one collision resolved: its *"Failures before `SWAPPED` preserve the old session wherever it is still alive"* meets #91's landed *"THE LIFECYCLE NEVER REFUSES THE SWAP"*. Decision 13 draws the line Amendment 17(f) already draws — the RECORD is never refused, the KILL always is when the record is incomplete — so both are kept and neither is overruled. | +| `proposal.md` | **matches**, extended with the measured cause and a dependencies/cross-links section. No line of the original was removed. | +| `design.md` | decisions 1–9 stand as written; **decisions 10–15 are added**, one per deviation. Two of the original decisions could not be taken as written — decision 5's new installed command, and decision 7's unscoped *"stop writing `LIVE`/`RUNNING` before `exec`"* — and each is answered in place, beside the text it qualifies, rather than quietly. | +| `specs/supervised-context-restart/spec.md` | four requirements **extended** (the intent as the authority, with #94's launcher boundary as the reason; the supervisor as a mode and not a `PATH` word; the pane invariant and the never-kill rule; the first prompt holding *with no fresh-session environment variable present at all*), and three scenarios added — one of them #94's own measured case. No requirement and no scenario was removed. | +| `tasks.md` | 29 of 46 ticked. §0 is added, naming what this slice landed and what it did not with a reason for each, and three tasks are **rewritten rather than ticked as written** (4.1, 4.6 and 5.1) and say so in their own lines. | + +**The one deviation from in-force protocol text**, and it is not treated as ratified by this change: Amendment 18 Addendum 2 (i-8) names two doors for the `/ctx` respawn — `lane `, *"or through the launcher directly"* — and this change respawns into neither, because what tmux now starts is the supervisor. The supervisor then takes the clause's SECOND door. The clause's stated ground (*no word on `PATH` for it alone*) is honoured exactly; its stated mechanism is not. Task 7.4 owes the amendment, and the divergence is named in `design.md` decisions 10 and 11, in `lane-handoff`'s own comment at the respawn, and in this change's pull request. + ## Impact The change affects `commands/ctx.md`, the handoff skill, `lane-handoff`, `lane-start`, `lanes-edit.sh`, lane lifecycle sidecars, tmux process management, and lane helper tests. It introduces an integration boundary with the installed `pclaude` launcher owned by workBenches: either the new supervisor invokes that launcher as a child, or coordinated work there supplies the equivalent non-`exec` supervision seam. The `add-crash-consistent-lane-worktree-recovery` change must expose fail-closed transition ownership, and `add-automatic-context-rollover` must depend on confirmed supervised restart rather than raw respawn acceptance. diff --git a/openspec/changes/add-supervised-context-restart/tasks.md b/openspec/changes/add-supervised-context-restart/tasks.md index 0cb4c124..7634108e 100644 --- a/openspec/changes/add-supervised-context-restart/tasks.md +++ b/openspec/changes/add-supervised-context-restart/tasks.md @@ -2,6 +2,12 @@ The first implementation pass landed the transaction, the supervisor, the exact fresh launch and the retry surface — the path `opensoft/openRepoTools#94` measured breaking — and deliberately did **not** land the prerequisite lifecycle work, the machine-readable staged payload, the crash-point matrix or the protocol amendment. Ticked boxes below are implemented AND covered by cases in `tests/test_lane_helpers.sh`; unticked ones are open, and the three that were rewritten rather than ticked-as-written say so in their own line. +**Found in the review pass, and fixed here rather than left for a later one:** + +- **A new operation inherited the last one's per-operation fields.** `set-restart-intent` keeps every field a transition does not name, which is what lets the supervisor write `starting` without blanking the digest. Five of the fields belong to ONE operation, though, and two of those are what the readiness predicate is built on: an inherited `new_transcript` is a uuid the new launch will never mint, so readiness could only run to its deadline; an inherited `old_transcript` is the wrong uuid to be *distinct from*, and being distinct from it is the check that catches `#94` itself. `old_transcript`, `new_transcript`, `attempt`, `reason` and `created` now fall back to nothing when the operation changes, and a caller that names one still wins. +- **`lane-start` nagged on every launch of every lane on a workstation whose `lanes-edit.sh` predates this change.** The estate's read fence is *"`0` an answer, `8` NO ANSWER and `2` a helper predating the read — both of those fall to the next rung"*; the restart-intent read treated `2` as a read that FAILED and printed a note. It is silent now, and only an unclassified code is said. +- **`--restart-status` told a reader that `lanes-edit.sh` has no `lane-state` when the lane merely has no snapshot.** The exits are told apart now (`8` the lane, `2` the helper, anything else a read that failed), which matters the day `#97` lands. + **Deferred, with the reason:** - **§1 entirely** — it consumes `add-crash-consistent-lane-worktree-recovery`'s lane lock, generation and operation, which are on `feat/crash-consistent-lane-worktree-recovery` and not on `main`. The restart intent is a sibling sidecar under the *same* control root (design decision 14) so nothing here blocks on that merge and nothing re-implements it; §1's fail-closed work belongs in that change's own pass. diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 56500af0..60c1ef14 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -9948,6 +9948,44 @@ run env LANES_LANE_STATE_ROOT= PROJECTS_ROOT=/nonexistent-projects-root \ is "…and there is nowhere to write one either" "$rc" 1 has "…naming the seam that gives it one" "$err" "LANES_LANE_STATE_ROOT" +# A NEW OPERATION INHERITS NOTHING OF THE LAST ONE'S. +# +# A TRANSITION KEEPS THE FIELDS IT DOES NOT NAME — that is what lets the +# supervisor write `starting` without blanking the handoff digest every later +# launch is fenced on. But five of the fields belong to ONE OPERATION and not +# to the lane, and a second `/ctx` that inherited them would read `attempt 3` +# and `reason "the launcher exited 127"` about a restart that is over. Two of +# them are worse than untidy, because the readiness predicate is built on the +# pair: an inherited `new_transcript` is a uuid the new launch will never mint, +# so readiness can only run to its deadline; an inherited `old_transcript` is +# the wrong uuid to be DISTINCT FROM, and being distinct from it is the check +# that catches openRepoTools#94 — a replacement that came up resuming the very +# transcript the /ctx paused. +run "$E" set-restart-intent repoSV-4 pending --expect none \ + --operation op-first --generation 1 --mode fresh-from-handoff \ + --agent claude --dir "$SV_DIR" --handoff "$SV_HANDOFF" --old-transcript "$SV_OLD" +is "a lane's first restart operation is written" "$rc" 0 +run "$E" set-restart-intent repoSV-4 failed --expect pending --expect-operation op-first \ + --bump-attempt --new-transcript "$SV_NEW" --reason "the launcher exited 127" +is "…it fails, with an attempt, an expected transcript and a bounded reason" "$rc" 0 +run "$E" set-restart-intent repoSV-4 pending --expect 'none|ready|failed' \ + --operation op-second --generation 2 --mode fresh-from-handoff \ + --agent claude --dir "$SV_DIR" --handoff "$SV_HANDOFF" --old-transcript "$SV_PAUSED" +is "a second /ctx over a FAILED restart mints a second operation" "$rc" 0 +run "$E" restart-intent repoSV-4 +has "…and its attempt count is its own" "$out" "$(printf 'attempt\t0')" +hasnt "…carrying no failure reason from the operation before it" "$out" "exited 127" +hasnt "…and not the transcript that operation's launch was preparing" "$out" "$SV_NEW" +has "…while the facts it NAMED are written" "$out" "$(printf 'old_transcript\t%s' "$SV_PAUSED")" +has "…and the ones it did not name, but which belong to the LANE, are kept" \ + "$out" "$(printf 'handoff\t%s' "$SV_HANDOFF")" +run "$E" set-restart-intent repoSV-4 pending --expect pending --operation op-third +is "a third operation, naming nothing but itself" "$rc" 0 +run "$E" restart-intent repoSV-4 +has "…records no transcript of the restart before it, because 'none' is an answer and another operation's uuid is a lie" \ + "$out" "$(printf 'old_transcript\tnone')" +has "…and still the lane's own handoff" "$out" "$(printf 'handoff\t%s' "$SV_HANDOFF")" + # ------------------------- 2. openRepoTools#94: the fresh launch, from the FILE SV_LANE_HANDOFF="$OPENREPOTOOLS_BIN_DIR/lane-handoff" From 40ec66219d268ae94b705f18df921f5ff78403de Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:48:37 +0000 Subject: [PATCH 03/25] WIP: the suite's /ctx and supervisor cases, rescued before a second rate limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The predecessor's working tree, committed as it stood so no third writer has to find it again. Two files, both of them the suite catching up with what the implementation already does: `tests/test_lane_helpers.sh` — the three Amendment 18 Addendum 2 (i-8) `/ctx` respawn cases now assert the SUPERVISOR line rather than the launch line, on all three PATHs (no `lane`, this estate's `lane`, a stranger's `lane`), which is the stronger property: what tmux is asked to start no longer depends on anything a person's shell profile can change. The handoff-skill cases assert the old tmux recipe's ABSENCE in executable form while the file still quotes it in the paragraph that says why it went. And every supervisor case pins `TMUX_PANE`, because the suite is very often run inside tmux and an inherited pane makes the pane check refuse for a reason the case was not written to test. `docs/README-lanes.md` — the `/ctx` paragraph says that what tmux starts is a supervisor, not the session itself. One suite failure is outstanding and under investigation; it passed in the previous run, which is the shape of a race rather than a defect, and this commit records the state rather than a verdict. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r --- docs/README-lanes.md | 8 +- tests/test_lane_helpers.sh | 189 ++++++++++++++++++++++++------------- 2 files changed, 132 insertions(+), 65 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 7110e4b8..3c8da530 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2090,8 +2090,12 @@ how the register has spelled Codex sessions since 2026-09-05. ### `/ctx` — one word, and everything after it is automatic `/ctx` (`/handoff --restart`) performs the handoff and then **restarts in -place**: `tmux respawn-pane -k` on the lane's own pane, with a NEW session of the -same agent whose **first prompt is the handoff's top block**. That block's +place**: `tmux respawn-pane -k` on the lane's own pane, and what comes up is a +NEW session of the same agent whose **first prompt is the handoff's top block**. +What tmux starts is not that session but a SUPERVISOR that launches it — the two +subsections below are why, and they are `openRepoTools#94` — so the sentence +holds end to end and no longer holds only as far as tmux accepting a command. +That block's `WRITERS` section lists every worktree the lane had running: its branch, its last commit, what it was holding, and the brief it was given, so the new session finds them rather than discovering them — and its first line is Addendum 1 (i)'s, the diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 60c1ef14..91bde76e 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -7659,12 +7659,26 @@ is "…and nothing was written" "$(grep -c '^RESUMED' "$LOGD/repoHF-7.md")" 0 # ------------------------------------------ 2. /ctx — the record, then the pane -# WHICH `lane` THE COMMAND CAN SEE IS THE QUESTION IN THREE OF THE CASES BELOW -# (Amendment 18 Addendum 2 (i-8)), so each of them runs on a PATH THIS FILE -# BUILT rather than on whatever the workstation happens to carry: -# `openRepoTools#43` places `lane` in `~/.local/bin`, and a case that asked -# "what happens where there is no `lane`" against a workstation that has one -# would go green on the answer to a different question. +# WHAT TMUX IS ASKED TO START CHANGED WITH `openRepoTools#94`, and these three +# cases are where that is held. It used to be THE LAUNCH: `lane ` where +# that word was on PATH, `pclaude --lane ` where it was not, +# with `LANE_START_FRESH=1` in front of it — so this command had to decide +# which of Amendment 18 Addendum 2 (i-8)'s two doors it could SEE, and had to +# read a `lane` on PATH to find out whether it was even this estate's word. +# +# NONE OF THAT IS HERE ANY MORE. What tmux starts is this command's own +# `--supervise` mode, BY ABSOLUTE PATH, and the supervisor takes (i-8)'s second +# door one level down. So the three cases below run THE SAME ACT on three +# different PATHs — no `lane`, this estate's `lane`, somebody else's `lane` — +# and the assertion is that the respawn line is the same every time. That is a +# stronger property than the one they held before: a respawn is the one act no +# later refusal can undo, and what it starts now depends on nothing a person's +# shell profile can change. +# +# Each still runs on a PATH THIS FILE BUILT rather than on whatever the +# workstation happens to carry: `openRepoTools#43` places `lane` in +# `~/.local/bin`, and a case that asked "what happens where there is no `lane`" +# against a workstation that has one would go green on a different question. a17_path_without() { # — this section's PATH with every directory that holds taken out a17p_want="$1" printf '%s' "$A17PATH" | tr ':' '\n' | while IFS= read -r a17p_d; do @@ -7683,67 +7697,77 @@ run env PATH="$A17PATH_NOLANE" FAKE_TMUX_WINDOW="hfsess:@21" CLAUDE_CODE_SESSION is "lane-handoff --restart exits 0" "$rc" 0 a17_tmux="$(cat "$FAKE_TMUX_A17_LOG")" has "…respawning the lane's own pane" "$a17_tmux" "respawn-pane -k -t hfsess:@21.%21" -# WITH NO `lane` ON PATH — which is this sandbox, and every workstation until -# `openRepoTools#43` places that word — the line is the launcher's, which is -# the door Amendment 18 Addendum 2 (i-8) leaves open in the same sentence that -# names `lane `: *"or through the launcher directly"*. The case below -# asks for the word itself, where it is there to be seen. -has "…with no \`lane\` on PATH, through the launcher, with --lane BEFORE the profile" "$a17_tmux" "pclaude --lane repoHF-5 team-05a" -has "…and the seam that makes the new session a FRESH one primed by the top block" "$a17_tmux" "LANE_START_FRESH=1" +# WITH NO `lane` ON PATH — which is this sandbox — the respawn is the +# supervisor, and it is spelled as an ABSOLUTE PATH because a respawned pane's +# `PATH` is whatever the person's shell profile makes of it. +has "…into this command's own supervisor, for one named operation" \ + "$a17_tmux" "lane-handoff --supervise --lane repoHF-5 --operation" +is "…by ABSOLUTE path, never a word a respawned pane's PATH resolves" \ + "$(printf '%s\n' "$a17_tmux" | awk '{ for (i = 1; i <= NF; i++) if ($i == "--supervise") { t = $(i - 1); print (substr(t, 1, 1) == "/") ? "abs" : t; exit } }')" "abs" +has "…with the workstation written INTO the command string, because tmux hands the server's environment" \ + "$a17_tmux" "LANES_WORKSTATION=Eagle" +hasnt "…and never the launch itself, which is what took the pane with it (openRepoTools#94)" \ + "$a17_tmux" "pclaude --lane repoHF-5 team-05a" +hasnt "…nor the environment seam that was measured not arriving" "$a17_tmux" "LANE_START_FRESH=1" +run "$E" restart-intent repoHF-5 +is "…and the restart intent the supervisor launches from reads back" "$rc" 0 +has "…as pending, for the operation the respawn line names" "$out" "$(printf 'state\tpending')" +has "…in the mode that authorises a fresh session" "$out" "$(printf 'mode\tfresh-from-handoff')" hasnt "…never \`restart \`, which Addendum 2 takes off the person's PATH" "$a17_tmux" "restart repoHF-5" is "THE RECORD WAS WRITTEN BEFORE THE RESPAWN, which is what the fake could see" \ "$(printf '%s\n' "$a17_tmux" | grep -o 'paused-lines=[0-9]*' | head -n1)" "paused-lines=1" has "…and the record is the lane's own PAUSED" "$(cat "$LOGD/repoHF-5.md")" "lane:repoHF-5 → swap;" -# AMENDMENT 18 ADDENDUM 2 (i-8) AND ITS ADOPTION LINE — *"opensoft/ -# openRepoTools#36 (`/ctx`) respawns with `lane `"*. Where the word is on -# PATH it is the word that is typed, with NO profile argument: `lane ` -# reads the record this act has just written for the lane's directory and -# profile, and asks nothing. -# -# THE FAKE CARRIES THE STRING EVERY BASH FILE THIS TOOLSET SHIPS CARRIES, -# because that is what `lane-handoff` reads it for: `lane` is an ordinary -# English word, and a pane respawned over somebody else's `lane` is a pane the -# person cannot get back. +# THIS ESTATE'S OWN `lane` ON PATH, AND IT CHANGES NOTHING (openRepoTools#94). +# Amendment 18 Addendum 2 (i-8)'s adoption line — *"opensoft/openRepoTools#36 +# (`/ctx`) respawns with `lane `"* — is the door this command used to +# take, and #94 is what it cost: `lane` is the HUMAN DISPATCHER, whose valid +# outcomes include attaching to a live session without launching anything, and +# the fresh-session requirement it was handed was an environment variable the +# launcher's own `tmux new-session` dropped on the way through. The clause's +# two doors are kept ONE LEVEL DOWN — the supervisor takes the second, the +# launcher directly — and its ground (*"no word is kept on `PATH` for it +# alone"*) is honoured exactly, because the supervisor is a MODE of a command +# that is already installed. +# +# THE FAKE IS STILL PLACED, because the property under test is that it makes NO +# DIFFERENCE: the word is on PATH, executable, and named exactly as the estate +# names it, and the respawn line is the same line as the case above. The +# bounded 8 KB read that used to classify this file — and the padding that +# proved its SIGPIPE corner (Copilot round 3 on openRepoTools#47) — are gone +# with the probe that needed them: nothing reads `lane` any more. mkdir -p "$SANDBOX/a17lane" cat > "$SANDBOX/a17lane/lane" <<'FAKE' #!/usr/bin/env bash # lane — the word, as lane-collision-protocol Amendment 18 Addendum 1 names it printf '%s\n' "$*" >> "${FAKE_LANE_LOG:-/dev/null}" FAKE -# AND IT IS LARGER THAN THE BOUNDED READ (Copilot round 3 on openRepoTools#47). -# The word is recognised by `head -c 8192 < file | grep …`, and under -# `set -o pipefail` a `grep -q` that exits on the match leaves `head` writing -# into a closed pipe: SIGPIPE, 141, and the estate's OWN `lane` classified as -# somebody else's. The marker is in the first line; the padding below is what -# makes `head` still have something to write when `grep` has seen it. -{ printf '# padding, so this file is larger than the bounded read:\n' - i=0 - while [ "$i" -lt 400 ]; do - printf '# %s\n' "................................................................" - i=$((i + 1)) - done -} >> "$SANDBOX/a17lane/lane" chmod +x "$SANDBOX/a17lane/lane" +: > "${FAKE_LANE_LOG:-/dev/null}" : > "$FAKE_TMUX_A17_LOG" export FAKE_TMUX_A17_WATCH="$LOGD/repoHF-8.md" run env PATH="$SANDBOX/a17lane:$A17PATH_NOLANE" FAKE_TMUX_WINDOW="hfsess:@21" CLAUDE_CODE_SESSION_ID="$HF_ID" \ CLAUDE_PROFILE_NAME=team-05a "$HANDOFF_CMD" --lane repoHF-8 --restart clear is "with \`lane\` on PATH, lane-handoff --restart exits 0" "$rc" 0 a17_lane_tmux="$(cat "$FAKE_TMUX_A17_LOG")" -has "…and the respawn line is \`lane \` — that word, resolved where it was found" \ +has "…and the respawn line is the SAME supervisor, with this estate's own \`lane\` sitting on PATH" \ + "$a17_lane_tmux" "lane-handoff --supervise --lane repoHF-8 --operation" +hasnt "…never the human dispatcher, whose valid outcomes include attaching to a live session" \ "$a17_lane_tmux" "a17lane/lane repoHF-8" -has "…still with the FRESH seam, which rides in the environment and survives the hand-on to lane-start" \ - "$a17_lane_tmux" "LANE_START_FRESH=1" -hasnt "…and with no profile argument: \`lane \` reads the record for it" "$a17_lane_tmux" "repoHF-8 team-05a" -hasnt "…never the launcher, where the word itself is there to be typed" "$a17_lane_tmux" "pclaude --lane repoHF-8" +hasnt "…nor the environment seam the launcher's tmux new-session drops" "$a17_lane_tmux" "LANE_START_FRESH=1" +hasnt "…never the launcher at this level either — that is the supervisor's door, one down" \ + "$a17_lane_tmux" "pclaude --lane repoHF-8" hasnt "…and never \`restart \`" "$a17_lane_tmux" "restart repoHF-8" is "…the record still written BEFORE the respawn" \ "$(printf '%s\n' "$a17_lane_tmux" | grep -o 'paused-lines=[0-9]*' | head -n1)" "paused-lines=1" -# A `lane` ON PATH THAT IS NOT THIS ESTATE'S WORD IS PASSED OVER FOR THE -# LAUNCHER, and said. The safe side of the two is the one that still starts the -# lane: a respawn is the one act no later refusal can undo. +# SOMEBODY ELSE'S `lane` ON PATH, AND IT CHANGES NOTHING EITHER. This case used +# to be the dangerous one: `lane` is an ordinary English word, a pane respawned +# over a stranger's `lane` is a pane the person cannot get back, and the command +# read the file's first 8 KB for this toolset's own marker to tell the two +# apart. That read is gone because the choice it fed is gone — the respawn line +# names this command's own absolute path — so a foreign `lane` is not passed +# over, not classified, and not mentioned. It is simply never consulted. mkdir -p "$SANDBOX/a17foreign" cat > "$SANDBOX/a17foreign/lane" <<'FAKE' #!/usr/bin/env bash @@ -7757,9 +7781,12 @@ run env PATH="$SANDBOX/a17foreign:$A17PATH_NOLANE" FAKE_TMUX_WINDOW="hfsess:@21" CLAUDE_PROFILE_NAME=team-05a "$HANDOFF_CMD" --lane repoHF-9 --restart clear is "a \`lane\` that is not this estate's word still exits 0" "$rc" 0 a17_foreign_tmux="$(cat "$FAKE_TMUX_A17_LOG")" -has "…respawning through the launcher instead" "$a17_foreign_tmux" "pclaude --lane repoHF-9 team-05a" -hasnt "…and never through the word it could not recognise" "$a17_foreign_tmux" "a17foreign/lane repoHF-9" -has "…saying which \`lane\` it passed over" "$err" "is not this estate's word" +has "…respawning into the same supervisor as the other two PATHs" \ + "$a17_foreign_tmux" "lane-handoff --supervise --lane repoHF-9 --operation" +hasnt "…and never through the word it does not read" "$a17_foreign_tmux" "a17foreign/lane repoHF-9" +hasnt "…nor through the launcher at this level" "$a17_foreign_tmux" "pclaude --lane repoHF-9" +hasnt "…and with nothing to SAY about a stranger's word, because nothing consulted it" \ + "$err" "is not this estate's word" # A RECORD THAT CANNOT BE WRITTEN REFUSES BEFORE ANYTHING IS KILLED. repoHF-2's # row carries only `session_…` footer ids, so no transcript uuid is knowable and @@ -8371,18 +8398,39 @@ has "…and the unknown one, which a plain handoff cannot know (Addendum 1 (h) "$hfsk" "THIS HANDOFF CANNOT KNOW WHICH KIND FOLLOWED IT" has "the skill's own record writer carries the kind sub-field too" "$hfsk" 'payload="$payload; kind $kind"' has "…and the free text after the why, in the addendum's spelling" "$hfsk" "kind unknown" -has "…and it asks window-session with the harness's own spelling of the window" \ - "$hfsk" "#{session_name}:#{window_id}" has "…and its row write SETS the whole cell, as the command does (Amendment 13(a))" \ "$hfsk" 'set-row-state "$lane" "PAUSED · $hs_line"' has "…with the line cut where a cut cannot land inside a character" "$hfsk" 'hs_line="${hs_line% *} ..."' hasnt "…and never the anchor the two writes it replaced had to guess at" "$hfsk" '"$state" "$state_new" "swap"' has "…and the measured fact that is the whole reason for the distinction" \ "$hfsk" "mints a NEW TRANSCRIPT ID IN THE SAME PROCESS" -has "and the respawn line Addendum 2 (i-8) names: \`lane \`" "$hfsk" 'LANE_START_FRESH=1 lane $lane' -has "…with the launcher as the door the same clause leaves open" "$hfsk" "pclaude --lane \$lane" -has "…chosen by whether that word is on PATH, which is the choice the command makes in code" \ +# openRepoTools#94 — THE SKILL'S `/ctx` STEP IS ONE COMMAND, AND CARRIES NO +# RECIPE OF ITS OWN. It used to spell out the destructive tail in shell — +# `tmux respawn-pane -k -t "$pane" "LANE_START_FRESH=1 lane $lane"`, chosen by +# `if command -v lane` — for a session to run against its own pane. On +# 2026-09-15T20:59Z that is what ran, with two environment assignments the model +# added on the spot, and the pane came back RESUMING the transcript the handoff +# had just paused. Two copies of one procedure that must stay byte-equal is the +# rejected alternative everywhere else in this file, and the one place it was +# allowed is the one place a session kills its own pane. +# +# THE ASSERTIONS ARE THEREFORE ABOUT ABSENCE AS MUCH AS PRESENCE, and the +# absent ones are `hasnt` on the EXECUTABLE forms: the file still QUOTES the old +# line, in the paragraph that explains why it is gone, so a `has` on the string +# alone would go green on the obituary. +has "the skill's /ctx step is the one command that owns the act" "$hfsk" 'lane-handoff --restart "$why"' +has "…and where the old recipe appears at all, it is as the thing that was REMOVED" \ + "$hfsk" "THE TMUX RECIPE THAT USED TO BE HERE IS GONE" +hasnt "…with no \`command -v lane\` choosing between two doors in prose" \ "$hfsk" 'if command -v lane >/dev/null 2>&1; then' +hasnt "…and no window read of its own left to feed one" "$hfsk" "#{session_name}:#{window_id}" +has "…naming what tmux is asked to start instead, by absolute path" \ + "$hfsk" "lane-handoff --supervise --lane --operation " +has "…and saying why an environment seam could not carry the fresh session" \ + "$hfsk" "tmux SERVER's environment" +has "…with the restart intent as the fourth gate before the kill" "$hfsk" "RESTART INTENT written to" +has "…and \`lane \` kept as the PERSON's door and not as this one" \ + "$hfsk" "IS STILL A PERSON'S DOOR AND IS NO LONGER THIS ONE" hasnt "…which is never \`restart \`" "$hfsk" 'respawn-pane -k -t "$pane" "restart' echo "== Amendment 18 Addendum 1: the word \`lane\`, the numbered pick, the three branches ==" @@ -9989,6 +10037,21 @@ has "…and still the lane's own handoff" "$out" "$(printf 'handoff\t%s' "$SV_H # ------------------------- 2. openRepoTools#94: the fresh launch, from the FILE SV_LANE_HANDOFF="$OPENREPOTOOLS_BIN_DIR/lane-handoff" +# THE PANE EVERY SUPERVISOR CASE BELOW STANDS IN, PINNED RATHER THAN INHERITED. +# `$TMUX_PANE` is set by tmux in every pane, and this suite is very often run +# INSIDE one — so without this the supervisor reads the pane of whatever window +# the person or the harness happens to be in, disagrees with the pane the +# intent names, and every case after the pane check refuses for a reason the +# case was not written to test. A suite that is green on a workstation outside +# tmux and red inside one is a suite that tests the workstation. +# +# `%33` IS THE BARE FORM tmux itself exports, and the intent's pane is the live +# record's `svsess:@33.%33`: the two agree component-wise, which is the +# comparison `pane_agrees` makes, and asserting it here is the positive half of +# the `%3`-is-not-`%33` case further down. A case that wants to DISAGREE sets +# `TMUX_PANE` again after this one, and the later assignment is the one `env` +# keeps. +SV_PANE='%33' : > "$FAKE_CLAUDE_LOG" run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ "$START" --dir "$SV_DIR" repoSV-2 --no-launch @@ -10123,36 +10186,36 @@ has "…with the intent left pending for the supervisor that never arrived" "$o # --------------------------------- 4. the supervisor: refusals before a launch SV_OP="$(printf '%s\n' "$out" | awk -F'\t' '$1=="operation"{print $2}')" -run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 "$SV_LANE_HANDOFF" \ +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 --operation op-stale is "a supervisor started for a stale operation refuses" "$rc" 2 has "…naming the operation that really holds the lane" "$err" "$SV_OP" hasnt "…and launches nothing" "$err" "restarting (operation" -run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 "$SV_LANE_HANDOFF" \ +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-1 --operation op-test-1 is "a supervisor whose intent is already \`starting\` refuses rather than starting a second session" "$rc" 2 has "…citing the one-transcript-one-process rule" "$err" "Amendment 18(h)" -run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE='%99' "$SV_LANE_HANDOFF" \ +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" TMUX_PANE='%99' "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 --operation "$SV_OP" is "a supervisor in a pane the intent does not name refuses" "$rc" 2 has "…naming both panes" "$err" "svsess:@33.%33" # AND THE COMPARISON IS COMPONENT-WISE, NOT A SUFFIX. `%3` is a SUFFIX of # `%33`, so a `case ... in *"$TMUX_PANE")` accepted a supervisor in pane 3 for # an intent written for pane 33 — the wrong window, decided by string tail. -run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE='%3' "$SV_LANE_HANDOFF" \ +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" TMUX_PANE='%3' "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 --operation "$SV_OP" is "…and a pane whose id is a SUFFIX of the intent's is still another pane" "$rc" 2 has "…named as the disagreement it is" "$err" "and this supervisor is in %3" -run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 "$SV_LANE_HANDOFF" \ +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 --operation "$SV_OP" is "a supervisor that finds a live holder refuses to make a second one" "$rc" 2 has "…naming the retire act and never a kill" "$err" "lane-end repoSV-3 --retire" rm -f "$sessions_dir/live-sv.json" -run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 "$SV_LANE_HANDOFF" \ +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 is "a supervisor with no operation refuses" "$rc" 2 has "…because one that took whatever it found would launch into somebody else's act" "$err" "--operation " @@ -10167,7 +10230,7 @@ FAKE chmod +x "$SV_BIN/pclaude-fail" export FAKE_SVLAUNCH_LOG="$SANDBOX/svlaunch.log" : > "$FAKE_SVLAUNCH_LOG" -run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-fail" LANE_SUPERVISOR_NO_PROMPT=1 \ +run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-fail" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" \ LANE_SUPERVISOR_READY_SECONDS=4 "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 --operation "$SV_OP" is "a launcher that exits nonzero is a FAILED restart and not a started one" "$rc" 3 @@ -10183,7 +10246,7 @@ has "…the attempt counted" "$out" "$(printf 'attempt\t1')" has "…and the reason bounded and recorded" "$out" "before readiness was confirmed" # THE RETRY IS THE SAME OPERATION, NOT A NEW ONE. -run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-fail" LANE_SUPERVISOR_NO_PROMPT=1 \ +run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-fail" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" \ LANE_SUPERVISOR_READY_SECONDS=4 "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 --operation "$SV_OP" is "a retry of a failed restart runs" "$rc" 3 @@ -10207,7 +10270,7 @@ chmod +x "$SV_BIN/pclaude-ok" export SV_READY_SRC SV_READY_DST="$sessions_dir/live-sv-new.json" "$E" set-restart-intent repoSV-3 pending --expect failed --new-transcript "$SV_NEW" >/dev/null 2>&1 : > "$FAKE_SVLAUNCH_LOG" -run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-ok" LANE_SUPERVISOR_NO_PROMPT=1 \ +run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-ok" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" \ LANE_SUPERVISOR_READY_SECONDS=20 FAKE_SVLAUNCH_SLEEP=8 "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 --operation "$SV_OP" is "a launch that comes up and is CONFIRMED exits 0 when its session later ends" "$rc" 0 @@ -10220,7 +10283,7 @@ has "…which is NOT the one the /ctx paused" "$out" "$(printf 'old_transcript\ rm -f "$SV_READY_DST" # A `ready` OPERATION IS HISTORY: a supervisor started for it again refuses. -run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-ok" LANE_SUPERVISOR_NO_PROMPT=1 \ +run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-ok" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" \ "$SV_LANE_HANDOFF" --supervise --lane repoSV-3 --operation "$SV_OP" is "a confirmed restart is not launched a second time" "$rc" 2 has "…and the door to the lane is the one word a person has" "$err" "lane repoSV-3" @@ -10264,7 +10327,7 @@ FAKE chmod +x "$SV_BIN/pclaude-quiet" "$E" set-restart-intent repoSV-3 pending --expect ready >/dev/null 2>&1 : > "$FAKE_SVLAUNCH_LOG" -run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-quiet" LANE_SUPERVISOR_NO_PROMPT=1 \ +run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-quiet" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" \ LANE_SUPERVISOR_READY_SECONDS=4 FAKE_SVLAUNCH_SLEEP=8 "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 --operation "$SV_OP" is "a child that stays alive without readiness evidence is INDETERMINATE, not running" "$rc" 3 From 37ecace3bd3d800f88c0f54b71c2843c900e21f2 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:30:22 +0000 Subject: [PATCH 04/25] The flaky case was `lanes-edit.sh` refusing its own no-op, and a sixty-second deadline that counted sleeps was never sixty seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two writers died at rate limits over this change and each left the same note: one suite failure, and it passed in the previous run. It is solved, and it is neither a race nor anything this branch wrote. **THE FLAKE: a write whose result is the line already there was REFUSED.** The case is Amendment 15's `--confirm in a window named for this lane in ANOTHER case exits 0`, and 5 is what `lanes-edit.sh` dies with when a write cannot prove what it did. `replace_line` proves it touched exactly one line by demanding `1 1` from `git diff --no-index --numstat` — and an edit that changes NOTHING produces no output at all, so the comparison was against an empty string and the act died 5 with `numstat: none`. The guard read *nothing changed* as *more than one line changed*, which are opposite facts. It is reached by ordinary use and not by a contrivance. `set-row-state` writes ` · $(utc_now) · ` and `utc_now` is SECONDS, so two stamps of one state phrase inside one second are byte-identical — and two `lane-start --no-launch` runs of one lane in one window are exactly that pair, which is what the `--confirm` case does. Eight identical writes in a loop against the shipped file: `rc=0 0 5 5 5 5 0 5`, decided by nothing but where the second boundary fell. Ten against the fixed one: 0 every time, the unchanged ones saying so. So a result byte-identical to the file it would replace is a write that is DONE. `replace_line` returns 0, writes nothing and says which line already read that way; `commit_push` already had *nothing staged — no commit made* on its own line, and none of the four callers ever wanted the file to DIFFER — they wanted it to SAY something. `set-row-state` reads a `REPLACE_LINE_NOOP` flag so its own note says UNCHANGED rather than announcing a replacement that did not happen. The new case pins it BY CONSTRUCTION rather than by racing the clock: `replace-in-row` with the same text on both sides is identical whatever the second is. On a workstation the old behaviour was a lane stamped twice in a second being told its write was refused, while the register already said precisely what the caller asked for. **THE DEADLINE: `supervise_watch` added 2 per turn and called it the elapsed time.** That counts the `sleep` and never the `live-holder` read between two of them — and that read resolves the lane, reads the published row and scans every session record this workstation has. The design's *"waits up to 60 seconds"* was sixty seconds PLUS thirty register reads, and the INDETERMINATE line then reported a number that had never elapsed to the one person reading it. It is wall clock now and reports the true elapsed beside the deadline it was measured against; the counted sleeps are kept as the FLOOR, so a workstation whose `date` cannot answer, or whose clock steps backwards, still terminates the loop. **And three things in the suite that rested on something nobody wrote down.** `trap cleanup EXIT INT TERM` cleaned up on TERM and RETURNED. `tests/run.sh` states the rule for its own lock — *"a handler that only cleans up and RETURNS releases the lock while this suite carries on running"* — and one handler on all three broke it here in the other direction: a TERM removed `$SANDBOX` and the run carried on against a sandbox that was gone, every case after it `env: '.../bin/lane-start': No such file or directory`, printing `1567 passed, 233 failed`. A killed run that reports two hundred failures is a transcript that lies about this repository, and somebody reads those. INT and TERM exit 130 and 143 now; EXIT still returns, because the status it carries is the suite's own. The two supervisor cases asserted an ORDER with a stopwatch: a fixed `sleep 8` in the fake child, inside which the observer had to fit two 2-second sleeps, two `live-holder` polls and its write, with nothing ordering the two events. Both children now wait for the file the observer writes — `state: ready`, and a `reason` naming INDETERMINATE — under a bounded cap. And two cases named `TMUX_PANE` twice in one `env`, relying on the later assignment winning. That is true of every `env` this runs under and is written down nowhere; they name their own pane instead. **One digest, three commands, pinned to one body.** `lanes-edit.sh` RECORDS the handoff digest, `lane-start` VERIFIES it before a launch and `lane-handoff` verifies it before a supervisor claims an operation — three installed files with no library between them, which is this estate's shape. Two of them are already proved to agree by behaviour; a case now pins the third, and pins that "the same" means the BODY and not the name. A copy that drifted would not fail loudly: it would record one digest and verify another. **And the deviation cites the decisions that record it.** `lane-handoff`'s comment at the respawn said "decision 5" where the proposal promises decisions 10 and 11 — 10 for the supervisor being a MODE rather than the installed word decision 5 proposed, 11 for neither of (i-8)'s doors being taken at that level — and design decision 10 spelled the mode `--supervise ` where the command takes `--supervise --lane `. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r --- lane-handoff | 36 +++++- lanes-edit.sh | 42 ++++++- .../add-supervised-context-restart/design.md | 2 +- .../add-supervised-context-restart/tasks.md | 23 ++++ tests/test_lane_helpers.sh | 117 ++++++++++++++++-- 5 files changed, 202 insertions(+), 18 deletions(-) diff --git a/lane-handoff b/lane-handoff index 4ee5ac32..4e64b1ac 100755 --- a/lane-handoff +++ b/lane-handoff @@ -537,10 +537,32 @@ ready_check() { # sw_lane="$1"; sw_op="$2"; sw_gen="$3"; sw_pidf="$4"; sw_new="$5"; sw_old="$6"; sw_pane="$7" + # THE DEADLINE IS WALL CLOCK, NOT A COUNT OF SLEEPS. This loop used to add + # 2 per turn and call that the elapsed time, which counts only the `sleep` + # and never the work between two of them — and the work is a `live-holder` + # read that resolves the lane, reads the published row and scans every + # session record this workstation has. On a loaded machine that is seconds, + # so a "60-second" deadline was sixty seconds PLUS thirty register reads, + # and the INDETERMINATE line then reported a number that had never elapsed. + # The design's promise is *"waits up to 60 seconds"* and a person waiting on + # a pane is owed the number they were told. + # + # THE COUNTED SLEEPS ARE KEPT AS THE FLOOR, so this loop terminates on a + # workstation whose `date` cannot answer or whose clock steps backwards: + # the wait is the GREATER of what was slept and what the clock says, and a + # clock that goes backwards simply leaves the counted sleeps deciding. + sw_t0="$(date +%s 2>/dev/null | tr -dc '0-9')" + sw_slept=0 sw_waited=0 while [ "$sw_waited" -lt "$SUPERVISE_READY_SECONDS" ]; do sleep 2 - sw_waited=$((sw_waited + 2)) + sw_slept=$((sw_slept + 2)) + sw_waited="$sw_slept" + sw_now="$(date +%s 2>/dev/null | tr -dc '0-9')" + if [ -n "$sw_t0" ] && [ -n "$sw_now" ] && [ "$sw_now" -gt "$sw_t0" ]; then + sw_el=$((sw_now - sw_t0)) + [ "$sw_el" -gt "$sw_waited" ] && sw_waited="$sw_el" + fi sw_rc=0 ready_check "$sw_lane" "$sw_pidf" "$sw_new" "$sw_old" "$sw_pane" || sw_rc=$? case "$sw_rc" in @@ -582,7 +604,7 @@ supervise_watch() { # < # and the lane is NOT marked running. LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$sw_lane" starting \ --expect starting --expect-operation "$sw_op" \ - --reason "INDETERMINATE: ${SUPERVISE_READY_SECONDS}s elapsed with the child alive and readiness unconfirmed; ${READY_WHY:-no evidence}" >/dev/null 2>&1 || : + --reason "INDETERMINATE: ${sw_waited}s elapsed (deadline ${SUPERVISE_READY_SECONDS}s) with the child alive and readiness unconfirmed; ${READY_WHY:-no evidence}" >/dev/null 2>&1 || : return 4 } @@ -1753,10 +1775,12 @@ if [ "$do_restart" = 1 ]; then # supervisor is a mode of a command that is already installed, and a person # still has one word, `lane`. # - # THE DEVIATION IS WRITTEN DOWN rather than taken quietly: it is decision 5 - # of `openspec/changes/add-supervised-context-restart/design.md`, and the - # protocol text that would ratify it is task 7.4's, unwritten as this - # lands. + # THE DEVIATION IS WRITTEN DOWN rather than taken quietly: it is decisions + # 10 and 11 of `openspec/changes/add-supervised-context-restart/design.md` + # — 10 for the supervisor being a MODE of this command rather than the new + # installed word decision 5 proposed, 11 for neither of (i-8)'s two doors + # being taken at THIS level — and the protocol text that would ratify it is + # task 7.4's, unwritten as this lands. # ---- THE PRE-KILL COMMIT POINT: THE RESTART INTENT (openRepoTools#94) # diff --git a/lanes-edit.sh b/lanes-edit.sh index fc3a62d0..dee54f71 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1022,6 +1022,10 @@ lane_from_text() { # `git diff --no-index --numstat` (must be exactly 1 added / 1 deleted). replace_line() { n="$1"; newline="$2" + # 1 where the line was already exactly what was asked for and nothing was + # written. A caller that prints what it did reads this rather than announcing + # a replacement that did not happen. + REPLACE_LINE_NOOP=0 TMPD="$(mktemp -d)" pre="$TMPD/pre"; out="$TMPD/out" cat -- "$LANES_FILE" > "$pre" @@ -1033,6 +1037,33 @@ replace_line() { } > "$out" after="$(wc -l < "$out" | tr -d ' ')" [ "$before" = "$after" ] || die "line count changed ($before -> $after); refusing" 5 + # A LINE THAT IS ALREADY EXACTLY WHAT WAS ASKED FOR IS A WRITE THAT IS DONE, + # AND NOT A PROOF THAT FAILED (measured 2026-09-16, `lanes-edit.sh`). The + # `numstat` below proves this edit touched ONE line by demanding `1 1` from + # `git diff --no-index` — and an edit that changes nothing produces NO OUTPUT + # AT ALL, so `$stat` came back empty, the equality was false, and the act + # died 5 with `numstat: none`. The guard read "nothing changed" as "more than + # one line changed", which are opposite facts. + # + # IT IS REACHED BY ORDINARY USE AND NOT BY A CONTRIVANCE. `set-row-state` + # writes ` · $(utc_now) · ` and `utc_now` is seconds, so two + # stamps of the SAME state phrase inside one second are byte-identical — two + # `lane-start --no-launch` runs of one lane in one window, which is exactly + # what `tests/test_lane_helpers.sh`'s Amendment 15 `--confirm` case does, and + # what made it fail about one run in two with `expected [0], got [5]`. On a + # workstation it is a lane stamped twice in a second being told its write was + # refused, when the register already says precisely what the caller asked for. + # + # THE CALLERS ARE SAFE WITH IT: nothing is written, so `commit_push` finds + # nothing staged and says so on its own line, and every one of the four + # callers (`set-row-state`, `append-session-id`, `replace-in-row` and + # `migrate-state-cells`) wanted the file to SAY something rather than to + # differ from what it said. + if cmp -s -- "$pre" "$out"; then + REPLACE_LINE_NOOP=1 + note "line $n of $LANES_FILE is already exactly what this write asked for — nothing rewritten, and nothing to commit" + return 0 + fi stat="$(git --no-pager diff --no-index --numstat -- "$pre" "$out" 2>/dev/null | head -n1 | cut -f1,2)" [ "$stat" = "$(printf '1\t1')" ] || die "edit touched more than one line (numstat: ${stat:-none}); refusing" 5 cat -- "$out" > "$LANES_FILE" # redirect FOLLOWS the symlink @@ -7957,7 +7988,16 @@ Nothing was written." 2 srs_new="$ROW_STATE · $(utc_now) · $ROW_STATE_LINE" srs_was="$(rstrip_spaces "$RSS_CELL")" replace_line "$n" "$RSS_HEAD$srs_new $RSS_TAIL" - note "state cell REPLACED: ${#srs_was} chars → ${#srs_new} chars" + # WHAT IT SAYS IS WHAT HAPPENED, and the two are not the same act. A state + # phrase stamped twice inside one second is byte-identical — `utc_now` is + # seconds — so the row already reads exactly this, nothing is written, and + # "REPLACED" beside `replace_line`'s own "nothing rewritten" would be two + # lines of one output contradicting each other. + if [ "${REPLACE_LINE_NOOP:-0}" = 1 ]; then + note "state cell UNCHANGED: it already reads exactly this ${#srs_new} chars, to the second" + else + note "state cell REPLACED: ${#srs_was} chars → ${#srs_new} chars" + fi # THE WHOLE LINE IN THE SUBJECT, as `append-row-status` put its whole text # there: the line is capped at 240 characters by the act above, and a # subject cut at 72 loses exactly the tail that says what happened. diff --git a/openspec/changes/add-supervised-context-restart/design.md b/openspec/changes/add-supervised-context-restart/design.md index 0fc81a92..175205d5 100644 --- a/openspec/changes/add-supervised-context-restart/design.md +++ b/openspec/changes/add-supervised-context-restart/design.md @@ -130,7 +130,7 @@ Tests SHALL run the real supervisor against fake launch children and isolated tm Decision 5 proposes "an openRepoTools command such as `lane-restart-supervisor`", installed by absolute path (tasks 4.1 and 4.6). **That contradicts Amendment 18 Addendum 2 (i-8) as ratified**, which removed `restart` from the installed set on exactly this ground: *"no word is kept on `PATH` for it alone — 'if we need it for ctx, keep it for that' is met by `lane`, which does the same thing under the one name"*, over Brett Heap's own ruling *"i do not see any reason to expose this to the user"*. A thirteenth installed word whose only caller is `/ctx` is the thing that clause refuses. -The supervisor is therefore `lane-handoff --supervise --operation ` — a mode of the command that already owns the `/ctx` transaction and is already installed. Everything the decision actually requires is kept: it is still a separate process from the launch it supervises, it is still what tmux starts, and it is still resolved **by absolute path** into the respawn line, because a respawned pane's `PATH` is whatever the person's shell profile makes of it. What is dropped is the new installed word, the twelve-file installer count it would have moved, and task 4.6's installer work, which becomes nothing to install. A person still has exactly one word, `lane`. +The supervisor is therefore `lane-handoff --supervise --lane --operation ` — a mode of the command that already owns the `/ctx` transaction and is already installed. Everything the decision actually requires is kept: it is still a separate process from the launch it supervises, it is still what tmux starts, and it is still resolved **by absolute path** into the respawn line, because a respawned pane's `PATH` is whatever the person's shell profile makes of it. What is dropped is the new installed word, the twelve-file installer count it would have moved, and task 4.6's installer work, which becomes nothing to install. A person still has exactly one word, `lane`. `--restart-status ` is the read-only surface of task 6.1 on the same command, for the same reason. diff --git a/openspec/changes/add-supervised-context-restart/tasks.md b/openspec/changes/add-supervised-context-restart/tasks.md index 7634108e..5727f4c1 100644 --- a/openspec/changes/add-supervised-context-restart/tasks.md +++ b/openspec/changes/add-supervised-context-restart/tasks.md @@ -7,6 +7,29 @@ The first implementation pass landed the transaction, the supervisor, the exact - **A new operation inherited the last one's per-operation fields.** `set-restart-intent` keeps every field a transition does not name, which is what lets the supervisor write `starting` without blanking the digest. Five of the fields belong to ONE operation, though, and two of those are what the readiness predicate is built on: an inherited `new_transcript` is a uuid the new launch will never mint, so readiness could only run to its deadline; an inherited `old_transcript` is the wrong uuid to be *distinct from*, and being distinct from it is the check that catches `#94` itself. `old_transcript`, `new_transcript`, `attempt`, `reason` and `created` now fall back to nothing when the operation changes, and a caller that names one still wins. - **`lane-start` nagged on every launch of every lane on a workstation whose `lanes-edit.sh` predates this change.** The estate's read fence is *"`0` an answer, `8` NO ANSWER and `2` a helper predating the read — both of those fall to the next rung"*; the restart-intent read treated `2` as a read that FAILED and printed a note. It is silent now, and only an unclassified code is said. - **`--restart-status` told a reader that `lanes-edit.sh` has no `lane-state` when the lane merely has no snapshot.** The exits are told apart now (`8` the lane, `2` the helper, anything else a read that failed), which matters the day `#97` lands. +- **The readiness deadline counted SLEEPS and called them seconds.** `supervise_watch` added 2 per turn, which counts the `sleep` and never the `live-holder` read between two of them — and that read resolves the lane, reads the published row and scans every session record this workstation has. So the design's *"waits up to 60 seconds"* was sixty seconds **plus thirty register reads**, and the `INDETERMINATE` line then reported a number that had never elapsed to the one person reading it. The deadline is wall clock now and the reason carries the true elapsed beside the deadline it was measured against; the counted sleeps are kept as the floor, so a workstation whose `date` cannot answer, or whose clock steps backwards, still terminates the loop. + +**THE ONE FLAKY CASE, AND ITS VERDICT: a defect in `lanes-edit.sh`, not a race and not this change's code.** + +The case is the Amendment 15 one, `--confirm in a window named for this lane in ANOTHER case exits 0` — `expected [0], got [5]` — and 5 is the code `lanes-edit.sh` dies with when a write cannot prove what it did. + +`replace_line` proves it touched exactly one line by demanding `1 1` from `git diff --no-index --numstat`. **An edit that changes nothing produces no output at all**, so the comparison was against an empty string and the act died `5` with `numstat: none`: the guard reading *nothing changed* as *more than one line changed*, which are opposite facts. And it is reached by ordinary use, not by a contrivance — `set-row-state` writes ` · $(utc_now) · ` and `utc_now` is SECONDS, so two stamps of one state phrase inside a second are byte-identical. Two `lane-start --no-launch` runs of one lane in one window are exactly that pair, and that is what the `--confirm` case does. + +Measured directly, eight identical writes in a loop against the shipped `lanes-edit.sh`: + +``` +1: rc=0 2: rc=0 3: rc=5 4: rc=5 5: rc=5 6: rc=5 7: rc=0 8: rc=5 + lanes-edit: edit touched more than one line (numstat: none); refusing +``` + +Ten against the fixed one: `rc=0` every time, the unchanged ones saying `line N … is already exactly what this write asked for — nothing rewritten` and `nothing staged … — no commit made`. + +So the fix is in `replace_line`: a result byte-identical to the file it would replace is a write that is **done**, not a proof that failed. It returns 0, writes nothing, and says so; `commit_push` already handled *nothing staged* on its own line, and none of the four callers wanted the file to DIFFER — they wanted it to SAY something. `set-row-state` reads a `REPLACE_LINE_NOOP` flag so its own note says `UNCHANGED` rather than announcing a replacement that did not happen. A case pins it by construction rather than by racing the clock (`replace-in-row` with the same text on both sides is identical whatever the second is). On a workstation the old behaviour was a lane stamped twice in one second being told its write was refused, while the register already said precisely what the caller asked for. + +**Two suite defects found beside it, and fixed in the same file:** + +- **Two supervisor cases asserted an ORDER with a stopwatch.** §6 and §6a gave their fake child a fixed `sleep 8`, so the observer had to complete two 2-second sleeps, two `live-holder` polls and its write inside those eight seconds, with nothing ordering the two events. Both children now wait for the file the observer writes (`state: ready`; a `reason` naming `INDETERMINATE`) under a bounded cap, so the case asserts the order it is about and is faster in the ordinary run. (They were green in the baseline run; the stopwatch is a flake waiting rather than the one that fired.) +- **`trap cleanup EXIT INT TERM` cleaned up on `TERM` and RETURNED.** `tests/run.sh` states the rule for its own lock — *"a handler that only cleans up and RETURNS releases the lock while this suite carries on running"* — and one handler on all three broke it here in the other direction. Measured: a `TERM` removed `$SANDBOX` and the run carried on against a sandbox that was gone, every case after it `env: '…/bin/lane-start': No such file or directory`, printing `1567 passed, 233 failed`. A killed run that reports two hundred failures is a transcript that lies about this repository. `INT` and `TERM` exit 130 and 143 now; `EXIT` still returns, because the status it carries is the suite's own. **Deferred, with the reason:** diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 91bde76e..4f73fb4b 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -87,7 +87,20 @@ cleanup() { [ -n "${SANDBOX:-}" ] && [ -d "$SANDBOX" ] && rm -rf -- "$SANDBOX" return 0 } -trap cleanup EXIT INT TERM +# AND A SIGNAL HANDLER THAT CLEANS UP MUST ALSO EXIT. `tests/run.sh` states +# this rule for its own lock — *"a handler that only cleans up and RETURNS +# releases the lock while this suite carries on running, which is the collision +# the lock exists for"* — and one handler on all three signals broke it here in +# the other direction. Measured 2026-09-16: a `TERM` sent to a run somebody +# wanted stopped removed `$SANDBOX` and then RETURNED, so the run carried on +# against a sandbox that was gone — every case after it `env: +# '.../bin/lane-start': No such file or directory` — and it printed +# `1567 passed, 233 failed`. A killed run that reports two hundred failures is a +# transcript that lies about this repository, and somebody reads those. +# `EXIT` still returns, because the status it carries is the suite's own. +trap cleanup EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM export HOME="$SANDBOX/home" export TMPDIR="$SANDBOX/tmp" @@ -9432,6 +9445,37 @@ is "…so the cell is three parts and stays three parts" \ is "…and it is BOUNDED: 240 for the line, plus the state and the instant" \ "$(printf '%s' "$a13_row" | awk -F' \\| ' '{ c = $NF; sub(/ \|$/, "", c); print (length(c) <= 280 ? "yes" : "no " length(c)) }')" "yes" +# ---- (a2) A WRITE WHOSE RESULT IS THE LINE ALREADY THERE IS DONE, NOT REFUSED. +# +# MEASURED 2026-09-16, and it is why the Amendment 15 `--confirm` case above +# went red about one run in two with `expected [0], got [5]`. `replace_line` +# proves it touched exactly one line by demanding `1 1` from +# `git diff --no-index --numstat`; an edit that changes NOTHING produces no +# output at all, the comparison was against an empty string, and the act died 5 +# with `numstat: none` — the guard reading "nothing changed" as "more than one +# line changed", which are opposite facts. +# +# IT IS REACHED BY ORDINARY USE. `set-row-state` writes +# ` · $(utc_now) · ` and `utc_now` is SECONDS, so two stamps of one +# state phrase inside a second are byte-identical — which is two +# `lane-start --no-launch` runs of one lane in one window, the pair the +# `--confirm` case makes. Eight such writes in a loop against the shipped +# `lanes-edit.sh` gave five exit 5 and three exit 0, decided by nothing but +# where the second boundary fell. +# +# THIS CASE DOES NOT RACE THE CLOCK TO SAY SO. `replace-in-row` with the same +# text on both sides is byte-identical BY CONSTRUCTION, whatever the second is, +# so what is asserted is the property and not a coincidence. +a13_noop_before="$(grep '^| `repoA13-1`' "$LANES")" +run env LANES_LANE=repoA13-1 "$E" replace-in-row repoA13-1 \ + "CI green; waiting on review" "CI green; waiting on review" +is "a write whose result is the line already there exits 0" "$rc" 0 +has "…saying the line already reads exactly that" "$err" "already exactly what this write asked for" +has "…and that there is nothing to commit" "$err" "nothing staged" +is "…leaving the row byte for byte as it was" "$(grep '^| `repoA13-1`' "$LANES")" "$a13_noop_before" +is "…and the checkout clean, so the next write is not refused for it" \ + "$(git -C "$WIP" status --porcelain -- lanes/LANES.md | grep -c . || :)" 0 + run "$E" set-row-state repoA13-1 "BUSY · doing things" is "an unknown STATE is refused" "$rc" 2 has "…naming the seven there are" "$err" "LIVE, PAUSED, LANDING #, LANDED, ENDED, RETIRED, HANDED OFF" @@ -10048,10 +10092,17 @@ SV_LANE_HANDOFF="$OPENREPOTOOLS_BIN_DIR/lane-handoff" # `%33` IS THE BARE FORM tmux itself exports, and the intent's pane is the live # record's `svsess:@33.%33`: the two agree component-wise, which is the # comparison `pane_agrees` makes, and asserting it here is the positive half of -# the `%3`-is-not-`%33` case further down. A case that wants to DISAGREE sets -# `TMUX_PANE` again after this one, and the later assignment is the one `env` -# keeps. +# the `%3`-is-not-`%33` case further down. A case that wants to DISAGREE names +# its OWN `TMUX_PANE` in place of this one rather than a second assignment to +# the same name after it: `env A=1 A=2` keeping the later is true of every +# `env` this runs under and is written down nowhere, and a case whose meaning +# rests on that is a case nobody can read. SV_PANE='%33' +# AND THE INTENT FILE THE TWO LONG-LIVED FAKES BELOW WAIT ON, which is where +# the supervisor's observer writes its verdict. `LANES_LANE_STATE_ROOT` is +# `$SV_STATE` for this whole section, so `lane_control_root` resolves on its +# first rung and the path is knowable from here. +export SV_INTENT="$SV_STATE/repoSV-3/restart-intent.yaml" : > "$FAKE_CLAUDE_LOG" run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ "$START" --dir "$SV_DIR" repoSV-2 --no-launch @@ -10102,6 +10153,30 @@ is "a handoff that changed since the intent was written blocks the launch" "$r has "…saying the top block is the first prompt" "$err" "FIRST PROMPT" git -C "$WIP" checkout -- handoffs/repoSV/repoSV-2.md 2>/dev/null || : +# ONE DIGEST, THREE COMMANDS, AND IT HAD BETTER BE ONE FUNCTION. `lanes-edit.sh` +# RECORDS the handoff's digest into the intent, `lane-start` VERIFIES it before +# it launches, and `lane-handoff` verifies it again before a supervisor claims +# the operation — three installed files with no library between them, which is +# this estate's shape (`lane_control_root` is shared with openRepoTools#91 the +# same way, byte for byte, and design decision 14 says so in as many words). +# +# THE TWO CASES ABOVE ALREADY PROVE TWO OF THEM AGREE BY BEHAVIOUR — the launch +# went through while the file was untouched and refused the moment a line was +# appended — so what is left to pin is that the third is the same act, and that +# "the same" means the BODY and not the name. A copy that drifted would not fail +# loudly: it would RECORD one digest and VERIFY another, and every launch after +# it would refuse a handoff nobody had changed. +sv_fn_body() { # — its body, indentation normalised + awk -v f="$2" 'index($0, f "()") == 1 { on = 1 } on { sub(/^[ \t]+/, ""); print } on && $0 == "}" { exit }' "$1" | tail -n +2 +} +sv_dig_canon="$(sv_fn_body "$SRC_DIR/lanes-edit.sh" lane_handoff_digest)" +is "the digest lanes-edit.sh records with is a function with a body" \ + "$( [ "$(printf '%s' "$sv_dig_canon" | grep -c 'sha256sum' || :)" -ge 1 ] && echo yes || echo no )" yes +is "…and the one lane-start verifies a launch with is that same body" \ + "$(sv_fn_body "$SRC_DIR/lane-start" handoff_digest_of)" "$sv_dig_canon" +is "…and the one lane-handoff refuses a stale operation with is too" \ + "$(sv_fn_body "$SRC_DIR/lane-handoff" handoff_digest)" "$sv_dig_canon" + # A `ready` INTENT IS HISTORY AND AUTHORISES NOTHING. run "$E" set-restart-intent repoSV-2 ready --expect pending --expect-operation op-94 is "readiness is recorded against the operation" "$rc" 0 @@ -10197,14 +10272,14 @@ run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" "$SV_LAN is "a supervisor whose intent is already \`starting\` refuses rather than starting a second session" "$rc" 2 has "…citing the one-transcript-one-process rule" "$err" "Amendment 18(h)" -run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" TMUX_PANE='%99' "$SV_LANE_HANDOFF" \ +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE='%99' "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 --operation "$SV_OP" is "a supervisor in a pane the intent does not name refuses" "$rc" 2 has "…naming both panes" "$err" "svsess:@33.%33" # AND THE COMPARISON IS COMPONENT-WISE, NOT A SUFFIX. `%3` is a SUFFIX of # `%33`, so a `case ... in *"$TMUX_PANE")` accepted a supervisor in pane 3 for # an intent written for pane 33 — the wrong window, decided by string tail. -run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" TMUX_PANE='%3' "$SV_LANE_HANDOFF" \ +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE='%3' "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 --operation "$SV_OP" is "…and a pane whose id is a SUFFIX of the intent's is still another pane" "$rc" 2 has "…named as the disagreement it is" "$err" "and this supervisor is in %3" @@ -10263,7 +10338,20 @@ cat > "$SV_BIN/pclaude-ok" <<'FAKE' #!/usr/bin/env bash printf '%s\n' "$*" >> "${FAKE_SVLAUNCH_LOG:-/dev/null}" cp "$SV_READY_SRC" "$SV_READY_DST" -sleep "${FAKE_SVLAUNCH_SLEEP:-8}" +# THE CHILD OUTLIVES THE OBSERVER'S VERDICT, AND WAITS FOR THE VERDICT RATHER +# THAN FOR A NUMBER OF SECONDS. The case is about an ORDER — the observer +# confirms readiness, and the session exits LATER — and `sleep 8` asserted that +# order with a stopwatch. The observer's poll is a `lanes-edit.sh live-holder`, +# which resolves the lane, reads the published row and scans every session +# record this workstation has; two of those while another lane runs this same +# suite cost more than the eight seconds the child was given, and the case went +# red on a machine that was merely busy. Waiting on the file the observer +# writes makes the ORDER the assertion, and is faster in the ordinary case. +i=0 +while [ "$i" -lt "${FAKE_SVLAUNCH_CAP:-60}" ]; do + grep -q '^state: ready' "${SV_INTENT:-/nonexistent}" 2>/dev/null && break + i=$((i + 1)); sleep 1 +done exit 0 FAKE chmod +x "$SV_BIN/pclaude-ok" @@ -10271,7 +10359,7 @@ export SV_READY_SRC SV_READY_DST="$sessions_dir/live-sv-new.json" "$E" set-restart-intent repoSV-3 pending --expect failed --new-transcript "$SV_NEW" >/dev/null 2>&1 : > "$FAKE_SVLAUNCH_LOG" run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-ok" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" \ - LANE_SUPERVISOR_READY_SECONDS=20 FAKE_SVLAUNCH_SLEEP=8 "$SV_LANE_HANDOFF" \ + LANE_SUPERVISOR_READY_SECONDS=20 "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 --operation "$SV_OP" is "a launch that comes up and is CONFIRMED exits 0 when its session later ends" "$rc" 0 has "…saying the replacement was confirmed" "$out" "was CONFIRMED" @@ -10321,14 +10409,23 @@ has "…because --restart mints its own" "$err" "MINTS its own operation" cat > "$SV_BIN/pclaude-quiet" <<'FAKE' #!/usr/bin/env bash printf '%s\n' "$*" >> "${FAKE_SVLAUNCH_LOG:-/dev/null}" -sleep "${FAKE_SVLAUNCH_SLEEP:-7}" +# ALIVE UNTIL THE DEADLINE HAS BEEN DECLARED, and not for a fixed count of +# seconds, for the reason `pclaude-ok` above gives. This child must still be +# running when the observer's deadline expires — that is what INDETERMINATE +# MEANS — and must then exit, because a supervisor blocked on its child is only +# able to report once the child is gone. +i=0 +while [ "$i" -lt "${FAKE_SVLAUNCH_CAP:-60}" ]; do + grep -q 'INDETERMINATE' "${SV_INTENT:-/nonexistent}" 2>/dev/null && break + i=$((i + 1)); sleep 1 +done exit 0 FAKE chmod +x "$SV_BIN/pclaude-quiet" "$E" set-restart-intent repoSV-3 pending --expect ready >/dev/null 2>&1 : > "$FAKE_SVLAUNCH_LOG" run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-quiet" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" \ - LANE_SUPERVISOR_READY_SECONDS=4 FAKE_SVLAUNCH_SLEEP=8 "$SV_LANE_HANDOFF" \ + LANE_SUPERVISOR_READY_SECONDS=4 "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 --operation "$SV_OP" is "a child that stays alive without readiness evidence is INDETERMINATE, not running" "$rc" 3 has "…and the supervisor says which it was" "$out" "INDETERMINATE" From d9b282e36fb4b294e2c53c5cfdfc33513eb1d197 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:36:28 +0000 Subject: [PATCH 05/25] The two spellings of six flags refuse the same empty value, and the credential assertion stops being vacuous on BSD (CI hygiene on #121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both come from this repository's own hygiene gate, and both are defects the rescued work carried in. **`--flag ""` refused an empty value and `--flag=` took one**, for `set-restart-intent`'s `--expect`, `--expect-generation`, `--expect-operation`, `--operation`, `--generation` and `--attempt`. The `=` spelling of an empty value then read as ABSENT, and every rung below the flag ran on a value the operator DID give: an `--expect=` would have fenced on nothing at all, and an `--expect-operation=` would have claimed an operation it was not entitled to — which is the fence this whole change is built on, opened by a spelling. This is `#26`'s paragraph (`the review of c3ebcfe`, `lane-start:707`) arriving at a new subcommand, and the six now refuse in both spellings. The flags beside them that take `[ "$#" -ge 2 ]` deliberately ACCEPT an empty value in both spellings, because `none` is an answer for those fields; the comment says which is which. **And the "no field could carry a credential" assertion was vacuous on BSD.** It read `grep -ci 'token\|secret\|password\|key'`, and `\|` is a GNU extension to BRE: BSD `grep` matches it LITERALLY and reports no error, so on the macOS job that assertion counted occurrences of the single string `token\|secret\|…` — none, always, whatever the record held. An assertion that cannot fail is not an assertion, and this one is the case that proves the restart intent has nowhere to put a secret. `-E` and a real alternation. `python3 -m pytest tests/test_repo_hygiene.py -q` — 144 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r --- lanes-edit.sh | 22 ++++++++++++++++------ tests/test_lane_helpers.sh | 2 +- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index dee54f71..8c842947 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -9423,18 +9423,28 @@ EOF sri_exp=""; sri_expg=""; sri_expo=""; sri_op=""; sri_gen=""; sri_mode="" sri_agent=""; sri_prof=""; sri_dir=""; sri_pane=""; sri_win=""; sri_hf="" sri_dig=""; sri_old=""; sri_new=""; sri_att=""; sri_bump=0; sri_reason="" + # THE TWO SPELLINGS OF ONE FLAG GIVE ONE ANSWER (#26, the review of + # `c3ebcfe`, and `lane-start` carries the same paragraph at its own + # arguments). `--flag ""` refused an empty value here and `--flag=` took + # one, so the `=` spelling of an empty operation, generation, attempt or + # expectation read as ABSENT — and every rung below the flag then ran on a + # value the operator DID give: an `--expect=` would have fenced on nothing, + # an `--expect-operation=` would have claimed an operation it was not + # entitled to. The six that refuse an empty value refuse it in both + # spellings; the ones below that take `[ "$#" -ge 2 ]` deliberately ACCEPT + # an empty value in both, because `none` is an answer for those fields. while [ $# -gt 0 ]; do case "$1" in --expect) sri_exp="${2-}"; [ -n "$sri_exp" ] || die "--expect needs a state word, 'none', or a '|'-separated set of them" 64; shift 2 ;; - --expect=*) sri_exp="${1#--expect=}"; shift ;; + --expect=*) sri_exp="${1#--expect=}"; [ -n "$sri_exp" ] || die "--expect needs a state word, 'none', or a '|'-separated set of them" 64; shift ;; --expect-generation) sri_expg="${2-}"; [ -n "$sri_expg" ] || die "--expect-generation needs a number" 64; shift 2 ;; - --expect-generation=*) sri_expg="${1#--expect-generation=}"; shift ;; + --expect-generation=*) sri_expg="${1#--expect-generation=}"; [ -n "$sri_expg" ] || die "--expect-generation needs a number" 64; shift ;; --expect-operation) sri_expo="${2-}"; [ -n "$sri_expo" ] || die "--expect-operation needs an operation id" 64; shift 2 ;; - --expect-operation=*) sri_expo="${1#--expect-operation=}"; shift ;; + --expect-operation=*) sri_expo="${1#--expect-operation=}"; [ -n "$sri_expo" ] || die "--expect-operation needs an operation id" 64; shift ;; --operation) sri_op="${2-}"; [ -n "$sri_op" ] || die "--operation needs an operation id" 64; shift 2 ;; - --operation=*) sri_op="${1#--operation=}"; shift ;; + --operation=*) sri_op="${1#--operation=}"; [ -n "$sri_op" ] || die "--operation needs an operation id" 64; shift ;; --generation) sri_gen="${2-}"; [ -n "$sri_gen" ] || die "--generation needs a number" 64; shift 2 ;; - --generation=*) sri_gen="${1#--generation=}"; shift ;; + --generation=*) sri_gen="${1#--generation=}"; [ -n "$sri_gen" ] || die "--generation needs a number" 64; shift ;; --mode) sri_mode="${2-}"; [ "$#" -ge 2 ] || die "--mode needs a value" 64; shift 2 ;; --mode=*) sri_mode="${1#--mode=}"; shift ;; --agent) sri_agent="${2-}"; [ "$#" -ge 2 ] || die "--agent needs a value" 64; shift 2 ;; @@ -9456,7 +9466,7 @@ EOF --new-transcript) sri_new="${2-}"; [ "$#" -ge 2 ] || die "--new-transcript needs a value" 64; shift 2 ;; --new-transcript=*) sri_new="${1#--new-transcript=}"; shift ;; --attempt) sri_att="${2-}"; [ -n "$sri_att" ] || die "--attempt needs a number" 64; shift 2 ;; - --attempt=*) sri_att="${1#--attempt=}"; shift ;; + --attempt=*) sri_att="${1#--attempt=}"; [ -n "$sri_att" ] || die "--attempt needs a number" 64; shift ;; --bump-attempt) sri_bump=1; shift ;; --reason) sri_reason="${2-}"; [ "$#" -ge 2 ] || die "--reason needs a value" 64; shift 2 ;; --reason=*) sri_reason="${1#--reason=}"; shift ;; diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 4f73fb4b..fcc35d45 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -9982,7 +9982,7 @@ has "…and the launch mode that authorises a fresh session" "$out" "$(printf ' is "…with a digest of the handoff, computed by the writer" \ "$( [ "$(printf '%s\n' "$out" | awk -F'\t' '$1=="digest"{print length($2)}')" -gt 16 ] && echo yes || echo no )" yes is "…and no field that could carry a credential" \ - "$(printf '%s\n' "$out" | grep -ci 'token\|secret\|password\|key' || :)" 0 + "$(printf '%s\n' "$out" | grep -Eci 'token|secret|password|key' || :)" 0 run "$E" set-restart-intent repoSV-1 pending --expect 'none|ready|failed' --operation op-test-2 is "a second /ctx does not supersede an operation in flight" "$rc" 7 From 5e390262eed982a8d94e67d318df4096db596ac2 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:39:58 +0000 Subject: [PATCH 06/25] Task 7.5 says why it is open, like the other five, rather than sitting unticked with nothing beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tasks.md` §0 names a reason for every deferral and named five; 7.5 — *amend `add-automatic-context-rollover` so its success condition is readiness-confirmed `RUNNING`* — was left unticked with nothing said, which reads as forgotten rather than as decided. There is nothing on this branch to amend. `openspec/changes/` here holds `add-supervised-context-restart` and `interactive-lane-name-repair` and nothing else; that change exists only as an untracked working copy in the main checkout. Writing a second copy of somebody else's proposal into this branch to tick a box would be worse than the open box. And what this change OWES it does exist, which is the half worth saying: the outcome to consume is the restart intent reaching `ready`, readable from any window with `lane-handoff --restart-status --lane `, and the requirement *Automatic rollover waits for supervised completion* is already in this change's spec for that proposal to point at. `openspec validate add-supervised-context-restart --strict` — valid. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r --- openspec/changes/add-supervised-context-restart/tasks.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/add-supervised-context-restart/tasks.md b/openspec/changes/add-supervised-context-restart/tasks.md index 5727f4c1..f78cf080 100644 --- a/openspec/changes/add-supervised-context-restart/tasks.md +++ b/openspec/changes/add-supervised-context-restart/tasks.md @@ -37,6 +37,7 @@ So the fix is in `replace_line`: a result byte-identical to the file it would re - **§3.1–3.3** — the staged semantic payload. `/ctx`'s skill step now calls `lane-handoff --restart` and carries no executable tmux recipe, which is decision 1's *one backend operation* and removes the second implementation of the destructive tail. The machine-readable payload file and its binding checks are a further step and are not in this pass. - **§6.4–6.5, §8.4, §8.7–8.8** — explicit finalize-or-stop actions, generation-advancing recovery, the crash-point matrix and the manual canary. - **§7.4** — the amendment that would authorise a supervised respawn and a durable restart intent. Design decisions 10 and 11 record where this implementation diverges from Amendment 18 Addendum 2 (i-8) as ratified; **this proposal is not treated as ratification of that divergence.** +- **§7.5** — amending `add-automatic-context-rollover` so its success condition is readiness-confirmed `RUNNING`. **There is nothing on this branch to amend:** `openspec/changes/` here holds `add-supervised-context-restart` and `interactive-lane-name-repair` and nothing else, and that change exists only as an untracked working copy in the main checkout. Writing a second copy of somebody else's proposal into this branch to tick a box would be worse than the open box. What this change owes it is the outcome to consume, and that exists: readiness-confirmed completion is the restart intent reaching `ready`, readable from any window with `lane-handoff --restart-status --lane `, and the requirement *Automatic rollover waits for supervised completion* is in this change's spec for that proposal to point at. ## 1. Prerequisite Lifecycle Contract From c4214265d9f6243c258d937881474ed12f65515c Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:57:06 +0000 Subject: [PATCH 07/25] A supervisor sent SIGTERM left the lane wedged, and a trap alone would never have fired (Copilot round 1 on #121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings, all five taken, and the first needed the process structure changed and not only a handler. **A `TERM` TO THE PANE ROOT LEFT THE INTENT `starting` FOR EVER.** That is the one state nothing recovers from by itself: `--supervise` refuses to launch from it, because a second session of one lane is Amendment 18(h)'s collision, and the next `/ctx` refuses too, because `--expect 'none|ready|failed'` is exactly what stops an ordinary context clear superseding an operation in flight. Meanwhile `--restart-status` said `IN FLIGHT: a supervisor holds this operation` about a supervisor that was gone. **AND A TRAP WOULD NOT HAVE FIRED, because bash defers one until the foreground command returns** — and this supervisor's foreground command is an interactive Claude that runs for hours. Measured in isolation: a shell running `sleep 20` in the foreground did not reach its `TERM` trap for the whole twenty seconds; the same shell with the sleep backgrounded and `wait`ed ran the trap in the same second. So the child is backgrounded and `wait`ed, and the two things a background job loses are put back, because this child is an interactive terminal program and neither loss is survivable for one: * ITS STDIN. With job control off — every non-interactive bash — an asynchronous command's stdin is redirected from `/dev/null`. Measured. Fd 9 holds this pane's own stdin, saved before the loop, and the child is started `<&9 9<&-`, the close so that no descriptor of this script's bookkeeping is handed on to Claude. * ITS `INT` AND `QUIT`. Bash sets both to SIG_IGN for an async command and SIG_IGN SURVIVES `exec`, so Claude would have come up with Ctrl-C dead, in the pane a person had just cleared the context of. Measured: `SigIgn 0000000000000006` on the child without the reset, `0000000000000000` with it. `trap - INT QUIT` in the child's subshell restores the disposition this shell was entered with, and does so even though the supervisor has since set `trap '' INT` for itself — which it now does deliberately, because Ctrl-C reaches the whole foreground process group and a supervisor that exited on it would tear the pane down under a Claude that was merely interrupted. `TERM` and `HUP` now mark the intent `failed`, fenced, with a reason naming the signal and whether the child was still running, and they KILL NOTHING (Amendment 8(f)). `failed` is safe even with a live child, because a retry is a NEW `--supervise` and that mode's own gate reads `live-holder` before it launches and refuses on a live one. The exact shipped structure was verified end to end: the parent's trap fires in the same second, the child survives the parent, the child's stdin is the pane's, its `SigIgn` is empty and its fd table is `0 1 2`. **THE INTENT WRITER WALKED PAST AN UNKNOWN SCHEMA.** The read side fails closed with `UNKNOWN-SCHEMA`; the write side copied the fields this version knows forward, dropped the ones it does not, and stamped the file `schema: 1` — the read-side protection undone by the other half of one command. It is checked inside the lock now and refuses with 2 rather than 7: 7 is *another act got there first, re-read and decide*, and this is *this copy of the tooling must not touch this file at all*. **AND `lane-start` RESUMED ON ONE.** `UNKNOWN-SCHEMA` fell through to the ordinary resume-source resolution, which is the SAFE half of the decision sitting on top of the unsafe one: it authorises no FRESH session, and the resume source waiting for a lane whose `/ctx` has just killed its pane is the transcript that `/ctx` PAUSED. That is `openRepoTools#94` reached down a second road. It refuses now, in Amendment 7(d)'s shape, naming both ways out — because it costs a person the lane until one of them is taken. **THE USAGE BLOCK PROMISED AN EXIT CODE NO PATH RETURNED.** `4 indeterminate` was unreachable: the observer's own `return 4` is read by nothing, because it runs in the background and the RECORD is what decides. So the record decides this too — an operation whose reason begins `INDETERMINATE:` ends 4 and says *ran past the readiness deadline still alive*. The `failed` transition STAYS, and that half of the review is declined with its reason: INDETERMINATE is the answer for a child that is ALIVE at the deadline, and this branch is reached only once that child has EXITED, which is a launch that ended having never been confirmed and is retryable — leaving it `starting` would wedge the lane in the state the first finding above exists to close. **AND THE FAILURE WRITE WAS NOT FENCED ON THE GENERATION.** The readiness finalizer always was; the failure write, the signal write and the INDETERMINATE note were not, so a superseded supervisor could publish an old verdict over a newer generation's record. All five state transitions carry it now — and on the generation CLAIMED rather than the one on disk, because the post-child branch has just re-`ri_load`ed and would have read the NEWER generation and matched it, which is precisely the write the fence exists to refuse. It is empty until a claim is made, so a signal arriving before one does not fence on a `0` nobody holds, and a signal arriving before the lane is even known says so and writes nothing. `openspec validate add-supervised-context-restart --strict` — valid. `python3 -m pytest tests/test_repo_hygiene.py -q` — 144 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r --- lane-handoff | 205 +++++++++++++++++- lane-start | 17 ++ lanes-edit.sh | 19 ++ .../add-supervised-context-restart/tasks.md | 8 + tests/test_lane_helpers.sh | 59 ++++- 5 files changed, 293 insertions(+), 15 deletions(-) diff --git a/lane-handoff b/lane-handoff index 4e64b1ac..6755f165 100755 --- a/lane-handoff +++ b/lane-handoff @@ -215,8 +215,12 @@ lane-handoff [--restart | --exit | --in-process] [--late --at ] requested by @/. Exit: 0 written · 2 refused · 1 a read failed. - --supervise adds: 3 the launch failed and there was no terminal here to - offer the retry on · 4 indeterminate (a live child, readiness unconfirmed). + --supervise adds: 3 the launch failed before readiness and there was no + terminal here to offer the retry on · 4 the same, for a launch that ran + PAST the readiness deadline still alive and only then ended (the record + carries INDETERMINATE and the intent is retryable either way) · 143 and + 129, a supervisor sent SIGTERM or SIGHUP, which marks the intent failed, + kills nothing, and says whether the child was still running. USAGE } @@ -602,8 +606,13 @@ supervise_watch() { # < # answer and not a failure. Nothing is killed (Amendment 8(f): ending # somebody's process is not a boundary script's act), nothing is retried, # and the lane is NOT marked running. + # FENCED ON THE GENERATION LIKE EVERY OTHER WRITE THIS OBSERVER MAKES. It is + # the same argument Copilot's round 1 makes about the failure write: an + # observer whose lane has moved on must not publish its verdict over a newer + # generation's record, and `$sw_gen` is the generation the supervisor + # CLAIMED rather than whatever is on disk by now. LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$sw_lane" starting \ - --expect starting --expect-operation "$sw_op" \ + --expect starting --expect-operation "$sw_op" --expect-generation "$sw_gen" \ --reason "INDETERMINATE: ${sw_waited}s elapsed (deadline ${SUPERVISE_READY_SECONDS}s) with the child alive and readiness unconfirmed; ${READY_WHY:-no evidence}" >/dev/null 2>&1 || : return 4 } @@ -661,12 +670,22 @@ supervise_argv() { # # nobody will answer. # # 3 = reported, no terminal · 10 = reported, ask the person. +# THE FAILURE IS FENCED ON THE GENERATION TOO, AND ON THE ONE THIS SUPERVISOR +# CLAIMED (Copilot round 1 on openRepoTools#121). The readiness finalizer has +# always carried `--expect-generation`; this write did not, so a supervisor +# whose lane had moved on could publish its old failure over a newer +# generation's record. The number has to be the one captured AT THE CLAIM and +# not the one on disk now — re-reading it would read the newer generation and +# match it, which is the stale write the fence exists to refuse. supervise_failed() { # sf_lane="$1"; sf_op="$2"; sf_why="$3" + sf_fence=() + [ -n "$SV_CLAIMED_GEN" ] && sf_fence=(--expect-generation "$SV_CLAIMED_GEN") LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$sf_lane" failed \ --expect 'starting|pending' --expect-operation "$sf_op" \ + ${sf_fence[@]+"${sf_fence[@]}"} \ --reason "$sf_why" >/dev/null 2>&1 || - note "the restart intent could not be marked failed (another operation may hold this lane now). Read it: $prog --restart-status --lane $sf_lane" + note "the restart intent could not be marked failed — its state, operation or generation moved, so this supervisor's failure is not the current one to publish. Read it: $prog --restart-status --lane $sf_lane" say "" say "RESTART FAILED — lane $sf_lane, operation $sf_op" say " $sf_why" @@ -708,8 +727,25 @@ supervise_prompt() { # # again under the same operation, generation, digest, directory and mode — the # spec's idempotent retry — and never because a child that ended is relaunched # on its own. +# THE CHILD, AS ONE WORD, so the two spellings of its launch differ only in the +# redirection that hands it this pane's stdin. It reads `sr_dir`, `sr_op`, +# `sr_pidf` and `SUPERVISE_CMD` from the run it is called inside. +supervise_child() { + ( trap - INT QUIT + if [ -n "$sr_dir" ] && [ "$sr_dir" != none ]; then + cd -- "$sr_dir" || exit 127 + fi + LANE_START_FRESH=1 LANE_RESTART_OPERATION="$sr_op" \ + exec sh -c 'printf "%s\n" "$$" > "$1"; shift; exec "$@"' _ "$sr_pidf" "${SUPERVISE_CMD[@]}" ) +} + supervise_run() { # sr_lane="$1"; sr_op="$2" + # THIS PANE'S OWN STDIN, SAVED, so the background child below can be given + # it back. Where fd 0 cannot be duplicated the child is started without it, + # which is what a foreground child would have had anyway on such a shell. + SV_STDIN_SAVED=0 + if exec 9<&0 2>/dev/null; then SV_STDIN_SAVED=1; fi sr_pidf="$(mktemp "${TMPDIR:-/tmp}/lane-supervisor.XXXXXX" 2>/dev/null || printf '')" [ -n "$sr_pidf" ] || die "no temporary file could be made to record the launch's pid, and without it this supervisor cannot tell a child that died from one that is starting. Nothing was started." 2 SV_PIDFILE="$sr_pidf" @@ -735,6 +771,13 @@ supervise_run() { # *) die "lane $sr_lane's restart intent could not be claimed (\`set-restart-intent\` exited $sr_crc), and this supervisor will not launch without a fenced hold on operation $sr_op. Nothing was started." 2 ;; esac ri_load "$sr_lane" >/dev/null 2>&1 || : + # THE GENERATION THIS SUPERVISOR HOLDS, taken once, here, where the claim + # has just succeeded and the record is this act's. Every later write of + # this operation is fenced on THIS number rather than on whatever the file + # says at the time, which is what makes a superseded supervisor's write + # refuse instead of landing. + SV_CLAIMED_GEN="${RI_GEN:-0}" + case "$SV_CLAIMED_GEN" in '' | *[!0-9]*) SV_CLAIMED_GEN=0 ;; esac if ! supervise_argv "$sr_lane" "$sr_op"; then supervise_failed "$sr_lane" "$sr_op" "there is no launcher and no lane-start on PATH here, so nothing could be started" @@ -757,21 +800,51 @@ supervise_run() { # say " this supervisor stays in the pane: whatever the launch does, it will say so here." say "" : > "$sr_pidf" - supervise_watch "$sr_lane" "$sr_op" "${RI_GEN:-0}" "$sr_pidf" "$RI_NEW" "$RI_OLD" "$RI_PANE" & + supervise_watch "$sr_lane" "$sr_op" "$SV_CLAIMED_GEN" "$sr_pidf" "$RI_NEW" "$RI_OLD" "$RI_PANE" & sr_watch=$! sr_status=0 + # THE CHILD RUNS IN THE BACKGROUND AND THIS PROCESS `wait`s ON IT, and + # that is not a preference either: **bash defers a trap until the + # foreground command it is running returns**. Measured — a `TERM` sent + # to a shell running `sleep 20` in the foreground does not reach its + # `TERM` trap for the whole twenty seconds, and the same shell with the + # sleep backgrounded and `wait`ed runs the trap in the same second. A + # supervisor whose child is an interactive Claude runs for hours, so a + # foreground child means the signal handling above would never fire when + # it is needed, which is the whole of Copilot round 1's finding. + # + # TWO THINGS A BACKGROUND JOB LOSES, AND BOTH ARE PUT BACK, because the + # child here is an interactive terminal program and neither loss is + # survivable for one: + # + # * ITS STDIN. With job control off — which is every non-interactive + # bash — an asynchronous command's stdin is redirected from + # `/dev/null`. Measured. So fd 9 holds this pane's own stdin, saved + # before the loop, and the child is started with `<&9`. + # * ITS `INT` AND `QUIT`. Bash sets both to SIG_IGN for an + # asynchronous command, and SIG_IGN SURVIVES `exec` — so Claude + # would come up with Ctrl-C dead, in the pane a person just cleared + # the context of. Measured: `SigIgn 0000000000000006` on the child + # without the reset, `0000000000000000` with it. `trap - INT QUIT` + # in the subshell restores the disposition this shell was entered + # with, which is the default, and it does so even though this shell + # has since set `trap '' INT` for itself. + # # THE PID IS WRITTEN BY THE PROCESS THAT BECOMES THE CHILD, and `sh -c` # is how, on bash 3.2 as well as on a modern one: `$$` inside it is # that shell's pid, and the `exec` on the next word makes that pid the # launch's own. `$BASHPID` would be the one-liner and macOS ships bash # 3.2, which does not have it. - if [ -n "$sr_dir" ] && [ "$sr_dir" != none ]; then - ( cd -- "$sr_dir" && LANE_START_FRESH=1 LANE_RESTART_OPERATION="$sr_op" \ - exec sh -c 'printf "%s\n" "$$" > "$1"; shift; exec "$@"' _ "$sr_pidf" "${SUPERVISE_CMD[@]}" ) || sr_status=$? + # `9<&-` CLOSES THE SAVED COPY IN THE CHILD once it has become its + # stdin: the pane's descriptor is this process's bookkeeping and is no + # part of the environment an interactive Claude should be handed. + if [ "$SV_STDIN_SAVED" = 1 ]; then + supervise_child <&9 9<&- & else - LANE_START_FRESH=1 LANE_RESTART_OPERATION="$sr_op" \ - sh -c 'printf "%s\n" "$$" > "$1"; shift; exec "$@"' _ "$sr_pidf" "${SUPERVISE_CMD[@]}" || sr_status=$? + supervise_child & fi + sr_child=$! + wait "$sr_child" || sr_status=$? kill "$sr_watch" 2>/dev/null wait "$sr_watch" 2>/dev/null # WHAT THE RECORD SAYS NOW IS WHAT DECIDES, and never this process's @@ -784,13 +857,38 @@ supervise_run() { # say " A session that exited on purpose is not relaunched here — that is \`lane $sr_lane\`." return 0 fi + # AND WHETHER IT WENT THROUGH `INDETERMINATE` IS PART OF THE ANSWER + # (Copilot round 1 on openRepoTools#121). The usage block promises + # `4 indeterminate`, and no path returned it: the observer's `return 4` + # is read by nothing, because the observer runs in the background and + # this process decides from the RECORD. So the record decides that too. + # + # THE `failed` TRANSITION STAYS, and it is the half Copilot asked to + # remove. `INDETERMINATE` is the answer for a child that is ALIVE at the + # deadline — the supervisor keeps waiting, kills nothing and marks + # nothing — and this branch is reached only once that child has EXITED. + # A launch that ended having never been confirmed is a failed launch by + # every reading, and it is retryable; leaving the intent `starting` + # there would wedge the lane in the one state neither a retry nor the + # next `/ctx` can move, which is the defect the signal handling above + # exists to close. What was owed was the DISTINCTION, and it is the + # exit status and the reason, not a different transition. sr_why="the launch ended with status $sr_status before readiness was confirmed" + sr_end=3 case "$RI_REASON" in - INDETERMINATE:*) sr_why="the launch ran without readiness evidence and then ended with status $sr_status; $RI_REASON" ;; + INDETERMINATE:*) + sr_why="the launch was INDETERMINATE — it ran past the readiness deadline still alive, then ended with status $sr_status; $RI_REASON" + sr_end=4 ;; esac supervise_failed "$sr_lane" "$sr_op" "$sr_why" sr_fs=$? - [ "$sr_fs" = 10 ] || return "$sr_fs" + # `10` IS "ASK THE PERSON" AND EVERY OTHER VALUE IS THE STATUS TO LEAVE + # WITH — except that an indeterminate end reports 4 rather than the 3 + # `supervise_failed` returns for "reported, no terminal here". + if [ "$sr_fs" != 10 ]; then + [ "$sr_fs" = 3 ] && return "$sr_end" + return "$sr_fs" + fi supervise_prompt "$sr_lane" "$sr_op" || return $? done } @@ -941,10 +1039,21 @@ fi # own front door. Where there is no terminal there is nobody to hold it for, and # the status is simply returned. SV_PIDFILE="" +SV_SIGNALLED=0 +# EMPTY UNTIL A CLAIM HAS BEEN MADE, and then the generation that claim held. +# A signal arriving before the claim must not fence a write on `0` — on a +# workstation with openRepoTools#91 the lane's generation is whatever it is, +# and `--expect-generation 0` against it would refuse the very write that keeps +# the lane out of the wedged `starting` state. +SV_CLAIMED_GEN="" sv_on_exit() { sv_hs=$? [ -z "$SV_PIDFILE" ] || rm -f -- "$SV_PIDFILE" 2>/dev/null [ "$sv_hs" = 0 ] && return 0 + # A SUPERVISOR SOMEBODY SIGNALLED AWAY IS NOT ONE ANYBODY IS WAITING TO + # READ. Holding the pane open for an Enter after a `tmux kill-pane` is a + # pane that will not close. + [ "$SV_SIGNALLED" = 1 ] && return 0 [ "${LANE_SUPERVISOR_NO_PROMPT:-0}" = 1 ] && return 0 [ -t 0 ] || return 0 printf '\n%s: this pane is held open so the lines above can be read (exit %s). Press Enter to close it. ' "$prog" "$sv_hs" >&2 @@ -952,8 +1061,80 @@ sv_on_exit() { return 0 } +# A SUPERVISOR THAT IS SIGNALLED AWAY LEAVES A RECOVERABLE RECORD, AND KILLS +# NOTHING (Copilot round 1 on openRepoTools#121). With only an `EXIT` trap, a +# `TERM` to the pane root — `tmux kill-pane`, a logout, a person's `kill` — +# ended this process with the intent still `starting`, which is the ONE state +# nothing can recover from by itself: `--supervise` refuses to launch from it +# (a second session of one lane is Amendment 18(h)'s collision), and the next +# `/ctx` refuses too, because `--expect 'none|ready|failed'` is what stops an +# ordinary context clear superseding an operation in flight. The lane was wedged +# until somebody edited the file by hand, and `--restart-status` said +# `IN FLIGHT: a supervisor holds this operation` about a supervisor that was +# gone. +# +# SO THE SIGNAL WRITES `failed`, WHICH IS THE RETRYABLE END. It is honest — +# this supervisor confirmed nothing — and it is safe even where the child is +# still alive, because a retry is a NEW `--supervise` and that mode's own gate +# reads `live-holder` before it launches anything and refuses on a live one, +# naming `lane-end --retire `. The reason records which signal it +# was and whether the child was still running, so the person reading +# `--restart-status` from another window is told the thing that decides what to +# do next. +# +# AND IT NEVER KILLS THE CHILD (Amendment 8(f): ending somebody's process is not +# a boundary script's act). Where the signal was `tmux kill-pane` the child goes +# with the pane anyway; where it was not, a running Claude is not this script's +# to end. +sv_signal() { # + SV_SIGNALLED=1 + # A SIGNAL BEFORE THIS PROCESS KNOWS WHICH OPERATION IT IS SUPERVISING has no + # record to write and says so: the traps are installed at the top of the mode, + # ahead of the arguments being checked, because a supervisor that could be + # signalled between those two lines and leave nothing behind is the same gap + # one instruction wide. + if [ -z "$lane" ] || [ -z "$operation" ]; then + say "" + say "$prog: SIG$1 before this supervisor had a lane and an operation to supervise — nothing was claimed, nothing was started and nothing is left behind." + exit "$2" + fi + sv_sg_child=""; sv_sg_alive="no child had been started" + if [ -n "$SV_PIDFILE" ] && [ -r "$SV_PIDFILE" ]; then + sv_sg_child="$(head -n 1 -- "$SV_PIDFILE" 2>/dev/null | tr -dc '0-9')" + fi + if [ -n "$sv_sg_child" ]; then + if kill -0 "$sv_sg_child" 2>/dev/null; then + sv_sg_alive="the launch (pid $sv_sg_child) was STILL RUNNING and was not killed" + else + sv_sg_alive="the launch (pid $sv_sg_child) had already ended" + fi + fi + sv_sg_fence=() + [ -n "$SV_CLAIMED_GEN" ] && sv_sg_fence=(--expect-generation "$SV_CLAIMED_GEN") + LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$lane" failed \ + --expect 'pending|starting' --expect-operation "$operation" \ + ${sv_sg_fence[@]+"${sv_sg_fence[@]}"} \ + --reason "the supervisor was sent SIG$1 before readiness was confirmed; $sv_sg_alive" \ + >/dev/null 2>&1 || : + say "" + say "$prog: SIG$1 — this supervisor is leaving operation $operation on lane $lane." + say " $sv_sg_alive." + say " The restart intent is \`failed\` and retryable, and nothing was killed:" + say " read it: $(quoted "$SELF_ABS") --restart-status --lane $lane" + say " retry: $(quoted "$SELF_ABS") --supervise --lane $lane --operation $operation" + exit "$2" +} + if [ "$do_supervise" = 1 ]; then trap sv_on_exit EXIT + # `INT` IS THE CHILD'S AND NOT THIS PROCESS'S. Ctrl-C reaches the whole + # foreground process group, and a supervisor that exited on it would tear + # the pane down under a Claude that was merely interrupted — so this process + # ignores it and the child's own subshell puts it back to default before the + # exec, which is the one thing that keeps Ctrl-C working in that pane. + trap '' INT + trap 'sv_signal TERM 143' TERM + trap 'sv_signal HUP 129' HUP [ -n "$lane" ] || die "--supervise needs the lane: $prog --supervise --lane --operation " 2 [ -n "$operation" ] || die "--supervise needs the operation it was started for: $prog --supervise --lane $lane --operation . A supervisor that took whatever intent it found would be a supervisor that launches a lane for an act somebody else is in the middle of." 2 diff --git a/lane-start b/lane-start index ce66acfa..fe72678b 100755 --- a/lane-start +++ b/lane-start @@ -2431,6 +2431,23 @@ if [ -n "$op_flag" ]; then die "--operation $op_flag is not lane $LANE's current restart operation, which is ${ri_op:-none} (state ${ri_state:-none}, generation ${ri_gen:-0}). A stale supervisor must never launch against a newer operation — nothing was renamed, written or started. Read it with: lanes-edit.sh restart-intent $LANE" 2 fi case "$ri_state" in + # AN INTENT THIS READER CANNOT UNDERSTAND REFUSES THE LAUNCH, AND DOES NOT + # QUIETLY RESUME (Copilot round 1 on openRepoTools#121). `lanes-edit.sh` + # answers `UNKNOWN-SCHEMA` where a newer tooling wrote the record, and that + # answer used to fall through this `case` to the ordinary resume-source + # resolution below — which is the SAFE half of the decision (it authorises no + # fresh session) sitting on top of the UNSAFE one: the lane whose intent that + # is has just had its pane killed by a `/ctx`, and the resume source waiting + # there is the transcript that `/ctx` PAUSED. Falling through would resume it. + # That is openRepoTools#94 exactly, reached down a second road. + # + # So it is a refusal, in the one shape Amendment 7(d) gives: a read that could + # not be made is never an answer. It costs a person the lane until the + # workstation's `lanes-edit.sh` is new enough to read the file — and the + # message says both ways out, which is what makes that recoverable rather than + # a wall. + UNKNOWN-SCHEMA) + die "lane $LANE has a restart intent this \`lanes-edit.sh\` cannot read: its schema is one this version does not know (\`lanes-edit.sh restart-intent $LANE\` answers UNKNOWN-SCHEMA). That is NOT 'no restart is in flight' — a read that could not be made is never an answer (Amendment 7(d)) — and starting anyway would resolve an ordinary resume source, which for a lane whose /ctx just killed its pane is the transcript that /ctx PAUSED. Nothing was renamed, written or started. Update this workstation's openRepoTools so it can read that record, or remove the intent once you know no restart is in flight: lanes-edit.sh restart-intent $LANE" 2 ;; pending|starting) if [ "$ri_mode" = fresh-from-handoff ]; then # THE DIRECTORY IS COMPARED BEFORE ANYTHING IS TAKEN. `real_of` is this diff --git a/lanes-edit.sh b/lanes-edit.sh index 8c842947..0c0053b9 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -9514,6 +9514,25 @@ EOF # THE FENCE, read under the same mutex the write takes, so that two # transitions cannot both read the intent before either writes it. acquire_lock + # A SCHEMA THIS WRITER DOES NOT KNOW IS NOT ITS RECORD TO REPLACE (Copilot + # round 1 on openRepoTools#121). The READ side already fails closed on one — + # it answers `UNKNOWN-SCHEMA` rather than reporting a launch it cannot + # understand — and the WRITE side walked straight past it: the fields it + # knows were copied forward, the ones it does not were dropped, and the file + # came out stamped `schema: 1`. A newer tooling's intent would have been + # silently rewritten as an older one's, which is the read-side protection + # undone by the other half of the same command. This is checked INSIDE the + # lock, with the fence, because the file may be replaced between a read and + # a write, and it refuses with 2 — not 7 — because 7 is *another act got + # there first, re-read and decide* and this is *this copy of the tooling + # must not touch this file at all*. + if [ -r "$sri_f" ]; then + sri_nows="$(lane_sidecar_field "$sri_f" schema 2>/dev/null || :)" + if [ -n "$sri_nows" ] && [ "$sri_nows" != "$LANE_RESTART_SCHEMA" ]; then + release_lock + die "lane $lane's restart intent is schema '$sri_nows' and this \`lanes-edit.sh\` writes schema $LANE_RESTART_SCHEMA. Nothing was written: taking a transition over it would copy forward the fields this version knows, drop the ones it does not, and stamp the file as an older schema — which is a newer tooling's record quietly replaced by an older one. Read it with a helper that knows that schema, or remove $sri_f once you know no restart is in flight." 2 + fi + fi sri_now=""; sri_nowg=""; sri_nowo=""; sri_nowa="" if [ -r "$sri_f" ]; then sri_now="$(lane_sidecar_field "$sri_f" state)" diff --git a/openspec/changes/add-supervised-context-restart/tasks.md b/openspec/changes/add-supervised-context-restart/tasks.md index f78cf080..c7e2015f 100644 --- a/openspec/changes/add-supervised-context-restart/tasks.md +++ b/openspec/changes/add-supervised-context-restart/tasks.md @@ -31,6 +31,14 @@ So the fix is in `replace_line`: a result byte-identical to the file it would re - **Two supervisor cases asserted an ORDER with a stopwatch.** §6 and §6a gave their fake child a fixed `sleep 8`, so the observer had to complete two 2-second sleeps, two `live-holder` polls and its write inside those eight seconds, with nothing ordering the two events. Both children now wait for the file the observer writes (`state: ready`; a `reason` naming `INDETERMINATE`) under a bounded cap, so the case asserts the order it is about and is faster in the ordinary run. (They were green in the baseline run; the stopwatch is a flake waiting rather than the one that fired.) - **`trap cleanup EXIT INT TERM` cleaned up on `TERM` and RETURNED.** `tests/run.sh` states the rule for its own lock — *"a handler that only cleans up and RETURNS releases the lock while this suite carries on running"* — and one handler on all three broke it here in the other direction. Measured: a `TERM` removed `$SANDBOX` and the run carried on against a sandbox that was gone, every case after it `env: '…/bin/lane-start': No such file or directory`, printing `1567 passed, 233 failed`. A killed run that reports two hundred failures is a transcript that lies about this repository. `INT` and `TERM` exit 130 and 143 now; `EXIT` still returns, because the status it carries is the suite's own. +**Found by Copilot round 1 on `opensoft/openRepoTools#121`, and every one of them taken:** + +- **A supervisor sent `SIGTERM` left the lane wedged.** With only an `EXIT` trap, a `TERM` to the pane root ended the process with the intent still `starting` — the one state nothing recovers from by itself, because `--supervise` refuses to launch from it and the next `/ctx` refuses too (`--expect 'none|ready|failed'` is what stops an ordinary clear superseding an operation in flight). **A trap alone would not have fired:** bash defers a trap until the foreground command returns, and this supervisor's foreground command is an interactive Claude. Measured — a shell running `sleep 20` in the foreground did not reach its `TERM` trap for twenty seconds; backgrounded and `wait`ed, it ran in the same second. So the child is backgrounded and `wait`ed, and the two things a background job loses are put back: its **stdin** (job control off redirects an async command's stdin from `/dev/null` — fd 9 holds the pane's own, and the child gets `<&9 9<&-`) and its **`INT`/`QUIT`** (bash sets both to `SIG_IGN` for an async command and SIG_IGN survives `exec`, so Claude would have come up with Ctrl-C dead — measured `SigIgn …0006` without the reset, `…0000` with). `TERM`/`HUP` now mark the intent `failed`, fenced, naming the signal and whether the child was still running, and kill nothing. +- **The intent writer walked past an unknown schema.** The read side fails closed with `UNKNOWN-SCHEMA`; the write side copied the fields it knew forward, dropped the rest and stamped `schema: 1`, which is the read-side protection undone by the other half of one command. Refused now, inside the lock, with 2 rather than 7 — 7 is *another act got there first*, this is *this copy of the tooling must not touch this file*. +- **`lane-start` resumed on an `UNKNOWN-SCHEMA` intent.** The fall-through authorised no FRESH session, which is what it was written for — and the resume source waiting for a lane whose `/ctx` had just killed its pane is the transcript that `/ctx` PAUSED. That is `#94` reached down a second road. It refuses now, naming both ways out. +- **The usage block promised an exit code no path returned.** `4 indeterminate` was never reachable: the observer's `return 4` is read by nothing, because it runs in the background and the record is what decides. An operation whose reason begins `INDETERMINATE:` now ends 4 and says *ran past the readiness deadline still alive*. The `failed` transition **stays** — `INDETERMINATE` is the answer for a child that is ALIVE at the deadline, and that branch is reached only once the child has exited, which is a launch that ended unconfirmed and is retryable; leaving it `starting` would wedge the lane in the state the first item above exists to stop. +- **The failure write was not fenced on the generation.** The readiness finalizer always was; the failure write, the signal write and the `INDETERMINATE` note were not, so a superseded supervisor could publish an old verdict over a newer generation's record. All five fenced now, on the generation **claimed** rather than the one on disk at write time — re-reading it would read the newer generation and match it, which is the write the fence exists to refuse. + **Deferred, with the reason:** - **§1 entirely** — it consumes `add-crash-consistent-lane-worktree-recovery`'s lane lock, generation and operation, which are on `feat/crash-consistent-lane-worktree-recovery` and not on `main`. The restart intent is a sibling sidecar under the *same* control root (design decision 14) so nothing here blocks on that merge and nothing re-implements it; §1's fail-closed work belongs in that change's own pass. diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index fcc35d45..30068e90 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -10018,11 +10018,31 @@ rm -f "$SV_STATE/repoSV-1/restart-intent.yaml.bak" run "$E" restart-intent repoSV-1 is "an intent whose schema this reader does not know still reads" "$rc" 0 has "…and says so rather than reporting a state it does not understand" "$out" "UNKNOWN-SCHEMA" +# AND NO LAUNCH GOES AHEAD AT ALL (Copilot round 1 on openRepoTools#121). An +# intent this reader cannot understand used to fall through to the ordinary +# resume-source resolution, which is the SAFE half of the decision sitting on +# top of the unsafe one: it authorises no FRESH session, and the resume source +# waiting for a lane whose /ctx just killed its pane is the transcript that /ctx +# PAUSED. That is openRepoTools#94 reached down a second road, so it refuses — +# a read that could not be made is never an answer (Amendment 7(d)). : > "$FAKE_CLAUDE_LOG" run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ "$START" --dir "$SV_DIR" repoSV-1 --no-launch -is "…and no launch treats it as authorising a fresh session" "$rc" 0 -has "…so the ordinary resume source decides" "$out" "--resume" +is "…and a launch of that lane REFUSES rather than resolving a resume source" "$rc" 2 +has "…saying a read that could not be made is not 'no restart in flight'" "$err" "Amendment 7(d)" +has "…and naming what resuming anyway would have opened" "$err" "the transcript that /ctx PAUSED" +hasnt "…and resolving no resume source at all" "$out" "--resume" +has "…while naming both ways out of it" "$err" "Update this workstation's openRepoTools" + +# AND THE WRITER WILL NOT REPLACE IT EITHER. The read side failing closed while +# the write side copied its known fields forward and stamped `schema: 1` over +# the rest is the read-side protection undone by the other half of one command. +run "$E" set-restart-intent repoSV-1 failed --expect starting --reason "over a schema this writer does not know" +is "a transition over an unknown schema is refused" "$rc" 2 +has "…naming the schema it found and the one it writes" "$err" "is schema '99'" +has "…and what it would have cost" "$err" "quietly replaced by an older one" +is "…leaving the record untouched" \ + "$(grep -c '^schema: 99' "$SV_STATE/repoSV-1/restart-intent.yaml" || :)" 1 # PUT IT BACK, because the supervisor cases below are asked on this same lane: # a `starting` intent is what proves a second supervisor refuses rather than # launching a second session of one lane. @@ -10329,6 +10349,25 @@ run "$E" restart-intent repoSV-3 has "…under the same operation" "$out" "$SV_OP" has "…with the attempt incremented" "$out" "$(printf 'attempt\t2')" +# EVERY WRITE THIS SUPERVISOR MAKES OF ITS OWN OPERATION IS FENCED ON THE +# GENERATION TOO (Copilot round 1 on openRepoTools#121). The readiness finalizer +# always carried `--expect-generation`; the failure write, the signal write and +# the INDETERMINATE note did not, so a supervisor whose lane had moved on could +# publish an old verdict over a newer generation's record — the stale-finalizer +# shape, reached from the failure side instead of the success side. +# +# AND THE NUMBER IS THE ONE CLAIMED, NOT THE ONE ON DISK. Re-reading it at write +# time would read the NEWER generation and match it, which is exactly the write +# the fence exists to refuse; `SV_CLAIMED_GEN` is taken once, where the claim +# succeeded. This is asserted over the source because the property is *no write +# is left unfenced* — a behaviour case can only ever show that the writes it +# happens to name are fenced. +is "every fenced intent write in lane-handoff fences on the generation as well as the operation" \ + "$(grep -c -- '--expect-generation "' "$SRC_DIR/lane-handoff" || :)" \ + "$(grep -c -- '--expect-operation "' "$SRC_DIR/lane-handoff" || :)" +is "…and that is not vacuously none of them" \ + "$( [ "$(grep -c -- '--expect-generation "' "$SRC_DIR/lane-handoff" || :)" -ge 4 ] && echo yes || echo no )" yes + # ------------------------- 6. the supervisor: a launch that BECOMES READY SV_READY_SRC="$SANDBOX/sv-ready-record.json" @@ -10427,8 +10466,22 @@ chmod +x "$SV_BIN/pclaude-quiet" run env PATH="$SVPATH" PCLAUDE="$SV_BIN/pclaude-quiet" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" \ LANE_SUPERVISOR_READY_SECONDS=4 "$SV_LANE_HANDOFF" \ --supervise --lane repoSV-3 --operation "$SV_OP" -is "a child that stays alive without readiness evidence is INDETERMINATE, not running" "$rc" 3 +is "a child that stays alive without readiness evidence is INDETERMINATE, not running" "$rc" 4 has "…and the supervisor says which it was" "$out" "INDETERMINATE" +# 4 AND NOT 3, WHICH IS THE DISTINCTION THE USAGE BLOCK PROMISES AND NO PATH +# USED TO RETURN (Copilot round 1 on openRepoTools#121). The observer's own +# `return 4` is read by nothing — it runs in the background and the record is +# what decides — so the record decides this too. The `failed` TRANSITION stays: +# INDETERMINATE is the answer for a child that is ALIVE at the deadline, and +# this branch is reached only once that child has exited, which is a launch that +# ended having never been confirmed and is retryable. Leaving it `starting` +# would wedge the lane in the one state neither a retry nor the next /ctx can +# move. +has "…as a launch that ran PAST the deadline and only then ended" "$out" "ran past the readiness deadline still alive" +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" \ + "$SV_LANE_HANDOFF" --restart-status --lane repoSV-3 +is "…and the retry surface is open on it" "$rc" 0 +has "…as a failed restart with one line to retry" "$out" "RETRY:" run "$E" restart-intent repoSV-3 hasnt "…the lane was never marked ready on a launch nothing proved" "$out" "$(printf 'state\tready')" has "…the deadline is in the record for a reader in another window" "$out" "INDETERMINATE" From cd92599efa810dde5d826fa859c0fac1497c599e Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:59:25 +0000 Subject: [PATCH 08/25] The manual says what a signalled supervisor leaves, and whose keystroke Ctrl-C is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1's two behaviour changes reach a person through the pane, and the manual described neither. **A supervisor somebody kills leaves the same retryable record.** `SIGTERM` or `SIGHUP` — a `tmux kill-pane`, a logout, a person's `kill` — mark the intent `failed` with a reason naming the signal and whether the child was still running, and exit 143 or 129, killing nothing. The paragraph says why that matters: without it a signalled supervisor left the intent `starting`, which is the one state nothing recovers from by itself, while `--restart-status` reported `IN FLIGHT: a supervisor holds this operation` about a supervisor that was gone. **And Ctrl-C in that pane belongs to the SESSION.** The supervisor ignores `SIGINT` so that interrupting Claude cannot tear the pane down under it, and the launch is started with `INT` and `QUIT` put back to their defaults so the keystroke reaches the session itself — which is the half a person would only discover by pressing it. The retry paragraph also says exit 4 beside exit 3 now, because the two mean different things and the command distinguishes them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r --- docs/README-lanes.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 3c8da530..36e98bc9 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2219,7 +2219,22 @@ RESTART FAILED — lane openRepoTools-3, operation ctx-20260915T210412Z-41233-11 Where there is a terminal it then asks once — `[r = retry, q = leave it]` — and a retry is **the same operation**: the same generation, digest, directory, profile and launch mode, with the attempt count incremented. Where there is no terminal -it exits 3 and says so. A deadline that expires with the child still ALIVE is a +it exits 3 — or **4**, where the launch ran past the readiness deadline still +alive and only then ended, which the record says as well. + +**And a supervisor somebody kills leaves the same retryable record.** `SIGTERM` +or `SIGHUP` — a `tmux kill-pane`, a logout, a person's `kill` — mark the intent +`failed` with a reason naming the signal and **whether the child was still +running**, print the read and retry lines, and exit 143 or 129. Nothing is killed +(Amendment 8(f)). Without that, a signalled supervisor left the intent `starting`, +which is the one state nothing recovers from by itself: a retry refuses to launch +from it, the next `/ctx` refuses to supersede it, and `--restart-status` reports +`IN FLIGHT: a supervisor holds this operation` about a supervisor that is gone. + +Ctrl-C in that pane belongs to the **session**, not to the supervisor: the +supervisor ignores `SIGINT` so that interrupting Claude cannot tear the pane down +under it, and the launch is started with `INT` and `QUIT` put back to their +defaults so the keystroke reaches the session itself. A deadline that expires with the child still ALIVE is a third answer, `INDETERMINATE`: nothing is killed (Amendment 8(f) — ending somebody's process is not a boundary script's act), nothing is retried, the lane is not marked running, and the reason goes into the record, because an interactive From 5164ce1535b0f4f21d6b9e5b48318865a3c74295 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:03:01 +0000 Subject: [PATCH 09/25] fix: fence supervised restart readiness and launch intent --- lane-handoff | 82 ++++++++++++++++++- lane-start | 57 +++++++++---- lanes-edit.sh | 37 +++++++++ .../add-supervised-context-restart/tasks.md | 8 ++ tests/test_lane_helpers.sh | 69 ++++++++++++++++ 5 files changed, 236 insertions(+), 17 deletions(-) diff --git a/lane-handoff b/lane-handoff index 6755f165..b0b95fbf 100755 --- a/lane-handoff +++ b/lane-handoff @@ -506,7 +506,23 @@ ready_check() { # ` and the statusline all read + # it — so a replacement answering for this lane under some other name is not + # the replacement this restart was for, whatever its transcript says. + # Compared case-insensitively, because Amendment 15 makes one lane's two + # spellings one lane; `none` on either side is a comparison that cannot be + # made and is said rather than failed shut, as `pane_agrees` does. + if [ -n "$rc_nm" ] && [ "$rc_nm" != none ]; then + if [ "$(printf '%s' "$rc_nm" | tr '[:upper:]' '[:lower:]')" != "$(printf '%s' "$rc_lane" | tr '[:upper:]' '[:lower:]')" ]; then + READY_WHY="the live holder is named '$rc_nm' and this lane is $rc_lane — a session answering for the lane under another name is not this restart's replacement (Amendment 2)" + return 1 + fi + fi if [ -n "$rc_new" ] && [ "$rc_new" != none ]; then if [ "$rc_sid" != "$rc_new" ]; then READY_WHY="the live holder's transcript is $rc_sid and this launch was preparing $rc_new" @@ -802,6 +818,9 @@ supervise_run() { # : > "$sr_pidf" supervise_watch "$sr_lane" "$sr_op" "$SV_CLAIMED_GEN" "$sr_pidf" "$RI_NEW" "$RI_OLD" "$RI_PANE" & sr_watch=$! + # PUBLISHED, so the signal handler can stop it: it is a background job of + # this shell and a signal to this shell is not a signal to it. + SV_WATCH_PID="$sr_watch" sr_status=0 # THE CHILD RUNS IN THE BACKGROUND AND THIS PROCESS `wait`s ON IT, and # that is not a preference either: **bash defers a trap until the @@ -847,6 +866,7 @@ supervise_run() { # wait "$sr_child" || sr_status=$? kill "$sr_watch" 2>/dev/null wait "$sr_watch" 2>/dev/null + SV_WATCH_PID="" # WHAT THE RECORD SAYS NOW IS WHAT DECIDES, and never this process's # memory of what it started: the observer is the only thing that ever # had positive evidence, and it wrote that evidence down. @@ -1040,6 +1060,7 @@ fi # the status is simply returned. SV_PIDFILE="" SV_SIGNALLED=0 +SV_WATCH_PID="" # EMPTY UNTIL A CLAIM HAS BEEN MADE, and then the generation that claim held. # A signal arriving before the claim must not fence a write on `0` — on a # workstation with openRepoTools#91 the lane's generation is whatever it is, @@ -1088,11 +1109,25 @@ sv_on_exit() { # to end. sv_signal() { # SV_SIGNALLED=1 + # THE OBSERVER GOES FIRST, AND IS REAPED (Copilot round 2 on + # openRepoTools#121). It is a background job of this shell and a signal to + # this process is not a signal to it: left running it keeps polling after its + # supervisor is gone, and the two verdicts then race. Neither can overwrite + # the other — both writes are fenced and the loser is refused — but the + # LOSER STILL SPEAKS, and a supervisor printing *the restart intent is + # `failed`* about an operation the observer had just confirmed `ready` is a + # line nobody can act on. So it is stopped, reaped, and the record is read + # back AFTER the write below rather than assumed. # A SIGNAL BEFORE THIS PROCESS KNOWS WHICH OPERATION IT IS SUPERVISING has no # record to write and says so: the traps are installed at the top of the mode, # ahead of the arguments being checked, because a supervisor that could be # signalled between those two lines and leave nothing behind is the same gap # one instruction wide. + if [ -n "${SV_WATCH_PID:-}" ]; then + kill "$SV_WATCH_PID" 2>/dev/null + wait "$SV_WATCH_PID" 2>/dev/null || : + SV_WATCH_PID="" + fi if [ -z "$lane" ] || [ -z "$operation" ]; then say "" say "$prog: SIG$1 before this supervisor had a lane and an operation to supervise — nothing was claimed, nothing was started and nothing is left behind." @@ -1116,12 +1151,23 @@ sv_signal() { # ${sv_sg_fence[@]+"${sv_sg_fence[@]}"} \ --reason "the supervisor was sent SIG$1 before readiness was confirmed; $sv_sg_alive" \ >/dev/null 2>&1 || : + ri_load "$lane" >/dev/null 2>&1 || : say "" say "$prog: SIG$1 — this supervisor is leaving operation $operation on lane $lane." say " $sv_sg_alive." - say " The restart intent is \`failed\` and retryable, and nothing was killed:" - say " read it: $(quoted "$SELF_ABS") --restart-status --lane $lane" - say " retry: $(quoted "$SELF_ABS") --supervise --lane $lane --operation $operation" + if [ "${RI_STATE:-}" = ready ]; then + # THE OBSERVER GOT THERE FIRST, and its evidence is better than this + # handler's: the replacement was confirmed before the signal arrived, and + # the `failed` write above was refused by the fence for exactly that + # reason. Saying `failed` here would be this process reporting its own + # intention over the record's fact. + say " The replacement was CONFIRMED before this signal arrived (transcript ${RI_NEW:-unrecorded}), so the intent is \`ready\` and this restart is DONE." + say " read it: $(quoted "$SELF_ABS") --restart-status --lane $lane" + else + say " The restart intent is \`${RI_STATE:-failed}\` and retryable, and nothing was killed:" + say " read it: $(quoted "$SELF_ABS") --restart-status --lane $lane" + say " retry: $(quoted "$SELF_ABS") --supervise --lane $lane --operation $operation" + fi exit "$2" } @@ -1168,6 +1214,36 @@ if [ "$do_supervise" = 1 ]; then pane_agrees "$RI_PANE" "$sv_pane_here" || die "lane $lane's restart intent was written for pane $RI_PANE and this supervisor is in $sv_pane_here. Nothing was started: a launch here would put this lane's session in a pane the lane's own record does not name, and that record is what every other reader goes by." 2 + # ---- the agent: the intent's, and the lane's own record, must still agree + # + # DESIGN DECISION 15 SAYS AGENT, PROFILE AND DIRECTORY ARE FENCED *EARLIER* — + # *"where a conflict refuses the launch before it is made, rather than + # re-checked after"* — and for the agent that was not true of any line + # (Copilot round 2 on openRepoTools#121). The intent records it and nothing + # read it back. So it is read back here, which is the place the decision + # names: before anything is claimed or started. + # + # IT IS THE CANONICAL AGENT AND NOT THE CHILD'S. What a live process IS + # cannot be read from this estate's records — `live-holder` answers a + # transcript, a pane, a name, a pid and a verdict, and no agent word — so + # what is compared is the lane's own recorded agent against the one this + # restart was written for. A lane whose agent changed under a restart is a + # restart whose contract changed under it, and launching the intent's agent + # into it would make the record and the process disagree. + sv_ag=""; sv_agrc=0 + sv_ag="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-agent "$lane" 2>/dev/null)" || sv_agrc=$? + case "$sv_agrc" in + 0) + if [ -n "$RI_AGENT" ] && [ "$RI_AGENT" != none ] && [ -n "$sv_ag" ] && [ "$sv_ag" != "$RI_AGENT" ]; then + die "lane $lane's restart intent was written for agent '$RI_AGENT' and the lane's own record now names '$sv_ag'. Nothing was started: the agent decides which launcher this lane is resumed by and what its first prompt even means, so a restart made for one agent into a lane recorded as another is a session neither record describes. Read it: $prog --restart-status --lane $lane" 2 + fi ;; + # 8 IS A LANE WHOSE RECORD NAMES NO AGENT and 2 A HELPER WITH NO WORD FOR + # ONE; both are answers, and a comparison that cannot be made is not a + # disagreement. + 8 | 2) : ;; + *) note "lane $lane's recorded agent could not be read (\`$LANES_EDIT lane-agent $lane\` exited $sv_agrc), so the intent's agent '${RI_AGENT:-none}' is not compared with it here. That is NOT 'they agree'." ;; + esac + # ---- the handoff's digest: the first prompt is that file, and only that file if [ -n "$RI_HANDOFF" ] && [ "$RI_HANDOFF" != none ] && [ -n "$RI_DIGEST" ] && [ "$RI_DIGEST" != none ]; then sv_now="$(handoff_digest "$RI_HANDOFF")" diff --git a/lane-start b/lane-start index fe72678b..b7c553d4 100755 --- a/lane-start +++ b/lane-start @@ -2408,20 +2408,37 @@ case "$ri_rc" in ri_hf="$(printf '%s\n' "$ri_out" | awk -F'\t' '$1 == "handoff" { print $2; exit }')" ri_dig="$(printf '%s\n' "$ri_out" | awk -F'\t' '$1 == "digest" { print $2; exit }')" ;; # 8 IS AN ANSWER — this lane has no restart in flight, which is every - # ordinary launch — 1 is a lane with no control root, which is the - # pre-cutover lane and not a failure either, and 2 IS A HELPER THAT PREDATES - # THIS READ: the estate's fence is one sentence and `lane-handoff` spells it - # out at its own register reads — *"`0` an answer, `8` NO ANSWER and `2` a - # helper predating the read — both of those fall to the next rung"*. An - # installed `lanes-edit.sh` from before openRepoTools#94 answers 2 from its - # unknown-subcommand arm, and a note printed on EVERY launch of EVERY lane on - # such a workstation is noise about a state that is simply the old one. - # Anything else is a read that FAILED, and a read that failed is never an - # answer (Amendment 7(d)): it is said, and the other two sources still - # decide, because refusing every launch on a helper that could not answer is - # not a safety property. - 8|1|2) : ;; - *) note "$LANES_EDIT restart-intent $LANE failed (exit $ri_rc) — this launch reads no restart intent, so a /ctx in flight for this lane is NOT visible here. If a context clear was just taken, run it again once the read works." ;; + # ordinary launch — and 2 IS A HELPER THAT PREDATES THIS READ: an installed + # `lanes-edit.sh` from before openRepoTools#94 answers 2 from its + # unknown-subcommand arm, and on that workstation no `/ctx` could have + # written an intent at all. The estate's fence is one sentence and + # `lane-handoff` spells it out at its own register reads — *"`0` an answer, + # `8` NO ANSWER and `2` a helper predating the read — both of those fall to + # the next rung"*. Both fall to the next rung here too, and neither is worth + # a note printed on every launch of every lane. + 8|2) : ;; + # AND 1 IS NOT ONE OF THEM (Copilot round 2 on openRepoTools#121). `1` is + # *the control root could not be derived*, which is a READ THAT COULD NOT BE + # MADE and never *there is no restart in flight* — and the two are opposite + # facts at this one gate. The sidecar `/ctx` wrote sits under a root resolved + # from the lane's recorded directory or `$PROJECTS_ROOT`, and the pane this + # launch runs in is a NEW one whose environment is not the one that wrote it; + # a root that resolved then and does not now leaves this command reading + # nothing and calling it nothing-in-flight. What it would then do is resolve + # an ordinary resume source — and for a lane whose `/ctx` has just killed its + # pane, that is the transcript the `/ctx` PAUSED. That is openRepoTools#94, + # recreated by the very command written to prevent it. + # + # SO IT REFUSES, and so does any code this fence does not classify. The old + # line said *refusing every launch on a helper that could not answer is not a + # safety property* — which is true of a lane with no restart in flight and + # false of the one gate whose wrong answer resumes a paused transcript. The + # refusal names what to run to see the failure and both ways out of it, + # because a refusal a person cannot act on is not better than the defect. + 1) + die "lane $LANE's restart intent could not be read: it has no lifecycle control root here (\`$LANES_EDIT restart-intent $LANE\` exits 1 — its record names no directory and \$PROJECTS_ROOT is not a directory in this environment). That is NOT 'no restart is in flight': the sidecar a /ctx writes lives under that root, and launching without reading it would resolve an ordinary resume source — which for a lane whose pane a /ctx has just replaced is the transcript that /ctx PAUSED (openRepoTools#94). Nothing was renamed, written or started. Give this run a root it can resolve: set \$LANES_LANE_STATE_ROOT, or make \$PROJECTS_ROOT (default \$HOME/projects) a directory — the lane's own record names its checkout once it has been started with one, and resolves on that." 2 ;; + *) + die "lane $LANE's restart intent read FAILED (\`$LANES_EDIT restart-intent $LANE\` exited $ri_rc), and a read that failed is never an answer (Amendment 7(d)). Nothing was renamed, written or started: this is the one launch gate whose wrong answer resumes the transcript a /ctx just paused (openRepoTools#94), so it refuses rather than falling through to an ordinary resume source. Run that command yourself to see what it says, then launch again." 1 ;; esac # AN `--operation` NAMES THE INTENT IT BELONGS TO, AND ONLY THAT ONE. if [ -n "$op_flag" ]; then @@ -2446,6 +2463,8 @@ case "$ri_state" in # workstation's `lanes-edit.sh` is new enough to read the file — and the # message says both ways out, which is what makes that recoverable rather than # a wall. + INCOMPLETE) + die "lane $LANE has a restart intent that is missing facts a launch is fenced on (\`lanes-edit.sh restart-intent $LANE\` answers INCOMPLETE and names them). Its writer records every field on every transition, so a record missing one was not written by this tooling — and launching from it would skip the very checks those fields exist for: the pane comparison and the handoff digest both treat an absent value as *cannot compare*. Nothing was renamed, written or started. Read it, and remove it once you know no restart is in flight: lanes-edit.sh restart-intent $LANE" 2 ;; UNKNOWN-SCHEMA) die "lane $LANE has a restart intent this \`lanes-edit.sh\` cannot read: its schema is one this version does not know (\`lanes-edit.sh restart-intent $LANE\` answers UNKNOWN-SCHEMA). That is NOT 'no restart is in flight' — a read that could not be made is never an answer (Amendment 7(d)) — and starting anyway would resolve an ordinary resume source, which for a lane whose /ctx just killed its pane is the transcript that /ctx PAUSED. Nothing was renamed, written or started. Update this workstation's openRepoTools so it can read that record, or remove the intent once you know no restart is in flight: lanes-edit.sh restart-intent $LANE" 2 ;; pending|starting) @@ -3551,8 +3570,18 @@ if (( fresh )) && [ -n "$ri_op" ] && (( ! no_launch )) && (( ! dry_run )); then ri_new="$lane_sid" [ -n "$ri_new" ] && [ "$ri_new" != unknown ] || ri_new="${new_sid:-}" ri_wrc=0 + # FENCED ON THE GENERATION AS WELL AS THE OPERATION (Copilot round 2 on + # openRepoTools#121), which is what every write the supervisor makes of this + # same intent carries. `$ri_gen` is the generation this run READ at the top, + # before anything was renamed or written, and the supervisor's claim keeps it + # — so a lane whose lifecycle advanced between the two has a launch here that + # writes nothing and says so, rather than stamping its expected transcript + # onto a newer generation's record. + ri_fence=() + case "${ri_gen:-}" in '' | *[!0-9]*) : ;; *) ri_fence=(--expect-generation "$ri_gen") ;; esac LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$LANE" starting \ --expect 'pending|starting' --expect-operation "$ri_op" \ + ${ri_fence[@]+"${ri_fence[@]}"} \ ${ri_new:+--new-transcript "$ri_new"} \ --reason "lane-start prepared the launch" >/dev/null 2>&1 || ri_wrc=$? case "$ri_wrc" in diff --git a/lanes-edit.sh b/lanes-edit.sh index 0c0053b9..9bb3abd5 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -7807,6 +7807,43 @@ lane_restart_read() { # *) printf 'state\tUNKNOWN-SCHEMA\n'; printf 'schema\t%s\n' "${lrr_schema:-}" printf 'file\t%s\n' "$lrr_f"; return 0 ;; esac + # AN ACTIVE INTENT MISSING A FACT A LAUNCH IS FENCED ON IS NOT A READABLE ONE + # (Copilot round 2 on openRepoTools#121). The schema check above is the whole + # of what this read used to verify, so a `pending` record carrying an + # operation and a mode and nothing else came back with empty values — and + # empty is exactly what every comparison downstream treats as *cannot + # compare*: `pane_agrees` returns agreement on an empty side, the digest check + # skips, and the launch goes ahead with none of the three checks those fields + # exist for. That is a silent launch from a record nobody wrote. + # + # PRESENT AND NOT EMPTY, WHICH IS NOT THE SAME AS *NOT `none`*. `none` IS an + # answer here — a workstation with neither `sha256sum` nor `shasum` records + # `digest: none`, and the writer writes `none` for every empty field on every + # transition — so what is checked is that the KEY IS THERE AT ALL, which is + # true of everything this tooling has ever written and false of a record that + # was truncated or edited by hand. + # + # ONLY FOR `pending` AND `starting`. A `ready` intent is history and a + # `failed` one is a diagnostic; neither authorises a launch, so neither needs + # the facts a launch is fenced on, and refusing to READ them would take the + # `--restart-status` surface away from the person trying to work out what + # happened. + lrr_state="$(lane_sidecar_field "$lrr_f" state)" + case "$lrr_state" in + pending | starting) + lrr_missing="" + for lrr_need in operation mode dir pane handoff digest; do + [ -n "$(lane_sidecar_field "$lrr_f" "$lrr_need")" ] || + lrr_missing="${lrr_missing:+$lrr_missing }$lrr_need" + done + if [ -n "$lrr_missing" ]; then + printf 'state\tINCOMPLETE\n' + printf 'intended_state\t%s\n' "$lrr_state" + printf 'missing\t%s\n' "$lrr_missing" + printf 'file\t%s\n' "$lrr_f" + return 0 + fi ;; + esac for lrr_k in state generation operation mode lane agent profile dir pane window \ handoff digest old_transcript new_transcript attempt workstation \ created updated reason; do diff --git a/openspec/changes/add-supervised-context-restart/tasks.md b/openspec/changes/add-supervised-context-restart/tasks.md index c7e2015f..e76b22fe 100644 --- a/openspec/changes/add-supervised-context-restart/tasks.md +++ b/openspec/changes/add-supervised-context-restart/tasks.md @@ -39,6 +39,14 @@ So the fix is in `replace_line`: a result byte-identical to the file it would re - **The usage block promised an exit code no path returned.** `4 indeterminate` was never reachable: the observer's `return 4` is read by nothing, because it runs in the background and the record is what decides. An operation whose reason begins `INDETERMINATE:` now ends 4 and says *ran past the readiness deadline still alive*. The `failed` transition **stays** — `INDETERMINATE` is the answer for a child that is ALIVE at the deadline, and that branch is reached only once the child has exited, which is a launch that ended unconfirmed and is retryable; leaving it `starting` would wedge the lane in the state the first item above exists to stop. - **The failure write was not fenced on the generation.** The readiness finalizer always was; the failure write, the signal write and the `INDETERMINATE` note were not, so a superseded supervisor could publish an old verdict over a newer generation's record. All five fenced now, on the generation **claimed** rather than the one on disk at write time — re-reading it would read the newer generation and match it, which is the write the fence exists to refuse. +**Found by Copilot round 2 on `opensoft/openRepoTools#121`, and every one of them taken:** + +- **Readiness never read the agent, and decision 15 said it was fenced earlier.** It was fenced nowhere: the intent recorded it and nothing read it back. The fence is where the decision says it is now — before anything is claimed or launched — and it compares the **canonical** agent, because what a live process IS cannot be read from this estate's records (`live-holder` answers a transcript, a pane, a name, a pid and a verdict, and no agent word). Readiness also reads the **holder's name**, which is evidence it already had in hand and skipped: Amendment 2 makes the session name the lane's messaging address, so a replacement answering for this lane under another name is not this restart's replacement. Compared case-insensitively, because Amendment 15 makes two spellings one lane. +- **A signal left the observer running.** It is a background job of the supervisor's shell, and a signal to that shell is not a signal to it. Neither verdict could overwrite the other — both writes are fenced and the loser is refused — but **the loser still spoke**, so a supervisor could print *the intent is `failed`* about an operation the observer had just confirmed `ready`. The observer is stopped and reaped first now, and the handler reads the record back and reports what it SAYS rather than what it tried to write. +- **`lane-start` treated `restart-intent`'s exit 1 as harmless.** `8` is *no restart in flight* and `2` is *a helper predating the feature*; both are answers. `1` is *the control root could not be derived* — a read that could not be made — and it sat silently in the same arm. The pane a supervised launch runs in is a NEW one whose environment is not the one that wrote the sidecar, so a root that resolved for the `/ctx` and not here left this command reading nothing, calling it nothing-in-flight, and resolving an ordinary resume source — which for that lane is the transcript the `/ctx` PAUSED. `#94`, recreated by the command written to prevent it. `1` and anything unclassified refuse now, naming the read to run and how to give the run a root. +- **`lane-start`'s write-back of the prepared transcript fenced only on the operation**, while every write the supervisor makes of the same intent fences on the generation too. Fenced now, on the generation this run read before anything was written. +- **A schema-1 record was accepted whatever it was missing.** A `pending` intent carrying an operation and a mode and nothing else came back with empty values — and empty is what every comparison downstream treats as *cannot compare*: the pane agrees, the digest check skips, and the launch proceeds with none of the three checks those fields exist for. An active intent missing `operation`, `mode`, `dir`, `pane`, `handoff` or `digest` now reads as `INCOMPLETE`, naming which, and every caller refuses on it. `none` is still an answer — what is required is that the KEY IS THERE, which is true of everything this tooling writes and false of a record truncated or edited by hand. + **Deferred, with the reason:** - **§1 entirely** — it consumes `add-crash-consistent-lane-worktree-recovery`'s lane lock, generation and operation, which are on `feat/crash-consistent-lane-worktree-recovery` and not on `main`. The restart intent is a sibling sidecar under the *same* control root (design decision 14) so nothing here blocks on that merge and nothing re-implements it; §1's fail-closed work belongs in that change's own pass. diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 30068e90..b0fc9e24 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -10050,6 +10050,32 @@ sed -i.bak 's/^schema: .*/schema: 1/' "$SV_STATE/repoSV-1/restart-intent.yaml" 2 sed -i '' 's/^schema: .*/schema: 1/' "$SV_STATE/repoSV-1/restart-intent.yaml" 2>/dev/null || : rm -f "$SV_STATE/repoSV-1/restart-intent.yaml.bak" +# AN ACTIVE INTENT MISSING A FACT A LAUNCH IS FENCED ON IS NOT READABLE EITHER +# (Copilot round 2 on openRepoTools#121). The schema check was the whole of what +# the read verified, so a `pending` record carrying an operation and a mode and +# nothing else came back with EMPTY values — and empty is what every comparison +# downstream treats as *cannot compare*: the pane agrees, the digest check +# skips, and the launch goes ahead with none of the three checks those fields +# exist for. `none` is still an answer; what is required is that the KEY IS +# THERE, which is true of everything this tooling writes. +sed -i.bak '/^digest: /d' "$SV_STATE/repoSV-1/restart-intent.yaml" 2>/dev/null || \ + sed -i '' '/^digest: /d' "$SV_STATE/repoSV-1/restart-intent.yaml" 2>/dev/null || : +rm -f "$SV_STATE/repoSV-1/restart-intent.yaml.bak" +run "$E" restart-intent repoSV-1 +is "an active intent missing a fenced field still reads" "$rc" 0 +has "…and says INCOMPLETE rather than handing back an empty one" "$out" "$(printf 'state\tINCOMPLETE')" +has "…naming the field that is gone" "$out" "$(printf 'missing\tdigest')" +has "…and the state it was trying to be" "$out" "$(printf 'intended_state\tstarting')" +run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ + "$START" --dir "$SV_DIR" repoSV-1 --no-launch +is "…and a launch of that lane refuses" "$rc" 2 +has "…saying which checks an absent value would have skipped" "$err" "cannot compare" +hasnt "…resolving no resume source" "$out" "--resume" +# PUT IT BACK for the supervisor cases below, which need this lane `starting`. +"$E" set-restart-intent repoSV-1 starting --expect starting --digest none >/dev/null 2>&1 || : +run "$E" restart-intent repoSV-1 +has "…and the record is whole again" "$out" "$(printf 'state\tstarting')" + # A LANE WITH NO CONTROL ROOT IS A READ THAT COULD NOT BE MADE, and never # 'this lane has no restart in flight' (Amendment 7(d)). run env LANES_LANE_STATE_ROOT= PROJECTS_ROOT=/nonexistent-projects-root "$E" restart-intent repoSV-4 @@ -10059,6 +10085,26 @@ run env LANES_LANE_STATE_ROOT= PROJECTS_ROOT=/nonexistent-projects-root \ "$E" set-restart-intent repoSV-4 pending --expect none is "…and there is nowhere to write one either" "$rc" 1 has "…naming the seam that gives it one" "$err" "LANES_LANE_STATE_ROOT" +# AND A LAUNCH THAT CANNOT DERIVE THE ROOT REFUSES RATHER THAN FALLING THROUGH +# (Copilot round 2 on openRepoTools#121). Exit 1 used to sit in the same arm as +# 8 and 2 and be SILENT — but 8 is *this lane has no restart in flight* and 2 is +# *this helper predates the feature*, both answers, while 1 is *the read could +# not be made*. The pane a supervised launch runs in is a NEW one whose +# environment is not the one that wrote the sidecar, so a root that resolved for +# the `/ctx` and does not resolve here leaves this command reading nothing and +# calling it nothing-in-flight — and what it does next is resolve an ordinary +# resume source, which for a lane whose pane a `/ctx` has just replaced is the +# transcript that `/ctx` PAUSED. openRepoTools#94, recreated by the command +# written to prevent it. +: > "$FAKE_CLAUDE_LOG" +run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ + LANES_LANE_STATE_ROOT= PROJECTS_ROOT=/nonexistent-projects-root \ + "$START" --dir "$SV_DIR" repoSV-4 --no-launch +is "a launch whose restart intent could not be read at all refuses" "$rc" 2 +has "…saying that is NOT 'no restart is in flight'" "$err" "NOT 'no restart is in flight'" +has "…naming what an ordinary resume would have opened" "$err" "the transcript that /ctx PAUSED" +has "…and how to give this run the root" "$err" "LANES_LANE_STATE_ROOT" +hasnt "…resolving no resume source" "$out" "--resume" # A NEW OPERATION INHERITS NOTHING OF THE LAST ONE'S. # @@ -10315,6 +10361,23 @@ run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" "$SV_LAN is "a supervisor with no operation refuses" "$rc" 2 has "…because one that took whatever it found would launch into somebody else's act" "$err" "--operation " +# AND AN AGENT THE LANE'S OWN RECORD DOES NOT NAME IS A REFUSAL BEFORE ANY +# LAUNCH (Copilot round 2 on openRepoTools#121). Design decision 15 says agent, +# profile and directory are fenced EARLIER — *"where a conflict refuses the +# launch before it is made, rather than re-checked after"* — and for the agent +# that was true of no line: the intent recorded it and nothing read it back. +# It is the CANONICAL agent that is compared and not the child's, because what a +# live process IS cannot be read from this estate's records: `live-holder` +# answers a transcript, a pane, a name, a pid and a verdict, and no agent word. +"$E" set-restart-intent repoSV-3 pending --expect pending --agent codex >/dev/null 2>&1 +run env PATH="$SVPATH" LANE_SUPERVISOR_NO_PROMPT=1 TMUX_PANE="$SV_PANE" "$SV_LANE_HANDOFF" \ + --supervise --lane repoSV-3 --operation "$SV_OP" +is "a supervisor whose intent names an agent the lane's record does not refuses" "$rc" 2 +has "…naming the one the restart was written for" "$err" "written for agent 'codex'" +has "…and the one the lane's record now names" "$err" "now names 'claude'" +hasnt "…and launches nothing" "$err" "restarting (operation" +"$E" set-restart-intent repoSV-3 pending --expect pending --agent claude >/dev/null 2>&1 + # ------------------------------- 5. the supervisor: a launch that FAILS cat > "$SV_BIN/pclaude-fail" <<'FAKE' @@ -10368,6 +10431,12 @@ is "every fenced intent write in lane-handoff fences on the generation as well is "…and that is not vacuously none of them" \ "$( [ "$(grep -c -- '--expect-generation "' "$SRC_DIR/lane-handoff" || :)" -ge 4 ] && echo yes || echo no )" yes +# AND SO DOES `lane-start`'S OWN WRITE OF THE PREPARED TRANSCRIPT (Copilot +# round 2). It is a transition of the same intent the supervisor claims and +# finalizes, made by a different process, and it fenced only on the operation. +is "lane-start's write-back of the prepared transcript is fenced on the generation too" \ + "$( [ "$(grep -c -- '--expect-generation' "$SRC_DIR/lane-start" || :)" -ge 1 ] && echo yes || echo no )" yes + # ------------------------- 6. the supervisor: a launch that BECOMES READY SV_READY_SRC="$SANDBOX/sv-ready-record.json" From 6d337d85c8b4dcd482270347e63569621b969b50 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:03:46 +0000 Subject: [PATCH 10/25] Report superseded restart attempts without offering a stale retry Exercise supervisor generation fencing behavior instead of counting source flags, and correct the generated Python child fixture. --- lane-handoff | 14 ++++++++-- tests/test_lane_helpers.sh | 28 +++----------------- tests/test_supervised_restart_regressions.py | 23 +++++++++++++++- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/lane-handoff b/lane-handoff index 33f76205..4e828102 100755 --- a/lane-handoff +++ b/lane-handoff @@ -711,11 +711,21 @@ supervise_failed() { # fi sf_fence=() [ -n "$SV_CLAIMED_GEN" ] && sf_fence=(--expect-generation "$SV_CLAIMED_GEN" --expect-attempt "$SV_CLAIMED_ATTEMPT") + sf_wrc=0 LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$sf_lane" failed \ --expect 'starting|pending' --expect-operation "$sf_op" \ ${sf_fence[@]+"${sf_fence[@]}"} \ - --reason "$sf_why" >/dev/null 2>&1 || - note "the restart intent could not be marked failed — its state, operation or generation moved, so this supervisor's failure is not the current one to publish. Read it: $prog --restart-status --lane $sf_lane" + --reason "$sf_why" >/dev/null 2>&1 || sf_wrc=$? + case "$sf_wrc" in + 0) : ;; + 7) + say "SUPERSEDED — this supervisor no longer owns operation $sf_op; no retry is authorized here." + say " read it: $prog --restart-status --lane $sf_lane" + return 2 ;; + *) + note "the restart failure could not be recorded (exit $sf_wrc); retry remains blocked. Read it: $prog --restart-status --lane $sf_lane" + return 1 ;; + esac say "" say "RESTART FAILED — lane $sf_lane, operation $sf_op" say " $sf_why" diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 4e064f63..b44f8aa5 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -13923,30 +13923,10 @@ run "$E" restart-intent repoSV-3 has "…under the same operation" "$out" "$SV_OP" has "…with the attempt incremented" "$out" "$(printf 'attempt\t2')" -# EVERY WRITE THIS SUPERVISOR MAKES OF ITS OWN OPERATION IS FENCED ON THE -# GENERATION TOO (Copilot round 1 on openRepoTools#121). The readiness finalizer -# always carried `--expect-generation`; the failure write, the signal write and -# the INDETERMINATE note did not, so a supervisor whose lane had moved on could -# publish an old verdict over a newer generation's record — the stale-finalizer -# shape, reached from the failure side instead of the success side. -# -# AND THE NUMBER IS THE ONE CLAIMED, NOT THE ONE ON DISK. Re-reading it at write -# time would read the NEWER generation and match it, which is exactly the write -# the fence exists to refuse; `SV_CLAIMED_GEN` is taken once, where the claim -# succeeded. This is asserted over the source because the property is *no write -# is left unfenced* — a behaviour case can only ever show that the writes it -# happens to name are fenced. -is "every fenced intent write in lane-handoff fences on the generation as well as the operation" \ - "$(grep -c -- '--expect-generation "' "$SRC_DIR/lane-handoff" || :)" \ - "$(grep -c -- '--expect-operation "' "$SRC_DIR/lane-handoff" || :)" -is "…and that is not vacuously none of them" \ - "$( [ "$(grep -c -- '--expect-generation "' "$SRC_DIR/lane-handoff" || :)" -ge 4 ] && echo yes || echo no )" yes - -# AND SO DOES `lane-start`'S OWN WRITE OF THE PREPARED TRANSCRIPT (Copilot -# round 2). It is a transition of the same intent the supervisor claims and -# finalizes, made by a different process, and it fenced only on the operation. -is "lane-start's write-back of the prepared transcript is fenced on the generation too" \ - "$( [ "$(grep -c -- '--expect-generation' "$SRC_DIR/lane-start" || :)" -ge 1 ] && echo yes || echo no )" yes +# Stale-generation/attempt behavior is exercised through the actual supervisor +# in test_supervised_restart_regressions.py. Counting flag spellings here cannot +# prove fencing: a new-operation reservation needs a generation expectation +# without expecting the prior operation, and array fences span separate lines. # ------------------------- 6. the supervisor: a launch that BECOMES READY diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index 05782a14..b0d97704 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -485,7 +485,7 @@ def test_restart_observer_confirms_late_uuid_prepared_by_real_child(restart_box) export CLAUDE_PROFILE_NAME=test-profile exec "$OPENREPOTOOLS_BIN_DIR/lane-start" repoZ 1 ''') - _write(box.resolved, '''#!/usr/bin/env python3 + _write(box.resolved, r'''#!/usr/bin/env python3 import json, os, pathlib, sys, time args=sys.argv[1:] sid=args[args.index('--session-id')+1] @@ -569,3 +569,24 @@ def test_restart_missing_checksum_is_not_launch_authority(restart_box): assert result.returncode == 2, result.stderr assert source.read_bytes() == before assert box.claude_runs() == "" + + +def test_restart_stale_supervisor_cannot_fail_or_retry_newer_operation(restart_box): + box = restart_box + prepare_restart(box) + child = _write(box.fakebin / "pclaude", '''#!/usr/bin/env bash +"$OPENREPOTOOLS_BIN_DIR/lanes-edit.sh" set-restart-intent repoZ-1 pending \ + --expect starting --expect-operation op-test --operation newer --generation 8 \ + --reason 'new owner' >/dev/null || exit 9 +exit 127 +''') + box.env.update(PCLAUDE=str(child), LANE_SUPERVISOR_NO_PROMPT="1") + result = supervisor(box, "--supervise", "--lane", LANE, "--operation", "op-test") + assert result.returncode == 2, (result.stdout, result.stderr) + record = dict(line.split("\t", 1) for line in helper(box, "restart-intent", LANE).stdout.splitlines()) + assert record["state"] == "pending" + assert record["generation"] == "8" + assert record["operation"] == "newer" + assert record["reason"] == "new owner" + assert "no retry is authorized" in result.stdout + assert "RESTART FAILED" not in result.stdout From 7ae8b40072c6be3ddfb6df8d18966edf07ce9ff7 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:29:03 +0000 Subject: [PATCH 11/25] Serialize legacy restart handoff writes and guard each launch attempt Validate complete restart records, preserve configured roots across respawn, and retain indeterminate ownership while holders remain. Allow new checkout launches without bypassing established intent roots. Fixes #156. --- docs/README-lanes.md | 11 +- lane-handoff | 46 ++- lane-start | 15 +- lanes-edit.sh | 124 +++++++-- skills/handoff/SKILL.md | 14 + specs/005-supervised-legacy-ctx/plan.md | 11 +- specs/005-supervised-legacy-ctx/tasks.md | 2 +- tests/test_lane_helpers.sh | 7 +- tests/test_supervised_restart_regressions.py | 277 ++++++++++++++++++- 9 files changed, 471 insertions(+), 36 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 5424dbf1..32f7970e 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2704,7 +2704,10 @@ no child launch from a partially prepared record. The first launch reserves `new_transcript` only while it is `none`. Every later write expects its exact operation, generation, attempt and transcript. The verified Rule 3 stamp is persisted with both `digest` and `prepared_digest` -before an atomic replacement of the resolved handoff target. Interruption leaves +before `lanes-edit.sh publish-handoff` checks the expected digest and exact +attempt under the workspace writer mutex, then atomically replaces the resolved +handoff target. Both backend refresh and resume stamps use this publisher; manual +semantic edits are staged in temporary files and use the same command. Interruption leaves either complete version valid for retry; unrelated changed prose refuses. Historical `ready` intents do not control an ordinary resume. @@ -3176,3 +3179,9 @@ git show :lanes/LANES.md # a lost row, since `pre-move/lanes` is an annotated tag on the last commit the orphan branch ever took. The same shape applies to `pre-move/handoffs` and `pre-move/workspaces`. + +The legacy supervisor uses the current profile launcher's existing-TMUX path, +which keeps the exact operation as a child and forwards its operation/attempt +tokens. The historical #94 incident above describes the earlier launcher. A +respawn also forwards configured `LANES_LANE_STATE_ROOT` and +`AGENT_PROTOCOL_ROOT` with shell quoting so its durable intent remains readable. diff --git a/lane-handoff b/lane-handoff index 4e828102..a730936a 100755 --- a/lane-handoff +++ b/lane-handoff @@ -825,6 +825,18 @@ supervise_run() { # SV_CLAIMED_ATTEMPT="$RI_ATTEMPT" case "$SV_CLAIMED_GEN" in '' | *[!0-9]*) SV_CLAIMED_GEN=0 ;; esac + # Repeat this check for every claimed attempt, including an interactive + # retry: a holder may have appeared while the previous failure waited. + sr_hrc=0 + LANES_NO_FETCH=1 "$LANES_EDIT" lane-holders "$sr_lane" >/dev/null 2>&1 || sr_hrc=$? + if [ "$sr_hrc" != 8 ]; then + LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$sr_lane" starting \ + --expect starting --expect-operation "$sr_op" --expect-generation "$SV_CLAIMED_GEN" --expect-attempt "$SV_CLAIMED_ATTEMPT" \ + --reason "INDETERMINATE: holder absence is unproven before attempt launch (read $sr_hrc)" >/dev/null 2>&1 || : + say "INDETERMINATE — holder absence is unproven; no child was launched and retry is blocked." + return 4 + fi + if ! supervise_argv "$sr_lane" "$sr_op"; then supervise_failed "$sr_lane" "$sr_op" "there is no launcher and no lane-start on PATH here, so nothing could be started" sr_fs=$? @@ -1139,7 +1151,9 @@ sv_signal() { # sv_sg_alive="the launch (pid $sv_sg_child) was STILL RUNNING and was not killed" else sv_sg_alive="the launch (pid $sv_sg_child) had already ended" - sv_sg_state=failed + sv_sg_hrc=0 + LANES_NO_FETCH=1 "$LANES_EDIT" lane-holders "$lane" >/dev/null 2>&1 || sv_sg_hrc=$? + [ "$sv_sg_hrc" != 8 ] || sv_sg_state=failed fi fi sv_sg_fence=() @@ -1438,6 +1452,23 @@ case "$lane" in *[!A-Za-z0-9._-]* | .* | -* | '') die "'$lane' is not a lane name (letters, digits, . _ -, not opening with . or -). Nothing was renamed and nothing was written: a name the register cannot hold is one every write below would refuse anyway, and the window would already have been renamed to it." 2 ;; esac +# A plain preservation writer cannot modify an in-flight restart's input. +# This read precedes window, row and log writes; publication checks it again +# under the helper mutex, together with the prior handoff checksum. +if [ "$do_restart" != 1 ]; then + lh_irrc=0 + lh_intent="$(LANES_NO_FETCH=1 "$LANES_EDIT" restart-intent "$lane")" || lh_irrc=$? + case "$lh_irrc" in + 0) + lh_istate="$(printf '%s\n' "$lh_intent" | awk -F'\t' '$1=="state" {print $2; exit}')" + case "$lh_istate" in + ready|failed) : ;; + *) die "a restart owns this lane, or its intent is malformed; preservation refused before window, handoff, row or log writes. Read it: $prog --restart-status --lane $lane" 2 ;; + esac ;; + 8) : ;; + *) die "the legacy restart intent cannot be read; preservation refused before any writes." 2 ;; + esac +fi if [ "$do_restart" = 1 ]; then LANES_NO_FETCH=1 "$LANES_EDIT" legacy-restart-check "$lane" || die "legacy /ctx restart refused at the managed-lane boundary. Nothing was written or respawned." 2 @@ -1761,6 +1792,7 @@ else if [ -z "$hs_tmpd" ]; then note "could not create a temporary directory, so the handoff was not refreshed: $handoff_file" else + hs_prior_digest="$(handoff_digest "$handoff_file")" { top_block printf -- '---\n\n' cat -- "$handoff_file" @@ -1775,7 +1807,12 @@ else # that is full, fails HERE — and `handoff_written=1` set regardless is # what lets `--restart` pass its gate and respawn the pane over a STALE # top block, which is the one document the new session is started with. - if cat -- "$hs_tmpd/out" > "$handoff_file"; then # the redirect FOLLOWS the symlink + hs_publish_args=(publish-handoff "$lane" "$handoff_file" "$hs_tmpd/out" --expect-digest "$hs_prior_digest") + if [ "$do_restart" = 1 ]; then + hs_publish_args+=(--operation "$operation" --generation "$ri_gen" --attempt 0 --state preparing --transcript none) + fi + if hs_published="$(LANES_NO_FETCH=1 "$LANES_EDIT" "${hs_publish_args[@]}")"; then + handoff_file="$hs_published" handoff_written=1 step "handoff refreshed: $handoff_file (+$hs_added lines at the head)" else @@ -2093,7 +2130,8 @@ if [ "$do_restart" = 1 ]; then # THE FRESH SESSION IS WRITTEN DOWN BEFORE ANYTHING IS KILLED, and that is # the whole of this change. `LANE_START_FRESH=1` in the respawn's command # string was the seam and it was measured NOT ARRIVING: `claude-profile` - # re-creates the child through `tmux new-session`, and tmux hands a command + # re-created the child through `tmux new-session` in that incident. The current + # launcher keeps an existing TMUX pane; an out-of-pane tmux launch hands a command # the SERVER's environment and not the client's — the launcher's own code # says so, which is why it threads six values into the command string # explicitly and why a seventh nobody thought to thread simply vanished. @@ -2164,6 +2202,8 @@ if [ "$do_restart" = 1 ]; then # and tmux would otherwise hand the pane whatever the SERVER has. launch_line="$(quoted "$SELF_ABS" --supervise --lane "$lane" --operation "$operation")" [ -n "$ws" ] && launch_line="LANES_WORKSTATION=$(quoted "$ws") $launch_line" + [ -z "${LANES_LANE_STATE_ROOT:-}" ] || launch_line="LANES_LANE_STATE_ROOT=$(quoted "$LANES_LANE_STATE_ROOT") $launch_line" + [ -z "${AGENT_PROTOCOL_ROOT:-}" ] || launch_line="AGENT_PROTOCOL_ROOT=$(quoted "$AGENT_PROTOCOL_ROOT") $launch_line" say " respawning $target: $launch_line" if tmux respawn-pane -k -t "$target" "$launch_line" 2>/dev/null; then say " respawned into the supervisor — it launches the fresh session, confirms it came up, and stays in this pane if it did not" diff --git a/lane-start b/lane-start index e5c2e617..1ab53730 100755 --- a/lane-start +++ b/lane-start @@ -1445,7 +1445,7 @@ step " its record refs are $record_window" # Active restarts refuse unowned callers before any binding repair or rename. # Detailed prompt and launch-fact validation follows before transcript writes. eri_rc=0 -eri_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" restart-intent "$LANE" 2>/dev/null)" || eri_rc=$? +eri_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" restart-intent "$LANE" --dir "$DIR" 2>/dev/null)" || eri_rc=$? case "$eri_rc" in 0) eri_state="$(printf '%s\n' "$eri_out" | awk -F'\t' '$1=="state" {print $2; exit}')" @@ -2874,7 +2874,7 @@ hs_resolve() { first_prompt=0 ri_state=""; ri_op=""; ri_gen=""; ri_mode=""; ri_dir=""; ri_hf=""; ri_dig=""; ri_rc=0; ri_expected_digest=""; ri_prompt=""; ri_active=0; ri_attempt=""; ri_prepared=""; ri_agent=""; ri_profile="" -ri_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" restart-intent "$LANE" 2>/dev/null)" || ri_rc=$? +ri_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" restart-intent "$LANE" --dir "$DIR" 2>/dev/null)" || ri_rc=$? case "$ri_rc" in 0) ri_state="$(printf '%s\n' "$ri_out" | awk -F'\t' '$1 == "state" { print $2; exit }')" @@ -4003,12 +4003,13 @@ if [ "$lane_verb" = RESUMED ] && [ "$row_count" = 1 ] && (( stamp_ok )); then fi ri_expected_digest="$ri_post_digest" fi - hs_publish="$(mktemp "$hs_real.resume.XXXXXX")" || die "the handoff publication could not be prepared. Nothing was launched." 2 - if ! cp -p -- "$hs_real" "$hs_publish" || ! cat -- "$hs_out" > "$hs_publish" || - ! cmp -s -- "$hs_real" "$hs_pre" || ! mv -f -- "$hs_publish" "$hs_real"; then - rm -f -- "$hs_publish" + hs_publish_args=(publish-handoff "$LANE" "$hs_real" "$hs_out" --expect-digest "$(handoff_digest_of "$hs_pre")") + if [ "$ri_active" = 1 ]; then + hs_publish_args+=(--operation "$ri_op" --generation "$ri_gen" --attempt "$ri_attempt" --state starting --transcript "$lane_sid") + fi + if ! LANES_NO_FETCH=1 "$LANES_EDIT" "${hs_publish_args[@]}" >/dev/null; then rm -rf -- "$hs_tmpd" - die "the handoff stamp could not be published atomically. Nothing was launched." 2 + die "the handoff stamp could not be published atomically under its writer lock. Nothing was launched." 2 fi step "handoff stamped at line $((hs_at + 1)) of $hs_rel: $hs_stamp" if [ "${LANES_NO_GIT:-0}" = 1 ]; then diff --git a/lanes-edit.sh b/lanes-edit.sh index 38baf251..e4499e18 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1065,6 +1065,7 @@ rename_undo() { } cleanup() { + [ -z "${pbh_tmp:-}" ] || rm -f -- "$pbh_tmp" rename_undo if [ "$LOCK_HELD" = 1 ]; then release_lock; fi [ -n "${RL_SNAP:-}" ] && [ -d "${RL_SNAP:-}" ] && rm -rf -- "$RL_SNAP" @@ -10544,6 +10545,15 @@ lane_control_root() { # [] printf '%s/.lane-state/%s\n' "${PROJECTS_ROOT%/}" "$lcr_lane" return 0 fi + # A checkout hint is a last resort, so reads cannot hide an intent that + # writers/status already placed under the established root rungs. + case "${3-}" in + /*) lcr_par="${3%/*}" + if [ -n "$lcr_par" ] && [ -d "$lcr_par" ]; then + printf '%s/.lane-state/%s\n' "$lcr_par" "$lcr_lane" + return 0 + fi ;; + esac return 8 } @@ -10628,9 +10638,32 @@ lane_handoff_digest() { # # parses with one `awk`. 0 with the fields, 8 where the lane has no intent at # all (the ordinary lane, and not a failure), 1 where the control root could # not be derived. -lane_restart_read() { # +# All schema-1 keys are mandatory; prepared_digest is an optional extension. +# One read validates presence and duplicate keys without per-field subprocesses. +lane_restart_missing() { # + awk -v lane="$2" ' + BEGIN {n=split("schema lane state generation operation mode agent profile dir pane window handoff digest old_transcript new_transcript attempt workstation created updated reason", keys, " ")} + /^[A-Za-z_]+:/ {k=$0; sub(/:.*/, "", k); v=$0; sub(/^[^:]*:[[:space:]]*/, "", v); seen[k]++; values[k]=v; if (v!="") good[k]=1} + END { + for (i=1;i<=n;i++) if (seen[keys[i]]!=1 || !good[keys[i]]) bad[keys[i]]=1 + if (values["lane"]!=lane) bad["lane"]=1 + if (values["generation"] !~ /^[0-9]+$/) bad["generation"]=1 + if (values["attempt"] !~ /^[0-9]+$/) bad["attempt"]=1 + n=split("operation mode agent profile old_transcript new_transcript", keys, " ") + for (i=1;i<=n;i++) if (values[keys[i]] !~ /^[A-Za-z0-9._-]+$/) bad[keys[i]]=1 + n=split("dir handoff", keys, " ") + for (i=1;i<=n;i++) if (values[keys[i]]!="none" && values[keys[i]] !~ /^\//) bad[keys[i]]=1 + n=split("digest prepared_digest", keys, " ") + for (i=1;i<=n;i++) if ((seen[keys[i]] && seen[keys[i]]!=1) || + (seen[keys[i]] && values[keys[i]]!="none" && (length(values[keys[i]])!=64 || values[keys[i]] !~ /^[0-9a-fA-F]+$/))) bad[keys[i]]=1 + for (k in bad) {printf "%s%s", sep, k; sep=" "} + } + ' "$1" +} + +lane_restart_read() { # [] lrr_lane="${1-}"; lrr_root=""; lrr_rc=0 - lrr_root="$(lane_control_root "$lrr_lane")" || lrr_rc=$? + lrr_root="$(lane_control_root "$lrr_lane" "" "${2-}")" || lrr_rc=$? [ "$lrr_rc" = 0 ] || return 1 lrr_f="$lrr_root/restart-intent.yaml" [ -e "$lrr_f" ] || [ -L "$lrr_f" ] || return 8 @@ -10667,21 +10700,11 @@ lane_restart_read() { # printf 'state\tUNKNOWN-STATE\nfile\t%s\n' "$lrr_f" return 0 fi - case "$lrr_state" in - preparing | pending | starting | failed) - lrr_missing="" - for lrr_need in operation mode dir pane handoff digest; do - [ -n "$(lane_sidecar_field "$lrr_f" "$lrr_need")" ] || - lrr_missing="${lrr_missing:+$lrr_missing }$lrr_need" - done - if [ -n "$lrr_missing" ]; then - printf 'state\tINCOMPLETE\n' - printf 'intended_state\t%s\n' "$lrr_state" - printf 'missing\t%s\n' "$lrr_missing" - printf 'file\t%s\n' "$lrr_f" - return 0 - fi ;; - esac + lrr_missing="$(lane_restart_missing "$lrr_f" "$lrr_lane")" || return 1 + if [ -n "$lrr_missing" ]; then + printf 'state\tINCOMPLETE\nintended_state\t%s\nmissing\t%s\nfile\t%s\n' "$lrr_state" "$lrr_missing" "$lrr_f" + return 0 + fi for lrr_num in generation attempt; do lrr_value="$(lane_sidecar_field "$lrr_f" "$lrr_num")" || return 1 case "$lrr_value" in ''|*[!0-9]*) @@ -14039,13 +14062,17 @@ EOF # 64 a usage error of this subcommand's own restart-intent) - lane="${1-}"; [ -n "$lane" ] || die "usage: restart-intent " 64 - [ "$#" -le 1 ] || die "restart-intent takes one lane: restart-intent " 64 + lane="${1-}"; [ -n "$lane" ] || die "usage: restart-intent [--dir ]" 64 + shift; lri_dir="" + if [ "$#" -gt 0 ]; then + [ "$#" = 2 ] && [ "$1" = --dir ] || die "usage: restart-intent [--dir ]" 64 + case "$2" in /*) lri_dir="$2" ;; *) die "--dir takes an absolute checkout" 64 ;; esac + fi check_lane_name "$lane" log_sync lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 lri_out=""; lri_rc=0 - lri_out="$(lane_restart_read "$lane")" || lri_rc=$? + lri_out="$(lane_restart_read "$lane" "$lri_dir")" || lri_rc=$? case "$lri_rc" in 0) : ;; 8) exit 8 ;; @@ -14054,6 +14081,59 @@ EOF printf '%s\n' "$lri_out" ;; + publish-handoff) + lane="${1-}"; pbh_target="${2-}"; pbh_source="${3-}" + [ "$#" -ge 3 ] || die "usage: publish-handoff --expect-digest [--operation --generation --attempt --state --transcript ]" 64 + shift 3; check_lane_name "$lane"; lane="$(canon_lane "$lane")" || exit 2 + pbh_digest=""; pbh_op=""; pbh_gen=""; pbh_attempt=""; pbh_state=""; pbh_sid="" + while [ "$#" -gt 0 ]; do + [ "$#" -ge 2 ] && [ -n "${2-}" ] || die "$1 needs a value" 64 + case "$1" in + --expect-digest) pbh_digest="$2" ;; + --operation) pbh_op="$2" ;; + --generation) pbh_gen="$2" ;; + --attempt) pbh_attempt="$2" ;; + --state) pbh_state="$2" ;; + --transcript) pbh_sid="$2" ;; + *) die "unknown publish-handoff flag $1" 64 ;; + esac + shift 2 + done + [ "${#pbh_digest}" = 64 ] || die "publication requires the complete prior SHA-256" 64 + case "$pbh_digest" in *[!0-9a-fA-F]*) die "publication requires SHA-256" 64 ;; esac + [ -f "$pbh_source" ] && [ -r "$pbh_source" ] && [ -s "$pbh_source" ] || die "the prepared handoff is unreadable or empty" 1 + pbh_links=0 + while [ -L "$pbh_target" ]; do + pbh_links=$((pbh_links+1)); [ "$pbh_links" -lt 40 ] || die "the handoff link chain cannot be resolved" 2 + pbh_link="$(readlink "$pbh_target")" || die "the handoff link could not be read" 1 + case "$pbh_link" in /*) pbh_target="$pbh_link" ;; *) pbh_target="$(dirname -- "$pbh_target")/$pbh_link" ;; esac + done + pbh_parent="$( CDPATH=''; cd -P -- "$(dirname -- "$pbh_target")" && pwd -P )" || die "the handoff parent is missing" 1 + pbh_target="$pbh_parent/$(basename -- "$pbh_target")" + [ -f "$pbh_target" ] && [ -r "$pbh_target" ] || die "the handoff target is unreadable" 1 + acquire_lock + pbh_irc=0; pbh_intent="$(lane_restart_read "$lane")" || pbh_irc=$? + case "$pbh_irc" in 0|8) : ;; *) release_lock; die "restart ownership could not be read; no handoff was published" 1 ;; esac + pbh_current="$(printf '%s\n' "$pbh_intent" | awk -F'\t' '$1=="state" {print $2; exit}')" + case "$pbh_current" in UNKNOWN-*|INCOMPLETE) release_lock; die "restart ownership is malformed; no handoff was published" 2 ;; esac + if [ -n "$pbh_op" ]; then + [ -n "$pbh_gen" ] && [ -n "$pbh_attempt" ] && [ -n "$pbh_state" ] && [ -n "$pbh_sid" ] || { release_lock; die "operation publication needs generation, attempt, state and transcript fences" 64; } + pbh_actual="$(printf '%s\n' "$pbh_intent" | awk -F'\t' ' + $1=="operation" {o=$2} $1=="generation" {g=$2} $1=="attempt" {a=$2} $1=="state" {s=$2} $1=="new_transcript" {t=$2} + END {printf "%s|%s|%s|%s|%s",o,g,a,s,t}')" + [ "$pbh_actual" = "$pbh_op|$pbh_gen|$pbh_attempt|$pbh_state|$pbh_sid" ] || { release_lock; die "this handoff publisher lost restart ownership; nothing was published" 7; } + else + case "$pbh_current" in preparing|pending|starting) release_lock; die "an active restart owns this handoff; nothing was published" 7 ;; esac + fi + [ "$(lane_handoff_digest "$pbh_target")" = "$pbh_digest" ] || { release_lock; die "the handoff changed before publication; nothing was published" 7; } + pbh_tmp="$(mktemp "$pbh_target.resume.XXXXXX")" || { release_lock; die "the publication temporary file could not be made" 1; } + if ! cp -p -- "$pbh_target" "$pbh_tmp" || ! cat -- "$pbh_source" > "$pbh_tmp" || ! mv -f -- "$pbh_tmp" "$pbh_target"; then + rm -f -- "$pbh_tmp"; release_lock; die "the handoff could not be published atomically" 1 + fi + release_lock + printf '%s\n' "$pbh_target" + ;; + legacy-restart-check) lane="${1-}"; check_lane_name "$lane" lane="$(canon_lane "$lane")" || exit 2 @@ -14212,6 +14292,8 @@ EOF release_lock die "lane $lane has an existing unreadable restart intent. Nothing was written: a failed read is not an absent operation." 1 fi + sri_missing="$(lane_restart_missing "$sri_f" "$lane")" || { release_lock; die "the restart record could not be read. Nothing was written." 1; } + [ -z "$sri_missing" ] || { release_lock; die "the existing restart record is malformed (missing, duplicate or invalid fields: $sri_missing). Nothing was written." 2; } sri_nows="$(lane_sidecar_field "$sri_f" schema)" || { release_lock; die "the restart schema could not be read. Nothing was written." 1; } if [ "$sri_nows" != "$LANE_RESTART_SCHEMA" ]; then release_lock @@ -14360,6 +14442,6 @@ EOF ;; *) - die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|retire-rows|archive-rows|migrate-state-cells|commit|rename-lane|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|binding|request-handoff|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|duplicate-holder|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|register-row-local|retired-identity|canon-lane|resolve-home|lane-holders|legacy-restart-check|restart-intent|set-restart-intent)" 2 + die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|retire-rows|archive-rows|migrate-state-cells|commit|rename-lane|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|binding|request-handoff|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|duplicate-holder|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|register-row-local|retired-identity|canon-lane|resolve-home|lane-holders|legacy-restart-check|publish-handoff|restart-intent|set-restart-intent)" 2 ;; esac diff --git a/skills/handoff/SKILL.md b/skills/handoff/SKILL.md index efba1f54..0716fa42 100644 --- a/skills/handoff/SKILL.md +++ b/skills/handoff/SKILL.md @@ -201,6 +201,20 @@ Only the second branch leaves anything blank, and it leaves it blank because not ## 2. Refresh the handoff +Canonical handoff publication is serialized by `lanes-edit.sh publish-handoff`. +Read the current file's SHA-256, write semantic updates to a temporary file, then +publish that complete prepared file with: + +```sh +"$L" publish-handoff "$lane" "$handoff_file" "$prepared_file" --expect-digest "$prior_sha256" +``` + +The helper checks the expected bytes and atomically replaces the resolved target +while holding the workspace writer mutex. It refuses an active restart owned by +another operation. Do not edit or redirect into the canonical handoff directly; +perform the existing explicit-pathspec commit/push after successful publication. + + The handoff path is the row's own handoff column. Derive it; do not guess it — and read the row FETCHED, because this decides state (Amendment 7's read-after-fetch rule governs it; step 1's window/record probe is the only no-fetch read this skill makes): diff --git a/specs/005-supervised-legacy-ctx/plan.md b/specs/005-supervised-legacy-ctx/plan.md index 2567295d..d954fcfc 100644 --- a/specs/005-supervised-legacy-ctx/plan.md +++ b/specs/005-supervised-legacy-ctx/plan.md @@ -36,4 +36,13 @@ preparing reservation, then request a new `/ctx`. Each starting attempt consumes `new_transcript: none` exactly once. Later writes expect the reserved UUID as well as operation, generation and attempt. Stamp publication resolves a final-component symlink and atomically replaces its target -from a same-directory temporary file after proving the source is unchanged. +from a same-directory temporary file. The helper holds the same writer mutex +through ownership and prior-digest checks and replacement. All shipped handoff +publishers use this path; manual edits must follow the staging/publication skill +contract. Existing records with missing, duplicate or invalid fields refuse +transitions rather than being silently repaired. + +Ordinary first launches use the resolved checkout as a control-root hint only +only after all established root rungs fail. Explicit configuration, recorded +directories and PROJECTS_ROOT retain precedence. Each claimed attempt repeats the +all-holder absence check before starting, including interactive retries. diff --git a/specs/005-supervised-legacy-ctx/tasks.md b/specs/005-supervised-legacy-ctx/tasks.md index 3c56dcbf..84444f7f 100644 --- a/specs/005-supervised-legacy-ctx/tasks.md +++ b/specs/005-supervised-legacy-ctx/tasks.md @@ -14,6 +14,6 @@ - [x] T012 Update command, skill, manual and capability contracts to the final scope. - [ ] T013 Run focused sandboxed restart fault cases and touched shell suite. - [x] T014 Complete expert follow-up review and address verified findings. -- [ ] T015 Commit/push PR updates and request exact-head Codex review. +- [x] T015 Commit/push PR updates and request exact-head Codex review (bot quota exhausted; follow-up updates retain the hold). - [ ] T016 Verify current-head per-push CI; record review quota limitations if present. - [ ] T017 After hold release/merge authorization, run macOS landing gate and merge. diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index b44f8aa5..d9b7aaa2 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -13583,7 +13583,7 @@ is "…and a launch of that lane refuses" "$rc" 2 has "…saying which checks an absent value would have skipped" "$err" "cannot compare" hasnt "…resolving no resume source" "$out" "--resume" # PUT IT BACK for the supervisor cases below, which need this lane `starting`. -"$E" set-restart-intent repoSV-1 starting --expect starting --digest none >/dev/null 2>&1 || : +printf 'digest: none\n' >> "$SV_STATE/repoSV-1/restart-intent.yaml" run "$E" restart-intent repoSV-1 has "…and the record is whole again" "$out" "$(printf 'state\tstarting')" @@ -13608,6 +13608,10 @@ has "…naming the seam that gives it one" "$err" "LANES_LANE_STATE_ROOT" # transcript that `/ctx` PAUSED. openRepoTools#94, recreated by the command # written to prevent it. : > "$FAKE_CLAUDE_LOG" +# The resolved checkout now supplies a root for a new lane. An existing +# nonregular intent at that root must still refuse as an unreadable record. +SV_UNREADABLE="${SV_DIR%/*}/.lane-state/repoSV-4/restart-intent.yaml" +mkdir -p "$SV_UNREADABLE" run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ LANES_LANE_STATE_ROOT= PROJECTS_ROOT=/nonexistent-projects-root \ "$START" --dir "$SV_DIR" repoSV-4 --no-launch @@ -13616,6 +13620,7 @@ has "…saying that is NOT 'no restart is in flight'" "$err" "NOT 'no restart i has "…naming what an ordinary resume would have opened" "$err" "the transcript that /ctx PAUSED" has "…and how to give this run the root" "$err" "LANES_LANE_STATE_ROOT" hasnt "…resolving no resume source" "$out" "--resume" +rmdir "$SV_UNREADABLE" # A NEW OPERATION INHERITS NOTHING OF THE LAST ONE'S. # diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index b0d97704..b78a5e52 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -81,7 +81,8 @@ def test_restart_failed_record_needs_launch_facts_before_retry(restart_box): assert result.returncode == 0, result.stderr assert "state\tINCOMPLETE" in result.stdout assert "intended_state\tfailed" in result.stdout - assert "missing\tdir pane handoff digest" in result.stdout + missing = next(line.split("\t", 1)[1].split() for line in result.stdout.splitlines() if line.startswith("missing\t")) + assert {"dir", "pane", "handoff", "digest", "agent", "window", "new_transcript"} <= set(missing) @pytest.mark.parametrize("write_rc", [7, 1]) @@ -590,3 +591,277 @@ def test_restart_stale_supervisor_cannot_fail_or_retry_newer_operation(restart_b assert record["reason"] == "new owner" assert "no retry is authorized" in result.stdout assert "RESTART FAILED" not in result.stdout + + +@pytest.mark.parametrize("field", ["agent", "window", "new_transcript", "created"]) +def test_restart_truncated_intent_is_never_repaired_by_transition(restart_box, field): + box = restart_box + prepare_restart(box) + path = intent_path(box) + path.write_text("\n".join(line for line in path.read_text().splitlines() if not line.startswith(field + ":")) + "\n") + before = path.read_bytes() + result = helper(box, "restart-intent", LANE) + assert "state\tINCOMPLETE" in result.stdout + assert field in result.stdout + result = helper(box, "set-restart-intent", LANE, "starting", "--expect", "pending") + assert result.returncode == 2, result.stderr + assert path.read_bytes() == before + + +def test_restart_publication_fences_changed_bytes_and_current_attempt(restart_box): + box = restart_box + source = prepare_restart(box) + before = source.read_bytes() + digest = hashlib.sha256(before).hexdigest() + prepared = box.root / "prepared.md" + _write(prepared, "# owned stamp\n\nPreserved task.\n", 0o600) + args = ("publish-handoff", LANE, str(source), str(prepared), "--expect-digest", digest, + "--operation", "op-test", "--generation", "7", "--attempt", "0", + "--state", "pending", "--transcript", "none") + # Changed source and stale intent must independently refuse publication. + source.write_text(source.read_text() + "Other writer's instructions.\n") + changed = source.read_bytes() + result = helper(box, *args) + assert result.returncode == 7, result.stderr + assert source.read_bytes() == changed + source.write_bytes(before) + assert helper(box, "set-restart-intent", LANE, "starting", "--attempt", "1").returncode == 0 + result = helper(box, *args) + assert result.returncode == 7, result.stderr + assert source.read_bytes() == before + + +def test_plain_handoff_refuses_active_restart_before_preservation(restart_box): + box = restart_box + source = prepare_restart(box) + before = {p: p.read_bytes() for p in (source, box.wip / "lanes" / "LANES.md", intent_path(box))} + result = supervisor(box, "--lane", LANE, "--dir", str(box.lane_dir), "handoff") + assert result.returncode == 2, (result.stdout, result.stderr) + assert "preservation refused before" in result.stderr + assert all(p.read_bytes() == contents for p, contents in before.items()) + + +@pytest.mark.parametrize("field,value", [("operation", "bad operation"), ("dir", "relative"), + ("digest", "bad-sha"), ("lane", "other-1"), + ("generation", "1:2"), ("attempt", "1:2")]) +def test_restart_writer_preserves_invalid_existing_values(restart_box, field, value): + box = restart_box + prepare_restart(box) + path = intent_path(box) + text = path.read_text() + contents = "\n".join(f"{field}: {value}" if line.startswith(f"{field}:") else line + for line in text.splitlines()) + "\n" + path.write_text(contents) + result = helper(box, "set-restart-intent", LANE, "starting", "--expect", "pending") + assert result.returncode == 2, result.stderr + assert path.read_text() == contents + assert "state\tINCOMPLETE" in helper(box, "restart-intent", LANE).stdout + + +def test_handoff_publishers_serialize_checksum_and_replacement(restart_box): + box = restart_box + source = prepare_restart(box) + digest = hashlib.sha256(source.read_bytes()).hexdigest() + ready, release = box.root / "publisher.locked", box.root / "publisher.release" + first, second = box.root / "first.md", box.root / "second.md" + first.write_text("First publisher.\n") + second.write_text("Second publisher.\n") + cp = shutil.which("cp") + _write(box.fakebin / "cp", f"""#!/usr/bin/env bash +case "$*" in + *.resume.*) + : > '{ready}' + while [ ! -e '{release}' ]; do sleep .05; done ;; +esac +exec '{cp}' "$@" +""") + def command(prepared): + return [str(box.bin / "lanes-edit.sh"), "publish-handoff", LANE, str(source), + str(prepared), "--expect-digest", digest, "--operation", "op-test", + "--generation", "7", "--attempt", "0", "--state", "pending", + "--transcript", "none"] + a = subprocess.Popen(command(first), cwd=box.lane_dir, env=box.env, + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + b = None + try: + deadline = time.monotonic() + 15 + while not ready.exists() and a.poll() is None and time.monotonic() < deadline: + time.sleep(.05) + assert ready.exists(), a.communicate(timeout=5) + b = subprocess.Popen(command(second), cwd=box.lane_dir, env=box.env, + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + release.touch() + out_a = a.communicate(timeout=20) + out_b = b.communicate(timeout=20) + assert a.returncode == 0, out_a + assert b.returncode == 7, out_b + assert source.read_text() == "First publisher.\n" + finally: + release.touch() + for process in (a, b): + if process is not None and process.poll() is None: + process.terminate() + process.communicate(timeout=10) + + +def test_new_lane_checkout_without_projects_root_can_launch(restart_box): + box = restart_box + box.env.pop("LANES_LANE_STATE_ROOT") + box.env["PROJECTS_ROOT"] = str(box.root / "missing-projects") + box.resolver() + result = box.start("--dir", str(box.lane_dir)) + assert result.returncode == 0, (result.stdout, result.stderr) + assert box.claude_runs() + + +def test_new_lane_checkout_hint_still_refuses_unreadable_intent(restart_box): + box = restart_box + box.env.pop("LANES_LANE_STATE_ROOT") + box.env["PROJECTS_ROOT"] = str(box.root / "missing-projects") + (box.lane_dir.parent / ".lane-state" / LANE / "restart-intent.yaml").mkdir(parents=True) + box.resolver() + result = box.start("--dir", str(box.lane_dir)) + assert result.returncode == 2, (result.stdout, result.stderr) + assert not box.claude_runs() + + +def test_interactive_retry_rechecks_holder_absence_before_launch(restart_box): + import pty + from conftest import REPO + box = restart_box + prepare_restart(box) + shutil.copy2(REPO / "lane-handoff", box.bin / "lane-handoff") + real = box.bin / "lanes-edit-real.sh" + (box.bin / "lanes-edit.sh").rename(real) + _write(box.bin / "lanes-edit.sh", f"""#!/usr/bin/env bash +if [ "$1" = lane-holders ] && command grep -q '^attempt: 2$' '{intent_path(box)}'; then + printf 'another holder\n' + exit 0 +fi +exec "$(dirname -- "$0")/lanes-edit-real.sh" "$@" +""") + launches = box.root / "launches" + child = _write(box.fakebin / "pclaude", f"""#!/usr/bin/env bash +printf 'launch\n' >> '{launches}' +exit 127 +""") + box.env.update(PCLAUDE=str(child)) + box.env.pop("LANE_SUPERVISOR_NO_PROMPT", None) + master, slave = pty.openpty() + output_path = box.root / "interactive.out" + with output_path.open("w") as output: + proc = subprocess.Popen([str(box.bin / "lane-handoff"), "--supervise", "--lane", LANE, + "--operation", "op-test"], cwd=box.lane_dir, env=box.env, + stdin=slave, stdout=output, stderr=output, start_new_session=True) + os.close(slave) + try: + deadline = time.monotonic() + 15 + while "retry this restart?" not in output_path.read_text() and proc.poll() is None and time.monotonic() < deadline: + time.sleep(.05) + assert "retry this restart?" in output_path.read_text(), output_path.read_text() + os.write(master, b"r\n") + assert proc.wait(timeout=15) == 4, output_path.read_text() + assert launches.read_text().splitlines() == ["launch"] + record = dict(line.split("\t", 1) for line in helper(box, "restart-intent", LANE).stdout.splitlines()) + assert record["state"] == "starting" and record["attempt"] == "2" + assert "before attempt launch" in record["reason"] + finally: + os.close(master) + try: os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: pass + if proc.poll() is None: proc.wait(timeout=10) + + +def test_checkout_hint_cannot_hide_existing_fallback_intent(restart_box): + box = restart_box + box.env.pop("LANES_LANE_STATE_ROOT") + fallback = box.root / "established-projects" + fallback.mkdir() + box.env["PROJECTS_ROOT"] = str(fallback) + source = box.wip / "handoffs" / "README.md" + result = helper(box, "set-restart-intent", LANE, "pending", "--expect", "none", + "--operation", "established", "--agent", "claude", "--handoff", str(source)) + assert result.returncode == 0, result.stderr + path = fallback / ".lane-state" / LANE / "restart-intent.yaml" + before = path.read_bytes() + box.resolver() + result = box.start("--dir", str(box.lane_dir)) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "supervisor operation token" in result.stderr + assert not box.claude_runs() + assert path.read_bytes() == before + + +@pytest.mark.parametrize("holder_rc", [0, 1]) +def test_signal_after_launcher_exit_keeps_live_or_unknown_holder_indeterminate(restart_box, holder_rc): + from conftest import REPO + box = restart_box + prepare_restart(box) + shutil.copy2(REPO / "lane-handoff", box.bin / "lane-handoff") + exited, probed, release = (box.root / name for name in ("child.exited", "holder.probed", "holder.release")) + real = box.bin / "lanes-edit-real.sh" + (box.bin / "lanes-edit.sh").rename(real) + _write(box.bin / "lanes-edit.sh", f"""#!/usr/bin/env bash +if [ "$1" = lane-holders ] && [ -e '{exited}' ]; then + : > '{probed}' + while [ ! -e '{release}' ]; do sleep .05; done + exit {holder_rc} +fi +exec "$(dirname -- "$0")/lanes-edit-real.sh" "$@" +""") + child = _write(box.fakebin / "pclaude", f"""#!/usr/bin/env bash +: > '{exited}' +exit 127 +""") + box.env.update(PCLAUDE=str(child), LANE_SUPERVISOR_NO_PROMPT="1") + output_path = box.root / "ended-child-signal.out" + with output_path.open("w") as output: + proc = subprocess.Popen([str(box.bin / "lane-handoff"), "--supervise", "--lane", LANE, + "--operation", "op-test"], cwd=box.lane_dir, env=box.env, + stdout=output, stderr=output, start_new_session=True) + try: + deadline = time.monotonic() + 15 + while not probed.exists() and proc.poll() is None and time.monotonic() < deadline: + time.sleep(.05) + assert probed.exists(), output_path.read_text() + proc.send_signal(signal.SIGTERM) + release.touch() + assert proc.wait(timeout=15) == 143, output_path.read_text() + record = dict(line.split("\t", 1) for line in helper(box, "restart-intent", LANE).stdout.splitlines()) + assert record["state"] == "starting" + assert "INDETERMINATE" in record["reason"] + finally: + release.touch() + try: os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: pass + if proc.poll() is None: proc.wait(timeout=10) + + +def test_respawn_restores_configured_roots_in_new_pane(restart_box): + box = restart_box + protocol = box.root / "protocol with spaces" + (box.home / ".agents").rename(protocol) + box.env["AGENT_PROTOCOL_ROOT"] = str(protocol) + box.env["LANES_LANE_STATE_ROOT"] = str(box.root / "state with spaces") + prepare_restart(box) + assert helper(box, "set-restart-intent", LANE, "ready", "--expect", "pending").returncode == 0 + real_tmux = box.fakebin / "tmux-real" + (box.fakebin / "tmux").rename(real_tmux) + pane_result = box.root / "pane-result" + _write(box.fakebin / "tmux", f"""#!/usr/bin/env bash +if [ "$1" = respawn-pane ]; then + for arg in "$@"; do line="$arg"; done + env -u LANES_LANE_STATE_ROOT -u AGENT_PROTOCOL_ROOT sh -c "$line" + printf '%s\n' "$?" > '{pane_result}' + exit 0 +fi +exec '{real_tmux}' "$@" +""") + child = _write(box.fakebin / "pclaude", "#!/usr/bin/env bash\nexit 127\n") + box.env.update(PCLAUDE=str(child), LANE_SUPERVISOR_NO_PROMPT="1", TMUX_PANE="%1") + result = supervisor(box, "--restart", "--lane", LANE, "--dir", str(box.lane_dir), "clear") + assert result.returncode == 0, (result.stdout, result.stderr) + assert pane_result.read_text().strip() == "3", (result.stdout, result.stderr) + record = dict(line.split("\t", 1) for line in helper(box, "restart-intent", LANE).stdout.splitlines()) + assert record["state"] == "failed" and record["attempt"] == "1" + assert "127" in record["reason"] From d0f712cb4a1e6bfa478733a2854d595816d6c322 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:30:53 +0000 Subject: [PATCH 12/25] Correct restart regression fixtures and retain agent conflict diagnostics --- lane-handoff | 2 +- tests/test_lane_helpers.sh | 2 +- tests/test_supervised_restart_regressions.py | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lane-handoff b/lane-handoff index a730936a..4361b445 100755 --- a/lane-handoff +++ b/lane-handoff @@ -1218,7 +1218,6 @@ if [ "$do_supervise" = 1 ]; then ready) die "lane $lane's restart operation $operation is already \`ready\`: that restart was confirmed and this record authorises no further launch. Nothing was started. To open the lane: lane $lane" 2 ;; *) die "lane $lane's restart intent is in state '${RI_STATE:-none}', which this supervisor does not know how to launch from. Nothing was started." 2 ;; esac - [ "$RI_AGENT" = claude ] || die "this legacy supervisor supports only the recorded Claude agent. Nothing was started." 2 [ "$RI_MODE" = fresh-from-handoff ] || die "lane $lane's restart intent names launch mode '${RI_MODE:-none}' and this supervisor makes only 'fresh-from-handoff' launches. Nothing was started." 2 @@ -1264,6 +1263,7 @@ if [ "$do_supervise" = 1 ]; then *) die "lane $lane's recorded agent could not be read (lane-agent exited $sv_agrc). Nothing was started: unknown agent identity cannot authorize a restart." 1 ;; esac + [ "$RI_AGENT" = claude ] || die "this legacy supervisor supports only the recorded Claude agent. Nothing was started." 2 [ -n "$RI_HANDOFF" ] && [ "$RI_HANDOFF" != none ] && [ -s "$RI_HANDOFF" ] && [ -n "$RI_DIGEST" ] && [ "$RI_DIGEST" != none ] || die "the supervised restart has no complete handoff checksum. Nothing was started." 2 diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index d9b7aaa2..85cc3a10 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -13714,7 +13714,7 @@ has "…saying which authority decided it" "$err" "restart intent op-94" # AND THE INTENT OUTRANKS A CONFLICT RATHER THAN SUBSTITUTING FOR IT. run env PATH="$A17PATH" FAKE_TMUX_WINDOW="svsess:@31" CLAUDE_PROFILE_NAME=team-05a \ - "$START" --dir "$SV_DIR" --operation op-not-this-one repoSV-2 --operation op-94 --no-launch + "$START" --dir "$SV_DIR" --operation op-not-this-one repoSV-2 --no-launch is "a launch naming another operation is refused" "$rc" 2 has "…naming the one that really holds the lane" "$err" "op-94" diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index b78a5e52..4059cc3f 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -462,7 +462,11 @@ def test_restart_all_holders_retains_duplicate_processes_for_one_uuid(restart_bo box = restart_box prepare_restart(box) sid = "10101010-1111-4111-8111-111111111111" - assert helper(box, "append-session-id", LANE, sid).returncode == 0 + row = next(line for line in (box.wip / "lanes" / "LANES.md").read_text().splitlines() + if line.startswith(f"| `{LANE}`")) + anchor = row.split("|")[2].strip() + result = helper(box, "append-session-id", LANE, anchor, f"`{sid}`") + assert result.returncode == 0, result.stderr procs = [subprocess.Popen(["sleep", "20"]) for _ in range(2)] try: for n, proc in enumerate(procs): From f5f4a0c7b5660a89231cbe320575ce98363efc25 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:42:15 +0000 Subject: [PATCH 13/25] Verify legacy restart pane, process state and complete binding --- docs/README-lanes.md | 8 +++ lane-handoff | 10 ++++ lanes-edit.sh | 14 +++++ specs/005-supervised-legacy-ctx/plan.md | 5 ++ tests/test_lane_helpers.sh | 3 + tests/test_supervised_restart_regressions.py | 60 ++++++++++++++++++-- 6 files changed, 95 insertions(+), 5 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 32f7970e..abaf74c7 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2720,6 +2720,14 @@ installed and not a thirteenth word: Amendment 18 Addendum 2 (i-8) is explicit that *"no word is kept on `PATH` for it alone"*, which is the ground on which `restart` was taken off `PATH` in the first place. +The legacy supervisor requires both the recorded pane and current TMUX_PANE to +be known and agree before claiming an attempt. A respawn forwards the configured +control/protocol roots and the launcher's LANES_HOST, LANES_OS and +LANES_CONTAINER identity alongside LANES_WORKSTATION. An existing unusable +control-root directory or ancestor is a read failure; only genuinely absent +storage permits an ordinary first launch. Readiness checks the child process +state and refuses an exited child awaiting reap. + What the supervisor does, in order: 1. **claims** the intent — `pending`/`failed` → `starting`, compare-and-swap on diff --git a/lane-handoff b/lane-handoff index 4361b445..f35d5d5d 100755 --- a/lane-handoff +++ b/lane-handoff @@ -492,6 +492,11 @@ ready_check() { # /dev/null || { READY_WHY="the child (pid $rc_pid) is gone"; return 2; } + rc_ps="$(ps -p "$rc_pid" -o stat= 2>/dev/null)"; rc_ps="$(printf '%s' "$rc_ps" | tr -d '[:space:]')" + case "$rc_ps" in + Z*) READY_WHY="the child (pid $rc_pid) exited and is awaiting reap"; return 2 ;; + '') READY_WHY="the child process state could not be established"; return 1 ;; + esac rc_out=""; rc_rc=0 rc_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-holders "$rc_lane" 2>/dev/null)" || rc_rc=$? case "$rc_rc" in @@ -1230,6 +1235,8 @@ if [ "$do_supervise" = 1 ]; then # assumed — this runs under a fake tmux in the suite and under none at all # where a person invoked it by hand. sv_pane_here="${TMUX_PANE:-}" + [ -n "$RI_PANE" ] && [ "$RI_PANE" != none ] && [ -n "$sv_pane_here" ] || + die "the restart pane binding cannot be verified: recorded ${RI_PANE:-none}, current ${sv_pane_here:-none}. Nothing was started." 2 pane_agrees "$RI_PANE" "$sv_pane_here" || die "lane $lane's restart intent was written for pane $RI_PANE and this supervisor is in $sv_pane_here. Nothing was started: a launch here would put this lane's session in a pane the lane's own record does not name, and that record is what every other reader goes by." 2 @@ -2204,6 +2211,9 @@ if [ "$do_restart" = 1 ]; then [ -n "$ws" ] && launch_line="LANES_WORKSTATION=$(quoted "$ws") $launch_line" [ -z "${LANES_LANE_STATE_ROOT:-}" ] || launch_line="LANES_LANE_STATE_ROOT=$(quoted "$LANES_LANE_STATE_ROOT") $launch_line" [ -z "${AGENT_PROTOCOL_ROOT:-}" ] || launch_line="AGENT_PROTOCOL_ROOT=$(quoted "$AGENT_PROTOCOL_ROOT") $launch_line" + [ -z "${LANES_HOST:-}" ] || launch_line="LANES_HOST=$(quoted "$LANES_HOST") $launch_line" + [ -z "${LANES_OS:-}" ] || launch_line="LANES_OS=$(quoted "$LANES_OS") $launch_line" + [ -z "${LANES_CONTAINER:-}" ] || launch_line="LANES_CONTAINER=$(quoted "$LANES_CONTAINER") $launch_line" say " respawning $target: $launch_line" if tmux respawn-pane -k -t "$target" "$launch_line" 2>/dev/null; then say " respawned into the supervisor — it launches the fresh session, confirms it came up, and stays in this pane if it did not" diff --git a/lanes-edit.sh b/lanes-edit.sh index e4499e18..e54ddaa6 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -10661,10 +10661,24 @@ lane_restart_missing() { # ' "$1" } +lane_restart_root_ok() { # missing directories are allowed; unusable existing ancestors are not + lrro_path="$1" + while [ -n "$lrro_path" ]; do + if [ -e "$lrro_path" ] || [ -L "$lrro_path" ]; then + [ -d "$lrro_path" ] && [ -r "$lrro_path" ] && [ -x "$lrro_path" ] || return 1 + fi + lrro_parent="$(dirname -- "$lrro_path")" || return 1 + [ "$lrro_parent" != "$lrro_path" ] || break + lrro_path="$lrro_parent" + done + return 0 +} + lane_restart_read() { # [] lrr_lane="${1-}"; lrr_root=""; lrr_rc=0 lrr_root="$(lane_control_root "$lrr_lane" "" "${2-}")" || lrr_rc=$? [ "$lrr_rc" = 0 ] || return 1 + lane_restart_root_ok "$lrr_root" || return 1 lrr_f="$lrr_root/restart-intent.yaml" [ -e "$lrr_f" ] || [ -L "$lrr_f" ] || return 8 [ -f "$lrr_f" ] && [ -r "$lrr_f" ] || return 1 diff --git a/specs/005-supervised-legacy-ctx/plan.md b/specs/005-supervised-legacy-ctx/plan.md index d954fcfc..6266d400 100644 --- a/specs/005-supervised-legacy-ctx/plan.md +++ b/specs/005-supervised-legacy-ctx/plan.md @@ -46,3 +46,8 @@ Ordinary first launches use the resolved checkout as a control-root hint only only after all established root rungs fail. Explicit configuration, recorded directories and PROJECTS_ROOT retain precedence. Each claimed attempt repeats the all-holder absence check before starting, including interactive retries. + +Require a known current pane before the supervisor claim. Forward the complete +launcher host/OS/container binding through respawn. Control-root reads inspect +existing directory ancestors so an unusable root cannot answer absence. Child +readiness rejects zombie process state and keeps unknown process state unready. diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 85cc3a10..027b498c 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -391,6 +391,9 @@ FAKE cat > "$SANDBOX/fakebin/ps" <<'FAKE' #!/usr/bin/env bash +# The supervised child is a real sandbox process. Its stat read must report +# real zombie/active state; the older signature fixtures still use records. +case "$*" in *"-o stat="*) exec /bin/ps "$@" ;; esac target="" while [ $# -gt 0 ]; do case "$1" in diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index 4059cc3f..d3edc32e 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -21,6 +21,7 @@ def restart_box(tmp_path: Path) -> Sandbox: box = Sandbox(tmp_path) box.env["LANES_LANE_STATE_ROOT"] = str(tmp_path / "state") box.env["LANES_NO_FETCH"] = "1" + box.env["TMUX_PANE"] = "%1" return box @@ -481,9 +482,17 @@ def test_restart_all_holders_retains_duplicate_processes_for_one_uuid(restart_bo proc.wait(timeout=5) -def test_restart_observer_confirms_late_uuid_prepared_by_real_child(restart_box): +@pytest.mark.parametrize("child_state", ["live", "zombie"]) +def test_restart_observer_confirms_late_uuid_prepared_by_real_child(restart_box, child_state): box = restart_box prepare_restart(box) + if child_state == "zombie": + ps = shutil.which("ps") + _write(box.fakebin / "ps", f"""#!/usr/bin/env bash +case "$*" in *"-o stat="*) printf 'Z\n'; exit 0 ;; esac +exec '{ps}' "$@" +""") + box.env["FAKE_CHILD_STATE"] = child_state # A fake profile launcher forwards both supervisor tokens in the existing # pane. The child itself executes real lane-start, including the stamp. launcher = _write(box.fakebin / "pclaude", '''#!/usr/bin/env bash @@ -511,6 +520,12 @@ def test_restart_observer_confirms_late_uuid_prepared_by_real_child(restart_box) pathlib.Path(os.environ['FAKE_CLAUDE_LOG']).write_text(' '.join(args)) intent=pathlib.Path(os.environ['LANES_LANE_STATE_ROOT'])/'repoZ-1'/'restart-intent.yaml' +if os.environ.get('FAKE_CHILD_STATE') == 'zombie': + time.sleep(1) + record_path.unlink() + historical_path=record_path.parent/'historical.json' + if historical_path.exists(): historical_path.unlink() + sys.exit(127) for _ in range(150): if 'state: ready\n' in intent.read_text(): break time.sleep(.1) @@ -520,9 +535,9 @@ def test_restart_observer_confirms_late_uuid_prepared_by_real_child(restart_box) box.env.update(PCLAUDE=str(launcher), LANE_SUPERVISOR_NO_PROMPT="1", LANE_SUPERVISOR_READY_SECONDS="15", FAKE_CC_PATH=str(box.resolved), TMUX_PANE="%1") result = supervisor(box, "--supervise", "--lane", LANE, "--operation", "op-test") - assert result.returncode == 0, (result.stdout, result.stderr) + assert result.returncode == (0 if child_state == "live" else 3), (result.stdout, result.stderr) record = dict(line.split("\t", 1) for line in helper(box, "restart-intent", LANE).stdout.splitlines()) - assert record["state"] == "ready" + assert record["state"] == ("ready" if child_state == "live" else "failed") assert record["attempt"] == "1" assert record["new_transcript"] != "none" assert "Preserved task." in box.claude_runs() @@ -847,6 +862,7 @@ def test_respawn_restores_configured_roots_in_new_pane(restart_box): (box.home / ".agents").rename(protocol) box.env["AGENT_PROTOCOL_ROOT"] = str(protocol) box.env["LANES_LANE_STATE_ROOT"] = str(box.root / "state with spaces") + box.env.update(LANES_HOST="fixture-host", LANES_OS="linux", LANES_CONTAINER="fixture-container") prepare_restart(box) assert helper(box, "set-restart-intent", LANE, "ready", "--expect", "pending").returncode == 0 real_tmux = box.fakebin / "tmux-real" @@ -855,13 +871,16 @@ def test_respawn_restores_configured_roots_in_new_pane(restart_box): _write(box.fakebin / "tmux", f"""#!/usr/bin/env bash if [ "$1" = respawn-pane ]; then for arg in "$@"; do line="$arg"; done - env -u LANES_LANE_STATE_ROOT -u AGENT_PROTOCOL_ROOT sh -c "$line" + env -u LANES_LANE_STATE_ROOT -u AGENT_PROTOCOL_ROOT -u LANES_HOST -u LANES_OS -u LANES_CONTAINER sh -c "$line" printf '%s\n' "$?" > '{pane_result}' exit 0 fi exec '{real_tmux}' "$@" """) - child = _write(box.fakebin / "pclaude", "#!/usr/bin/env bash\nexit 127\n") + child = _write(box.fakebin / "pclaude", """#!/usr/bin/env bash +[ "$LANES_HOST" = fixture-host ] && [ "$LANES_OS" = linux ] && [ "$LANES_CONTAINER" = fixture-container ] || exit 66 +exit 127 +""") box.env.update(PCLAUDE=str(child), LANE_SUPERVISOR_NO_PROMPT="1", TMUX_PANE="%1") result = supervisor(box, "--restart", "--lane", LANE, "--dir", str(box.lane_dir), "clear") assert result.returncode == 0, (result.stdout, result.stderr) @@ -869,3 +888,34 @@ def test_respawn_restores_configured_roots_in_new_pane(restart_box): record = dict(line.split("\t", 1) for line in helper(box, "restart-intent", LANE).stdout.splitlines()) assert record["state"] == "failed" and record["attempt"] == "1" assert "127" in record["reason"] + + +@pytest.mark.parametrize("pane", ["", "%2"]) +def test_supervisor_refuses_unknown_or_different_pane(restart_box, pane): + box = restart_box + prepare_restart(box) + before = intent_path(box).read_bytes() + box.env["TMUX_PANE"] = pane + result = supervisor(box, "--supervise", "--lane", LANE, "--operation", "op-test") + assert result.returncode == 2, (result.stdout, result.stderr) + assert "pane" in result.stderr + assert intent_path(box).read_bytes() == before + assert not box.claude_runs() + + +@pytest.mark.parametrize("kind", ["file", "ancestor-file", "broken-link"]) +def test_existing_invalid_state_root_is_not_absent_intent(restart_box, kind): + box = restart_box + root = Path(box.env["LANES_LANE_STATE_ROOT"]) + if kind == "file": + root.write_text("not a directory\n") + elif kind == "ancestor-file": + root.write_text("not a directory\n") + box.env["LANES_LANE_STATE_ROOT"] = str(root / "child") + else: + root.symlink_to(box.root / "missing-target") + assert helper(box, "restart-intent", LANE).returncode == 1 + box.resolver() + result = box.start() + assert result.returncode == 2, (result.stdout, result.stderr) + assert not box.claude_runs() From 2daf66f747986f5653a8eba59fc5802d3d710d50 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:11:40 +0000 Subject: [PATCH 14/25] Keep supervisor retry prompts visible when saving stdin --- lane-handoff | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lane-handoff b/lane-handoff index f35d5d5d..b22de909 100755 --- a/lane-handoff +++ b/lane-handoff @@ -790,7 +790,9 @@ supervise_run() { # # it back. Where fd 0 cannot be duplicated the child is started without it, # which is what a foreground child would have had anyway on such a shell. SV_STDIN_SAVED=0 - if exec 9<&0 2>/dev/null; then SV_STDIN_SAVED=1; fi + # Scope stderr suppression to the descriptor save. A bare exec also + # redirects this shell's stderr permanently, hiding the retry prompt. + if { exec 9<&0; } 2>/dev/null; then SV_STDIN_SAVED=1; fi sr_pidf="$(mktemp "${TMPDIR:-/tmp}/lane-supervisor.XXXXXX" 2>/dev/null || printf '')" [ -n "$sr_pidf" ] || die "no temporary file could be made to record the launch's pid, and without it this supervisor cannot tell a child that died from one that is starting. Nothing was started." 2 SV_PIDFILE="$sr_pidf" From 04108884198b6f941dea0c1c9d61bf36a39b5a74 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:24:46 +0000 Subject: [PATCH 15/25] Acknowledge held supervisor pane in interactive retry regression --- tests/test_supervised_restart_regressions.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index d3edc32e..1f5a4848 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -779,6 +779,14 @@ def test_interactive_retry_rechecks_holder_absence_before_launch(restart_box): time.sleep(.05) assert "retry this restart?" in output_path.read_text(), output_path.read_text() os.write(master, b"r\n") + # The terminal stays open for an indeterminate result until its + # message is acknowledged, matching the real supervisor pane. + deadline = time.monotonic() + 15 + while "Press Enter to close it" not in output_path.read_text() and proc.poll() is None and time.monotonic() < deadline: + time.sleep(.05) + assert "INDETERMINATE" in output_path.read_text(), output_path.read_text() + assert "Press Enter to close it" in output_path.read_text(), output_path.read_text() + os.write(master, b"\n") assert proc.wait(timeout=15) == 4, output_path.read_text() assert launches.read_text().splitlines() == ["launch"] record = dict(line.split("\t", 1) for line in helper(box, "restart-intent", LANE).stdout.splitlines()) From a9506724c1526a734b2c1b82990ffbb908c4daa4 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:09:55 +0000 Subject: [PATCH 16/25] Fence legacy restart inputs before pane replacement and reap observers safely --- docs/README-lanes.md | 10 + lane-handoff | 90 +++++-- lane-start | 4 +- lanes-edit.sh | 76 ++++-- specs/005-supervised-legacy-ctx/analysis.md | 10 +- specs/005-supervised-legacy-ctx/plan.md | 11 +- specs/005-supervised-legacy-ctx/spec.md | 19 +- specs/005-supervised-legacy-ctx/tasks.md | 4 +- tests/test_supervised_restart_regressions.py | 245 +++++++++++++++++++ 9 files changed, 414 insertions(+), 55 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index abaf74c7..a7ba607c 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2679,6 +2679,16 @@ prompt. #### Legacy compatibility and preparation recovery +Supervised manual `/ctx` is a legacy compatibility path for Claude. Readiness +uses trusted Claude-native interactive session records for agent provenance. +Before replacing the old pane, the backend verifies the complete pending launch, +an existing absolute checkout and the canonical handoff checksum. Equivalent +handoff symlink paths retain their filesystem identity. Intent records reject +unknown keys, malformed lines and control characters in launch facts; those facts +are never flattened into different paths. Lane rename refuses unfinished or +failed restart ownership under the shared writer mutex. Observer cleanup uses +private cancellation and owned-child waiting, never a saved numeric PID signal. + Supervised manual `/ctx` is a legacy compatibility path. Run `lanes-edit.sh legacy-restart-check ` before any preservation writes; managed lanes and unreadable managed ownership refuse. The check uses feature diff --git a/lane-handoff b/lane-handoff index b22de909..8390f757 100755 --- a/lane-handoff +++ b/lane-handoff @@ -515,6 +515,8 @@ ready_check() { # - sw_lane="$1"; sw_op="$2"; sw_gen="$3"; sw_pidf="$4"; sw_new="$5"; sw_old="$6"; sw_pane="$7"; sw_attempt="$8" + sw_lane="$1"; sw_op="$2"; sw_gen="$3"; sw_pidf="$4"; sw_new="$5"; sw_old="$6"; sw_pane="$7"; sw_attempt="$8"; sw_stop="$9" # THE DEADLINE IS WALL CLOCK, NOT A COUNT OF SLEEPS. This loop used to add # 2 per turn and call that the elapsed time, which counts only the `sleep` # and never the work between two of them — and the work is a `live-holder` @@ -586,7 +588,9 @@ supervise_watch() { # < sw_slept=0 sw_waited=0 while [ "$sw_waited" -lt "$SUPERVISE_READY_SECONDS" ]; do + [ ! -e "$sw_stop" ] || return 0 sleep 2 + [ ! -e "$sw_stop" ] || return 0 sw_slept=$((sw_slept + 2)) sw_waited="$sw_slept" sw_now="$(date +%s 2>/dev/null | tr -dc '0-9')" @@ -610,6 +614,7 @@ supervise_watch() { # < # stale observer of a superseded operation writes nothing: exit 7 # is the intent refusing it, and that refusal is the whole point of # the fence. + [ ! -e "$sw_stop" ] || return 0 sw_frc=0 LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$sw_lane" ready \ --expect starting --expect-operation "$sw_op" --expect-generation "$sw_gen" --expect-attempt "$sw_attempt" \ @@ -633,6 +638,7 @@ supervise_watch() { # < # observer whose lane has moved on must not publish its verdict over a newer # generation's record, and `$sw_gen` is the generation the supervisor # CLAIMED rather than whatever is on disk by now. + [ ! -e "$sw_stop" ] || return 0 LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$sw_lane" starting \ --expect starting --expect-operation "$sw_op" --expect-generation "$sw_gen" --expect-attempt "$sw_attempt" \ --reason "INDETERMINATE: ${sw_waited}s elapsed (deadline ${SUPERVISE_READY_SECONDS}s) with the child alive and readiness unconfirmed; ${READY_WHY:-no evidence}" >/dev/null 2>&1 || : @@ -852,7 +858,7 @@ supervise_run() { # continue fi sr_dir="$RI_DIR" - if [ -n "$sr_dir" ] && [ "$sr_dir" != none ] && [ ! -d "$sr_dir" ]; then + if [ ! -d "$sr_dir" ] || [ ! -r "$sr_dir" ] || [ ! -x "$sr_dir" ]; then supervise_failed "$sr_lane" "$sr_op" "the intent's checkout $sr_dir does not exist here, so the launch was not made" sr_fs=$? [ "$sr_fs" = 10 ] || return "$sr_fs" @@ -865,7 +871,9 @@ supervise_run() { # say " this supervisor stays in the pane: whatever the launch does, it will say so here." say "" : > "$sr_pidf" - supervise_watch "$sr_lane" "$sr_op" "$SV_CLAIMED_GEN" "$sr_pidf" "$RI_NEW" "$RI_OLD" "$RI_PANE" "$RI_ATTEMPT" & + SV_WATCH_DIR="$(mktemp -d "${TMPDIR:-/tmp}/lane-observer.XXXXXX" 2>/dev/null)" || + die "no private observer directory could be created. Nothing was started." 2 + supervise_watch "$sr_lane" "$sr_op" "$SV_CLAIMED_GEN" "$sr_pidf" "$RI_NEW" "$RI_OLD" "$RI_PANE" "$RI_ATTEMPT" "$SV_WATCH_DIR/stop" & sr_watch=$! # PUBLISHED, so the signal handler can stop it: it is a background job of # this shell and a signal to this shell is not a signal to it. @@ -913,9 +921,7 @@ supervise_run() { # fi sr_child=$! wait "$sr_child" || sr_status=$? - kill "$sr_watch" 2>/dev/null - wait "$sr_watch" 2>/dev/null - SV_WATCH_PID="" + sv_stop_watch # WHAT THE RECORD SAYS NOW IS WHAT DECIDES, and never this process's # memory of what it started: the observer is the only thing that ever # had positive evidence, and it wrote that evidence down. @@ -1098,11 +1104,30 @@ fi SV_PIDFILE="" SV_SIGNALLED=0 SV_WATCH_PID="" +SV_WATCH_DIR="" +# Never signal a saved PID: an observer can finish hours before its child. +# Cancellation uses a private marker; wait addresses our owned shell job. +sv_stop_watch() { + if [ -n "$SV_WATCH_DIR" ]; then + : > "$SV_WATCH_DIR/stop" + # A signal can arrive after the fork but before PID publication. $! + # identifies that owned shell job; before the fork it is absent or a + # previously waited owned job. Waiting never signals a recycled PID. + sv_wait_pid="${SV_WATCH_PID:-${!:-}}" + [ -z "$sv_wait_pid" ] || wait "$sv_wait_pid" 2>/dev/null || : + SV_WATCH_PID="" + rm -f -- "$SV_WATCH_DIR/stop" 2>/dev/null + rmdir -- "$SV_WATCH_DIR" 2>/dev/null || : + SV_WATCH_DIR="" + fi +} + # Empty until this supervisor has successfully claimed a specific attempt. SV_CLAIMED_GEN="" SV_CLAIMED_ATTEMPT="" sv_on_exit() { sv_hs=$? + sv_stop_watch [ -z "$SV_PIDFILE" ] || rm -f -- "$SV_PIDFILE" 2>/dev/null [ "$sv_hs" = 0 ] && return 0 # A SUPERVISOR SOMEBODY SIGNALLED AWAY IS NOT ONE ANYBODY IS WAITING TO @@ -1134,11 +1159,7 @@ sv_signal() { # # ahead of the arguments being checked, because a supervisor that could be # signalled between those two lines and leave nothing behind is the same gap # one instruction wide. - if [ -n "${SV_WATCH_PID:-}" ]; then - kill "$SV_WATCH_PID" 2>/dev/null - wait "$SV_WATCH_PID" 2>/dev/null || : - SV_WATCH_PID="" - fi + sv_stop_watch if [ -z "$lane" ] || [ -z "$operation" ]; then say "" say "$prog: SIG$1 before this supervisor had a lane and an operation to supervise — nothing was claimed, nothing was started and nothing is left behind." @@ -1273,6 +1294,9 @@ if [ "$do_supervise" = 1 ]; then esac [ "$RI_AGENT" = claude ] || die "this legacy supervisor supports only the recorded Claude agent. Nothing was started." 2 + case "$RI_DIR" in /*) : ;; *) die "the supervised restart needs an absolute checkout. Nothing was started." 2 ;; esac + [ -d "$RI_DIR" ] && [ -r "$RI_DIR" ] && [ -x "$RI_DIR" ] || + die "the supervised restart checkout is missing or unusable. Nothing was started." 2 [ -n "$RI_HANDOFF" ] && [ "$RI_HANDOFF" != none ] && [ -s "$RI_HANDOFF" ] && [ -n "$RI_DIGEST" ] && [ "$RI_DIGEST" != none ] || die "the supervised restart has no complete handoff checksum. Nothing was started." 2 @@ -1509,16 +1533,6 @@ if [ "$do_restart" = 1 ]; then fi step "lane=$lane${window_ref:+ window=$window_ref} workstation=${ws:-}" -# THE OTHER TWO NAMES MADE TO MATCH IT, mechanically. The window is renamed -# here; the SESSION's name is the lane's messaging address (Amendment 2) and -# there is no API to rename a running session from outside it, so where the -# live record's name is not the lane the act is PRINTED — it is the operator's -# `/rename`, and it is not a question. -if have_tmux && [ -n "$window_name" ] && [ "$window_name" != "$lane" ]; then - tmux rename-window "$lane" 2>/dev/null && - step "window renamed '$window_name' → '$lane' (Amendment 8(b): the window IS the lane)" -fi - # THE LIVE RECORD OF THIS WINDOW — the session's uuid, the PANE `--exit` and # `--restart` type into, and the name `/rename` may be owed. `window-session` # is the one implementation of this read (Amendment 11 clause (h)). @@ -1622,6 +1636,27 @@ case "$profile_name" in *[!A-Za-z0-9._-]* | '') profile_name="" ;; esac +# A restart must have a supported, exact checkout before preservation or kill. +# Ordinary handoffs may still record a missing directory as diagnostics. +if [ "$do_restart" = 1 ]; then + [ "$agent" = claude ] || die "this legacy restart supports only Claude. Nothing was respawned." 2 + [ -n "$dir" ] && [ -d "$dir" ] && [ -r "$dir" ] && [ -x "$dir" ] || + die "a supervised restart requires an existing absolute checkout directory. Nothing was respawned." 2 + for restart_fact in "$dir" "${pane:-${TMUX_PANE:-}}" "$window_ref"; do + case "$restart_fact" in *[$'\001'-$'\037'$'\177']*) die "restart launch facts cannot contain control characters. Nothing was respawned." 2 ;; esac + done +fi + +# THE OTHER TWO NAMES MADE TO MATCH IT, mechanically. The window is renamed +# here; the SESSION's name is the lane's messaging address (Amendment 2) and +# there is no API to rename a running session from outside it, so where the +# live record's name is not the lane the act is PRINTED — it is the operator's +# `/rename`, and it is not a question. +if have_tmux && [ -n "$window_name" ] && [ "$window_name" != "$lane" ]; then + tmux rename-window "$lane" 2>/dev/null && + step "window renamed '$window_name' → '$lane' (Amendment 8(b): the window IS the lane)" +fi + # ---------------------------- the LATE record's preconditions, read FIRST # # BEFORE THE POLL AND BEFORE THE REFRESH, because a refusal must leave the lane @@ -2182,10 +2217,15 @@ if [ "$do_restart" = 1 ]; then # READ BACK BEFORE THE KILL, because a write that reported success and a # record a second process can read are not the same fact, and the process # that could fix the difference is the one about to die. - ri_back="$(LANES_NO_FETCH=1 "$LANES_EDIT" restart-intent "$lane" 2>/dev/null \ - | awk -F'\t' '$1 == "operation" { print $2; exit }')" || : - [ "$ri_back" = "$operation" ] || - die "the restart intent was written and reads back as operation '${ri_back:-nothing}' rather than $operation, so this pane is left exactly as it is: the supervisor would launch for an operation this act does not own. The record above IS written. Restart by hand: $restart_cmd" 2 + ri_load "$lane" || die "the pending restart intent could not be read back. Nothing was respawned." 2 + [ "$RI_STATE" = pending ] && [ "$RI_OP" = "$operation" ] && [ "$RI_GEN" = "$ri_gen" ] && + [ "$RI_ATTEMPT" = 0 ] && [ "$RI_MODE" = fresh-from-handoff ] && [ "$RI_AGENT" = claude ] && + [ "$RI_PROFILE" = "${profile_name:-none}" ] && [ "$RI_DIR" = "$dir" ] && [ -d "$RI_DIR" ] && + [ "$RI_PANE" = "$target" ] && [ "$RI_WINDOW" = "${window_ref:-none}" ] && + [ "$RI_OLD" = "${transcript:-none}" ] && [ "$RI_NEW" = none ] && + [ "$RI_HANDOFF" = "$handoff_file" ] && [ -s "$RI_HANDOFF" ] && + [ "$RI_DIGEST" != none ] && [ -n "$RI_DIGEST" ] && [ "$(handoff_digest "$RI_HANDOFF")" = "$RI_DIGEST" ] || + die "the pending restart launch facts could not be verified before replacing the pane. Nothing was respawned; read the intent: $prog --restart-status --lane $lane" 2 say " restart intent $operation written (generation $ri_gen, mode fresh-from-handoff)" # ---- THE RESPAWN, INTO A SUPERVISOR AND NOT INTO THE LAUNCH ITSELF diff --git a/lane-start b/lane-start index 1ab53730..18e4092a 100755 --- a/lane-start +++ b/lane-start @@ -3001,7 +3001,7 @@ case "$ri_state" in ri_cell="$(cell 7 | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" ri_row_hf="$(hs_resolve "$ri_cell")" || die "the restart handoff the row names is missing. Nothing was started." 2 [ -n "$ri_hf" ] && [ "$ri_hf" != none ] && [ -s "$ri_hf" ] && - [ "$(real_of "$ri_hf")" = "$(real_of "$ri_row_hf")" ] || + [ "$ri_hf" -ef "$ri_row_hf" ] || die "the restart intent and register name different handoffs. Nothing was started." 2 ri_expected_digest="$(handoff_digest_of "$ri_hf")" fresh=1 @@ -4110,7 +4110,7 @@ if (( first_prompt )); then fp_file="" [ -n "$fp_cell" ] && fp_file="$(hs_resolve "$fp_cell" 2>/dev/null || :)" if [ "$ri_active" = 1 ] && (( fresh )); then - [ -n "$fp_file" ] && [ "$(real_of "$fp_file")" = "$(real_of "$ri_hf")" ] || + [ -n "$fp_file" ] && [ "$fp_file" -ef "$ri_hf" ] || die "the delivered restart prompt is not the intent-bound handoff. Nothing was launched." 2 [ "$ri_expected_digest" = none ] || [ "$(handoff_digest_of "$fp_file")" = "$ri_expected_digest" ] || die "the restart handoff changed after preparation. Nothing was launched." 2 diff --git a/lanes-edit.sh b/lanes-edit.sh index e54ddaa6..d8cafd02 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -10566,19 +10566,17 @@ lane_sidecar_field() { # BEGIN { k = k ": " } substr($0, 1, length(k)) == k { v = substr($0, length(k) + 1) - sub(/^[ \t]+/, "", v); sub(/[ \t]+$/, "", v) print v; exit }' "$1" } -# A VALUE IS ONE LINE OF `key: value`, so a newline or a leading space in one -# would make the file unreadable by the reader above. Both are flattened here -# rather than refused, because a value this can spoil is a branch name or a -# path and losing the WHOLE record over one is the worse trade. -lane_sidecar_value() { # - lsv="${1-}" - lsv="${lsv//$'\r'/ }" - lsv="${lsv//$'\n'/ }" - printf '%s' "$lsv" +# Diagnostic prose is bounded and flattened; launch identity is never normalized. +lane_sidecar_value() { # + printf '%s' "${1-}" | LC_ALL=C tr '[:cntrl:]' ' ' +} + +lane_restart_value_ok() { # exact values must survive flat and tabular readers + case "${1-}" in *[$'\001'-$'\037'$'\177']*) return 1 ;; esac + return 0 } # THE ATOMIC REPLACE. Written beside the target and renamed over it, so a @@ -10587,8 +10585,12 @@ lane_sidecar_put() { # ; the whole body on stdin lsp_f="${1-}"; lsp_d="${lsp_f%/*}" [ -n "$lsp_f" ] || return 1 mkdir -p -- "$lsp_d" 2>/dev/null || return 1 - lsp_t="$lsp_f.tmp.$$" + [ ! -d "$lsp_f" ] || return 1 + lsp_t="$(umask 077; mktemp "$lsp_d/.restart-intent.XXXXXX" 2>/dev/null)" || return 1 + [ -n "$lsp_t" ] || return 1 cat > "$lsp_t" 2>/dev/null || { rm -f -- "$lsp_t" 2>/dev/null; return 1; } + lsp_bad="$(lane_restart_missing "$lsp_t" "$2")" || { rm -f -- "$lsp_t"; return 1; } + [ -z "$lsp_bad" ] || { rm -f -- "$lsp_t"; return 1; } mv -- "$lsp_t" "$lsp_f" 2>/dev/null || { rm -f -- "$lsp_t" 2>/dev/null; return 1; } return 0 } @@ -10642,8 +10644,19 @@ lane_handoff_digest() { # # One read validates presence and duplicate keys without per-field subprocesses. lane_restart_missing() { # awk -v lane="$2" ' - BEGIN {n=split("schema lane state generation operation mode agent profile dir pane window handoff digest old_transcript new_transcript attempt workstation created updated reason", keys, " ")} - /^[A-Za-z_]+:/ {k=$0; sub(/:.*/, "", k); v=$0; sub(/^[^:]*:[[:space:]]*/, "", v); seen[k]++; values[k]=v; if (v!="") good[k]=1} + BEGIN { + n=split("schema lane state generation operation mode agent profile dir pane window handoff digest old_transcript new_transcript attempt workstation created updated reason", keys, " ") + for (i=1;i<=n;i++) allowed[keys[i]]=1 + allowed["prepared_digest"]=1 + } + /^$/ { next } + { + if ($0 ~ /[[:cntrl:]]/ || $0 !~ /^[A-Za-z_]+: /) {bad["record"]=1; next} + k=$0; sub(/:.*/, "", k) + if (!(k in allowed)) {bad["unknown-key"]=1; next} + v=substr($0,length(k)+3) + seen[k]++; values[k]=v; if (v!="") good[k]=1 + } END { for (i=1;i<=n;i++) if (seen[keys[i]]!=1 || !good[keys[i]]) bad[keys[i]]=1 if (values["lane"]!=lane) bad["lane"]=1 @@ -10758,6 +10771,11 @@ lane_restart_put() { # `, a published log — with nothing written. acquire_lock + # Rename and restart publication share this mutex. An unfinished operation + # owns its name and canonical inputs, including recovery from failed. + for rl_check in "$rl_old" "$rl_new"; do + rl_root_rc=0 + lane_control_root "$rl_check" >/dev/null || rl_root_rc=$? + [ "$rl_root_rc" != 8 ] || continue # no root any writer could have used + [ "$rl_root_rc" = 0 ] || die "restart control root could not be read. Nothing was written." 1 + rl_restart=""; rl_restart_rc=0 + rl_restart="$(lane_restart_read "$rl_check")" || rl_restart_rc=$? + case "$rl_restart_rc" in + 8) continue ;; + 0) rl_restart_state="$(printf '%s\n' "$rl_restart" | awk -F'\t' '$1=="state" {print $2; exit}')" + [ "$rl_restart_state" = ready ] || die "lane $rl_check has restart intent $rl_restart_state; finish its supervised recovery before renaming. Nothing was written." 2 ;; + *) die "lane $rl_check restart intent could not be read. Nothing was written." 1 ;; + esac + done + rl_oh="$(rows_named_ci "$rl_old" 2>/dev/null || :)" rl_on="$(printf '%s' "$rl_oh" | grep -c . || :)" rl_lh="$(rows_named_ci_local "$rl_old" 2>/dev/null || :)" @@ -14278,6 +14313,9 @@ EOF # writing shell's and is never expanded here. case "$sri_dir" in ''|/*) : ;; *) die "--dir takes an ABSOLUTE path: this record is read by the supervisor in a pane whose working directory is not this one, and a relative path there names a different place. Got '$sri_dir'." 64 ;; esac case "$sri_hf" in ''|/*) : ;; *) die "--handoff takes an ABSOLUTE path, for the same reason --dir does: the launch that reads it back stands somewhere else. Got '$sri_hf'." 64 ;; esac + for sri_exact in "$sri_dir" "$sri_pane" "$sri_win" "$sri_hf" "$sri_old" "$sri_new" "$sri_dig" "$sri_prepared"; do + lane_restart_value_ok "$sri_exact" || die "restart launch facts cannot contain control characters. Nothing was written." 64 + done check_lane_name "$lane" log_sync lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 diff --git a/specs/005-supervised-legacy-ctx/analysis.md b/specs/005-supervised-legacy-ctx/analysis.md index 8884a4e0..d2128ed6 100644 --- a/specs/005-supervised-legacy-ctx/analysis.md +++ b/specs/005-supervised-legacy-ctx/analysis.md @@ -12,9 +12,17 @@ Preparation (T006), attempt ownership (T007), launch facts (T008), stamp recover (T009), asynchronous readiness (T010) and interruption handling (T011) each have entrypoint fault cases in `tests/test_supervised_restart_regressions.py`. The existing shell suite retains integration coverage and CI is the full suite of -record. T013, T015 and T016 remain open until their actual validation/publication +record. T013 and T016 remain open until their actual validation results are recorded; expert follow-up review T014 is complete; T017 remains subject to the user's existing merge hold. +The final nine-role expert panel verified strict schema preservation, private +intent temporary files, file-alias identity, complete pre-respawn launch checks, +rename fencing and cooperative observer cleanup. Focused regression fixtures +cover those findings and the observer fork/PID-publication signal boundary. +Shell parsing, error-level ShellCheck and strict OpenSpec validation pass. The +serialized local restart run is queued behind other workstation pytest jobs; +its outcome and current-head CI remain validation gates, not assumed passes. + Known boundary: managed enrollment and legacy runtime integration are feature 001's responsibility. This change refuses existing or unknown managed ownership and provides no installation, automatic rollover or real-session canary. diff --git a/specs/005-supervised-legacy-ctx/plan.md b/specs/005-supervised-legacy-ctx/plan.md index 6266d400..d1b7da78 100644 --- a/specs/005-supervised-legacy-ctx/plan.md +++ b/specs/005-supervised-legacy-ctx/plan.md @@ -43,7 +43,7 @@ contract. Existing records with missing, duplicate or invalid fields refuse transitions rather than being silently repaired. Ordinary first launches use the resolved checkout as a control-root hint only -only after all established root rungs fail. Explicit configuration, recorded +after all established root rungs fail. Explicit configuration, recorded directories and PROJECTS_ROOT retain precedence. Each claimed attempt repeats the all-holder absence check before starting, including interactive retries. @@ -51,3 +51,12 @@ Require a known current pane before the supervisor claim. Forward the complete launcher host/OS/container binding through respawn. Control-root reads inspect existing directory ancestors so an unusable root cannot answer absence. Child readiness rejects zombie process state and keeps unknown process state unready. + +Final review hardening validates the complete pending launch before pane respawn, +uses filesystem identity for equivalent handoff aliases, and refuses control +characters rather than normalizing launch paths. The shared schema parser rejects +unknown and malformed content on reads, existing-record transitions and generated +record publication. Intent writes use private same-directory temporary files. +Rename takes the existing mutex and refuses unfinished/failed restart records. +Observers stop through a private marker and are reaped before marker removal, +without signaling a numeric PID that may already have been recycled. diff --git a/specs/005-supervised-legacy-ctx/spec.md b/specs/005-supervised-legacy-ctx/spec.md index 6d8cf7f5..075b054d 100644 --- a/specs/005-supervised-legacy-ctx/spec.md +++ b/specs/005-supervised-legacy-ctx/spec.md @@ -6,7 +6,7 @@ by openRepoTools-3; neither its branch nor its worktree is an input to this work ## Scope -Provide a supervised manual `/ctx` restart for confirmed legacy lanes. The +Provide a supervised manual `/ctx` restart for confirmed legacy Claude lanes. The managed JSON ledger remains the sole lifecycle, readiness, operation and generation authority for enrolled managed lanes. Legacy entrypoints refuse managed or unknown ownership before preservation writes or launch. @@ -26,13 +26,19 @@ diagnostic lifecycle snapshots. Readiness never writes another lifecycle store. canonical preservation record and never respawns a pane. - FR-003: Validate complete intent schema, state, generation, operation, attempt and launch facts. Existing unreadable/malformed records cannot be - interpreted as absent or rewritten to a supported schema. + interpreted as absent or rewritten to a supported schema. Schema 1 rejects + unknown keys, duplicate keys, malformed lines and control delimiters; exact + launch identities are preserved byte for byte and stored through private + same-directory temporary files. - FR-004: Fence each attempt's claim, transcript preparation, readiness, timeout, signal and failure writes by operation, generation and attempt. A delayed child of an earlier attempt cannot consume a newer attempt. - FR-005: Bind the launch's canonical handoff, agent, profile, directory and pane to the recorded operation. An ordinary launcher cannot consume an - active restart without its supervisor token. + active restart without its supervisor token. Before respawn, require an + existing absolute checkout and verify every pending launch fact and checksum. + Equivalent handoff symlink spellings refer to the same file. Lane rename + refuses unfinished and failed restart ownership under the shared mutex. - FR-006: Preserve the handoff bytes used for the prompt across owned resume stamps. Prepare durable evidence before publishing a stamp; an interruption at either publication boundary remains diagnosable and retryable. Unrelated @@ -40,10 +46,13 @@ diagnostic lifecycle snapshots. Readiness never writes another lifecycle store. - FR-007: Observe the UUID prepared by the current attempt after launch, and require one live process with the intended transcript and binding. Preserve the existing preferred `live-holder` interface; enumerate all holders for - readiness and refuse ambiguity. + readiness and refuse ambiguity. Claude-native interactive session record + provenance supplies agent evidence within this Claude-only contract. - FR-008: If a signal leaves a child alive or child absence unproven, preserve an indeterminate active operation. Do not advertise or permit a competing - retry. A known-ended child permits retry of the same operation. + retry. A known-ended child permits retry of the same operation. Observer + cleanup uses a private cancellation marker and waits for its owned child; + it never signals a saved PID that may have been recycled. - FR-009: Failure/status output describes the persisted state. Recovery uses the supervised operation; ordinary lane resume cannot bypass a failed restart. A completed intent remains history and permits ordinary resume. diff --git a/specs/005-supervised-legacy-ctx/tasks.md b/specs/005-supervised-legacy-ctx/tasks.md index 84444f7f..cc8e6c11 100644 --- a/specs/005-supervised-legacy-ctx/tasks.md +++ b/specs/005-supervised-legacy-ctx/tasks.md @@ -13,7 +13,7 @@ - [x] T011 Preserve indeterminate active ownership when a signal leaves a child alive or unknown. - [x] T012 Update command, skill, manual and capability contracts to the final scope. - [ ] T013 Run focused sandboxed restart fault cases and touched shell suite. -- [x] T014 Complete expert follow-up review and address verified findings. -- [x] T015 Commit/push PR updates and request exact-head Codex review (bot quota exhausted; follow-up updates retain the hold). +- [x] T014 Complete expert follow-up review and address verified findings, including final schema, rename, pre-kill and observer hardening. +- [x] T015 Commit/push PR updates and request exact-head Codex review; retain the existing merge hold. - [ ] T016 Verify current-head per-push CI; record review quota limitations if present. - [ ] T017 After hold release/merge authorization, run macOS landing gate and merge. diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index 1f5a4848..6a294327 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -14,6 +14,7 @@ import pytest from test_lane_start_claude_current import Sandbox, LANE, _write, pytestmark +from conftest import REPO @pytest.fixture @@ -22,6 +23,13 @@ def restart_box(tmp_path: Path) -> Sandbox: box.env["LANES_LANE_STATE_ROOT"] = str(tmp_path / "state") box.env["LANES_NO_FETCH"] = "1" box.env["TMUX_PANE"] = "%1" + tmux = box.fakebin / "tmux" + tmux.write_text(tmux.read_text().replace("#!/usr/bin/env bash\n", '''#!/usr/bin/env bash +if [ "${1-}" = respawn-pane ]; then + printf 'respawn-pane %s\\n' "$*" >> "$FAKE_TMUX_LOG" + exit 0 +fi +''', 1)) return box @@ -927,3 +935,240 @@ def test_existing_invalid_state_root_is_not_absent_intent(restart_box, kind): result = box.start() assert result.returncode == 2, (result.stdout, result.stderr) assert not box.claude_runs() + + +@pytest.mark.parametrize("extra", ["future_key: preserve-me\n", "garbage\n", "profile:test\n", + "schema: 1\n", "prepared_digest: none\n", + "pane: testsess:@1.%1\tignored\n"]) +def test_restart_strict_schema_preserves_unrecognized_or_malformed_records(restart_box, extra): + box = restart_box + prepare_restart(box) + path = intent_path(box) + path.write_text(path.read_text() + extra) + before = path.read_bytes() + read = helper(box, "restart-intent", LANE) + assert "state\tINCOMPLETE" in read.stdout, (read.stdout, read.stderr) + write = helper(box, "set-restart-intent", LANE, "starting", "--expect", "pending") + assert write.returncode == 2, write.stderr + assert path.read_bytes() == before + assert box.start("--operation", "op-test").returncode == 2 + assert not box.claude_runs() + + +@pytest.mark.parametrize("field", ["dir", "handoff", "pane", "window"]) +@pytest.mark.parametrize("control", ["\n", "\r", "\t"]) +def test_restart_writer_rejects_record_delimiters_without_changing_identity(restart_box, field, control): + box = restart_box + prepare_restart(box) + before = intent_path(box).read_bytes() + value = (str(box.lane_dir) if field in {"dir", "handoff"} else "testsess:@1.%1") + control + "tail" + result = helper(box, "set-restart-intent", LANE, "pending", f"--{field}", value) + assert result.returncode == 64, (result.stdout, result.stderr) + assert intent_path(box).read_bytes() == before + + +def test_restart_writer_does_not_follow_predictable_temporary_symlink(restart_box): + box = restart_box + prepare_restart(box) + sentinel = box.root / "sentinel" + sentinel.write_text("unrelated bytes\n") + result = subprocess.run(["bash", "-c", 'ln -s "$1" "$2.tmp.$$"; exec "$3" set-restart-intent "$4" starting --expect pending', + "_", str(sentinel), str(intent_path(box)), str(box.bin / "lanes-edit.sh"), LANE], + env=box.env, cwd=box.lane_dir, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, (result.stdout, result.stderr) + assert sentinel.read_text() == "unrelated bytes\n" + assert list(intent_path(box).parent.glob("restart-intent.yaml.tmp.*"))[0].is_symlink() + assert intent_path(box).stat().st_mode & 0o777 == 0o600 + + +@pytest.mark.parametrize("alias", ["file", "parent"]) +def test_restart_accepts_equivalent_handoff_file_identity(restart_box, alias): + box = restart_box + source = prepare_restart(box) + physical = source + if alias == "file": + physical = source.with_name("backing.md") + source.rename(physical) + source.symlink_to(physical.name) + box.git("-C", str(box.wip), "add", "--", str(source), str(physical)) + box.git("-C", str(box.wip), "commit", "-qm", "use leaf handoff alias") + box.git("-C", str(box.wip), "push", "-q", "origin", "main") + else: + link = source.parent.with_name("handoff-alias") + link.symlink_to(source.parent, target_is_directory=True) + source = link / source.name + registry = box.wip / "lanes" / "LANES.md" + registry.write_text(registry.read_text().replace(str(physical.relative_to(box.wip)), str(source.relative_to(box.wip)))) + box.git("-C", str(box.wip), "add", "--", "lanes/LANES.md", str(link)) + box.git("-C", str(box.wip), "commit", "-qm", "use handoff directory alias") + box.git("-C", str(box.wip), "push", "-q", "origin", "main") + result = helper(box, "set-restart-intent", LANE, "pending", "--handoff", str(source.resolve())) + assert result.returncode == 0, result.stderr + first = box.start("--operation", "op-test") + assert first.returncode == 0, (first.stdout, first.stderr) + assert "Preserved task." in box.claude_runs() + assert "RESUMED by" in physical.read_text() + if alias == "file": + assert source.is_symlink() + assert helper(box, "set-restart-intent", LANE, "failed", "--expect", "starting").returncode == 0 + assert helper(box, "set-restart-intent", LANE, "starting", "--expect", "failed", "--bump-attempt", "--new-transcript", "none").returncode == 0 + retry = box.start("--operation", "op-test") + assert retry.returncode == 0, (retry.stdout, retry.stderr) + + +@pytest.mark.parametrize("state", ["preparing", "pending", "starting", "failed", "ready"]) +def test_restart_rename_preserves_unfinished_operation_inputs(restart_box, state): + box = restart_box + source = prepare_restart(box) + assert helper(box, "set-restart-intent", LANE, state, "--expect", "pending").returncode == 0 + tracked = [p for p in box.wip.rglob("*") if p.is_file() and ".git" not in p.parts] + before = {p: p.read_bytes() for p in tracked} + record = intent_path(box).read_bytes() + box.env["LANES_SESSION"] = "10101010-1111-4111-8111-111111111111" + result = helper(box, "rename-lane", LANE, "repoZ-2", "--no-github") + if state == "ready": + assert result.returncode == 0, (result.stdout, result.stderr) + assert "repoZ-2" in (box.wip / "lanes" / "LANES.md").read_text() + else: + assert result.returncode == 2, (result.stdout, result.stderr) + assert "supervised recovery" in result.stderr + assert {p: p.read_bytes() for p in tracked} == before + assert set(tracked) == {p for p in box.wip.rglob("*") if p.is_file() and ".git" not in p.parts} + assert intent_path(box).read_bytes() == record + + +@pytest.mark.parametrize("kind", ["relative", "missing", "none", "agent"]) +def test_restart_refuses_unsupported_checkout_or_agent_before_preservation(restart_box, kind): + box = restart_box + source = prepare_restart(box) + assert helper(box, "set-restart-intent", LANE, "ready", "--expect", "pending").returncode == 0 + before = source.read_bytes() + registry = (box.wip / "lanes" / "LANES.md").read_bytes() + box.tmux_log.write_text("") + args = ["--restart", "--lane", LANE] + if kind in {"relative", "missing"}: + args += ["--dir", "relative" if kind == "relative" else str(box.root / "absent")] + elif kind == "agent": + args += ["--agent", "codex"] + else: + real = box.bin / "lanes-edit-real.sh" + (box.bin / "lanes-edit.sh").rename(real) + _write(box.bin / "lanes-edit.sh", f"""#!/usr/bin/env bash +[ "$1" != lane-dir ] || exit 8 +exec '{real}' "$@" +""") + box.env.pop("WORKBENCHES_CLAUDE_LANE_DIR", None) + box.env.pop("CLAUDE_CODE_SESSION_ID", None) + result = supervisor(box, *args, "clear") + assert result.returncode == 2, (result.stdout, result.stderr) + assert source.read_bytes() == before + assert (box.wip / "lanes" / "LANES.md").read_bytes() == registry + assert "rename-window" not in box.tmux_log.read_text() + assert "respawn-pane" not in box.tmux_log.read_text() + + +@pytest.mark.parametrize("fact", ["dir", "digest", "pane", "agent"]) +def test_restart_verifies_complete_pending_record_before_respawn(restart_box, fact): + box = restart_box + prepare_restart(box) + assert helper(box, "set-restart-intent", LANE, "ready", "--expect", "pending").returncode == 0 + real = box.bin / "lanes-edit-real.sh" + (box.bin / "lanes-edit.sh").rename(real) + value = {"dir": "none", "digest": "none", "pane": "wrong-pane", "agent": "codex"}[fact] + _write(box.bin / "lanes-edit.sh", f"""#!/usr/bin/env bash +if [ "$1" = set-restart-intent ] && [ "$3" = pending ]; then + '{real}' "$@" || exit $? + sed 's|^{fact}: .*|{fact}: {value}|' '{intent_path(box)}' > '{intent_path(box)}.fixture' + mv '{intent_path(box)}.fixture' '{intent_path(box)}' + exit 0 +fi +exec '{real}' "$@" +""") + result = supervisor(box, "--restart", "--lane", LANE, "--dir", str(box.lane_dir), "clear") + assert result.returncode == 2, (result.stdout, result.stderr) + assert "before replacing the pane" in result.stderr + assert "respawn-pane" not in box.tmux_log.read_text() + assert not box.claude_runs() + + +@pytest.mark.parametrize("finish", ["exit", "signal"]) +def test_completed_observer_cleanup_never_signals_saved_pid(restart_box, finish): + box = restart_box + prepare_restart(box) + shutil.copy2(REPO / "lane-handoff", box.bin / "lane-handoff") + signals = box.root / "positive-kill.log" + bash_env = _write(box.root / "bash-env", f"""kill() {{ + case "$1" in -0) : ;; *) printf '%s\\n' "$*" >> '{signals}' ;; esac + builtin kill "$@" +}} +""", 0o600) + release = box.root / "release-child" + launcher = _write(box.fakebin / "pclaude", '''#!/usr/bin/env bash +export CLAUDE_PROFILE_NAME=test-profile +exec "$OPENREPOTOOLS_BIN_DIR/lane-start" repoZ 1 +''') + _write(box.resolved, r'''#!/usr/bin/env python3 +import json, os, pathlib, time +sid=os.sys.argv[os.sys.argv.index('--session-id')+1] +pid=os.getpid() +stat=pathlib.Path(f'/proc/{pid}/stat') +start=stat.read_text().split()[21] if stat.exists() else '' +record=pathlib.Path(os.environ['HOME'])/'.claude'/'sessions'/'owned.json' +record.write_text(json.dumps(dict(pid=pid,sessionId=sid,procStart=start,cwd=os.getcwd(), + tmux='testsess:@1.%1',name='repoZ-1',nameSource='user',status='busy'),separators=(',',':'))+'\n') +release=pathlib.Path(os.environ['OBSERVER_TEST_RELEASE']) +for _ in range(300): + if release.exists(): break + time.sleep(.1) +record.unlink() +''') + box.env.update(PCLAUDE=str(launcher), LANE_SUPERVISOR_NO_PROMPT="1", + LANE_SUPERVISOR_READY_SECONDS="15", BASH_ENV=str(bash_env), + OBSERVER_TEST_RELEASE=str(release), FAKE_CC_PATH=str(box.resolved)) + unrelated = subprocess.Popen(["sleep", "60"]) + proc = subprocess.Popen([str(box.bin / "lane-handoff"), "--supervise", "--lane", LANE, + "--operation", "op-test"], env=box.env, cwd=box.lane_dir, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + if "state: ready\n" in intent_path(box).read_text(): break + assert proc.poll() is None + time.sleep(.1) + else: + pytest.fail("observer never confirmed readiness") + # Let the observer return while the long-lived child remains alive. + time.sleep(.3) + if finish == "signal": + proc.send_signal(signal.SIGTERM) + proc.wait(timeout=10) + assert (box.home / ".claude" / "sessions" / "owned.json").exists() + release.touch() + out, err = proc.communicate(timeout=10) + assert proc.returncode == (143 if finish == "signal" else 0), (out, err) + assert not signals.exists(), signals.read_text() if signals.exists() else "" + assert unrelated.poll() is None + assert not list(Path(box.env["TMPDIR"]).glob("lane-observer.*")) + finally: + release.touch() + if proc.poll() is None: + proc.terminate() + proc.communicate(timeout=10) + unrelated.terminate() + unrelated.wait(timeout=5) + + +def test_signal_between_observer_fork_and_pid_publication_reaps_owned_job(restart_box): + box = restart_box + prepare_restart(box) + path = box.bin / "lane-handoff" + shutil.copy2(REPO / "lane-handoff", path) + source = path.read_text() + assert '\t\tsr_watch=$!' in source + path.write_text(source.replace('\t\tsr_watch=$!', '\t\tkill -TERM "$$"\n\t\tsr_watch=$!', 1)) + result = subprocess.run([str(path), "--supervise", "--lane", LANE, "--operation", "op-test"], + env=box.env, cwd=box.lane_dir, capture_output=True, text=True, timeout=15) + assert result.returncode == 143, (result.stdout, result.stderr) + assert not box.claude_runs() + assert not list(Path(box.env["TMPDIR"]).glob("lane-observer.*")) + assert "state: ready\n" not in intent_path(box).read_text() From 6c5b74176e9dbf7f9aad5a4ff8ec18ef0cbbb136 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:16:10 +0000 Subject: [PATCH 17/25] Write each pending restart current launch facts explicitly --- lane-handoff | 8 +++----- tests/test_supervised_restart_regressions.py | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/lane-handoff b/lane-handoff index 8390f757..a2272ab6 100755 --- a/lane-handoff +++ b/lane-handoff @@ -2202,11 +2202,9 @@ if [ "$do_restart" = 1 ]; then --operation "$operation" --generation "$ri_gen" --mode fresh-from-handoff --agent "$agent" --pane "$target" - --old-transcript "${transcript:-none}") - [ -n "$profile_name" ] && ri_args+=(--profile "$profile_name") - case "$dir" in /*) ri_args+=(--dir "$dir") ;; esac - case "$handoff_file" in /*) ri_args+=(--handoff "$handoff_file") ;; esac - [ -n "$window_ref" ] && ri_args+=(--window "$window_ref") + --old-transcript "${transcript:-none}" + --profile "${profile_name:-none}" --window "${window_ref:-none}" + --dir "$dir" --handoff "$handoff_file") ri_wrc=0 LANES_NO_FETCH=1 "$LANES_EDIT" "${ri_args[@]}" >/dev/null 2>&1 || ri_wrc=$? case "$ri_wrc" in diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index 6a294327..217c91b9 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -897,7 +897,8 @@ def test_respawn_restores_configured_roots_in_new_pane(restart_box): [ "$LANES_HOST" = fixture-host ] && [ "$LANES_OS" = linux ] && [ "$LANES_CONTAINER" = fixture-container ] || exit 66 exit 127 """) - box.env.update(PCLAUDE=str(child), LANE_SUPERVISOR_NO_PROMPT="1", TMUX_PANE="%1") + box.env.update(PCLAUDE=str(child), CLAUDE_PROFILE_NAME="test-profile", + LANE_SUPERVISOR_NO_PROMPT="1", TMUX_PANE="%1") result = supervisor(box, "--restart", "--lane", LANE, "--dir", str(box.lane_dir), "clear") assert result.returncode == 0, (result.stdout, result.stderr) assert pane_result.read_text().strip() == "3", (result.stdout, result.stderr) @@ -1172,3 +1173,19 @@ def test_signal_between_observer_fork_and_pid_publication_reaps_owned_job(restar assert not box.claude_runs() assert not list(Path(box.env["TMPDIR"]).glob("lane-observer.*")) assert "state: ready\n" not in intent_path(box).read_text() + + +def test_new_restart_explicitly_clears_previous_operations_profile(restart_box): + box = restart_box + prepare_restart(box) + assert helper(box, "set-restart-intent", LANE, "ready", "--expect", "pending").returncode == 0 + # This pane has no current account profile; a historical account is not + # permission to select it silently for this operation. + box.env.pop("CLAUDE_PROFILE_NAME", None) + result = supervisor(box, "--restart", "--lane", LANE, "--dir", str(box.lane_dir), "clear") + assert result.returncode == 0, (result.stdout, result.stderr) + record = dict(line.split("\t", 1) for line in helper(box, "restart-intent", LANE).stdout.splitlines()) + assert record["state"] == "pending" + assert record["profile"] == "none" + assert record["operation"] != "op-test" + assert "respawn-pane" in box.tmux_log.read_text() From 0e4b311dfa33abca55282a4974cb96c7d1c584ed Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:24:43 +0000 Subject: [PATCH 18/25] Reconcile signals across restart claims and lock handoff resolution --- docs/README-lanes.md | 7 ++ lane-handoff | 30 +++++++- lanes-edit.sh | 3 +- specs/005-supervised-legacy-ctx/plan.md | 9 ++- tests/test_supervised_restart_regressions.py | 80 +++++++++++++++++++- 5 files changed, 124 insertions(+), 5 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index a7ba607c..3ca7471f 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3203,3 +3203,10 @@ which keeps the exact operation as a child and forwards its operation/attempt tokens. The historical #94 incident above describes the earlier launcher. A respawn also forwards configured `LANES_LANE_STATE_ROOT` and `AGENT_PROTOCOL_ROOT` with shell quoting so its durable intent remains readable. + +Legacy lane rename is an explicit publication exception: it already holds the +writer mutex and now refuses unfinished/failed restart ownership before moving +inputs. Its existing four-file transaction uses EXIT rollback, including stamp +write failures; it is not crash atomic across those files. This change does not +claim stronger crash durability for ordinary rename. Restart preservation and +resume-stamp publishers use the atomic checksum-fenced publication helper. diff --git a/lane-handoff b/lane-handoff index a2272ab6..b74e7f17 100755 --- a/lane-handoff +++ b/lane-handoff @@ -817,10 +817,20 @@ supervise_run() { # LANES_NO_FETCH=1 "$LANES_EDIT" legacy-restart-check "$sr_lane" || die "managed ownership blocks this legacy retry. Nothing was started." 2 sr_claim_gen="$RI_GEN" + SV_CLAIMED_GEN=""; SV_CLAIMED_ATTEMPT=""; SV_CHILD_LAUNCH_STARTED=0 + SV_DEFERRED_SIGNAL=""; SV_DEFERRED_STATUS="" + SV_CLAIMING=1 sr_crc=0 LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$sr_lane" starting \ --expect "$RI_STATE" --expect-operation "$sr_op" --expect-generation "${RI_GEN:-0}" --expect-attempt "$RI_ATTEMPT" \ "${sr_att[@]}" --new-transcript none --reason "supervisor claimed the operation" >/dev/null 2>&1 || sr_crc=$? + # The writer is the authority for a successful CAS; publish its exact + # requested identity before replaying a signal deferred during the claim. + if [ "$sr_crc" = 0 ]; then + SV_CLAIMED_GEN="$sr_claim_gen"; SV_CLAIMED_ATTEMPT="$sr_claim_attempt" + fi + SV_CLAIMING=0 + [ -z "$SV_DEFERRED_SIGNAL" ] || sv_signal "$SV_DEFERRED_SIGNAL" "$SV_DEFERRED_STATUS" case "$sr_crc" in 0) : ;; 7) die "lane $sr_lane's restart intent moved between reading it and claiming it: another act owns operation $sr_op now. Nothing was started. Read it: $prog --restart-status --lane $sr_lane" 2 ;; @@ -914,6 +924,7 @@ supervise_run() { # # `9<&-` CLOSES THE SAVED COPY IN THE CHILD once it has become its # stdin: the pane's descriptor is this process's bookkeeping and is no # part of the environment an interactive Claude should be handed. + SV_CHILD_LAUNCH_STARTED=1 # conservative before the fork/pid-file boundary if [ "$SV_STDIN_SAVED" = 1 ]; then supervise_child <&9 9<&- & else @@ -1105,6 +1116,9 @@ SV_PIDFILE="" SV_SIGNALLED=0 SV_WATCH_PID="" SV_WATCH_DIR="" +SV_CLAIMING=0 +SV_DEFERRED_SIGNAL=""; SV_DEFERRED_STATUS="" +SV_CHILD_LAUNCH_STARTED=0 # Never signal a saved PID: an observer can finish hours before its child. # Cancellation uses a private marker; wait addresses our owned shell job. sv_stop_watch() { @@ -1144,6 +1158,12 @@ sv_on_exit() { # Cancellation writes only an owned attempt. A live or unknown child keeps # starting and blocks retry; an unclaimed pending intent remains unchanged. sv_signal() { # + # Bash may deliver a deferred signal as the CAS returns, before the caller + # records its successful claim. Finish publication before reconciliation. + if [ "$SV_CLAIMING" = 1 ]; then + SV_DEFERRED_SIGNAL="$1"; SV_DEFERRED_STATUS="$2" + return 0 + fi SV_SIGNALLED=1 # THE OBSERVER GOES FIRST, AND IS REAPED (Copilot round 2 on # openRepoTools#121). It is a background job of this shell and a signal to @@ -1184,12 +1204,20 @@ sv_signal() { # [ "$sv_sg_hrc" != 8 ] || sv_sg_state=failed fi fi + if [ "$SV_CHILD_LAUNCH_STARTED" = 0 ]; then + sv_sg_alive="no child was launched" + sv_sg_hrc=0 + LANES_NO_FETCH=1 "$LANES_EDIT" lane-holders "$lane" >/dev/null 2>&1 || sv_sg_hrc=$? + [ "$sv_sg_hrc" != 8 ] || sv_sg_state=failed + fi sv_sg_fence=() [ -n "$SV_CLAIMED_GEN" ] && sv_sg_fence=(--expect-generation "$SV_CLAIMED_GEN" --expect-attempt "$SV_CLAIMED_ATTEMPT") + sv_sg_reason="supervisor received SIG$1 before readiness; $sv_sg_alive" + [ "$sv_sg_state" != starting ] || sv_sg_reason="INDETERMINATE: $sv_sg_reason" LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$lane" "$sv_sg_state" \ --expect 'pending|starting' --expect-operation "$operation" \ ${sv_sg_fence[@]+"${sv_sg_fence[@]}"} \ - --reason "INDETERMINATE: supervisor received SIG$1 before readiness; $sv_sg_alive" \ + --reason "$sv_sg_reason" \ >/dev/null 2>&1 || : ri_load "$lane" >/dev/null 2>&1 || : say "" diff --git a/lanes-edit.sh b/lanes-edit.sh index d8cafd02..894d6612 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -10649,7 +10649,6 @@ lane_restart_missing() { # for (i=1;i<=n;i++) allowed[keys[i]]=1 allowed["prepared_digest"]=1 } - /^$/ { next } { if ($0 ~ /[[:cntrl:]]/ || $0 !~ /^[A-Za-z_]+: /) {bad["record"]=1; next} k=$0; sub(/:.*/, "", k) @@ -14151,6 +14150,7 @@ EOF [ "${#pbh_digest}" = 64 ] || die "publication requires the complete prior SHA-256" 64 case "$pbh_digest" in *[!0-9a-fA-F]*) die "publication requires SHA-256" 64 ;; esac [ -f "$pbh_source" ] && [ -r "$pbh_source" ] && [ -s "$pbh_source" ] || die "the prepared handoff is unreadable or empty" 1 + acquire_lock pbh_links=0 while [ -L "$pbh_target" ]; do pbh_links=$((pbh_links+1)); [ "$pbh_links" -lt 40 ] || die "the handoff link chain cannot be resolved" 2 @@ -14160,7 +14160,6 @@ EOF pbh_parent="$( CDPATH=''; cd -P -- "$(dirname -- "$pbh_target")" && pwd -P )" || die "the handoff parent is missing" 1 pbh_target="$pbh_parent/$(basename -- "$pbh_target")" [ -f "$pbh_target" ] && [ -r "$pbh_target" ] || die "the handoff target is unreadable" 1 - acquire_lock pbh_irc=0; pbh_intent="$(lane_restart_read "$lane")" || pbh_irc=$? case "$pbh_irc" in 0|8) : ;; *) release_lock; die "restart ownership could not be read; no handoff was published" 1 ;; esac pbh_current="$(printf '%s\n' "$pbh_intent" | awk -F'\t' '$1=="state" {print $2; exit}')" diff --git a/specs/005-supervised-legacy-ctx/plan.md b/specs/005-supervised-legacy-ctx/plan.md index d1b7da78..4cc2cc9b 100644 --- a/specs/005-supervised-legacy-ctx/plan.md +++ b/specs/005-supervised-legacy-ctx/plan.md @@ -37,7 +37,7 @@ Each starting attempt consumes `new_transcript: none` exactly once. Later writes expect the reserved UUID as well as operation, generation and attempt. Stamp publication resolves a final-component symlink and atomically replaces its target from a same-directory temporary file. The helper holds the same writer mutex -through ownership and prior-digest checks and replacement. All shipped handoff +through ownership and prior-digest checks and replacement. All restart preservation and resume-stamp publishers use this path; manual edits must follow the staging/publication skill contract. Existing records with missing, duplicate or invalid fields refuse transitions rather than being silently repaired. @@ -60,3 +60,10 @@ record publication. Intent writes use private same-directory temporary files. Rename takes the existing mutex and refuses unfinished/failed restart records. Observers stop through a private marker and are reaped before marker removal, without signaling a numeric PID that may already have been recycled. + +Legacy lane rename is an explicit publication exception: it already holds the +writer mutex and now refuses unfinished/failed restart ownership before moving +inputs. Its existing four-file transaction uses EXIT rollback, including stamp +write failures; it is not crash atomic across those files. This change does not +claim stronger crash durability for ordinary rename. Restart preservation and +resume-stamp publishers use the atomic checksum-fenced publication helper. diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index 217c91b9..947bdecb 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -938,7 +938,7 @@ def test_existing_invalid_state_root_is_not_absent_intent(restart_box, kind): assert not box.claude_runs() -@pytest.mark.parametrize("extra", ["future_key: preserve-me\n", "garbage\n", "profile:test\n", +@pytest.mark.parametrize("extra", ["future_key: preserve-me\n", "garbage\n", "profile:test\n", "\n", "schema: 1\n", "prepared_digest: none\n", "pane: testsess:@1.%1\tignored\n"]) def test_restart_strict_schema_preserves_unrecognized_or_malformed_records(restart_box, extra): @@ -1189,3 +1189,81 @@ def test_new_restart_explicitly_clears_previous_operations_profile(restart_box): assert record["profile"] == "none" assert record["operation"] != "op-test" assert "respawn-pane" in box.tmux_log.read_text() + + +@pytest.mark.parametrize("claim", ["success", "lost"]) +def test_signal_during_claim_publication_reconciles_only_owned_attempt(restart_box, claim): + box = restart_box + prepare_restart(box) + before = intent_path(box).read_bytes() + real = box.bin / "lanes-edit-real.sh" + (box.bin / "lanes-edit.sh").rename(real) + action = f"'{real}' \"$@\"; result=$?" if claim == "success" else "result=7" + _write(box.bin / "lanes-edit.sh", f"""#!/usr/bin/env bash +case "$*" in + *"supervisor claimed the operation"*) + {action} + kill -TERM "$PPID" + exit "$result" ;; +esac +exec '{real}' "$@" +""") + result = supervisor(box, "--supervise", "--lane", LANE, "--operation", "op-test") + assert result.returncode == 143, (result.stdout, result.stderr) + assert not box.claude_runs() + if claim == "lost": + assert intent_path(box).read_bytes() == before + assert "before the restart claim" in result.stdout + else: + record = dict(line.split("\t", 1) for line in helper(box, "restart-intent", LANE).stdout.splitlines()) + assert record["state"] == "failed" and record["attempt"] == "1" + assert record["generation"] == "7" and record["operation"] == "op-test" + assert "no child was launched" in record["reason"] + assert "retry:" in result.stdout + + +def test_handoff_target_is_resolved_after_waiting_for_writer_mutex(restart_box): + box = restart_box + original = prepare_restart(box) + assert helper(box, "set-restart-intent", LANE, "ready", "--expect", "pending").returncode == 0 + other = _write(box.wip / "handoffs" / "other.md", "other preserved work\n", 0o600) + alias = box.wip / "handoffs" / "alias.md" + alias.symlink_to(original.name) + prepared = _write(box.root / "prepared.md", "replacement bytes\n", 0o600) + digest = hashlib.sha256(original.read_bytes()).hexdigest() + before = (original.read_bytes(), other.read_bytes()) + lock = box.wip / "lanes" / ".lanes-edit.lock" + lock.mkdir() + (lock / "pid").write_text(str(os.getpid()) + "\n") + marker = box.root / "waiting-for-writer" + real_mkdir = shutil.which("mkdir") + _write(box.fakebin / "mkdir", f"""#!/usr/bin/env bash +case "$*" in *.lanes-edit.lock*) touch '{marker}' ;; esac +exec '{real_mkdir}' "$@" +""") + proc = subprocess.Popen([str(box.bin / "lanes-edit.sh"), "publish-handoff", LANE, + str(alias), str(prepared), "--expect-digest", digest], + env=box.env, cwd=box.lane_dir, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + try: + deadline = time.monotonic() + 10 + while not marker.exists() and time.monotonic() < deadline: + assert proc.poll() is None + time.sleep(.02) + assert marker.exists() + alias.unlink() + alias.symlink_to(other.name) + (lock / "pid").unlink() + lock.rmdir() + out, err = proc.communicate(timeout=15) + assert proc.returncode == 7, (out, err) + assert "changed before publication" in err + assert (original.read_bytes(), other.read_bytes()) == before + assert alias.is_symlink() and alias.resolve() == other + finally: + if lock.exists(): + (lock / "pid").unlink(missing_ok=True) + lock.rmdir() + if proc.poll() is None: + proc.terminate() + proc.communicate(timeout=5) From 733557cfea32cf68903b3028fe9b3476021f65fe Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:35:33 +0000 Subject: [PATCH 19/25] Require bound handoff identity and usable managed ownership readers --- lane-handoff | 6 ++++ lanes-edit.sh | 7 +++- skills/handoff/SKILL.md | 6 +++- tests/test_supervised_restart_regressions.py | 34 ++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/lane-handoff b/lane-handoff index b74e7f17..496f2aa1 100755 --- a/lane-handoff +++ b/lane-handoff @@ -1881,6 +1881,12 @@ else # top block, which is the one document the new session is started with. hs_publish_args=(publish-handoff "$lane" "$handoff_file" "$hs_tmpd/out" --expect-digest "$hs_prior_digest") if [ "$do_restart" = 1 ]; then + # Bind preservation publication to this operation before replacing + # any canonical bytes; the helper verifies file identity under lock. + LANES_NO_FETCH=1 "$LANES_EDIT" set-restart-intent "$lane" preparing \ + --expect preparing --expect-operation "$operation" --expect-generation "$ri_gen" --expect-attempt 0 \ + --handoff "$handoff_file" --digest "$hs_prior_digest" >/dev/null || + { rm -rf -- "$hs_tmpd"; die "the preservation handoff could not be bound to this restart. Nothing was published or respawned." 2; } hs_publish_args+=(--operation "$operation" --generation "$ri_gen" --attempt 0 --state preparing --transcript none) fi if hs_published="$(LANES_NO_FETCH=1 "$LANES_EDIT" "${hs_publish_args[@]}")"; then diff --git a/lanes-edit.sh b/lanes-edit.sh index 894d6612..96268aa9 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -14170,6 +14170,9 @@ EOF $1=="operation" {o=$2} $1=="generation" {g=$2} $1=="attempt" {a=$2} $1=="state" {s=$2} $1=="new_transcript" {t=$2} END {printf "%s|%s|%s|%s|%s",o,g,a,s,t}')" [ "$pbh_actual" = "$pbh_op|$pbh_gen|$pbh_attempt|$pbh_state|$pbh_sid" ] || { release_lock; die "this handoff publisher lost restart ownership; nothing was published" 7; } + pbh_bound="$(printf '%s\n' "$pbh_intent" | awk -F'\t' '$1=="handoff" {print $2; exit}')" + [ -n "$pbh_bound" ] && [ "$pbh_bound" != none ] && [ "$pbh_bound" -ef "$pbh_target" ] || + { release_lock; die "the publication target is not the operation-bound handoff; nothing was published" 2; } else case "$pbh_current" in preparing|pending|starting) release_lock; die "an active restart owns this handoff; nothing was published" 7 ;; esac fi @@ -14188,7 +14191,9 @@ EOF # Use the managed service's affirmative read when that installation exists. # Exit 8 means confirmed absence; every unknown/error answer refuses. lrc_managed="${SELF%/*}/lane-managed" - if [ -x "$lrc_managed" ]; then + if [ -e "$lrc_managed" ] || [ -L "$lrc_managed" ]; then + [ -f "$lrc_managed" ] && [ -r "$lrc_managed" ] && [ -x "$lrc_managed" ] || + die "the installed managed ownership reader is unusable. Legacy restart refuses without writing or launching." 1 lrc_rc=0 LANES_NO_FETCH=1 "$lrc_managed" legacy-check --lane "$lane" >/dev/null || lrc_rc=$? case "$lrc_rc" in diff --git a/skills/handoff/SKILL.md b/skills/handoff/SKILL.md index 0716fa42..ea21bf97 100644 --- a/skills/handoff/SKILL.md +++ b/skills/handoff/SKILL.md @@ -201,7 +201,7 @@ Only the second branch leaves anything blank, and it leaves it blank because not ## 2. Refresh the handoff -Canonical handoff publication is serialized by `lanes-edit.sh publish-handoff`. +Manual preservation and restart/resume-stamp publication is serialized by `lanes-edit.sh publish-handoff`. Read the current file's SHA-256, write semantic updates to a temporary file, then publish that complete prepared file with: @@ -213,6 +213,10 @@ The helper checks the expected bytes and atomically replaces the resolved target while holding the workspace writer mutex. It refuses an active restart owned by another operation. Do not edit or redirect into the canonical handoff directly; perform the existing explicit-pathspec commit/push after successful publication. +Legacy `rename-lane` is a separate four-file transaction under the same mutex; +it refuses unfinished/failed restart ownership and uses EXIT rollback. Its +existing direct stamp writer is an explicit exception to this atomic publication +contract, not a crash-atomic rename guarantee. The handoff path is the row's own handoff column. Derive it; do not guess it — and read the row FETCHED, diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index 947bdecb..404a67c5 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -1267,3 +1267,37 @@ def test_handoff_target_is_resolved_after_waiting_for_writer_mutex(restart_box): if proc.poll() is None: proc.terminate() proc.communicate(timeout=5) + + +@pytest.mark.parametrize("kind", ["non-executable", "directory", "broken-link"]) +def test_existing_unusable_managed_reader_is_not_confirmed_absence(restart_box, kind): + box = restart_box + reader = box.bin / "lane-managed" + if kind == "non-executable": _write(reader, "#!/bin/sh\nexit 8\n", 0o600) + elif kind == "directory": reader.mkdir() + else: reader.symlink_to(box.root / "missing-reader") + result = helper(box, "legacy-restart-check", LANE) + assert result.returncode == 1, (result.stdout, result.stderr) + assert "reader is unusable" in result.stderr + assert not intent_path(box).exists() + + +@pytest.mark.parametrize("target", ["bound", "different"]) +def test_operation_publication_requires_its_bound_handoff(restart_box, target): + box = restart_box + bound = prepare_restart(box) + other = _write(box.wip / "handoffs" / "different.md", bound.read_text(), 0o600) + prepared = _write(box.root / "prepared.md", "preserved replacement\n", 0o600) + before = (bound.read_bytes(), other.read_bytes()) + chosen = bound if target == "bound" else other + digest = hashlib.sha256(chosen.read_bytes()).hexdigest() + result = helper(box, "publish-handoff", LANE, str(chosen), str(prepared), + "--expect-digest", digest, "--operation", "op-test", "--generation", "7", + "--attempt", "0", "--state", "pending", "--transcript", "none") + assert result.returncode == (0 if target == "bound" else 2), (result.stdout, result.stderr) + if target == "different": + assert "operation-bound handoff" in result.stderr + assert (bound.read_bytes(), other.read_bytes()) == before + else: + assert bound.read_bytes() == prepared.read_bytes() + assert other.read_bytes() == before[1] From 69838c580072542bac3354beaf2605eb9173eff2 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:07:02 +0000 Subject: [PATCH 20/25] Supply checkout facts to partial handoff failure fixtures --- tests/test_lane_helpers.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 027b498c..60b50f37 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -9581,6 +9581,8 @@ hasnt "…nor is there a PAUSED line for that lane" "$(cat "$LOGD/repoHF-2.md")" # state from, and the HANDOFF's top block is literally the new session's first # prompt. Each of the two below writes the PAUSED line and then fails at one of # the others, and neither may kill the only process that could put it right. +# Both supply the sandbox checkout so the earlier restart-directory preflight +# passes and each case reaches the partial preservation failure it exercises. hf_seed_handoff "$WIP/handoffs/repoHF/repoHF-11.md" repoHF-11 git -C "$WIP" add -- handoffs/repoHF/repoHF-11.md >/dev/null 2>&1 git -C "$WIP" commit -q -m "seed repoHF-11's handoff" @@ -9602,7 +9604,7 @@ git -C "$WIP" push -q origin main : > "$FAKE_TMUX_A17_LOG" export FAKE_TMUX_A17_WATCH="$LOGD/repoHF-11.md" run env PATH="$A17PATH" FAKE_TMUX_WINDOW="hfsess:@21" CLAUDE_CODE_SESSION_ID="$HF_ID" \ - CLAUDE_PROFILE_NAME=team-05a "$HANDOFF_CMD" --lane repoHF-11 --restart clear + CLAUDE_PROFILE_NAME=team-05a "$HANDOFF_CMD" --lane repoHF-11 --dir "$HF_DIR" --restart clear is "a /ctx whose ROW could not be flipped REFUSES" "$rc" 2 has "…saying the register would read RUNNING for a session that was just replaced" "$err" "the ROW WAS NOT FLIPPED" has "…and that the pane is exactly as it was" "$err" "this pane is left exactly as it is" @@ -9617,7 +9619,7 @@ has "…while the record itself IS written, because a swap is never left unwri : > "$FAKE_TMUX_A17_LOG" export FAKE_TMUX_A17_WATCH="$LOGD/repoHF-12.md" run env PATH="$A17PATH" FAKE_TMUX_WINDOW="hfsess:@21" CLAUDE_CODE_SESSION_ID="$HF_ID" \ - CLAUDE_PROFILE_NAME=team-05a "$HANDOFF_CMD" --lane repoHF-12 --restart clear + CLAUDE_PROFILE_NAME=team-05a "$HANDOFF_CMD" --lane repoHF-12 --dir "$HF_DIR" --restart clear is "a /ctx whose HANDOFF could not be refreshed REFUSES" "$rc" 2 has "…saying the top block is what the new session is started with" "$err" "the HANDOFF WAS NOT REFRESHED" is "…and the pane was never respawned" "$(grep -c 'respawn-pane' "$FAKE_TMUX_A17_LOG")" 0 From 0bf6f0ace9e6e51f41104fbd77d57664e0e8ca7e Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:27:16 +0000 Subject: [PATCH 21/25] Fix macOS restart validation and terminal regression fixtures --- lane-handoff | 8 +++- lanes-edit.sh | 14 ++++-- specs/005-supervised-legacy-ctx/tasks.md | 2 + tests/test_supervised_restart_regressions.py | 46 +++++++++++++++++--- 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/lane-handoff b/lane-handoff index a2b09bd9..b11f7bf7 100755 --- a/lane-handoff +++ b/lane-handoff @@ -123,6 +123,12 @@ say() { printf '%s\n' "$*"; } note() { printf '%s: %s\n' "$prog" "$*" >&2; } step() { printf '%s: %s\n' "$prog" "$*" >&2; } die() { printf '\n%s: REFUSED: %s\n' "$prog" "$1" >&2; exit "${2:-2}"; } +# Exact launch facts must not carry record delimiters, on Bash 3.2 too. +restart_fact_ok() { + local LC_ALL=C + case "${1-}" in *[[:cntrl:]]*) return 1 ;; esac + return 0 +} # EVERY ARGUMENT AS ONE SHELL WORD, WITHOUT `printf %q` AND WITHOUT A PIPE — # measured on the macOS job at `db206db` and `ac0c697`, where three cases of # this branch's own went red and no other platform could see it. One line, two @@ -1795,7 +1801,7 @@ if [ "$do_restart" = 1 ]; then [ -n "$dir" ] && [ -d "$dir" ] && [ -r "$dir" ] && [ -x "$dir" ] || die "a supervised restart requires an existing absolute checkout directory. Nothing was respawned." 2 for restart_fact in "$dir" "${pane:-${TMUX_PANE:-}}" "$window_ref"; do - case "$restart_fact" in *[$'\001'-$'\037'$'\177']*) die "restart launch facts cannot contain control characters. Nothing was respawned." 2 ;; esac + restart_fact_ok "$restart_fact" || die "restart launch facts cannot contain control characters. Nothing was respawned." 2 done fi diff --git a/lanes-edit.sh b/lanes-edit.sh index 6f099e62..7d9f57e1 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -10742,7 +10742,9 @@ lane_restart_sidecar_value() { # } lane_restart_value_ok() { # exact values must survive flat and tabular readers - case "${1-}" in *[$'\001'-$'\037'$'\177']*) return 1 ;; esac + # POSIX class matching also works in Bash 3.2; its ANSI-C range did not. + local LC_ALL=C + case "${1-}" in *[[:cntrl:]]*) return 1 ;; esac return 0 } @@ -13286,6 +13288,13 @@ Nothing was written." 2 # refusals — a row under ``, a published log — with nothing written. acquire_lock + # Refuse a spelling-only rename before probing both roots: on a + # case-insensitive filesystem they are the same intent, whose stored lane + # carries the canonical spelling rather than the requested new spelling. + if [ "$(lc "$rl_old")" = "$(lc "$rl_new")" ]; then + die "'$rl_old' and '$rl_new' are ONE name under any case (Amendment 15), so this is not a rename at all: it is a change to the row's own SPELLING, which every reader already resolves to and which Amendment 15(d) makes a hand act — the newer row's session id(s) appended to the older row's cell, in one commit. Nothing was written." 2 + fi + # Rename and restart publication share this mutex. An unfinished operation # owns its name and canonical inputs, including recovery from failed. for rl_check in "$rl_old" "$rl_new"; do @@ -13323,9 +13332,6 @@ Nothing was written." 2 # rebase the two together. rl_nh="$(printf '%s\n%s\n' "$(rows_named_ci "$rl_new" 2>/dev/null || :)" "$(rows_named_ci_local "$rl_new" 2>/dev/null || :)" | grep -v '^$' | LC_ALL=C sort -u || :)" if [ -n "$rl_nh" ]; then - if [ "$(lc "$rl_old")" = "$(lc "$rl_new")" ]; then - die "'$rl_old' and '$rl_new' are ONE name under any case (Amendment 15), so this is not a rename at all: it is a change to the row's own SPELLING, which every reader already resolves to and which Amendment 15(d) makes a hand act — the newer row's session id(s) appended to the older row's cell, in one commit. Nothing was written." 2 - fi die "lane '$rl_new' already has a row, spelled $(printf '%s\n' "$rl_nh" | tr '\n' ' ')— a lane name is ONE name under any case (Amendment 15(a)), so renaming into it would make TWO rows for one name, which is the state every writer in this file refuses until 15(d)'s merge. Nothing was written." 2 fi diff --git a/specs/005-supervised-legacy-ctx/tasks.md b/specs/005-supervised-legacy-ctx/tasks.md index 923e1db6..47cedc1c 100644 --- a/specs/005-supervised-legacy-ctx/tasks.md +++ b/specs/005-supervised-legacy-ctx/tasks.md @@ -19,3 +19,5 @@ - [ ] T017 After hold release/merge authorization, run macOS landing gate and merge. - [x] T018 Reconcile landed PR #97, preserving separate helper namespaces, managed ownership fences and completed restart history; verify helper identity and focused integration cases. New-head full-suite results are recorded in the PR review record. + +- [ ] T019 Repair macOS landing regressions: Bash 3.2 control rejection, case-insensitive spelling-only rename preflight, and a controlling terminal for the interactive retry fixture. Verify locally and rerun exact-head landing CI. diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index 277c6d96..e7b30fbf 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -754,6 +754,8 @@ def test_new_lane_checkout_hint_still_refuses_unreadable_intent(restart_box): def test_interactive_retry_rechecks_holder_absence_before_launch(restart_box): import pty + import fcntl + import termios from conftest import REPO box = restart_box prepare_restart(box) @@ -776,10 +778,16 @@ def test_interactive_retry_rechecks_holder_absence_before_launch(restart_box): box.env.pop("LANE_SUPERVISOR_NO_PROMPT", None) master, slave = pty.openpty() output_path = box.root / "interactive.out" + + def own_terminal(): + # Match a pane: the supervisor owns a controlling tty in its session. + # An openpty fd alone is only a tty, which differs on macOS. + fcntl.ioctl(0, termios.TIOCSCTTY, 0) with output_path.open("w") as output: proc = subprocess.Popen([str(box.bin / "lane-handoff"), "--supervise", "--lane", LANE, "--operation", "op-test"], cwd=box.lane_dir, env=box.env, - stdin=slave, stdout=output, stderr=output, start_new_session=True) + stdin=slave, stdout=output, stderr=output, start_new_session=True, + preexec_fn=own_terminal) os.close(slave) try: deadline = time.monotonic() + 15 @@ -795,16 +803,25 @@ def test_interactive_retry_rechecks_holder_absence_before_launch(restart_box): assert "INDETERMINATE" in output_path.read_text(), output_path.read_text() assert "Press Enter to close it" in output_path.read_text(), output_path.read_text() os.write(master, b"\n") - assert proc.wait(timeout=15) == 4, output_path.read_text() + try: + status = proc.wait(timeout=15) + except subprocess.TimeoutExpired as error: + raise AssertionError("supervisor did not exit after Enter:\n" + output_path.read_text()) from error + assert status == 4, output_path.read_text() assert launches.read_text().splitlines() == ["launch"] record = dict(line.split("\t", 1) for line in helper(box, "restart-intent", LANE).stdout.splitlines()) assert record["state"] == "starting" and record["attempt"] == "2" assert "before attempt launch" in record["reason"] finally: + # Reap the owned supervisor before closing its controlling tty. + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=10) os.close(master) - try: os.killpg(proc.pid, signal.SIGTERM) - except ProcessLookupError: pass - if proc.poll() is None: proc.wait(timeout=10) def test_checkout_hint_cannot_hide_existing_fallback_intent(restart_box): @@ -968,6 +985,17 @@ def test_restart_writer_rejects_record_delimiters_without_changing_identity(rest assert intent_path(box).read_bytes() == before +def test_restart_writer_rejects_record_delimiters_all_ascii_controls(restart_box): + box = restart_box + prepare_restart(box) + before = intent_path(box).read_bytes() + for code in [*range(1, 32), 127]: + result = helper(box, "set-restart-intent", LANE, "pending", "--pane", + "testsess:@1.%1" + chr(code) + "tail") + assert result.returncode == 64, (code, result.stdout, result.stderr) + assert intent_path(box).read_bytes() == before + + def test_restart_writer_does_not_follow_predictable_temporary_symlink(restart_box): box = restart_box prepare_restart(box) @@ -1054,7 +1082,7 @@ def test_restart_reconcile_rename_preserves_unfinished_operation_inputs(restart_ assert intent_path(box).read_bytes() == record -@pytest.mark.parametrize("kind", ["relative", "missing", "none", "agent"]) +@pytest.mark.parametrize("kind", ["relative", "missing", "none", "agent", "tab", "newline"]) def test_restart_refuses_unsupported_checkout_or_agent_before_preservation(restart_box, kind): box = restart_box source = prepare_restart(box) @@ -1067,6 +1095,10 @@ def test_restart_refuses_unsupported_checkout_or_agent_before_preservation(resta args += ["--dir", "relative" if kind == "relative" else str(box.root / "absent")] elif kind == "agent": args += ["--agent", "codex"] + elif kind in {"tab", "newline"}: + unsafe = box.root / ("checkout" + ("\t" if kind == "tab" else "\n") + "tail") + unsafe.mkdir() + args += ["--dir", str(unsafe)] else: real = box.bin / "lanes-edit-real.sh" (box.bin / "lanes-edit.sh").rename(real) @@ -1082,6 +1114,8 @@ def test_restart_refuses_unsupported_checkout_or_agent_before_preservation(resta assert (box.wip / "lanes" / "LANES.md").read_bytes() == registry assert "rename-window" not in box.tmux_log.read_text() assert "respawn-pane" not in box.tmux_log.read_text() + if kind in {"tab", "newline"}: + assert "control characters" in result.stderr @pytest.mark.parametrize("fact", ["dir", "digest", "pane", "agent"]) From 6b92e3597fd1cf320b283a5c663c4e76abb88016 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:12:41 +0000 Subject: [PATCH 22/25] Recover ordinary resume after aborted restart preparation --- docs/README-lanes.md | 9 ++ lane-handoff | 11 +- lane-start | 47 +++++- .../add-supervised-context-restart/design.md | 14 ++ .../specs/supervised-context-restart/spec.md | 18 ++- specs/005-supervised-legacy-ctx/spec.md | 11 +- specs/005-supervised-legacy-ctx/tasks.md | 2 + tests/test_supervised_restart_regressions.py | 142 ++++++++++++++++++ 8 files changed, 243 insertions(+), 11 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index bf9119cc..fbf96df7 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2712,6 +2712,15 @@ lanes-edit.sh set-restart-intent failed --expect preparing \ A new `/ctx` can then refresh the preservation record. This command authorizes no child launch from a partially prepared record. +If preparation failed before `pending`, the failed intent keeps mode +`preservation-only`, attempt 0 and no new transcript. It is not a failed launch +and cannot be given to `--supervise`. Once every holder is confirmed absent, +`lane ` may use ordinary resume without restart operation/attempt tokens or fresh-session selectors. +It rechecks the same failed preparation before binding changes and before +launch, leaves the intent unchanged, and retains the normal binding safeguards. +A live or unknown holder still refuses. Failed `fresh-from-handoff` launches +remain restricted to their same-operation supervisor. + The first launch reserves `new_transcript` only while it is `none`. Every later write expects its exact operation, generation, attempt and transcript. The verified Rule 3 stamp is persisted with both `digest` and `prepared_digest` diff --git a/lane-handoff b/lane-handoff index b11f7bf7..e854cb7a 100755 --- a/lane-handoff +++ b/lane-handoff @@ -1103,7 +1103,16 @@ if [ "$do_status" = 1 ]; then say "lifecycle separate diagnostics; never used to decide restart readiness" case "$RI_STATE" in preparing) say "PREPARING: old pane was not respawned; inspect preservation and reconcile this exact reservation before a new /ctx." ;; - failed) say ""; say "RETRY: $(quoted "$SELF_ABS") --supervise --lane $lane --operation ${RI_OP:-}" ;; + failed) + say "" + if [ "$RI_MODE" = preservation-only ] && [ "$RI_ATTEMPT" = 0 ] && [ "$RI_NEW" = none ]; then + say "PREPARATION FAILED: the old pane was not respawned; no supervised launch was attempted." + say "When every holder is confirmed absent, ordinary resume is available: lane $lane" + say "From the original live session, a new /ctx may retry preservation." + else + say "RETRY: $(quoted "$SELF_ABS") --supervise --lane $lane --operation ${RI_OP:-}" + fi ;; + pending) say ""; say "NOT YET CLAIMED: no supervisor has taken this operation. The pane it was written for may never have been respawned." ;; starting) say ""; say "IN FLIGHT: this attempt is reserved; a child may still be alive. Do not start a second one — read the pane it is in (${RI_PANE:-pane not recorded})." ;; ready) say ""; say "DONE: this restart was confirmed. It authorises no further launch; an ordinary \`lane $lane\` resumes the lane." ;; diff --git a/lane-start b/lane-start index c41e51df..57ed0ee9 100755 --- a/lane-start +++ b/lane-start @@ -1444,6 +1444,30 @@ step " its record refs are $record_window" # Active restarts refuse unowned callers before any binding repair or rename. # Detailed prompt and launch-fact validation follows before transcript writes. +preparation_failure_identity="" +preparation_failure_snapshot() { # validated restart-intent output + printf '%s\n' "$1" | awk -F'\t' ' + {v[$1]=$2} END {printf "%s|%s|%s|%s|%s|%s", v["state"],v["mode"],v["attempt"],v["new_transcript"],v["operation"],v["generation"]}' +} +preparation_failure_resume_check() { # validated restart-intent output + pfr_identity="$(preparation_failure_snapshot "$1")" + case "$pfr_identity" in failed\|preservation-only\|0\|none\|*) : ;; + *) die "a failed supervised launch requires its same-operation supervisor; ordinary resume refuses. Nothing was launched." 2 ;; + esac + [ -z "$preparation_failure_identity" ] || [ "$pfr_identity" = "$preparation_failure_identity" ] || + die "the failed preparation changed during ordinary recovery. Nothing was launched." 2 + [ -z "${op_flag:-}${LANE_RESTART_OPERATION:-}${LANE_RESTART_ATTEMPT:-}" ] || + die "failed preparation permits only ordinary resume without restart operation or attempt tokens. Nothing was launched." 2 + [ "${fresh_flag:-0}" != 1 ] && [ "${LANE_START_FRESH:-0}" != 1 ] || + die "failed preparation permits only ordinary resume, not a fresh session. Nothing was launched." 2 + LANES_NO_FETCH=1 "$LANES_EDIT" legacy-restart-check "$LANE" || + die "managed or unknown ownership blocks failed-preparation recovery. Nothing was launched." 2 + pfr_holder_rc=0 + LANES_NO_FETCH=1 "$LANES_EDIT" lane-holders "$LANE" >/dev/null || pfr_holder_rc=$? + [ "$pfr_holder_rc" = 8 ] || + die "ordinary recovery from failed preparation requires confirmed absence of every holder (lane-holders exited $pfr_holder_rc). Nothing was launched." 2 + preparation_failure_identity="$pfr_identity" +} eri_rc=0 eri_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" restart-intent "$LANE" --dir "$DIR" 2>/dev/null)" || eri_rc=$? case "$eri_rc" in @@ -1452,7 +1476,7 @@ case "$eri_rc" in eri_op="$(printf '%s\n' "$eri_out" | awk -F'\t' '$1=="operation" {print $2; exit}')" case "$eri_state" in preparing) die "this restart is still preparing its preservation records. Nothing was changed." 2 ;; - failed) die "a failed supervised restart requires the same-operation supervisor; ordinary lane resume refuses. Nothing was changed." 2 ;; + failed) preparation_failure_resume_check "$eri_out" ;; pending|starting) eri_token="${op_flag:-${LANE_RESTART_OPERATION:-}}" [ -n "$eri_token" ] || die "an active restart requires its supervisor operation token. Nothing was changed." 2 @@ -2840,8 +2864,9 @@ fi # rather than in an environment. Only `pending` and `starting` authorise a # launch: a `ready` intent is HISTORY (which is what makes an ordinary # `lane ` after a successful `/ctx` an ordinary resume), and a -# `failed` one is the supervisor's to retry, which moves it back to -# `starting` first. +# failed launch is the supervisor's to retry, moving it back to `starting`. +# Failed preparation authorizes only guarded ordinary resume with unchanged +# intent history; it has no supervised launch authority. # 2. `--fresh`, for the caller that reaches this command directly and has no # launcher between it and here. # 3. `LANE_START_FRESH=1`, kept exactly as it was so that nothing written @@ -2942,6 +2967,11 @@ case "$ri_rc" in *) die "lane $LANE's restart intent read FAILED (\`$LANES_EDIT restart-intent $LANE\` exited $ri_rc), and a read that failed is never an answer (Amendment 7(d)). Nothing was renamed, written or started: this is the one launch gate whose wrong answer resumes the transcript a /ctx just paused (openRepoTools#94), so it refuses rather than falling through to an ordinary resume source. Run that command yourself to see what it says, then launch again." 1 ;; esac +if [ -n "$preparation_failure_identity" ]; then + pfr_current="$(preparation_failure_snapshot "$ri_out")" + [ "$pfr_current" = "$preparation_failure_identity" ] || + die "the failed preparation changed during ordinary recovery. Nothing was launched." 2 +fi if [ -n "${LANE_RESTART_OPERATION:-}" ]; then [ -z "$op_flag" ] || [ "$op_flag" = "$LANE_RESTART_OPERATION" ] || die "the explicit restart operation disagrees with the supervisor token. Nothing was started." 2 @@ -2977,7 +3007,10 @@ case "$ri_state" in UNKNOWN-SCHEMA) die "lane $LANE has a restart intent this \`lanes-edit.sh\` cannot read: its schema is one this version does not know (\`lanes-edit.sh restart-intent $LANE\` answers UNKNOWN-SCHEMA). That is NOT 'no restart is in flight' — a read that could not be made is never an answer (Amendment 7(d)) — and starting anyway would resolve an ordinary resume source, which for a lane whose /ctx just killed its pane is the transcript that /ctx PAUSED. Nothing was renamed, written or started. Update this workstation's openRepoTools so it can read that record, or remove the intent once you know no restart is in flight: lanes-edit.sh restart-intent $LANE" 2 ;; failed) - die "lane $LANE has a failed supervised restart. Ordinary lane resume is not its recovery; retry the same operation with lane-handoff --supervise --lane $LANE --operation $ri_op. Nothing was started." 2 ;; + [ -n "$preparation_failure_identity" ] || + die "a failed restart appeared during ordinary launch. Recheck it before recovery. Nothing was launched." 2 + preparation_failure_resume_check "$ri_out" ;; + preparing) die "this restart is still preparing its preservation records. No launch is authorized. Read it: lanes-edit.sh restart-intent $LANE" 2 ;; pending|starting) @@ -4272,6 +4305,12 @@ if (( dry_run )); then exit 0 fi +if [ -n "$preparation_failure_identity" ]; then + pfr_final="$(LANES_NO_FETCH=1 "$LANES_EDIT" restart-intent "$LANE" --dir "$DIR")" || + die "the failed preparation could not be rechecked before ordinary launch. Nothing was launched." 2 + preparation_failure_resume_check "$pfr_final" +fi + cd -- "$DIR" step "exec: $(quoted "${cmd[@]}")" exec "${cmd[@]}" diff --git a/openspec/changes/add-supervised-context-restart/design.md b/openspec/changes/add-supervised-context-restart/design.md index 2ceffa41..98bec269 100644 --- a/openspec/changes/add-supervised-context-restart/design.md +++ b/openspec/changes/add-supervised-context-restart/design.md @@ -205,3 +205,17 @@ Rollback disables supervised respawn and returns `/ctx` to warning plus non-rest - Should successful readiness archive old restart intents immediately or retain a bounded per-lane history outside the append-only event log? - What explicit operator command names best distinguish retry, finalize an indeterminate-but-valid child, cancel, and generation-advancing recovery? - When workBenches adds a first-class operation option, should the compatibility environment seam be removed immediately or after one release overlap? + + +### 2026-10-05 preparation recovery correction + +Exact-head Codex review on PR #121 identified that failed preparation was being +routed to a supervisor which correctly refuses preservation-only mode. Within +the approved legacy scope, distinguish a never-launched failed preparation from +a failed fresh launch. Normal resume may recover the former only with strict +all-holder absence, no operation/attempt tokens, attempt 0 and no new transcript. +Recheck the same operation/generation and qualifying fields before binding acts +and before exec. Preserve the failed intent as diagnostics; never label it ready +or import its counters into ordinary resume. These checks retain normal launch +safeguards and do not create an atomic ordinary-launch reservation. Failed +supervised launches and preparing reservations remain refused by ordinary resume. diff --git a/openspec/changes/add-supervised-context-restart/specs/supervised-context-restart/spec.md b/openspec/changes/add-supervised-context-restart/specs/supervised-context-restart/spec.md index bdebea03..48ff20f6 100644 --- a/openspec/changes/add-supervised-context-restart/specs/supervised-context-restart/spec.md +++ b/openspec/changes/add-supervised-context-restart/specs/supervised-context-restart/spec.md @@ -60,11 +60,23 @@ transcript and matching pane. Only the legacy intent becomes `ready`. Cancellation before claim SHALL preserve the unclaimed intent. A live or unknown child or unknown holder absence SHALL retain `starting` with an indeterminate reason, blocking retry. Only a proven ended launch with no live holder SHALL -become `failed`. Retry SHALL preserve operation and increment attempt. Ordinary -lane resume SHALL refuse a failed supervised restart; historical ready intents -SHALL NOT override ordinary resume. No automatic rollover or real session canary +become a failed launch. Retry SHALL preserve operation and increment attempt. +Ordinary lane resume SHALL refuse a failed supervised launch. Caught preparation +failure SHALL retain a failed preservation-only intent with attempt 0 and no new +transcript. That class SHALL permit ordinary resume only without restart tokens or fresh-session selectors, +after confirmed absence of every holder, and with unchanged operation/generation +and qualifying fields checked before binding changes and before launch. The +intent SHALL remain unchanged; no readiness or supervised claim is manufactured. +`preparing` SHALL remain blocked. Historical ready intents SHALL NOT override +ordinary resume. No automatic rollover or real session canary is authorized by this change. #### Scenario: Supervisor ends while its child lives - **WHEN** TERM or HUP reaches the claimed supervisor - **THEN** the child is left alive and retry remains blocked + + +#### Scenario: Preparation fails and the original session later exits +- **WHEN** preservation fails before pending, then every original holder is confirmed absent +- **THEN** ordinary lane resume uses its normal source and retains the failed preparation intent +- **AND** a live/unknown holder, restart token, changed operation or nonzero attempt refuses recovery diff --git a/specs/005-supervised-legacy-ctx/spec.md b/specs/005-supervised-legacy-ctx/spec.md index 075b054d..0959bd0c 100644 --- a/specs/005-supervised-legacy-ctx/spec.md +++ b/specs/005-supervised-legacy-ctx/spec.md @@ -53,9 +53,14 @@ diagnostic lifecycle snapshots. Readiness never writes another lifecycle store. retry. A known-ended child permits retry of the same operation. Observer cleanup uses a private cancellation marker and waits for its owned child; it never signals a saved PID that may have been recycled. -- FR-009: Failure/status output describes the persisted state. Recovery uses - the supervised operation; ordinary lane resume cannot bypass a failed - restart. A completed intent remains history and permits ordinary resume. +- FR-009: Failure/status output describes the persisted state. A failed + fresh launch requires its supervised operation; ordinary resume cannot bypass + it. A failed preparation (`preservation-only`, attempt 0, no new transcript) + authorizes no supervised launch. With no explicit/inherited restart tokens or fresh-session selectors + and every holder confirmed absent, ordinary resume is permitted. Recheck the + same operation/generation and qualifying fields before binding changes and + before launch, retaining the failed intent unchanged. `preparing` still refuses. + A completed intent remains history and permits ordinary resume. - FR-010: Preserve main's binding, holder, performance and Claude resolver behavior. Use Bash 3.2-compatible implementation and the repository test wrapper in the dev container. CI remains the suite of record. diff --git a/specs/005-supervised-legacy-ctx/tasks.md b/specs/005-supervised-legacy-ctx/tasks.md index 47cedc1c..80974505 100644 --- a/specs/005-supervised-legacy-ctx/tasks.md +++ b/specs/005-supervised-legacy-ctx/tasks.md @@ -21,3 +21,5 @@ - [x] T018 Reconcile landed PR #97, preserving separate helper namespaces, managed ownership fences and completed restart history; verify helper identity and focused integration cases. New-head full-suite results are recorded in the PR review record. - [ ] T019 Repair macOS landing regressions: Bash 3.2 control rejection, case-insensitive spelling-only rename preflight, and a controlling terminal for the interactive retry fixture. Verify locally and rerun exact-head landing CI. + +- [ ] T020 Distinguish never-launched preparation failure in ordinary resume and status, with repeated identity/holder checks and negative recovery cases; validate and review the final head. diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index e7b30fbf..21133d1e 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -455,6 +455,148 @@ def test_restart_preparation_error_releases_only_its_reservation(restart_box): assert "only 'fresh-from-handoff'" in retry.stderr assert box.claude_runs() == "" assert "respawn-pane" not in box.tmux_log.read_text() + (box.fakebin / "mktemp").unlink() + prior = helper(box, "last-session", LANE) + assert prior.returncode == 0, prior.stderr + transcript = prior.stdout.strip() + dirname = "".join(ch if ch.isascii() and ch.isalnum() else "-" for ch in str(box.lane_dir)) + _write(box.home / ".claude" / "projects" / dirname / (transcript + ".jsonl"), + '{"type":"user","message":{"role":"user","content":"preserved work"}}\n', 0o600) + unchanged = intent_path(box).read_bytes() + status = supervisor(box, "--restart-status", "--lane", LANE) + assert "PREPARATION FAILED" in status.stdout + assert "RETRY:" not in status.stdout + resumed = box.start() + assert resumed.returncode == 0, (resumed.stdout, resumed.stderr) + assert "--resume " + transcript in box.claude_runs() + assert "--session-id" not in box.claude_runs() + assert intent_path(box).read_bytes() == unchanged + + +def failed_preparation(box): + prepare_restart(box) + result = helper(box, "set-restart-intent", LANE, "failed", "--expect", "pending", + "--mode", "preservation-only", "--attempt", "0", "--new-transcript", "none") + assert result.returncode == 0, result.stderr + records = {p for root in (box.wip / "lanes", box.wip / "handoffs") + for p in root.rglob("*") if p.is_file()} + records.add(intent_path(box)) + return {p: p.read_bytes() for p in records} + + +@pytest.mark.parametrize("holder_rc", [0, 1, 2]) +def test_preparation_recovery_requires_confirmed_holder_absence(restart_box, holder_rc): + box = restart_box + before = failed_preparation(box) + real = box.bin / "lanes-edit-real.sh" + (box.bin / "lanes-edit.sh").rename(real) + _write(box.bin / "lanes-edit.sh", f"""#!/usr/bin/env bash +[ "$1" != lane-holders ] || exit {holder_rc} +exec '{real}' "$@" +""") + box.tmux_log.write_text("") + result = box.start() + assert result.returncode == 2, (result.stdout, result.stderr) + assert "confirmed absence" in result.stderr + assert all(p.read_bytes() == data for p, data in before.items()) + assert "rename-window" not in box.tmux_log.read_text() + assert box.claude_runs() == "" + + +@pytest.mark.parametrize("kind", ["attempt", "transcript", "launch", "preparing", "operation-token", "environment-operation", "environment-attempt", "fresh", "environment-fresh"]) +def test_preparation_recovery_rejects_launch_ownership_or_tokens(restart_box, kind): + box = restart_box + failed_preparation(box) + changes = {"attempt": ("--attempt", "1"), + "transcript": ("--new-transcript", "11111111-2222-4333-8444-555555555555"), + "launch": ("--mode", "fresh-from-handoff")} + if kind in changes or kind == "preparing": + result = helper(box, "set-restart-intent", LANE, "preparing" if kind == "preparing" else "failed", + *changes.get(kind, ())) + assert result.returncode == 0, result.stderr + before = intent_path(box).read_bytes() + args, env = (), {} + if kind == "operation-token": args = ("--operation", "op-test") + if kind == "environment-operation": env["LANE_RESTART_OPERATION"] = "op-test" + if kind == "environment-attempt": env["LANE_RESTART_ATTEMPT"] = "0" + if kind == "fresh": args = ("--fresh",) + if kind == "environment-fresh": env["LANE_START_FRESH"] = "1" + box.tmux_log.write_text("") + result = box.start(*args, **env) + assert result.returncode == 2, (result.stdout, result.stderr) + assert intent_path(box).read_bytes() == before + assert "rename-window" not in box.tmux_log.read_text() + assert box.claude_runs() == "" + + +@pytest.mark.parametrize("stage", [2, 3]) +def test_preparation_recovery_rechecks_operation_before_launch(restart_box, stage): + box = restart_box + failed_preparation(box) + count = box.root / "intent-reads" + real = box.bin / "lanes-edit-real.sh" + (box.bin / "lanes-edit.sh").rename(real) + _write(box.bin / "lanes-edit.sh", f"""#!/usr/bin/env bash +if [ "$1" = restart-intent ]; then + n=$(cat '{count}' 2>/dev/null || printf 0) + n=$((n + 1)); printf '%s\n' "$n" > '{count}' + if [ "$n" = {stage} ]; then + '{real}' set-restart-intent "$2" failed --operation newer-preparation --generation 8 --mode preservation-only --attempt 0 --new-transcript none >/dev/null || exit 1 + fi +fi +exec '{real}' "$@" +""") + result = box.start() + assert result.returncode == 2, (result.stdout, result.stderr) + assert "changed during ordinary recovery" in result.stderr + assert box.claude_runs() == "" + final = helper(box, "restart-intent", LANE) + assert "operation\tnewer-preparation" in final.stdout + assert "generation\t8" in final.stdout + assert "state\tfailed" in final.stdout + + +@pytest.mark.parametrize("stage,holder_rc", [(2, 0), (3, 1)]) +def test_preparation_recovery_rechecks_holders_before_launch(restart_box, stage, holder_rc): + box = restart_box + failed_preparation(box) + before = intent_path(box).read_bytes() + count = box.root / "holder-reads" + real = box.bin / "lanes-edit-real.sh" + (box.bin / "lanes-edit.sh").rename(real) + _write(box.bin / "lanes-edit.sh", f"""#!/usr/bin/env bash +if [ "$1" = lane-holders ]; then + n=$(cat '{count}' 2>/dev/null || printf 0) + n=$((n + 1)); printf '%s\n' "$n" > '{count}' + if [ "$n" = {stage} ]; then exit {holder_rc}; fi + exit 8 +fi +exec '{real}' "$@" +""") + result = box.start() + assert result.returncode == 2, (result.stdout, result.stderr) + assert "confirmed absence" in result.stderr + assert box.claude_runs() == "" + assert intent_path(box).read_bytes() == before + + +@pytest.mark.parametrize("damage", ["attempt", "transcript"]) +def test_preparation_recovery_rejects_malformed_identity(restart_box, damage): + box = restart_box + failed_preparation(box) + path = intent_path(box) + text = path.read_text() + text = text.replace("attempt: 0\n", "attempt: -1\n") if damage == "attempt" else text.replace("new_transcript: none\n", "") + path.write_text(text) + before = {p: p.read_bytes() for root in (box.wip / "lanes", box.wip / "handoffs", path.parent) + for p in root.rglob("*") if p.is_file()} + box.tmux_log.write_text("") + result = box.start() + assert result.returncode == 2, (result.stdout, result.stderr) + assert "INCOMPLETE" in result.stderr + assert all(p.read_bytes() == data for p, data in before.items()) + assert "rename-window" not in box.tmux_log.read_text() + assert box.claude_runs() == "" def live_record(box, pid, transcript, suffix): From b20882a20a424d15279f7168387facd4ea9a4391 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:10:34 +0000 Subject: [PATCH 23/25] fix(tests): drain supervisor PTY before macOS exit --- .github/workflows/tests.yml | 5 +++ specs/005-supervised-legacy-ctx/tasks.md | 2 +- tests/test_supervised_restart_regressions.py | 36 ++++++++++++++++++-- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1f738bdb..de9103ef 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -358,5 +358,10 @@ jobs: 'BEGIN { print "TAKEN" }' 2>&1 | head -n1 git --version; python3 --version + # Exercise tty teardown before the long suite so a macOS-only fixture + # regression fails promptly; the full suite remains the landing gate. + - name: interactive supervisor terminal check + run: python3 -m pytest tests/test_supervised_restart_regressions.py -q -k interactive_retry_rechecks_holder_absence_before_launch + - name: tests run: python3 -m pytest tests -q diff --git a/specs/005-supervised-legacy-ctx/tasks.md b/specs/005-supervised-legacy-ctx/tasks.md index 80974505..228aac8e 100644 --- a/specs/005-supervised-legacy-ctx/tasks.md +++ b/specs/005-supervised-legacy-ctx/tasks.md @@ -20,6 +20,6 @@ - [x] T018 Reconcile landed PR #97, preserving separate helper namespaces, managed ownership fences and completed restart history; verify helper identity and focused integration cases. New-head full-suite results are recorded in the PR review record. -- [ ] T019 Repair macOS landing regressions: Bash 3.2 control rejection, case-insensitive spelling-only rename preflight, and a controlling terminal for the interactive retry fixture. Verify locally and rerun exact-head landing CI. +- [ ] T019 Repair macOS landing regressions: Bash 3.2 control rejection, case-insensitive spelling-only rename preflight, and a controlling terminal with consumed PTY output and guaranteed cleanup for the interactive retry fixture. Run the terminal case first in macOS CI before the full landing suite. Verify locally and rerun exact-head landing CI. - [ ] T020 Distinguish never-launched preparation failure in ordinary resume and status, with repeated identity/holder checks and negative recovery cases; validate and review the final head. diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index 21133d1e..ac98ff8c 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -898,6 +898,7 @@ def test_interactive_retry_rechecks_holder_absence_before_launch(restart_box): import pty import fcntl import termios + import errno from conftest import REPO box = restart_box prepare_restart(box) @@ -919,8 +920,34 @@ def test_interactive_retry_rechecks_holder_absence_before_launch(restart_box): box.env.update(PCLAUDE=str(child)) box.env.pop("LANE_SUPERVISOR_NO_PROMPT", None) master, slave = pty.openpty() + fcntl.fcntl(master, fcntl.F_SETFL, fcntl.fcntl(master, fcntl.F_GETFL) | os.O_NONBLOCK) output_path = box.root / "interactive.out" + def drain_terminal(): + # Consume terminal echo as a pane does. Darwin waits for queued tty + # output during session-leader exit, even when stdout is a file. + while True: + try: + if not os.read(master, 4096): + return + except BlockingIOError: + return + except OSError as error: + if error.errno == errno.EIO: # the slave has closed on Linux + return + raise + + def wait_with_terminal(timeout): + deadline = time.monotonic() + timeout + while True: + drain_terminal() + status = proc.poll() + if status is not None: + return status + if time.monotonic() >= deadline: + raise subprocess.TimeoutExpired(proc.args, timeout) + time.sleep(.01) + def own_terminal(): # Match a pane: the supervisor owns a controlling tty in its session. # An openpty fd alone is only a tty, which differs on macOS. @@ -934,6 +961,7 @@ def own_terminal(): try: deadline = time.monotonic() + 15 while "retry this restart?" not in output_path.read_text() and proc.poll() is None and time.monotonic() < deadline: + drain_terminal() time.sleep(.05) assert "retry this restart?" in output_path.read_text(), output_path.read_text() os.write(master, b"r\n") @@ -941,12 +969,13 @@ def own_terminal(): # message is acknowledged, matching the real supervisor pane. deadline = time.monotonic() + 15 while "Press Enter to close it" not in output_path.read_text() and proc.poll() is None and time.monotonic() < deadline: + drain_terminal() time.sleep(.05) assert "INDETERMINATE" in output_path.read_text(), output_path.read_text() assert "Press Enter to close it" in output_path.read_text(), output_path.read_text() os.write(master, b"\n") try: - status = proc.wait(timeout=15) + status = wait_with_terminal(15) except subprocess.TimeoutExpired as error: raise AssertionError("supervisor did not exit after Enter:\n" + output_path.read_text()) from error assert status == 4, output_path.read_text() @@ -955,7 +984,9 @@ def own_terminal(): assert record["state"] == "starting" and record["attempt"] == "2" assert "before attempt launch" in record["reason"] finally: - # Reap the owned supervisor before closing its controlling tty. + # Disconnect even on assertion/timeout: an unread master must + # not prevent Darwin from completing the owned process's exit. + os.close(master) if proc.poll() is None: proc.terminate() try: @@ -963,7 +994,6 @@ def own_terminal(): except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=10) - os.close(master) def test_checkout_hint_cannot_hide_existing_fallback_intent(restart_box): From fb28987b105c41b3cfbdb6116aa34fbb89c67f3f Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:46:47 +0000 Subject: [PATCH 24/25] fix(lanes): retain restart authority across checkout recording --- .github/workflows/tests.yml | 4 +- docs/README-lanes.md | 12 +- lanes-edit.sh | 60 ++++- .../add-supervised-context-restart/design.md | 18 ++ .../specs/supervised-context-restart/spec.md | 10 + specs/005-supervised-legacy-ctx/spec.md | 5 + specs/005-supervised-legacy-ctx/tasks.md | 2 + tests/test_supervised_restart_regressions.py | 244 ++++++++++++++++++ 8 files changed, 338 insertions(+), 17 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index de9103ef..e27bc64a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -360,8 +360,8 @@ jobs: # Exercise tty teardown before the long suite so a macOS-only fixture # regression fails promptly; the full suite remains the landing gate. - - name: interactive supervisor terminal check - run: python3 -m pytest tests/test_supervised_restart_regressions.py -q -k interactive_retry_rechecks_holder_absence_before_launch + - name: restart terminal and control-root checks + run: python3 -m pytest tests/test_supervised_restart_regressions.py -q -k "interactive_retry_rechecks_holder_absence_before_launch or restart_root" - name: tests run: python3 -m pytest tests -q diff --git a/docs/README-lanes.md b/docs/README-lanes.md index fbf96df7..fe2e307a 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2642,8 +2642,16 @@ seventh was never written, so it was never there. An environment cannot cross a boundary another repository owns. **A file can.** Before anything is killed, `/ctx` writes a RESTART INTENT under the lane's own legacy control root, independently of lifecycle snapshots, -resolved the same three ways (`$LANES_LANE_STATE_ROOT`, the checkout's parent -`.lane-state/`, `$PROJECTS_ROOT/.lane-state/`): +using an explicit `$LANES_LANE_STATE_ROOT` when configured. Otherwise, an existing +intent under the recorded checkout's parent or `$PROJECTS_ROOT/.lane-state/` +retains its location through reservation, preservation, cleanup and later reads. +Distinct occupied intent locations or unreadable candidate ancestry refuse. +Aliases of the same control-root directory count once; separate leaf symlink +or hardlink entries refuse. With no existing intent, the recorded-parent rung precedes +the projects-root fallback, with a checkout hint only when neither answers. +Root selection shares the writer mutex with CAS. Recording a nested checkout +cannot move this operation; PR #97 diagnostics retain their independent root +selection and may reside elsewhere: ``` $ lanes-edit.sh restart-intent openRepoTools-3 diff --git a/lanes-edit.sh b/lanes-edit.sh index 7d9f57e1..bc4f4c49 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -10687,10 +10687,11 @@ EOF LANE_RESTART_SCHEMA=1 -# THE CONTROL ROOT — three rungs, in order. 0 with the path (which need not -# exist yet), 8 where no rung answers. -lane_restart_control_root() { # [] +# THE CONTROL ROOT — explicit override, retained intent, then creation rungs. +# 0 with a path, 8 with no rung, 1 for unknown or competing locations. +lane_restart_control_root() { # [] [] lcr_lane="${1-}"; lcr_pay="${2-}"; lcr_dir=""; lcr_par="" + lcr_record_root=""; lcr_projects_root=""; lcr_existing="" [ -n "$lcr_lane" ] || return 8 if [ -n "${LANES_LANE_STATE_ROOT:-}" ]; then printf '%s/%s\n' "${LANES_LANE_STATE_ROOT%/}" "$lcr_lane" @@ -10704,12 +10705,43 @@ lane_restart_control_root() { # [] /*) lcr_par="${lcr_dir%/*}" ;; *) lcr_par="" ;; esac - if [ -n "$lcr_par" ] && [ -d "$lcr_par" ]; then - printf '%s/.lane-state/%s\n' "$lcr_par" "$lcr_lane" + if [ -n "$lcr_par" ]; then + lcr_record_root="$lcr_par/.lane-state/$lcr_lane" + lane_restart_root_ok "$lcr_record_root" || return 1 + [ -d "$lcr_par" ] || lcr_record_root="" + fi + if [ -n "${PROJECTS_ROOT:-}" ]; then + lcr_projects_root="${PROJECTS_ROOT%/}/.lane-state/$lcr_lane" + lane_restart_root_ok "$lcr_projects_root" || return 1 + [ -d "$PROJECTS_ROOT" ] || lcr_projects_root="" + fi + # PAUSED may first record a nested checkout after reservation. Existing + # intent authority stays at its physical location; validity is the reader's + # gate, never a reason to bypass an occupied path. Unknown ancestry refuses. + # Only directory aliases share an atomic replacement entry; separate leaf + # symlinks/hardlinks would split into two records on the next replacement. + for lcr_candidate in "$lcr_record_root" "$lcr_projects_root"; do + [ -n "$lcr_candidate" ] || continue + lcr_file="$lcr_candidate/restart-intent.yaml" + if [ -e "$lcr_file" ] || [ -L "$lcr_file" ]; then + if [ -n "$lcr_existing" ] && [ "$lcr_candidate" != "$lcr_existing" ] && + [ ! "$lcr_candidate" -ef "$lcr_existing" ]; then + note "lane $lcr_lane has distinct restart-intent locations in $lcr_existing and $lcr_candidate; reconcile these records before continuing. Nothing was written." + return 1 + fi + [ -n "$lcr_existing" ] || lcr_existing="$lcr_candidate" + fi + done + if [ -n "$lcr_existing" ]; then + printf '%s\n' "$lcr_existing" return 0 fi - if [ -n "${PROJECTS_ROOT:-}" ] && [ -d "$PROJECTS_ROOT" ]; then - printf '%s/.lane-state/%s\n' "${PROJECTS_ROOT%/}" "$lcr_lane" + if [ -n "$lcr_record_root" ]; then + printf '%s\n' "$lcr_record_root" + return 0 + fi + if [ -n "$lcr_projects_root" ]; then + printf '%s\n' "$lcr_projects_root" return 0 fi # A checkout hint is a last resort, so reads cannot hide an intent that @@ -15788,15 +15820,17 @@ EOF check_lane_name "$lane" log_sync lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 + # Select the physical intent under the CAS mutex: a waiting writer must + # see a reservation another writer established before releasing the lock. + acquire_lock + managed_seam_refuse "$lane" "set-restart-intent" sri_root=""; sri_rrc=0 sri_root="$(lane_restart_control_root "$lane")" || sri_rrc=$? - [ "$sri_rrc" = 0 ] || - die "lane $lane has no lifecycle control root, so there is nowhere to record that its restart is $sri_state: its record names no directory (Amendment 11(c)) and \$PROJECTS_ROOT is not a directory here. Set \$LANES_LANE_STATE_ROOT, or start the lane through lane-start so that its record carries a dir." 1 + [ "$sri_rrc" = 0 ] || { + release_lock + die "lane $lane's restart control root is unavailable, unreadable or ambiguous. Nothing was written. Inspect its recorded directory and projects-root intents, or configure an explicit state root." 1 + } sri_f="$sri_root/restart-intent.yaml" - # THE FENCE, read under the same mutex the write takes, so that two - # transitions cannot both read the intent before either writes it. - acquire_lock - managed_seam_refuse "$lane" "set-restart-intent" # A SCHEMA THIS WRITER DOES NOT KNOW IS NOT ITS RECORD TO REPLACE (Copilot # round 1 on openRepoTools#121). The READ side already fails closed on one — # it answers `UNKNOWN-SCHEMA` rather than reporting a launch it cannot diff --git a/openspec/changes/add-supervised-context-restart/design.md b/openspec/changes/add-supervised-context-restart/design.md index 98bec269..be4925de 100644 --- a/openspec/changes/add-supervised-context-restart/design.md +++ b/openspec/changes/add-supervised-context-restart/design.md @@ -219,3 +219,21 @@ and before exec. Preserve the failed intent as diagnostics; never label it ready or import its counters into ordinary resume. These checks retain normal launch safeguards and do not create an atomic ordinary-launch reservation. Failed supervised launches and preparing reservations remain refused by ordinary resume. + + +### 2026-10-05 durable restart control-root correction + +Exact-head Codex review at b20882a identified a reservation whose initial +projects-root fallback changes after PAUSED first records a nested checkout. +Keep restart intent discovery bounded to the recorded-checkout and projects-root +candidates: a sole existing physical intent retains authority independently of +state or validity; distinct occupied intent locations or unreadable ancestry +refuse. Aliases of the same control-root directory count once; leaf symlinks and +hardlinks in distinct directories refuse because atomic replacement would split +their records. An explicit state-root override remains authoritative, +and checkout hints remain a last resort when no established rung exists. +Root selection and CAS run under the same writer mutex. This fixes completion, +cleanup and later readers without an environment-only pin or a new pointer/schema. +PR #97 diagnostic roots remain unchanged: restart storage can retain its original +fallback while diagnostics follow the recorded checkout. Broader root discovery +and explicit checkout hint creation remain deferred in #159. diff --git a/openspec/changes/add-supervised-context-restart/specs/supervised-context-restart/spec.md b/openspec/changes/add-supervised-context-restart/specs/supervised-context-restart/spec.md index 48ff20f6..d34de7dc 100644 --- a/openspec/changes/add-supervised-context-restart/specs/supervised-context-restart/spec.md +++ b/openspec/changes/add-supervised-context-restart/specs/supervised-context-restart/spec.md @@ -22,6 +22,16 @@ register, log or window. It SHALL complete the same reservation as `pending` only after preservation succeeds, and SHALL refuse another active operation. Catchable failure SHALL fence cleanup to that exact reservation. An abandoned reservation SHALL have an explicit inspection and reconciliation path. +Without an explicit state-root override, reservation, completion, cleanup and +later reads SHALL retain the sole existing intent among recorded-checkout and +projects-root candidates. Distinct occupied intent locations or unreadable +candidate ownership SHALL refuse. Root selection and CAS SHALL share the writer +mutex. Recording a nested checkout SHALL NOT strand the reserved operation. + +#### Scenario: Preservation records a previously unknown nested checkout +- **WHEN** a reservation was created at the projects-root fallback and PAUSED records a nested checkout +- **THEN** pending completion, failed cleanup and later readers retain that existing intent +- **AND** a second distinct intent in the other candidate refuses without changing either record #### Scenario: Two context restarts overlap - **WHEN** a second ctx finds an active reservation diff --git a/specs/005-supervised-legacy-ctx/spec.md b/specs/005-supervised-legacy-ctx/spec.md index 0959bd0c..41fa530f 100644 --- a/specs/005-supervised-legacy-ctx/spec.md +++ b/specs/005-supervised-legacy-ctx/spec.md @@ -24,6 +24,11 @@ diagnostic lifecycle snapshots. Readiness never writes another lifecycle store. pause log or register. A preparing/pending/starting operation cannot be superseded by ordinary `/ctx`. A refused competing restart changes no canonical preservation record and never respawns a pane. + Reservation, completion, cleanup and later reads retain the sole existing + intent among recorded-checkout and projects-root candidates, unless an + explicit state-root override is supplied. Distinct competing records or + unreadable candidate ownership refuse. Root selection and CAS share the + writer mutex; recording a nested checkout cannot strand the reservation. - FR-003: Validate complete intent schema, state, generation, operation, attempt and launch facts. Existing unreadable/malformed records cannot be interpreted as absent or rewritten to a supported schema. Schema 1 rejects diff --git a/specs/005-supervised-legacy-ctx/tasks.md b/specs/005-supervised-legacy-ctx/tasks.md index 228aac8e..553f34a6 100644 --- a/specs/005-supervised-legacy-ctx/tasks.md +++ b/specs/005-supervised-legacy-ctx/tasks.md @@ -23,3 +23,5 @@ - [ ] T019 Repair macOS landing regressions: Bash 3.2 control rejection, case-insensitive spelling-only rename preflight, and a controlling terminal with consumed PTY output and guaranteed cleanup for the interactive retry fixture. Run the terminal case first in macOS CI before the full landing suite. Verify locally and rerun exact-head landing CI. - [ ] T020 Distinguish never-launched preparation failure in ordinary resume and status, with repeated identity/holder checks and negative recovery cases; validate and review the final head. + +- [ ] T021 Keep restart reservation, completion, cleanup and later readers at the sole existing control root; refuse competing/unreadable roots, select under the CAS mutex, and validate nested checkout, failure cleanup, aliases and writer waiting cases. diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index ac98ff8c..d24c3216 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -1637,3 +1637,247 @@ def test_restart_reconcile_case_only_rename_preserves_completed_history(restart_ if p.is_file() and ".git" not in p.parts} == before assert box.git("-C", str(box.wip), "rev-parse", "HEAD") == commit assert "state\tready" in helper(box, "restart-intent", LANE).stdout + + +def restart_root_without_recorded_checkout(box): + source = prepare_restart(box) + box.restart_root_seed = intent_path(box).read_bytes() + intent_path(box).unlink() + old_dir = box.lane_dir + log = box.wip / "lanes" / "log" / f"{LANE}.md" + log.write_text(log.read_text().replace(f"; dir {old_dir}", "")) + box.git("-C", str(box.wip), "add", "--", str(log)) + box.git("-C", str(box.wip), "commit", "-qm", "legacy record without checkout") + box.git("-C", str(box.wip), "push", "-q", "origin", "main") + box.env.pop("LANES_LANE_STATE_ROOT") + box.env["PROJECTS_ROOT"] = str(old_dir.parent) + nested = old_dir.parent / "nested" / "repoZ" + nested.mkdir(parents=True) + box.git("init", "-q", "-b", "main", str(nested)) + box.git("-C", str(nested), "remote", "add", "origin", "https://github.com/opensoft/repoZ.git") + box.lane_dir = nested + assert helper(box, "lane-dir", LANE).returncode == 8 + fallback = old_dir.parent / ".lane-state" / LANE / "restart-intent.yaml" + alternate = nested.parent / ".lane-state" / LANE / "restart-intent.yaml" + return source, fallback, alternate + + +def record_nested_restart_checkout(box): + prior = box.env + session = helper(box, "last-session", LANE).stdout.strip() + box.env = dict(prior, LANES_LANE=LANE, LANES_SESSION=session) + try: + result = helper(box, "log", "PAUSED", f"lane:{LANE}", "→", f"swap; dir {box.lane_dir}", "fixture") + finally: + box.env = prior + assert result.returncode == 0, (result.stdout, result.stderr) + + +def test_restart_root_retains_reservation_after_nested_checkout_preservation(restart_box): + box = restart_box + source, fallback, alternate = restart_root_without_recorded_checkout(box) + result = supervisor(box, "--restart", "--lane", LANE, "--dir", str(box.lane_dir), "clear") + assert result.returncode == 0, (result.stdout, result.stderr) + assert "respawn-pane" in box.tmux_log.read_text() + assert not alternate.exists() + record = dict(line.split("\t", 1) for line in helper(box, "restart-intent", LANE).stdout.splitlines()) + assert record["state"] == "pending" and record["file"] == str(fallback) + assert record["dir"] == str(box.lane_dir) and record["handoff"] == str(source) + status = supervisor(box, "--restart-status", "--lane", LANE) + assert status.returncode == 0 and "intent pending" in status.stdout + before = fallback.read_bytes() + result = box.start("--dir", str(box.lane_dir)) + assert result.returncode == 2 and "supervisor operation token" in result.stderr + assert fallback.read_bytes() == before and not box.claude_runs() + + +def test_restart_root_cleanup_after_paused_fails_original_reservation(restart_box): + box = restart_box + _, fallback, alternate = restart_root_without_recorded_checkout(box) + real = box.bin / "lanes-edit-real.sh" + (box.bin / "lanes-edit.sh").rename(real) + _write(box.bin / "lanes-edit.sh", f"""#!/usr/bin/env bash +if [ "$1" = set-restart-intent ] && [ "$3" = pending ]; then exit 1; fi +exec '{real}' "$@" +""") + result = supervisor(box, "--restart", "--lane", LANE, "--dir", str(box.lane_dir), "clear") + assert result.returncode == 2, (result.stdout, result.stderr) + assert "PAUSED" in box.lane_log() and "respawn-pane" not in box.tmux_log.read_text() + assert not alternate.exists() + record = dict(line.split("\t", 1) for line in helper(box, "restart-intent", LANE).stdout.splitlines()) + assert record["file"] == str(fallback) and record["state"] == "failed" + assert record["mode"] == "preservation-only" and record["attempt"] == "0" + + +@pytest.mark.parametrize("entry", ["read", "write", "start", "rename"]) +def test_restart_root_conflicts_refuse_without_changing_either_intent(restart_box, entry): + box = restart_box + _, fallback, alternate = restart_root_without_recorded_checkout(box) + assert helper(box, "set-restart-intent", LANE, "preparing", "--expect", "none", "--operation", "held").returncode == 0 + record_nested_restart_checkout(box) + alternate.parent.mkdir(parents=True, exist_ok=True) + alternate.write_bytes(fallback.read_bytes()) # identical bytes are separate authority + before = {p: p.read_bytes() for p in [fallback, alternate, box.wip / "lanes" / "LANES.md"]} + box.resolver() + if entry == "read": + result = helper(box, "restart-intent", LANE) + elif entry == "write": + result = helper(box, "set-restart-intent", LANE, "failed", "--expect", "preparing") + elif entry == "rename": + result = helper(box, "rename-lane", LANE, "repoZ-2") + else: + result = box.start("--dir", str(box.lane_dir)) + assert result.returncode != 0, (result.stdout, result.stderr) + assert all(p.read_bytes() == contents for p, contents in before.items()) + assert not box.claude_runs() and "respawn-pane" not in box.tmux_log.read_text() + + +@pytest.mark.parametrize("alias", ["lane-root", "state-root"]) +def test_restart_root_directory_aliases_preserve_one_authority_across_replacement(restart_box, alias): + box = restart_box + _, fallback, alternate = restart_root_without_recorded_checkout(box) + assert helper(box, "set-restart-intent", LANE, "preparing", "--expect", "none", "--operation", "held").returncode == 0 + record_nested_restart_checkout(box) + target = alternate.parent if alias == "lane-root" else alternate.parent.parent + # Move any independent diagnostic snapshot aside in this fixture before + # making the alias. Restart selection must not mutate diagnostic files. + if target.exists(): + target.rename(box.root / "pre-alias-diagnostics") + target.parent.mkdir(parents=True, exist_ok=True) + target.symlink_to(fallback.parent if alias == "lane-root" else fallback.parent.parent, target_is_directory=True) + result = helper(box, "set-restart-intent", LANE, "failed", "--expect", "preparing", "--expect-operation", "held") + assert result.returncode == 0, (result.stdout, result.stderr) + record = helper(box, "restart-intent", LANE) + assert record.returncode == 0 and "operation\theld" in record.stdout + assert "state\tfailed" in record.stdout + assert alternate.resolve() == fallback.resolve() + assert alternate.read_bytes() == fallback.read_bytes() + + +@pytest.mark.parametrize("alias", ["symlink", "hardlink"]) +def test_restart_root_distinct_file_aliases_refuse_before_atomic_replacement(restart_box, alias): + box = restart_box + _, fallback, alternate = restart_root_without_recorded_checkout(box) + assert helper(box, "set-restart-intent", LANE, "preparing", "--expect", "none", "--operation", "held").returncode == 0 + record_nested_restart_checkout(box) + alternate.parent.mkdir(parents=True, exist_ok=True) + if alias == "symlink": + alternate.symlink_to(fallback) + else: + os.link(fallback, alternate) + before = fallback.read_bytes() + assert helper(box, "restart-intent", LANE).returncode == 1 + result = helper(box, "set-restart-intent", LANE, "failed", "--expect", "preparing") + assert result.returncode == 1, (result.stdout, result.stderr) + assert alternate.read_bytes() == fallback.read_bytes() == before + + +@pytest.mark.parametrize("kind", ["malformed", "directory", "broken-link", "unusable-parent"]) +def test_restart_root_does_not_hide_unusable_fallback_after_checkout_is_recorded(restart_box, kind): + box = restart_box + _, fallback, alternate = restart_root_without_recorded_checkout(box) + fallback.parent.mkdir(parents=True, exist_ok=True) + if kind == "malformed": + fallback.write_text("schema: 1\nstate: pending\n") + elif kind == "directory": + fallback.mkdir() + elif kind == "broken-link": + fallback.symlink_to(box.root / "missing-intent") + else: + fallback.parent.rmdir() + fallback.parent.write_text("not a directory\n") + record_nested_restart_checkout(box) + read = helper(box, "restart-intent", LANE) + assert read.returncode != 8, (read.stdout, read.stderr) + if kind == "malformed": + assert read.returncode == 0 and "INCOMPLETE" in read.stdout + else: + assert read.returncode == 1, (read.stdout, read.stderr) + result = helper(box, "set-restart-intent", LANE, "preparing", "--expect", "none", "--operation", "other") + assert result.returncode != 0, (result.stdout, result.stderr) + assert not alternate.exists() + + +def test_restart_root_waiting_writer_discovers_new_authority_under_mutex(restart_box): + box = restart_box + _, fallback, alternate = restart_root_without_recorded_checkout(box) + record_nested_restart_checkout(box) + lock = box.wip / "lanes" / ".lanes-edit.lock" + lock.mkdir() + (lock / "pid").write_text(str(os.getpid())) + waited = box.root / "writer.waited" + sleep = shutil.which("sleep") + _write(box.fakebin / "sleep", f"""#!/usr/bin/env bash +: > '{waited}' +exec '{sleep}' "$@" +""") + process = subprocess.Popen([str(box.bin / "lanes-edit.sh"), "set-restart-intent", LANE, + "preparing", "--expect", "none", "--operation", "waiting"], + cwd=box.lane_dir, env=box.env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 15 + while not waited.exists() and process.poll() is None and time.monotonic() < deadline: + time.sleep(.05) + assert waited.exists() + # Another writer establishes a valid fallback before this waiter owns + # the mutex. It must discover that authority instead of creating nested. + fallback.parent.mkdir(parents=True, exist_ok=True) + fallback.write_bytes(box.restart_root_seed) + before = fallback.read_bytes() + lock.joinpath("pid").unlink() + lock.rmdir() + out = process.communicate(timeout=15) + assert process.returncode == 7, out + assert fallback.read_bytes() == before and not alternate.exists() + finally: + if lock.exists(): + lock.joinpath("pid").unlink(missing_ok=True) + lock.rmdir() + if process.poll() is None: + process.terminate() + process.communicate(timeout=10) + + +@pytest.mark.parametrize("kind", ["file", "broken-link"]) +def test_restart_root_unusable_configured_parent_cannot_disappear_from_selection(restart_box, kind): + box = restart_box + _, fallback, alternate = restart_root_without_recorded_checkout(box) + record_nested_restart_checkout(box) + bad = box.root / "unusable-projects" + if kind == "file": + bad.write_text("not a directory\n") + else: + bad.symlink_to(box.root / "missing-projects") + box.env["PROJECTS_ROOT"] = str(bad) + assert helper(box, "restart-intent", LANE).returncode == 1 + result = helper(box, "set-restart-intent", LANE, "preparing", "--expect", "none", "--operation", "other") + assert result.returncode == 1, (result.stdout, result.stderr) + assert not alternate.exists() and not fallback.exists() + + +@pytest.mark.parametrize("candidate", ["recorded", "projects"]) +def test_restart_root_unsearchable_parent_ancestry_is_unknown_not_absent(restart_box, candidate): + if os.geteuid() == 0: + pytest.skip("permission regression requires an unprivileged runner") + box = restart_box + _, fallback, alternate = restart_root_without_recorded_checkout(box) + assert helper(box, "set-restart-intent", LANE, "preparing", "--expect", "none", "--operation", "held").returncode == 0 + record_nested_restart_checkout(box) + before = fallback.read_bytes() + if candidate == "recorded": + blocked = box.lane_dir.parent + box.lane_dir = Path(box.env["PROJECTS_ROOT"]) / "repoZ" # accessible caller cwd + else: + blocked = box.root / "blocked" + root = blocked / "projects" + root.mkdir(parents=True) + box.env["PROJECTS_ROOT"] = str(root) + blocked.chmod(0) + try: + assert helper(box, "restart-intent", LANE).returncode == 1 + result = helper(box, "set-restart-intent", LANE, "failed", "--expect", "preparing") + assert result.returncode == 1, (result.stdout, result.stderr) + finally: + blocked.chmod(0o755) + assert fallback.read_bytes() == before and not alternate.exists() From fd09e2d2dbff4b652def4608a416f96a82b81fb2 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:20:14 +0000 Subject: [PATCH 25/25] fix: name state root override in restart refusal --- lanes-edit.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index bc4f4c49..845a4470 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -15828,7 +15828,7 @@ EOF sri_root="$(lane_restart_control_root "$lane")" || sri_rrc=$? [ "$sri_rrc" = 0 ] || { release_lock - die "lane $lane's restart control root is unavailable, unreadable or ambiguous. Nothing was written. Inspect its recorded directory and projects-root intents, or configure an explicit state root." 1 + die "lane $lane's restart control root is unavailable, unreadable or ambiguous. Nothing was written. Inspect its recorded directory and projects-root intents, or configure LANES_LANE_STATE_ROOT explicitly." 1 } sri_f="$sri_root/restart-intent.yaml" # A SCHEMA THIS WRITER DOES NOT KNOW IS NOT ITS RECORD TO REPLACE (Copilot