You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
One binding per lane: the record says host/os/container, liveness is pronounced only from inside that binding, and a second place ASKS for the handoff before it binds (Amendment 18 acts 1-10) - #83
Tooling half of lane-collision-protocol Amendment 18 — ONE BINDING PER LANE. Ratified by Brett Heap 2026-09-14T13:15:18Z as revision 4, verbatim "merge 37 and 78, ratify revision 4"; brettheap/new-workstation#34/#35. Closes the acts of #38.
A pid does not cross a pid namespace, and this tooling pretended it did. Two bench containers on one host share the profile directory, so pyBench reads a session record cloudBench wrote and then kill -0s a pid in cloudBench's namespace — which is either nothing or some other process. A lane live and writing in cloudBench read NOT LIVE from pyBench, and lane-start there took the name: two bindings, one lane, one append-only log, reached through this protocol's own liveness read. The record could not say otherwise because it did not carry where a lane runs below the workstation's name.
The acts
1 — the record says where the lane is.write_event, the one writer every caller goes through, appends host <name>; os <linux|macos|wsl|windows>; container <name|none> to every STARTED, RESUMED and PAUSED, read from LANES_HOST/LANES_OS/LANES_CONTAINER with clause (a)'s fallbacks — hostname -s outside a container, the Rule 10 workstation name inside one, the kernel probe for the OS, the container's own hostname for its name. The three are read live at every write and never cached: measured 2026-09-15T12:17Z, when a bench container on this estate was recreated with a new hostname and its ~/.local/bin went with it. lanes-edit.sh workstation grows six columns, three values and three sources, after the two it has always printed.
2 — lanes-edit.sh binding <lane>.<host> <container> <window> <utc> <session> <os> <here|elsewhere> <live|gone|unknown>; 0 bound, 8 free, 1 the log could not be read. The seventh column is clause (b)'s whole point — liveness is pronounced only from inside the binding's own host and container — and the eighth is its one exception: one host's launcher mounts ONE tmux socket into every container it starts, so a window gone from that shared server is a pane no session can be in, which is openRepoTools#30's takeover path unchanged.
3 — HANDOFF-REQUESTED, and lanes-edit.sh request-handoff <lane>. A lane-kind verb that changes no state: every last-line reader enumerates the five state verbs by name and none of them sees it, so lane-last after a request still answers STARTED. The act is the clause's own order, and the order is load-bearing — the line is written and pushed first, so the bound session can read it from anywhere; then, and only where the bound pane is on this tmux server, /handoff --exit requested by <uuid>@<host>/<container> is typed into it (Amendment 12's M1, and only while that pane's current command is claude); then the wait, polling the published log every fifteen seconds for a release newer than the request.
4 — lane-start and lane refuse, then ask. A second fence rather than a replacement for step 3: where the binding is this host's and this container's, live_holder has already pronounced and nothing changes; where it is not, the refusal stands whatever kill -0 said. On a terminal both words ask, naming the binding; --request-handoff is that y written down for a caller whose stdin is not one; --force <why> is clause (e)'s override; passing both is refused, because two answers to one question are not an answer. Both doors call the one mechanism, so the line, the pane, the interval and the refusal cannot come to differ between them. --dry-run prints the plan.
5 — the guard answers from the bound side. Clause (d): a request newer than this session's binding refuses that one prompt, names who asked and from where, and types the handoff into this pane. Clause (e): a PAUSED this session did not write refuses every prompt from then on, naming the line and who forced it. Both are gated on the binding being this session's — by its session field or by its window, which is the /clear, the usage reset and the profile switch.
6 — the empty wait refuses, and only a word forces. Exit 2 with the facts: the binding, the request and its UTC, whether the pane was reachable and typed into, and --force. That is a second invocation and never automatic; it writes PAUSED … → on behalf of <bound uuid>; forced by <uuid>@<host>/<container>; why <why> in the requester's own session field (openRepoTools#30's release-on-behalf precedent), and then binds.
7 — lane-handoff --exit — already landed in #47, with requested by … as a why. Nothing was owed here; the guard types exactly that line and the flag was exercised against it.
8 — the launcher's half is opensoft/openBenches#77 and is NOT in this PR. What it must export, into every session and every container it starts, beside the LANES_WORKSTATION it already exports: LANES_HOST (the machine's short hostname as it reads outside any container), LANES_OS (linux/macos/wsl/windows — and windows is the launcher's word alone, since this toolset is bash and runs in WSL2 there) and LANES_CONTAINER (the bench's name: py-bench, cloud-bench, …). This toolset only ever READS them; its fallbacks are what answer until that export reaches a workstation.
9, 10 — the clause (i) attach line, tests and docs.lanes prints a LIVE lane's one act filled in — tmux switch-client -t <session>:<@id> inside tmux, tmux attach -t <session>:<@id> outside, never a second lane-start, because clause (h) is one live process per transcript. The choice is made in the read (column 10), so the listing and the picker cannot offer two different acts for one row. docs/README-lanes.md gains the whole clause; AGENTS.md's lane paragraph gains the rule an assistant gets wrong if it is inferred rather than read.
Three judgement calls, said rather than buried
ENDED and RETIRED carry none of the three. Amendment 18's Adoption list names lane-end's two "for the record"; clause (a) — the RULE — names STARTED, RESUMED and PAUSED, and clause (b) reads the binding out of the first two. Amendment 11(c) keeps ENDED/RETIRED payload-free as edit 1 of six (R-A11-15), and lane-end already refuses to write one for exactly that reason: a payload there would be a seventh edit to in-force text. A verb that RELEASES a binding has none to describe, so nothing is lost by the omission and a ratified count is not quietly broken by it.
A line written before the clause is matched on its HOST alone. Clause (a): "readers treat its binding as the window on the row's workstation, which is what they read today" — and today's reading has no container in it. Without that, every lane of an estate whose lines all predate this clause would read as another container's the moment the asker was in one — which is every bench session on a launcher-configured workstation, and a refusal of the whole estate on the day this lands. The suite caught it in lane-start --dry-run repoA11 7, and the cutover is asserted in both directions.
The forced release's why is a NAMED sub-field, why <text>. Clause (e) spells it as a sub-field with no name; every reader in this file matches a sub-field by the word it opens with, so a why beginning host is unreachable would be read as that line's host, refused by the writer's own check, and the person would be told about a field they did not write. Named, it can collide with nothing and the words are still exactly the ones they typed. The line's own three separators (, , ; , —) are folded rather than refused, because the person typed a sentence and not a grammar.
One boundary worth a word
The request line's session field is the requester's transcript uuid, and Amendment 7(b) admits nothing else there — not a placeholder, and never the literal unknown, which is in this estate's append-only log four times already with no later line able to correct any of them. A bare shell with no $CLAUDE_CODE_SESSION_ID and no live record for its window therefore cannot make a request: it is refused, naming --session <uuid> and the other way out (ask from inside the session that wants the lane). That is the honest reading of the grammar rather than a gap in the build; if it bites in practice it is a word for Brett Heap, not a fix to make quietly.
Counts
lanes-edit.sh +~950 lines: the three fact readers and their fence, binding_subfields_add/_check in the writer, lane_binding_scan, binding_is_here, binding_window_state, requester_uuid, the binding and request-handoff arms, the guard's two reads, the LIVE attach line in column 10.
lane-start +~150, lane +~170, lanes +12, AGENTS.md +12, docs/README-lanes.md +~250.
tests/test_lane_helpers.sh +~440 lines, one new section: the three facts and each fallback under the container seam; the sub-fields written and read back and refused when a caller writes them wrong; ENDED carrying none; the binding read in every state including a pre-amendment line, from inside and outside a container; a bound lane refused from another host and from another container, and NOT refused where its window is gone or where the binding is this place's; the question on a fake terminal and the flag without one; the request line written FIRST and in the requester's own session field; the pane typed into only when the window resolves and only while it runs claude; the wait ended by a PAUSED the second clone pushed; the empty wait refusing with the facts and releasing nothing; --force writing the on-behalf PAUSED; the guard refusing after a request and after a forced release, and the prompt after it; the LIVE lane's attach line inside and outside tmux.
tests/test_repo_hygiene.py: the AGENTS.md cap 265 → 278, with the dated entry naming the rule the lines bought.
… taken: the record says host/os/container, `binding` reads it, and `request-handoff` writes the ask, types the pane and waits (Amendment 18 acts 1, 2, 3, 5, 6)
A pid does not cross a pid namespace and this tooling pretended it did: two
bench containers on one host share the profile directory, so pyBench read a
session record cloudBench wrote, `kill -0`-d a pid in cloudBench's namespace,
called the lane dead and started writing it from a second place. The record
could not say otherwise because it did not carry where a lane runs below the
workstation's name.
It does now. `write_event` — the ONE writer, for the reason this file puts the
container refusal at the dispatcher — appends `host <name>; os
<linux|macos|wsl|windows>; container <name|none>` to every `STARTED`,
`RESUMED` and `PAUSED`, read from `LANES_HOST`/`LANES_OS`/`LANES_CONTAINER`
with clause (a)'s fallbacks and never from a record: a container's hostname
names THAT container for as long as it exists, measured here on
2026-09-15T12:17Z when this bench was recreated as `cb999b9fd177` and its
`~/.local/bin` went with it. `ENDED` and `RETIRED` stay payload-free, which is
Amendment 11(c)'s ratified edit 1 of six and what `lane-end` already refuses to
break; a verb that releases a binding has no binding to describe.
`binding <lane>` is the read: host, container, window, utc, session, os, then
whether THIS place is inside the binding's own host and container — the only
place liveness may be pronounced from — and what this tmux server says about
that window, because a shared server plus a window that is gone is clause (b)'s
one exception and today's takeover path.
`HANDOFF-REQUESTED` joins the verb set as a lane-kind verb that changes no
state, and `request-handoff <lane>` is clause (c)'s mechanism in one place: the
line written and PUSHED first so a bound session reads it from anywhere, then —
and only where the bound pane is on this tmux server — `/handoff --exit
requested by <uuid>@<host>/<container>` typed into it, then the wait. An empty
wait refuses with the facts and names `--force`, which is a second invocation
that writes the release on the bound session's behalf in the requester's own
session field.
The prompt guard answers both from the bound side: a request newer than this
session's binding refuses that one prompt and types the handoff into this pane,
and a `PAUSED` this session did not write refuses every prompt from then on,
naming the line and who forced it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
…it for the handoff, a LIVE lane's one act is the attach, and a line written before the clause still reads as the window on its row's workstation (Amendment 18 acts 4, 10)
The fence goes in after the window read and before anything is renamed or
written, and it is a SECOND fence rather than a replacement for step 3: where
the binding is this host's and this container's, `live_holder` has already
pronounced and nothing changes; where it is not, the refusal stands whatever
`kill -0` said, because the record it read is from another pid namespace. The
one exception is a window gone from a tmux server the two share — one host's
launcher mounts one socket into every container it starts — and that is
openRepoTools#30's takeover path, unchanged.
On a terminal both words ASK, naming the binding; `--request-handoff` is that
`y` written down for a caller whose stdin is not one; `--force <why>` is clause
(e)'s override, and passing both is refused because two answers to one question
are not an answer. Each then calls one mechanism — `lanes-edit.sh
request-handoff` — so the line that is written, the pane that is typed into, the
interval that is polled and the refusal that ends an empty wait cannot come to
differ between the two doors.
A LINE WRITTEN BEFORE THE CLAUSE IS MATCHED ON ITS HOST ALONE, which is clause
(a)'s own cutover sentence: its binding is "the window on the row's
workstation", and today's reading has no container in it. Without that, every
lane of an estate whose lines all predate this clause would read as another
container's the moment the asker was in one — which is every bench session on a
launcher-configured workstation, and a refusal of the whole estate on the day
this lands. The suite caught it: `lane-start --dry-run repoA11 7`, whose lane
records a directory and no host.
And `lanes` prints a LIVE lane's one act filled in — `tmux switch-client` inside
tmux, `tmux attach` outside, the target naming the session AND the window
because `attach -t <session>` lands on whatever that session last made current.
The choice is made in the read, so the listing and the picker cannot offer two
different acts for one row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ease's why is a named sub-field so a sentence cannot open a field nobody wrote (Amendment 18 act 9, and the docs)
The section drives the amendment's own seams — `LANES_HOST`, `LANES_OS`,
`LANES_CONTAINER`, the three the launcher exports — and fakes nothing about a
container but the one bit that says whether this is one. Two of them are PINNED
for the section, for the reason `LANES_WORKSTATION` is pinned for the whole
file: what is under test is the REGISTER'S reading of a host and a container,
not the machine the suite runs on, and taking the values from that machine made
the section green inside a bench container and red on a bare workstation. The
fallbacks are asserted directly, with `env -u`, so the pin can hide nothing.
Each lane is one act's: `lane-start` on a lane REBINDS it, so the case that
proves a binding of this place is not refused has a lane of its own and the
guard and the attach cases read the binding their fixture seeded. The first
draft did not, and repoBind-6 arrived at the guard bound to the window the
earlier case had bound it to — a case rewriting another case's premise from
forty lines away.
And the forced release's WHY is written as `why <text>`. Clause (e) spells it as
a sub-field with no name; every reader in this file matches a sub-field by the
word it OPENS with, so a why beginning `host is unreachable` would be read as
that line's `host`, refused by the writer's own check, and the person told about
a field they did not write. Named, it collides with nothing and the words are
still the ones they typed — and the line's own three separators are folded to a
middle dot inside it rather than refused, because the person typed a sentence
and not a grammar.
The two `lane` cases that asserted the old BOUND ELSEWHERE refusal — *"the
request itself is #38's act 4 and does not exist yet"* — now answer clause (c)'s
question instead, which is what Addendum 1 (i-2) routes that branch to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… words
`LANE_START_HANDOFF_ANSWER` printed `Ask it to hand off…` where the terminal
asks `ask it to hand off…`, so a person reading a non-interactive log saw a
line that is not the one they would have been asked — and the suite read the
difference before anybody else could.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This PR implements Amendment 18’s one-binding-per-lane protocol: records now identify host/OS/container context, liveness is only authoritative from the binding’s own namespace, competing launchers request and await handoff before binding, forced release is explicit and guarded, and live lanes expose attach actions instead of duplicate starts.
Sequence diagram for the lane handoff request
sequenceDiagram
participant Requester
participant LaneStart as lane-start_or_lane
participant Tooling as lanes-edit.sh
participant Log
participant BoundPane as BoundSession
Requester->>LaneStart: --request-handoff
LaneStart->>Tooling: request-handoff
Tooling->>Log: write_event HANDOFF-REQUESTED
Tooling->>Log: push published log
opt bound window is on this tmux server and runs claude
Tooling->>BoundPane: guard_type /handoff --exit requested by <uuid>@<host>/<container>
end
loop every LANES_POLL_SECONDS until wait expires
Tooling->>Log: lane_binding_scan
end
alt PAUSED, ENDED, or RETIRED arrives
Log-->>Tooling: binding released
Tooling-->>LaneStart: lane is free, bind here
else wait expires
Tooling-->>LaneStart: refusal, use --force for a second invocation
end
Loading
State diagram for one lane binding lifecycle
stateDiagram-v2
[*] --> Free
Free --> Bound: write_event STARTED or RESUMED
Bound --> Requested: write_event HANDOFF_REQUESTED
Requested --> Bound: request wait expires
Requested --> Free: PAUSED, ENDED, or RETIRED
Bound --> Free: PAUSED, ENDED, or RETIRED
Requested --> Free: request-handoff --force
Free --> Bound: second place binds after release
Loading
File-Level Changes
Change
Details
Files
Persist host, OS, and container binding facts and derive fail-closed liveness from the binding context.
Adds live environment/kernel probes with launcher-variable fallbacks and expands workstation output with sources.
Appends and validates binding subfields on STARTED, RESUMED, and PAUSED while preserving payload-free ENDED and RETIRED records.
Adds the binding read with legacy-record handling, host/container locality checks, shared-tmux window exception, and explicit live/gone/unknown results.
Implement the handoff-request protocol so competing launchers ask the current holder before binding.
Adds HANDOFF-REQUESTED as a non-state-changing lane verb and implements request-handoff with push-first ordering, optional pane signaling, polling, refusal, dry-run, and explicit force release.
Routes lane-start and lane through the shared handoff mechanism, including terminal prompting, noninteractive --request-handoff, --force validation, and consistent refusal behavior.
Extends prompt guards to react to pending requests or externally forced PAUSED records only when the current session/window owns the binding.
lanes-edit.sh lane-start lane tests/test_lane_helpers.sh docs/README-lanes.md
Make live-lane listings offer an attach action instead of starting a second process.
Computes the attach command in the shared lane read, selecting switch-client inside tmux and attach outside it, targeting session plus window id.
Updates lane/lane-start surfaces and tests to verify live lanes are attached rather than rebound.
lanes-edit.sh lane lane-start lanes tests/test_lane_helpers.sh
Document Amendment 18 behavior and update repository hygiene coverage.
Adds operational rules, command contracts, fallback semantics, protocol ordering, guard behavior, and launcher export requirements to the agent guidance and lane manual.
Adds extensive coverage for probes, record compatibility, locality, handoff sequencing, waiting, force release, guards, and attach rendering.
Raises the AGENTS.md line cap and records the dated rule entry.
Trigger a new review: Comment @sourcery-ai review on the pull request.
Continue discussions: Reply directly to Sourcery's review comments.
Generate a GitHub issue from a review comment: Ask Sourcery to create an
issue from a review comment by replying to it. You can also reply to a
review comment with @sourcery-ai issue to create an issue from it.
Generate a pull request title: Write @sourcery-ai anywhere in the pull
request title to generate a title at any time. You can also comment @sourcery-ai title on the pull request to (re-)generate the title at any time.
Generate a pull request summary: Write @sourcery-ai summary anywhere in
the pull request body to generate a PR summary at any time exactly where you
want it. You can also comment @sourcery-ai summary on the pull request to
(re-)generate the summary at any time.
Generate reviewer's guide: Comment @sourcery-ai guide on the pull
request to (re-)generate the reviewer's guide at any time.
Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
pull request to resolve all Sourcery comments. Useful if you've already
addressed all the comments and don't want to see them anymore.
Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
request to dismiss all existing Sourcery reviews. Especially useful if you
want to start fresh with a new review - don't forget to comment @sourcery-ai review to trigger a new review!
…cal read says where its fetch is
Clause (c)'s request line says WHO is asking and from WHERE — `by host <h>;
container <c>; window <w>; wait <n>s` — and carries no sentence, so words handed
to it are words a person believes they wrote into an append-only log and nobody
did. Refused with 64, naming `--force` with the words already in it, which is
the one act that does write a sentence and the one whose sentence is read.
And `lane`'s BOUND ELSEWHERE read says why it is local like every other read in
that word: the fetch that matters is one layer down rather than missing —
`request-handoff` fetches before it writes, so a release this checkout has not
pulled answers 8 there and the branch falls to the available one, and
`lane-start` reads the binding after a fetch before it binds anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…container with no configured workstation — it reaches the log without passing `log`
`R-A11-14` puts ONE guard at the dispatcher rather than one per writer, and it
lists the subcommands that write. `request-handoff` writes — clause (c)'s
`HANDOFF-REQUESTED` and clause (e)'s forced `PAUSED`, both through `write_event`
directly rather than through `log` — and it was in no list at all. In a
container with no `$LANES_WORKSTATION` it would have filed both under the
container's id, which is the exact defect that put six lines in one lane's log
and which no later line can correct.
And one field further along than the `session <uuid>@<ws>` that guard has always
protected: clause (a)'s own `host` falls back to the Rule 10 workstation name,
which in that state IS the container id — so the read prints it, honestly, and
the writer must never write it. Measured here: `workstation` in this bench with
the seam unset answers `cb999b9fd177 container-unset cb999b9fd177 workstation`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved critical and moderate findings affect atomic lane ownership, binding-aware selection, handoff timing, guard behavior, and forced-release safety.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Implements Amendment 18’s one-binding-per-lane protocol with binding metadata, handoff coordination, liveness checks, and live-lane attachment.
Changes:
Records host, OS, and container bindings.
Adds handoff requests, waits, forced takeover, and prompt guards.
Updates lane selection, documentation, and tests.
File summaries
File
Review summary
tests/test_repo_hygiene.py
Reviewed; no final comments.
tests/test_lane_helpers.sh
Reviewed; no final comments.
lanes-edit.sh
Critical findings: handoff results do not reserve ownership (2 votes); forced release can target a newly bound lane (1 vote); picker grouping can select another container’s binding (2 votes). Moderate findings: wait deadlines can be exceeded (3 votes); forced-release guards can be bypassed after rebinding (2 votes); same-second rebinding is missed (1 vote); window identity can collide across tmux contexts (2 votes).
lanes
Moderate finding (1 vote): attach rendering does not enforce binding locality and the shared-window exception.
lane-start
Critical finding (1 vote): handoff completion is not atomically reserved before startup.
lane
Critical finding (1 vote): the picker can launch after a stale availability result without rechecking or reserving the lane.
docs/README-lanes.md
Reviewed; no final comments.
AGENTS.md
Reviewed; no final comments.
Review details
Suppressed comments (2)
lanes:405
This new attach branch trusts r_state=LIVE, but lanes_rows derives that solely from local session records/PIDs and never checks the binding's host/container. With shared profile records, a lane running in another container can be rendered as LIVE (or locally available when its PID is not visible), so this branch offers the wrong act even though Amendment 18 requires UNKNOWN/elsewhere outside the binding. Make the row state and column-10 read use binding locality and the shared-window-gone exception before rendering attach.
# A LIVE LANE'S ONE ACT IS THE ATTACH AND NEVER A SECOND PROCESS (Amendment
# 18 clause (i), on the question of 2026-09-14T13:47Z). The read chose
# between `switch-client` and `attach` — it knows whether this process is
# inside tmux — and filled the target in; this renderer says which kind of
lanes-edit.sh:8434
Rebinding detection uses only second-resolution UTC values. A release and a new STARTED/RESUMED can be appended in the same second, leaving rh_utc2 = rh_utc; the request is then treated as still waiting on the original binding and eventually times out instead of refusing because the lane was rebound. Compare an append-order/event identity (or another version that changes for every log line), not just the timestamp.
rh_deadline=$(( $(date -u +%s) + rh_wait ))
[ "$rh_wait" = 0 ] || note "waiting up to ${rh_wait}s for lane $lane to be released (polling the published log every ${rh_poll}s) …"
while : ; do
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved binding-locality, concurrency, and state-integrity findings block safe approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (9)
lane:883
This branch assumes the grouping made the lane elsewhere only from the row workstation, but Amendment 18 also makes the binding's container part of locality. A binding in another container on this same host is not proven dead by this process; the picker can classify it as available and enter go_available instead of requesting a handoff, allowing a second launch when the launcher-side fence is absent. Make the shared grouping/read path include binding host/container locality.
# THE GROUPING PUT THIS LANE HERE BY THE REGISTER'S WORKSTATION COLUMN; THE
# BINDING READ IS WHAT THE QUESTION IS ASKED ABOUT. The two can disagree — a row
# filed on another workstation whose log's last lane-kind line released the
# binding is FREE, and asking a place that is not holding it for a handoff it
# cannot give is a question nobody can answer. So the read decides, and an
lane-start:1834
This binding check runs before the later write path acquires write_event's lock and is not revalidated under that lock. Two concurrent lane-start processes can both observe exit 8 (free), then each append a STARTED/RESUMED binding; serializing the commits does not serialize the check, leaving two holders of one lane. Recheck or claim the binding atomically while holding the same lock before any launch-side writes.
The deadline is checked before sleeping, but this unconditionally sleeps a full poll interval afterward. With --wait 1 and the default 15-second poll interval, the command can block for roughly 15 seconds and can even accept a release that arrived after the requested deadline, so --wait is not an upper bound. Sleep for the smaller of the poll interval and the remaining time, then recheck the deadline before polling.
while : ; do
if [ "$(date -u +%s)" -ge "$rh_deadline" ]; then break; fi
sleep "$rh_poll"
log_sync
lanes-edit.sh:4353
Generated binding values only pass the generic character check here, so LANES_OS=plan9 is appended as os plan9 even though the binding contract and binding_subfields_check accept only linux, macos, wsl, or windows. This permanently writes a binding that violates the documented read format. Apply the same OS enum validation to generated values before appending them, and update the drop diagnostic for that case.
if ! lanes_binding_value_ok "$bsa_v"; then
bsa_drop="$bsa_drop $bsa_f=${bsa_v:-<empty>}"
continue
fi
lanes-edit.sh:6264
The scan erases win (and the other binding fields) for released as well as free. The guard later relies on gb_win to recognize a forced PAUSED after /clear or a profile switch changed the transcript UUID; after this erase gb_mine is false and the old session can continue prompting despite the forced release. Keep the binding fields for released and blank them only for free; the public binding arm already maps released state to exit 8.
if (state == "free" || state == "released") { host = ""; cont = ""; win = ""; os = ""; legacy = "" }
lanes-edit.sh:8345
This self-window check runs before locality is established and compares only the @id. tmux allocates/reuses IDs independently on different servers (commonly both servers have @0), so a remote-host binding can collide with an unrelated local window and request-handoff will refuse it as though the requester were already in the bound window. Gate the check on the binding's host/container and compare the session together with the window ID.
rh_wstate="$(binding_window_state "$rh_host" "$rh_win")"
if binding_is_this_window "$rh_win"; then
die "lane $lane's binding IS this window ($rh_win): a handoff request is what a SECOND place makes of the first, and there is nothing here to ask. Nothing was written." 2
lanes-edit.sh:8447
Every waiter that observes the same released|free result returns success, but the caller only receives a boolean and then writes/launches outside this read. Two concurrent lane-start --request-handoff invocations can therefore observe one PAUSED and both create separate next bindings. The handoff needs an atomic reservation/compare-and-claim for the first claimant instead of treating this observation as ownership.
case "$rh_state2" in
released|free)
note "lane $lane is FREE — the binding was released while this request waited. Bind it."
exit 0 ;;
lanes-edit.sh:6228
When a forced PAUSED is followed by a new STARTED/RESUMED, clearing x_verb/x_sess/x_pay here erases the release before the displaced session reaches its next prompt. The scan then ends in bound, so the guard at 6827-6836 no longer refuses that old session and it can continue writing after the takeover. Preserve the forced-release identity across the rebind and have the guard match it to the displaced session, with a regression for force-then-rebind.
The binding scan defines ordering by file position, but this wait detects a rebind only when the new binding's UTC differs from the old one. A release and rebind can legally be appended within the same second, making rh_utc2 equal to rh_utc; the requester then misses that the lane was taken and eventually reports an empty wait or suggests --force against the new holder. Track the request/release position or another append-order generation instead of comparing timestamps.
if [ -n "$rh_utc2" ] && [ "$rh_utc2" != "$rh_utc" ]; then
die "lane $lane was released and BOUND AGAIN while this request waited — its binding is now window ${rh_win2:-none} in container ${rh_cont2:-none} on host ${rh_host2:-unknown} (session ${rh_bsess2:-unknown}, $rh_utc2). The request this run wrote is answered and the lane is somebody else's: ask again, or take it up with that place." 2
…ow-id test waits for locality, `--force` re-reads before it writes, the wait keeps to its bound, and clause (e)'s refusal outlives the next binding (Copilot round 1 on #83)
THE PICK WAS THE MEASURED DEFECT ONE SURFACE ALONG. `lanes_rows` calls a binding
with no live record here IDLE, and `lane-groups` filed an IDLE row as AVAILABLE
on the workstation column alone — so another container's LIVE lane on this same
host was offered to be launched again, which is this amendment's own collision
reached through the pick instead of through `kill -0`. The read answers it now:
`lane_row_facts` captures the BINDING line's host and container (a `PAUSED`'s
host is where the release was written, not where the lane was), `lanes_rows`
turns them into column 13 — `here`, `elsewhere`, or empty for a row with no
binding line — and `lane_groups` files an `elsewhere` IDLE row as elsewhere
whatever its workstation column says. Fork-free: the awk lower-cases, and this
place's three values are lower-cased once for the whole listing. An empty column
13 is filed exactly as it was before the column existed, and a pre-clause line
is matched on the host alone.
THE SELF-WINDOW FENCE IS A LOCAL TEST AND IS NOW ASKED ONLY WHERE THE BINDING IS
LOCAL. A tmux `@id` names a window on ONE server and tmux reissues them from
`@0`, so `@44` here and `@44` on Raven were never one window — and refusing the
request would have left that lane unaskable from the one place that needs to ask.
`--force` RE-READS THE BINDING IMMEDIATELY BEFORE THE APPEND and refuses when it
moved, naming what it moved to. A release names the session it acts for; written
out of a scan made before the uuid was resolved, it could have named the old
holder while releasing a new one — the one act this clause makes loud, made
silently against somebody who never had a chance to answer.
`--wait <s>` IS AN UPPER BOUND A PERSON NAMED. The sleep is bounded by what is
left of it; a fifteen-second poll used to overrun a `--wait 1` and then answer.
What the interval decides is how OFTEN the log is asked, never how long the
caller is held. Declined, with the reason in the thread: rejecting a release
merely OBSERVED after the deadline — a lane that is free is free, and refusing
it would send the caller back to ask a handoff of a place that has given it.
AND CLAUSE (e)'s REFUSAL OUTLIVES THE NEXT BINDING, which is what "every prompt
from then on" means. `lane_binding_scan` keeps a session-scoped track beside the
binding — this session's own last lane-kind line, and the release another
session wrote after it — and those fields do not move when a third place binds
the lane. Two narrowings keep it honest: a session that wrote no line of its own
has been released from nothing (otherwise every fresh transcript in a lane's
window would be refused for ever by its predecessor's `PAUSED`), and the
exception is a release that NAMES this session, which is evidence needing no
line of its own.
THE RESERVATION IS FILED AND NOT INVENTED (#84). A handoff
that landed is an observation: two places can wait on one release and both bind,
and `commit_push` rebases both appends rather than refusing the second. Clause
(c) says *"the requester binds as it would have"*, and that bind has been
read-then-write since Amendment 6; Rule 1's race — which CLAIMED lands first,
with a rescan and a CLAIM-LOST — exists for an OBJECT and for nothing else. What
this round does instead is make the read the LAST thing before the first act:
`lane-start` re-reads `binding` immediately before the rename and refuses naming
where the lane went, so the window is one read wide rather than a question, a
rename, two register writes and a launch wide.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Seven moderate review findings remain in lane-start and lanes-edit.sh.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (5)
lane-start:1848
A binding whose window is gone on this same host/container has b_where=here and b_wstate=gone, so this if branch consumes it before the following elif [ "$b_wstate" = gone ] can run. If the local session record still looks live, step 3 can refuse the lane instead of applying Amendment 18(b)'s window-gone dead/takeover exception; handle gone before the locality branch.
if [ "$b_where" = here ]; then
step "binding: this host ($b_host) and container ($b_cont), window $b_win since $b_utc — liveness here is this place's to pronounce, and step 3 has (Amendment 18(b))"
elif [ "$b_wstate" = gone ]; then
lanes-edit.sh:6917
The window fallback for gb_mine compares only the tmux @id; unlike the request path above, it does not first verify that the binding's host and container are this process's. Since window IDs are local to a tmux server and are reused, a prompt guard in another host/container can coincidentally match the ID and answer a remote lane's request or forced release as if it were the bound session.
The direct request-handoff path computes rh_wstate but never treats gone as the dead-binding exception. For a window that is gone from the shared tmux server, this command still writes HANDOFF-REQUESTED, waits for a release that cannot arrive, and returns a refusal instead of the documented free/takeover result.
The handoff notification targets ${rh_win##* }, which is a tmux window id (@…), not the bound session's pane. tmux send-keys -t @id sends to that window's active pane; if the window has multiple panes and the lane's Claude pane is not active, the request is typed into another pane (or another Claude session) while the actual holder never sees it. The binding format needs a pane-specific target or a lookup that identifies the pane belonging to the recorded session before sending keys.
rh_typed=no
if [ "$rh_wstate" = live ]; then
rh_trc=0
guard_type "${rh_win##* }" "$rh_type" || rh_trc=$?
lanes-edit.sh:4353
The generated os value only passes lanes_binding_value_ok, which accepts any token, so an export such as LANES_OS=plan9 is appended as os plan9; the enum validation currently runs only for caller-supplied subfields in binding_subfields_check. This lets the writer create records outside Amendment 18's declared grammar and leaves subsequent reads with invalid OS metadata. Validate the generated value before appending it, or reject the invalid export and use the probe.
if ! lanes_binding_value_ok "$bsa_v"; then
bsa_drop="$bsa_drop $bsa_f=${bsa_v:-<empty>}"
continue
fi
…a modern one is matched on the host's own name AND its container, and a LIVE row of another container is the pick's BOUND ELSEWHERE (Copilot round 2 on #83)
LEGACY WAS `host` ABSENT, AND THE WRITER DROPS SUB-FIELDS INDEPENDENTLY. A line
carrying a `container` and an `os` and no `host` is a MODERN line that lost one
field — `R-A11-11`'s posture, a binding that loses its container still says which
host it is on — and reading it as pre-amendment ignored the container it does
carry, which is the fence opening for the one place it exists to close. All
three absent is the only shape that is really from before this clause;
anything less fails closed, in `lane_binding_scan` and in `lane_row_facts` both.
THE WORKSTATION-NAME FALLBACK IS LEGACY-ONLY. `$WS` is clause (a)'s cutover
affordance: a line written before this amendment carries no `host` and its
binding is "the window on the row's workstation", so the workstation's name is
all such a line has to be matched on. Allowed for a MODERN record it let another
machine's binding read `here` wherever the two names coincided, and `lane-start`
then skips the fence — the one refusal this clause exists to make. Clause (a)'s
own fallback still works through it without a second rule: a writer inside a
container with no `$LANES_HOST` writes the workstation name into `host`, and a
reader in that same container computes the same name the same way.
AND THE RULE IS IN ONE PLACE AGAIN. `lanes_rows` carried a shell copy of the
comparison for speed — the listing asks it of every lane and may not spend two
`lc` forks a lane — and the two were already diverging when the review read
them. `binding_is_here_lc` is the rule, taking values the awk has lower-cased;
`binding_is_here` is two `lc`s in front of it.
A `LIVE` ROW IS NOT THIS PLACE'S TO PRONOUNCE ON EITHER. `LIVE` is a live
session record HERE naming one of the row's ids, and `record_fields_are_live`
ends in a `kill -0` of a pid that, across a container seam on one host, is in
another namespace: absent (the row reads IDLE, which round 1 already filed
elsewhere) or SOME OTHER PROCESS, which is a false LIVE. So `lane_groups` asks
the locality column BEFORE the state. Two things do not change: a PARKED lane of
another container is still AVAILABLE, because parking IS the handoff and column
13 carries the locality of a binding that STANDS; and the person keeps the
attach — watching a lane and taking it are two acts and only the second is the
collision, so `lane`'s elsewhere branch names the attach filled in wherever that
window is live on this tmux server, in the question and in the refusal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
…the plan for the re-read instead of refusing on a release it did not make (Copilot round 3 on #83)
TWO PROMISES THIS FILE MAKES ABOUT THE THREE FACTS, and one of them was a
process-start snapshot. *"Read live at every write and never cached"* is what
the comment, the manual and the PR all say; the code probed once at startup —
which is what every READ wants, because nothing can change under a short read,
and is not what the sentence promises. There is one invocation where the gap is
not theoretical: `request-handoff` can sit in a five-minute wait between its
start and a later act. The probes are one function now, called at startup for
the reads and AGAIN by `binding_subfields_add` immediately before the sub-fields
are appended, dropping the lower-cased locality cache with them so no reader is
left holding what the probe used to say. Two forks per write.
AND A DRY RUN HAS NOTHING TO RE-READ, BECAUSE IT DID NOTHING.
`request-handoff --dry-run` prints its plan and answers 0 without releasing
anything, so the unconditional re-read before the rename saw the binding still
standing, took the "released and BOUND AGAIN" arm, and `lane-start --dry-run
--request-handoff` — the one run whose whole job is to print a plan — could
never print one. The re-read is skipped under `--dry-run` and the step it would
have made is printed as a plan line of its own, so the plan still states the
fence rather than hiding it.
The third finding of the round was already fixed: `lane_groups` asks the
locality column BEFORE the state as of c672b5d, which is what that comment
(written against 58a1fbd) asks for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Six unresolved findings remain, including two critical binding-safety issues.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
lane-start:1846
The here branch runs before the gone exception. A binding on this host/container whose shared tmux window disappeared is therefore treated as locally pronounceable and never enters the takeover path; live-holder can still see the old live record and refuse it as elsewhere. Check gone before treating the binding as here, as lane already does.
if [ "$b_where" = here ]; then
lanes-edit.sh:8613
This re-read can report free/released while retaining the old rh2_utc and rh2_sess fields: lane_binding_scan clears the binding fields but intentionally leaves the original binding identity for diagnostics. In that case this condition passes and appends a forced PAUSED on behalf of a binding that has already gone away. Require the re-read to still be bound/requested and compare the full binding identity before writing.
if [ "$rh2_utc" != "$rh_utc" ] || [ "$rh2_sess" != "$rh_bsess" ]; then
lanes-edit.sh:8701
Rebinding detection is keyed only by rh_utc, but utc_now has one-second precision and the re-read already provides the new session, host, container, and window. A release followed by a new binding in the same second is therefore treated as the original binding and this request waits until timeout instead of reporting that another place owns the lane. Compare the binding identity, not just its timestamp.
if [ -n "$rh_utc2" ] && [ "$rh_utc2" != "$rh_utc" ]; then
lanes-edit.sh:8569
This self-window check compares only the @id even though binding_window_state has already established that an ID resolving in a different tmux session is gone. If tmux reuses that ID locally, request-handoff refuses as though the caller is standing in the bound pane instead of allowing the clause (b) gone-window takeover path. Restrict this refusal to a live binding window (or compare the recorded session as well).
if [ "$rh_here" = here ] && binding_is_this_window "$rh_win"; then
…isjunct become LOCAL tests like every other (Copilot round 4 on #83), with the suite's own window table restored whole
THE MERGE IS A UNION AND THE VERB SETS ARE THE ONE PLACE IT HAD TO BE READ.
Amendment 13 adds `NOTED` and `RULED` as narrative verbs deliberately OUTSIDE
`is_lane_verb`, because every state reader takes the five by name and a sixth
inside that predicate would have made the narrative move the lane.
`HANDOFF-REQUESTED` is INSIDE it, and the difference is the OBJECT rather than
the state: that predicate's one caller is the `log` arm's check that a lane
verb's object is `lane:<name>`, which the request needs and which 13 gives its
two by their own arm. None of the three moves a lane and none is seen by a state
reader — said in the code where the two rules meet.
`binding_window_state` KEPT ITS OWN COPY OF THE HOST TEST, and round 2 fixed
only the other one. It accepted the Rule 10 workstation name for EVERY record —
so a MODERN line naming another machine could be judged `gone` here, and `gone`
is the one answer that makes a binding elsewhere read DEAD and hands
`lane-start` and `lane` the takeover path. A takeover of another machine's lane,
out of a name that happened to coincide, is this clause's own collision reached
through its own exception. The host half is one function now
(`binding_host_is_here_lc`): this machine's own hostname, plus the workstation
name only for a pre-amendment line, which has nothing else to be matched on.
`binding_is_here_lc` adds the container — the pid namespace is host AND
container — and the window test does not, because the tmux server one host's
launcher mounts into every container it starts IS the host.
AND THE GUARD'S WINDOW DISJUNCT IS THREE TESTS AND NOT ONE. It exists for the
`/clear`, where the harness minted a new id and the binding's window is still
this one; compared alone an `@id` made this session the answerer for any binding
carrying the same number, and the guard would type `/handoff --exit` into this
pane and refuse this prompt for a request nobody made of it. The binding must be
in this host and container, its window must RESOLVE here with its own session
(Amendment 11(h)'s agreement rule, which is what `live` means), and only then is
the id compared.
The suite's own window table is restored WHOLE by the two re-exports that
borrow it — a restore that loses a window is a premise changed from a distance,
which is what the save at the top of that section exists to stop — and the two
round-4 cases have lanes of their own, because `lane-start` REBINDS the lane it
takes and the case that asserts the takeover path is the one that moves the
window it was asserting about.
Local: `bash tests/test_lane_helpers.sh` 2653 passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Seven moderate findings remain in binding parsing, validation, race handling, and ownership enforcement.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (6)
lane:950
binding returns the window as <session>:<index> <@id>, but split_window only understands the older <session>:<@id>... form and truncates this value at the space. When this branch sees a live binding elsewhere, it therefore builds the watch command with the numeric index (for example tmux switch-client -t bindsess:0) instead of the verified window id (bindsess:@61), so it can attach to the wrong window or fail. Parse the binding's first field and trailing @id explicitly before constructing the watch target.
if [ "$ge_wstate" = live ] && split_window "$ge_win"; then
if [ -n "$(this_session)" ]; then ge_watch="tmux switch-client -t $W_SESS:$W_ID"
else ge_watch="tmux attach -t $W_SESS:$W_ID"
lanes-edit.sh:9508
The forced-release re-read does not verify that the lane is still bound/requested. When the original holder writes PAUSED after the first scan, lane_binding_scan returns released but intentionally retains the old binding's UTC and session fields, so these comparisons pass and this command appends a second forced PAUSED to an already-free lane. Check the re-read state before writing, and treat an intervening release as already-free rather than forcing it again.
if [ "$rh2_utc" != "$rh_utc" ] || [ "$rh2_sess" != "$rh_bsess" ]; then
die "lane $lane's binding CHANGED while this run was preparing to force it. It was window $rh_win in container $rh_cont on host $rh_host (session ${rh_bsess:-unknown}, $rh_utc); it is now ${rh2_state:-free}${rh2_win:+, window $rh2_win in container ${rh2_cont:-none} on host ${rh2_host:-unknown} (session ${rh2_sess:-unknown}, $rh2_utc)}. Nothing was written: a forced release names the session it acts for, and this one would have named a session that no longer holds the lane while releasing one that does. Read it and decide again:
$SELF binding $lane" 2
fi
lanes-edit.sh:9595
A rebound is detected only by comparing the binding UTC. utc_now has one-second resolution, so the holder can release and another place can bind within the same second; the new binding then has the same UTC and this wait continues as if the original binding were still present, instead of refusing that the lane belongs to somebody else. Compare an append/file position (or otherwise carry the binding generation) rather than a timestamp alone.
if [ -n "$rh_utc2" ] && [ "$rh_utc2" != "$rh_utc" ]; then
die "lane $lane was released and BOUND AGAIN while this request waited — its binding is now window ${rh_win2:-none} in container ${rh_cont2:-none} on host ${rh_host2:-unknown} (session ${rh_bsess2:-unknown}, $rh_utc2). The request this run wrote is answered and the lane is somebody else's: ask again, or take it up with that place." 2
fi ;;
lanes-edit.sh:1830
Adding HANDOFF-REQUESTED to the lane-kind set makes it a valid line, but swapped_candidates() still skips only NOTED and RULED when selecting the last lane-kind event. If a request races with a release and is appended after a swap PAUSED, that request becomes the selected verb and the paused swap disappears from swapped, so the lane cannot be resumed through the swap recovery path. Make every state reader, including swapped_candidates, ignore this non-state verb.
is_lane_verb() { case "$1" in STARTED|PAUSED|RESUMED|ENDED|RETIRED|HANDOFF-REQUESTED) return 0 ;; esac; return 1; }
lanes-edit.sh:9505
The recheck treats a binding as unchanged when only its UTC and session match. A transcript can be resumed by the same session UUID in another host/container/window, and the log timestamps have only second precision, so a release and rebind within one second can pass this check; the forced PAUSED would then release the new holder. Compare the full binding identity (at least host, container, window, UTC, and session) before writing on behalf of the holder.
if [ "$rh2_utc" != "$rh_utc" ] || [ "$rh2_sess" != "$rh_bsess" ]; then
lanes-edit.sh:7253
The window-based ownership fallback is computed into gb_mine for /clear and profile-switch cases, but it is only used for the pending-request branch. If the bound session's UUID changed while remaining in this window, lane_binding_scan "$G_LANE" "$G_ID" has no me_pos and clears gb_fverb; consequently a forced PAUSED written by another session is not surfaced by this guard, even though the binding is still this window and clause (e) requires every prompt to be refused. Carry the local-window ownership into the forced-release tracking as well.
if [ -n "$gb_fverb" ] && [ -n "$gb_fsess" ]; then
guard_triple
note "THIS LANE'S BINDING WAS RELEASED BY SOMEBODY ELSE (Amendment 18(e)). Lane $G_LANE's log carries a \`$gb_fverb\` at ${gb_futc:-an instant this line does not name}, written by session $gb_fsess — not by this one — after this session's own last line:"
note " ${gb_fpay:-(no payload)}"
note "Another place forced the lane away and has bound it, or is binding it now. TWO PLACES NEVER BOTH WRITE A LANE: every prompt in this session is refused from here on, and this is not a state a rename or a relaunch clears."
note "The exits, and both are yours: hand this session off properly, which records what it holds and ends it —"
note " lane-handoff --exit \"released from under me by $gb_fsess\""
note "— or end the session. To take the lane BACK, do it from a place that can bind it, after this one has stopped."
return 2
…refuses a caller's by (Copilot round 5 on #83)
`binding_subfields_check` refuses a caller-written `os` that is none of
linux, macos, wsl, windows; the value this writer APPENDS comes from
`$LANES_OS`, which is nobody's to validate but this writer's — and
`LANES_OS=plan9` would otherwise persist on every binding line as a value the
same file refuses when a caller supplies it, in a log nothing rewrites.
DROPPED RATHER THAN REFUSED, which is the posture this function already takes
for every other value (`R-A11-11`: a binding that loses its container still says
which host it is on). `host` and `container` are what clause (b) reads and what
every locality decision is made on; `os` is information. A lane-kind line is
never lost over a field no reader decides anything by — and the drop is printed,
naming the sub-field and why, so it is never silent.
WIP: `bash tests/test_lane_helpers.sh` was at 795 passed / 0 failed when this
was committed for a handoff; the run then reported ONE failure that is not this
change's — `without --confirm nothing is asked` (Amendment 8 section) exited 5,
which is `lanes-edit.sh`'s "an edit moved more than one line, or a log could not
be renamed" and is the first thing to chase on resuming.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.
Prevent fixture sleep processes from surviving test timeout
tests/test_lane_helpers.sh:665
These sleeps now run for TIMEOUT_SECONDS + 600, while tests/test_lane_helpers_suite.py:112-115 kills the Bash subprocess at exactly TIMEOUT_SECONDS. On that timeout path the shell's EXIT trap cannot run, so the already-started LIVE_PID (and any later fixture sleeps) are orphaned for up to 4200 seconds, contaminating subsequent runs on the runner. Keep the fixture below the hard timeout or change the Python wrapper to terminate the whole process group and clean up on timeout.
…#131#133#135#136): one cap conflict, settled at the count of what merged
One textual conflict, the AGENTS.md line cap in
tests/test_repo_hygiene.py: this branch raised 265 to 278 for Amendment
18's ONE BINDING rule, and #119 raised the same 265 to 292 for the
cycle's three rulings. AGENTS.md itself auto-merged to 305 lines,
265 + 13 + 27, with both paragraphs whole and #135's profile-only guard
sentence beside them. So the cap is 305, with a dated MEET AT entry in
the form the 224 entry's rule asks for, rather than either side's
number.
Everything else auto-merged and was read for meaning, not just for
markers. #135 hoists the global-flag parse in lanes-edit.sh above the
path probes and exits 0 for `guard` under exact CLAUDE_NO_LANE=1; this
branch's guard clauses (d) and (e) sit inside the guard, after that
exit, which is right because a profile-only session holds no binding.
Every positional read between the new and old parse sites is inside a
function. #129's lclaude-first launcher in `lane` is the one this
branch's elsewhere and available paths already call. #133's
stable_lane_row and #129's lclaude fakes are cases of their own and
touch none of this branch's.
Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ve-the-listing
#83 (Amendment 18, one binding per lane) and this branch both added
columns to the end of `lanes-edit.sh lanes`'s row, and both changed what
column 10 says for a LIVE lane.
THE COLUMNS. #83 landed first, so its locality keeps column 13, and
Amendment 19's four move to 14-17 (class, started, head, flags) in every
reader: `lane_groups` (the class test was still reading $13, which is now
#83's `here`/`elsewhere`), `lane`'s split_row, `lanes`'s loop, the sweep's
row reader in `lane-end`, the manual's table, the field count (17) and
the eighteen `a19_field` cases. Column 13 is now printed `none` where no
binding stands rather than left empty: those three readers split the row
with a tab-IFS `read`, and a tab is IFS whitespace, so an empty 13 would
have handed the class to the variable named for the locality and shifted
every field after it. `lane_groups` files `none` exactly as it filed
empty (only `elsewhere` changes the group); #83's one assertion of the
empty value now asserts `none`.
COLUMN 10 FOR A LIVE ROW is #83's: the attach filled in (`tmux
switch-client`/`tmux attach -t <session>:<@id>`), rendered by `lanes` as
`attach:`. It supersedes this branch's `lane <name>` for the same row,
which was only ever there because the column said `none`; the two A19
assertions of that word now assert only that a LIVE row is never offered
a launch.
THE LIVENESS FIXTURE is #83's derived form (TIMEOUT_SECONDS + 600, read
out of the wrapper), which at this branch's 5400 is 6000. The wrapper's
pair test now accepts that form, proving the sed read finds the number
the wrapper runs with.
The rest is unions: both facts reads, both sections of the suite (A19
then A18, each restoring its own fixtures), both writers in the
workstation guard, both subcommand lists, and the AGENTS.md cap at the
merged count, 316.
Lane: openRepoTools-3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…Amendment 18 hands over
At the #83 merge (7c1aef2) Linux `tests` went red on two of #83's own
Amendment 18 cases, both `lane-start` exiting 2:
FAIL …which it now does, with nothing refused expected [0], got [2]
FAIL `lane-start --dry-run --request-handoff` prints a plan instead of
refusing on a release it did not make expected [0], got [2]
Both lanes (repoBind-3 after a forced release, repoBind-12 bound in
another container) have their own window in the shared tmux server —
`cloudsess:4 @62 repoBind-3`, `cloudsess:6 @65 repoBind-12` — and the
server-wide window-name fence d0310f6 added to step 4 refused the second
place's bind because another window carried the name. Amendment 18 rules
that takeover in: a bench in another container keeps the lane's name
until that session hands off, and binding after a release or through
`--request-handoff` is the flow. main has no such fence and is green.
The fence keeps the case it was written for: a NEW lane (no row), whose
name on any other window is certainly foreign because nothing has bound
it. That is this branch's own assertion — `REPOA-8` on @99 still
refuses — and the manual's sentence now says which launches it asks.
Lane: openRepoTools-3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e-listing
#139 (wip init refuses an adopted checkout ahead of origin's main) landed
after #83. It touches `openRepoTools`, its own tests, README.md and a
different docstring of tests/test_repo_hygiene.py than this branch does;
the merge is clean and nothing of it is resolved by hand.
Lane: openRepoTools-3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…retires a repository's dormant rows on a word, and a retired position is never reissued (#93)
* Nineteen lines for five lanes: a closed or dormant lane leaves the listing, one sweep retires a repository's dormant rows on a word, and a retired position is never reissued (Amendment 19)
`lanes` inside the openxFactory checkout on 2026-09-14 listed NINETEEN rows for
five lanes: the five live under team-01b, and fourteen marked `NO LOG` — lanes
that ran between 2026-08-27 and 2026-09-02, before Amendment 7 gave every lane
an object log, and that no writer today can close, because `lane-end` wants a
live session or a log to write into and they have neither. They sat at the top
of every listing and the next free position was read past them. Brett Heap,
verbatim: "openxfactory has 19 lanes showing with command 'lanes'. we need tools
to clean them up" — and, of the three choices he was given, "Retire them in one
sweep", "Hide by default, --closed shows", "Amendment 19". Ratified
2026-09-14T13:46:16Z, verbatim "ratify 19".
(a) TWO STATES BELOW PARKED. CLOSED is a lane whose object log's last lane-kind
line is `ENDED` or `RETIRED`. DORMANT is a row with NO object log and no live
session on this workstation — asked of the facts table the listing already
builds, not of the filesystem, so it costs nothing. One row with no log is NOT
dormant: one whose state cell is Amendment 13(a)'s phrase naming a state other
than ENDED, RETIRED or the migration's MIGRATED. The register says where a lane
IS (13(a)), and hiding a row that says PAUSED would hide a lane a person can
still pick up.
(b) THE LISTING. `lanes` lists neither by default; `--closed` adds them — a
CLOSED lane with the closing line's verb and its time, a DORMANT row as `NO LOG`
with its start date, its state cell's head and FLAGS where that cell says the
work was unpushed, lost, a loss or owed, or still says LIVE. It combines with
`--all`, `--here` and the narrowing flags, and `--lane <name>` is never filtered:
its caller has named the lane, and an empty answer would read as "no such lane".
The row gains four columns at the end — class, started, the cell's head, the
flags — and both surfaces that split it by position were taught to read them.
THE NEXT FREE POSITION IS COMPUTED OVER EVERY ROW: `next-free` now unions the
rows on stdin with this checkout's register and the archive below, so a position
the listing hides still holds, and a retired `<repo>-<n>` is never reissued.
A LIVE lane's line now prints the ATTACH — `lane <name>`, the same word, under
its own label (Amendment 18 Addendum 1: a lane live here is attached to and
never started a second time). The column said `none` for the one row where the
act is "go to it".
(c) THE SWEEP. `lane-end --retire-dormant <repo> [--reason "<why>"] [--yes]` is
a DRY RUN that names every dormant row of that repository with its start, its
workstation and its cell's head, and FLAGS the ones above — so the person sees
what the sweep will close before it closes it; an agent's stdin is not a
terminal, so the word is `--yes` typed by a person. With `--yes` it is ONE
commit (`lanes-edit.sh retire-rows`, the lock held once, the scan inside it):
each lane's log is created with a single `RETIRED` line carrying the reason and
the row's own last words, and the row's state becomes
`RETIRED · <UTC> · <why>; was: <head>`. Clause (c) spells that cell
`RETIRED <UTC> · <why> · was: <head>`, which is four parts where 13(a)'s cell
has three and whose first word is a state no reader knows; the phrase written
here carries the same four facts through `row_state_check`, the gate
`set-row-state` uses. It refuses a lane a live session holds here, a row whose
log carries a lane-kind line other than ENDED/RETIRED (a parked lane is not
dormant), a repository with no dormant row (nothing to do is said, not done),
and a session-record read that failed — a read that failed is not "nothing is
live". Any one refuses the whole run before a byte is written, because one
commit is one decision. Nothing outside the register and the logs is touched.
(d) THE ARCHIVE, a second act on a second word: `lanes-edit.sh archive-rows
<repo> [--yes]` moves every RETIRED row of that repository into
`lanes/archive/LANES-retired.md` in one commit naming each lane. The archive is
written FIRST and the rows removed second — `delete_lines` proves N removed and
zero rewritten — so a refusal between them leaves a copy in the archive and the
register whole, never a row in neither. `register_lanes`, the register index and
the next-free reader all read the archive beside the register, which is the
amendment's own sentence.
The payload on a `RETIRED` line is this amendment's own: 7(b) gives that verb
none and A11 clause (c) keeps it that way, and 19(c) — later, ratified, spelled
out character by character — carries the row's last words into the log so that
nothing the row said is lost when the row stops saying it.
Tests: a seeded register of its own (a live lane, a parked one, an ended one,
three rows with no log, and a seventh whose 13(a) cell says PAUSED) — the
default lists two, `--closed` six, the dry run flags the `unpushed` cell and
writes nothing, `--yes` writes three logs and three cells in ONE commit whose
every path is under `lanes/`, a second `--yes` says nothing is dormant, a live
lane and a parked one are refused by the writer itself, the RETIRED line's
session field is the WRITER's uuid (7(b)), `archive-rows` moves the rows and the
position still skips them, and the wrapper prints the attach for a live lane.
Three existing cases are amended rather than worked around: the row's field
count, the pair that asserted an ENDED lane is still in the listing (19(b)
amends 11(j)), and the pick's fixture read, which now asks for `--closed` so
that the partition still has a closed row to drop.
Docs: the two states, the sweep and the archive in `docs/README-lanes.md`, plus
the `--retire` paragraph that still described Amendment 13(a)'s removed two-step
(Copilot round 4 on #82, suppressed comment `docs/README-lanes.md:463`).
Closes#42
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* The retired `append-row-status` names its replacements on a machine with no workspace too (#82, Copilot round 4)
It is out of the dispatcher's WRITER list and says of itself that it refuses
"before the workstation guard and before the register is even looked for" — but
the generic guard one case below caught it all the same. On a machine with no
configured workspace the caller got the WORKSPACE refusal (exit 1) instead of
the RETIREMENT refusal (exit 2) naming `set-row-state` and `log NOTED`, which
are the two acts that replace it. A compatibility refusal that only fires where
the register is already reachable is not one.
Taken from the suppressed comments of Copilot's last review on #82
(`lanes-edit.sh:7525`), which its writer was cut off before reading; the rest
are filed as issues of their own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* `add-row` refuses a lane name the archive holds, so a retired position cannot be reissued by hand either (Amendment 19)
`next-free` counts the archive and never OFFERS a retired `<repo>-<n>`, but
nothing refused one that was typed: `archive-rows` moves a RETIRED row out of
`lanes/LANES.md`, and every duplicate test this writer makes reads the register
alone — so `lane-start <repo> 4` on an archived position would have appended a
second row for a lane whose object log already exists. That log is APPEND-ONLY:
the new lane would write its life into the old one's file, and every read of it
— who holds what, when it was claimed, which session paused it — would answer
for two lanes at once.
The archive is asked beside the register, the refusal names the file, the
spelling it found and that lane's log, and it stays a REFUSAL rather than a
resolution: `row_line` and `canon_lane` still read the register alone, because a
line number from the archive would rewrite the wrong file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* A lane named twice in one sweep is a refusal, and the archive report drops a `--` sed will not need
Two small ones, both about acts that are ONE commit. `retire-rows repo19-4
repo19-4` would have appended the same `RETIRED` line to that lane's log twice,
inside a single commit, in a file that is append-only and that no later line can
correct — so a duplicate in the argument list is refused by name, compared on
the RESOLVED lane so that two spellings of one lane are one lane here as they
are everywhere else (Amendment 15).
And `archive-rows`'s dry run printed its names through `sed -n 's/^/ /p' --
<file>`; the `--` buys nothing there and this file is parsed and run under BSD
userland on the macOS job. One `awk` instead, which is how the rest of this file
indents a list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* The pick's read case follows the amendment, and the archive's refusal of a reissued name is asserted
Two test changes. `lanes --prefix repoPick` no longer carries the CLOSED lane —
the pick hid it before the read did, and 19(b) takes it out of the read too —
so that case now asserts the absence and a new one asserts that `--closed` puts
it back with the line saying which verb closed it. What has not moved is the
POSITION, and the footer assertion beside it still proves that.
And the archive's half of "a retired `<repo>-<n>` is never reissued" is asserted
where it is enforced: `add-row` refuses a lane name the archive holds, naming
the archive and that lane's log, while the free position beside it is still
added.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* The sweep's `--reason` is refused as an argument, once, rather than as a cell half-way down the scan
It goes into every row the sweep rewrites, so a `|`, a second ` · ` or a newline
in it is a refusal of the ARGUMENT — named as one, with the words that are
wrong — instead of `row_state_check` dying on the first row with a message about
a cell the caller never wrote. Nothing had been written either way (every test
in that act is made before the first byte), but a refusal a person cannot act on
is a refusal that costs them the run twice.
The cap beside it is 180 where the cell's own line is 240, because the row's OWN
last words go in beside the reason and a reason that fills the cell leaves none
of them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* The unknown-subcommand refusal names `retire-rows` and `archive-rows`, because it is where a person learns what this file answers to
`tests/test_repo_hygiene.py::test_the_unknown_subcommand_refusal_names_every_
subcommand_there_is` caught it on the Windows job, which is the one that runs
the static tests alone: the two new arms existed and worked, and a person who
mistyped either was told by omission that it does not exist. That is F-X16's own
defect — `fetch-age` was missing from the same list for the same reason — and
the test derives the list from the dispatcher's own arms precisely because a
restated list is the thing that drifts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* A published archive this read could not render is not an empty archive
`archive_text` swallowed a failed `git show` and answered with nothing, which
for this file is not a small loss: its rows hold POSITIONS, and read as absent a
retired `<repo>-<n>` comes back on offer — the one thing Amendment 19(d) exists
to prevent. It now falls through to this checkout's own copy and says on stderr
that it did, which is `log_sync`'s own posture for a fetch that did not answer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* An untracked log — or an untracked archive — is somebody's uncommitted work, and neither act may commit it
The finding `migrate-state-cells` took in round 2 of #82, in the two acts that
have its shape. A TRACKED file that is dirty is already refused for the whole
run by `refuse_dirty_checkout`, which exempts the register alone and reads
staged and unstaged alike; an UNTRACKED file is in neither of those lists, so
appending to it would put a peer's first lines into this act's one commit, under
this act's message.
For the sweep that is a lane's object log: a dormant row usually has none at
all, but a MIGRATED one has a log of NOTED lines, and that is the file this
meets. For the move it is the archive itself. Both are checked in the scan,
before a byte is written, and both name the commit that settles them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* The untracked-log refusal is asserted where the sweep would have committed it
`repo19-6` — the row the `add-row` case leaves behind, dormant and with no log —
gets an uncommitted log beside it, and the sweep refuses by name rather than
carrying a peer's first lines into its own commit. Nothing is written and the
file is taken away again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* The manual names the six other refusals and what the archive does when it cannot be read
The section listed clause (c)'s three refusals and stopped there, while the act
also refuses a checkout behind or dirty, a row whose separators hide the state
cell, a log needing Amendment 15's rename, an untracked log, a lane named twice
and a `--reason` that would forge a cell — and the archive half now says that a
published file it cannot render falls back rather than reading as empty, and
that `add-row` refuses a name the archive holds. A manual that lists some of the
refusals reads as a list of all of them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* Copilot round 1 on #93: the liveness test reads both rows, the writer re-proves the listing's dormancy, the archive is read where it actually is, and a flag is never a value
Six takes, each with its own reason.
**A flag is not a value.** `lane-end --retire-dormant repo --reason --yes` took
`--yes` as the reason, fell back to the dry run and exited 0 — a malformed line
that RAN and did not do what it said. Refused now, in `lane-end`'s `--reason`
and in `retire-rows`'s `--reason` and `--writer`, with the `=` spelling named
for a reason that really begins with a dash.
**The LIVE test reads BOTH rows' session ids**, in the listing and in the
writer. The published cell is R19's source for everything else, but a lane whose
`append-session-id` has landed in this checkout and not yet been pushed carries
its current transcript in the local row alone — and `live_session_ids` can see
that very session running. Read from the published cell only, such a lane is not
LIVE, which under 19(a) makes it DORMANT: hidden, and offered to the sweep,
while somebody is working in it.
**The writer re-proves the row's own claim.** Amendment 13(a) makes the state
cell the lane's current state, and the listing does not hide a no-log row whose
cell is the phrase naming anything but `ENDED`/`RETIRED`/`MIGRATED`. The sweep
trusted `lane-end`'s filtered list for that half, so `retire-rows <lane>` by
hand could retire a row the listing calls parked — two surfaces disagreeing
about one answer, which is the defect clause (h) exists to prevent.
**`next-free` reads the PUBLISHED register and archive**, with this checkout's
as the fallback: reading only the local files missed a row that is on
`origin/<branch>` and not pulled here — hidden from the stdin rows by clause (b)
and absent from the union, so its position came back on offer. And a source that
EXISTS and cannot be read is now a refusal rather than an empty set, because a
position is handed out once and a read that failed is never an answer (7(d)).
**`add-row` asks both copies of the archive**: `archive_text` answers with the
published file wherever there is one, so a move committed here and not yet
pushed was invisible and its name came back on offer.
**`archive-rows` refuses a row of its repository that it cannot take apart**,
where it used to skip it silently: the act says it moves EVERY `RETIRED` row,
and a row whose separators hide the state cell may be one — moving the others
and saying nothing about that one reports a partial act as a whole one.
Declined, with the amendment's own words: making DORMANT depend on a log FILE
existing rather than on the log carrying a lane-kind line. The sweep's own
refusal is "a log that already carries a lane-kind line other than
ENDED/RETIRED", so the two must be the same test or they disagree; and
`migrate-state-cells` creates logs of `NOTED` lines, which under the file test
would take exactly the fourteen rows this amendment was written for out of the
sweep's reach, silently. The one already fixed in this branch is the
unknown-subcommand list, which now names both new verbs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* The sweep's dry run holds its columns against the register's own free text
Measured on the live register, where the fourteen dormant rows of openxFactory
carry `started` cells like `2026-09-02 ~22:30Z; RESUMED 09-03 12:55Z and 19:27Z`
and workstation cells reading `<workstation? — owner fills>`: either pushes
every column after it out of line, in the one report a person reads to decide
whether to say the word. Both are cut to their width; the LANE is widened
instead of cut, because it is the name they act on. The cut is a bash expansion
and not printf's `%.Ns`, which counts BYTES and would tear the `≤` and `—` those
same cells carry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* Copilot round 2 on #93: the pick drops a row by its class, the dry run previews the act it will run, and no read of the archive answers "empty" for "unreadable"
Nine takes.
**The pick was still reachable by a dormant row.** `lane_groups` filtered on the
STATE word, and a dormant row's state is `NO LOG`, which is in none of its
lists — so a row arriving from a read that does not hide it (`--closed`, or
`lane <name>`'s own `--lane` read, which is never filtered because its caller
named the lane) was grouped as available, and the word would have offered to
bind a lane whose last act was its last. It drops on column 13 now, which is the
read's own answer to exactly that question.
**The dry run must be a preview of the act it prints.** `--reason` was checked
only inside `retire_rows`, which `--yes` reaches and the dry run does not, so a
reason carrying a `|` printed a clean preview of a run that could not happen.
Checked in `lane-end` too, by the writer's own rules, before anything prints.
And the reason is forwarded as ONE argument — `--reason=<why>` — because split
in two, the documented spelling for a reason that BEGINS with a dash arrived at
the writer as a flag and was refused there by design.
**The liveness snapshot is taken inside the lock.** Read before `acquire_lock`,
a lane could go live behind it: `lane-start` appends its id to the row under
that same mutex, so the scan would read the new id and compare it against a
fence that predates it. And the lane-kind check reads THIS CHECKOUT's copy of
the log beside the published one, for the reason the session ids are read from
both rows: the `behind` guard refuses a checkout that is behind, one that is
AHEAD is legitimate, and there a `STARTED` that exists only here was invisible.
**No read of the archive answers "empty" for "unreadable."** `archive_text`
returns non-zero where the published file exists, will not render, and there is
no local copy to fall back to; `lane_position_rows` treats an empty answer from
a non-empty register as a failure; `add-row` refuses rather than missing a
retired name; and the listing — which may not refuse, because it sits in front
of a launch — SAYS that the archive could not be read and that the position will
be refused rather than guessed.
**`add-row` compares the POSITION and not only the name.** `lane_next_free`
reserves `repo-4` and `repo-4a` as one position — "a re-cut position is not
free" — so a name test alone let `repo-4a` through against a retired `repo-4`.
**An empty listing is not an empty register.** Since 19(b) a repository whose
every row is CLOSED or DORMANT lists nothing, and `lanes`'s empty branch offered
"next free position: 1" — a number a dormant row holds. The position is asked
for now (`next-free` reads the register and the archive itself, so it answers
over an empty listing), and the line says which of the two emptinesses it is.
Six cases added: the pick dropping on the class, the dormant-only repository's
footer, the reason refused before the preview, the `=` spelling surviving, and
the two from round 1's archive work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* The manual says what an empty listing means now, and that the pick drops a row on its class
Two sentences for two things a person meets: a repository whose every row is
closed or dormant lists NOTHING, and the footer there says which emptiness it is
and offers the position it read rather than assuming 1; and a closed or dormant
row never reaches the numbered pick even when it is asked for by name, because
`lane <name>`'s read is deliberately unfiltered and `lane-groups` drops it on the
class rather than on the state word.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* The empty listing will not offer a position over a read that did not answer either
The branch I added for a repository whose rows are all hidden fell back to 1 whenever `next-free` failed — including the case that read refuses ON PURPOSE since round 2, a source it must count that could not be read. Offering 1 there hands out a position a retired row may hold, which is the one thing the amendment is for. The un-upgraded workstation (exit 2, no `next-free` at all) keeps the old answer of 1, because that is what this branch printed before the read existed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* Copilot round 3 on #93: the sweep's flags belong to the sweep, the hidden rows are asked about rather than inferred, and no log read answers "no lane-kind line" for "could not be read"
Six takes, one already in this branch, one declined again.
**The sweep's own flags belong to the sweep.** They are parsed for every
invocation, so `lane-end <lane> --yes` ran the ORDINARY ending — a line that
reads like a confirmed sweep doing something else entirely — and `lane-end
<lane> --reason "<why>"` dropped the words on the floor. Both are refusals now,
each naming the act the word belongs to.
**Whether there are hidden rows is ASKED.** The empty branch used
`next-free != 1` as a proxy, which is wrong for the register holding ONE hidden
row at position 2 or later: there the lowest free position really is 1, and the
listing said "no lane of <repo> is recorded" about a repository that has one.
The `--closed` read answers it directly, on the path that has already found
nothing to show.
**No log read answers "it has no lane-kind line" for "it could not be read."**
`lane_log_events` ends in `git show … | parse_log_stream` and this file sets no
`pipefail`, so a `git show` that failed was masked by the parser exiting 0 over
empty input — and an unreadable log then read as the one answer that lets the
sweep through. The published object is proved readable by name first, and this
checkout's copy is read with the same posture instead of `|| :` (the untracked
guard does not catch a TRACKED file that will not open).
**`next-free` reads the published register even where the working tree has no
file**, which is `register_text`'s own rule; gated on the local file existing,
it skipped the published source entirely on a checkout that lacks it.
**A row is never classed DORMANT over a liveness read that failed**: dormant is
"no object log AND no live session here", and where the session records could
not be read the second half was never established. The class is left unset, so
the row stays LISTED and the sweep is never offered it — the listing already
says the read failed; this is what it does about it.
Already in this branch: the empty listing refusing to offer a position over a
failed `next-free` (`a8e56dd`). Declined again, with 19(c)'s own words: refusing
a lane whose log already ENDS. The clause refuses "a lane-kind line other than
`ENDED`/`RETIRED`", so a closed log passing is the amendment's text, and writing
a `RETIRED` line into a finished lane is Amendment 6(d)'s own act — what
`lane-end --retire` has always meant, in a file where a second closing line
loses nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* One `log_path_ci` per lane in the sweep, not two
Round 3's readability probe resolved the published log path and the guard below it resolved the same path again — `log_path_ci` is an `ls-tree` of the whole log directory and this loop runs per lane, so that is a second read of the same tree for an answer already in hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* Copilot round 4 on #93: a log only this checkout has is still an object log, the archive is not exempt from the dirty guard, and two more reads stop answering "empty" for "failed"
Four takes and one decline.
**A log this checkout has and `origin` has not is still an object log.** The
facts pass reads the PUBLISHED logs (R19), so a log committed here and not yet
pushed is in no fact the listing has — while the sweep has read the local log
since round 3. The two would have disagreed about the amendment's own condition
on a checkout that is merely AHEAD. One `ls` of the log directory for the whole
listing, lower-cased and matched per lane, because a lane name is one name under
any case.
**The archive is no longer exempt from `refuse_dirty_checkout`.** It was,
because this act writes it — but `handle_preexisting` captures the REGISTER
alone, so a tracked edit somebody else left in the archive rode into this commit
under this act's message. Dropping the exemption also makes a half-done move
safe: if the append lands and the removal or the commit does not, the archive is
dirty and the next run refuses by name instead of appending the same rows twice.
**Two more reads stop answering "empty" for "failed".** `archive-rows` read the
register inside a here-document, where a failed `awk` is discarded and the act
reports "no row has the state RETIRED" about a file it never read; and
`lane-end --retire-dormant` counted zero dormant rows and announced that none
are dormant when the listing had just said this workstation's session records
could not be read — a reason it has not got, since DORMANT is "no object log AND
no live session here".
**Declined**, with the sibling act's precedent: staging the log appends and
replacing them atomically. Nothing is half-PUBLISHED — the commit is the only
thing that publishes, and every refusal is made in the scan — and a failure in
the writes themselves leaves the checkout dirty and the register unpublished,
which `git status` shows and `git checkout -- lanes` undoes whole, with the next
run refused by the dirty guard rather than made twice. That is what
`migrate-state-cells` does under the same amendment family and the same review.
The manual now says it in those terms rather than leaving it to the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
* The pick stopped answering inside the terminal's minute: the state cell's head and its flag words leave the table every lane walks
Amendment 19(b) put two FREE-TEXT fields — the state cell's head, up to 280
characters, and the flag words read from the whole of that cell — into
`lanes_register_index`, which is the table `lanes_rows` hands to `table_lookup`
ONCE PER LANE IN THE ESTATE. That is ruling 12's own trap, and it was measured:
on a seeded register of 132 rows one `lanes --dir … --repo … --prefix <repo>`
took **42 s** where this branch's parent took **14**, and on the live register
8.8 s against 4.9. `lane`'s numbered pick prints NOTHING until that read
returns, so in CI its terminal cases — which write one answer into a pty and
give the word sixty seconds to say something — were killed with no output at
all. **Fifty assertions**, on every platform: the pick's own thirteen and every
`lanes`/`lane-start` case downstream of them, plus the suite's `sleep 3000`
liveness fixture, which the thirteen minutes of timeouts outran.
**The two fields are a table of their own** — `lanes_register_index --cells`,
`<lane>` and those two and nothing else — built ON FIRST USE and asked for
BELOW the narrowing filter, and only for a row with NO OBJECT LOG. That is the
only row they are about: 19(a)'s dormant row is a row whose CELL is all the
listing has, while a row with a log has the log, and `lanes` prints neither
field for it. The four short facts every row needs, `started` among them —
`--closed` prints it for a CLOSED row too, and it is a date and not free text —
stay where they were.
Measured after: 16.7 s against the parent's 11.7 on the same 132 rows, and
level with it on the live register. Every class (column 13) and every DORMANT
row of the live register is byte-identical across the change; what moves is
columns 15 and 16 of a row that HAS a log, which are `none` now.
**The case that catches it** is the structure and not a stopwatch — a clock
assertion on a shared runner is a flake, while a row that has a log carrying
its cell's text is the defect itself, in one field. `repo19-3`'s cell now says
`unpushed` and `lost` on purpose, and four assertions read the two fields off
the two rows that have logs and off the dormant rows beside them. Three of the
four fail on the parent commit and all four pass here.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r
* The manual says which rows carry the state cell's head, because "every row" is what cost the listing three times its time
The table row in `docs/README-lanes.md` listed Amendment 19's four new columns
without saying that the last two are a row's ONLY where it has no object log.
That is not a detail of the rendering: they are free text of up to 280
characters per row, the table the listing joins on is walked once per lane, and
carrying them for every row is what took `lanes --prefix <repo>` from 14 s to
42 s on a register of 132 rows and killed `lane`'s terminal cases in CI. The
sentence carries the measurement, so the next person who wants a fifth
free-text column reads the cost beside the column.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r
* A row the cells pass did not see is not a row with no state cell
The split one commit up reads the state cell's head and flag words from a
SECOND pass over the same register text, and an empty answer from it is not
the same fact as an empty cell: `mig_cell_is_phrase` is false over nothing, so
a row the second pass missed would be classed DORMANT — hidden from the listing
and offered to the sweep — even where its cell says `PAUSED`, which is the one
case Amendment 13(a) is in this rule to protect. The two passes read ONE
register text, so a row in the first table and not the second is a read that
answered differently the second time (Amendment 7(d)), and `lr_cellrow` carries
whether it was seen. Without it the class is left unset and the row stays
LISTED, which is the posture round 3 gave the liveness read one screen up.
The published pass is the one that carries it, because the published row is the
only one the class is ever decided on: `lr_row` is `lr_ixl`, and a row this
checkout alone has is in no class either way.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r
* The ten macOS reds at 758a536 were two defects: a `-v` that spans lines, which one-true-awk refuses outright, and a liveness fixture bounded below the timeout that was raised past it
Both are invisible on Linux, and both answered as something other than
themselves, which is why the job's transcript had to be reverse-engineered
before either could be named.
SIX OF THEM: `awk -v` CARRIES ONE LINE, AND `delete_lines` HANDED IT MANY.
A `-v name=value` is processed as if it were a string literal and a string
literal cannot span lines: macOS's awk is the one-true-awk, which refuses a
newline in one outright — `awk: newline in string … at source line 1`, exit 2,
NO OUTPUT AT ALL — while gawk and mawk accept it silently. So `archive-rows
--yes` appended the three rows to the archive, read an EMPTY rewrite of the
register, failed its own line-count proof and died 5 with the register whole:
`archive-rows --yes exits 0` got 5, `…in ONE commit` got 0, `…and the rows are
out of the register` got 3, and three more fell out behind them. Measured both
ways against a shim that IS that rule, on this workstation:
pre-fix, one-true-awk rc=5 rows-left=4 archived=3
awk: newline in string list... at source line 1
lanes-edit: removing 3 row(s) changed the line
count by 8; refusing
post-fix, one-true-awk rc=0 rows-left=1 archived=3 committed
post-fix, gawk rc=0 rows-left=1 archived=3 committed
The list goes through the environment and is read with `ENVIRON`, which is
POSIX awk and has no such restriction — the spelling `who_landing` settled on
when it paid for this same rule in round 5 of A9 Addendum 4 (R-A9-11). The pass
is read with its OWN status as well: an awk that failed writes an empty file,
and an empty file is not "no rows matched" (Amendment 7(d)).
AND THE CASE THAT CATCHES IT EVERYWHERE. A shim on `PATH` refuses a `-v` whose
value carries a newline exactly as one-true-awk does and `exec`s the real awk
for everything else; `repo19d` is a repository of two RETIRED rows archived
under it, asserted to exit 0, in one commit, with the rows moved. The shim is
itself asserted two cases above — it bites, and it passes a one-line `-v`
through — so it cannot make the case pass vacuously. AGENTS.md now carries the
rule beside the bash-3.2 one, for the reason that one is there: a constraint
only one job can see belongs where it is read before the code is written.
FOUR OF THEM: THE LIVENESS FIXTURE EXPIRED INSIDE A RUN THE WRAPPER WAS STILL
WAITING ON. `tests/test_lane_helpers_suite.py` lets this file run for 3600 s —
raised from 2400 when the macOS job spent 2722 s in it — while the fixture was
still bounded at 3000, so the suite was licensed to outlive its own evidence by
600 s. It did: tests-macos reached the Amendment 19 section, the last 5% of the
file, with the fixture already reaped, and the LIVE lane's attach line and the
sweep's live-session refusal both read `none`. 4200 is 3600 plus ten minutes.
The pair is now ASSERTED and not merely written down twice: the wrapper reads
the `sleep <n> & LIVE_PID=$!` line and refuses a bound that does not exceed its
own `TIMEOUT_SECONDS`, so the next person to raise one is told about the other
in seconds on every platform, rather than in an hour on one.
Copilot's round 5 on #93 is filed as #120 and claimed, under the two-round cap:
the register rewrite's unchecked status in both places that make one, the
sweep's published-path preflight asking a weaker question than `ensure_log`'s
own, and `retire-rows`' refusal telling an already-CLOSED log to `lane-end`
itself. The first two are taken there as real; the third is declined as a rule
with 19(c)'s own word — *carries*, not *ends with* — and taken as a sentence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r
* Keep lane fixtures isolated from host launchers and tmux names
Provide a non-colliding window table for unrelated lane-start checks and pin the picker to its fake launcher.
Lane: openRepoTools-3
* Fence archive writes and bypass grep wrapper
Stop archive-rows when the register rewrite fails, and prove duplicate archive rows with the real grep command. Cover read-only register and wrapper retry cases.
Lane: openRepoTools-3
* Keep live lane binding reads local
Preserve the picker no-fetch contract for both register-row probes before a LIVE attachment; add a regression wrapper that refuses fetch-capable probes.
Lane: openRepoTools-3
* fix: fence retired lane identities across rebase and attach
Lane: openrepotools-3 (openRepoTools-3)
* test: align helper dispatch and early fixture setup
Lane: openrepotools-3 (openRepoTools-3)
* fix: refuse divergent attach and malformed dormant rows
Lane: openrepotools-3 (openRepoTools-3)
* fix: neutralize rejected add-row before later pushes
Lane: openrepotools-3 (openRepoTools-3)
* test: keep lclaude visible in no-lane fixture
Lane: openrepotools-3 (openRepoTools-3)
* test: clear explicit launcher override in fallback case
Lane: openrepotools-3 (openRepoTools-3)
* Put the lclaude fixture back in a17bin, where the case that needs it can see it
The one red line on Linux at 08dad90, in both `tests` and
`tests-no-submodule`, was "the lane-aware restart line is printed when
lclaude is installed": `lane-handoff` printed `pclaude --lane repoHF-1`
because no `lclaude` was on the PATH that case runs under.
925995b meant to move the --restart case's fake `lclaude` into a directory
of its own, but both cases open with the identical `cat > a17bin/lclaude`
line and the hunk landed on the FIRST: that case then wrote its fake into
`a17-launcher-only` (not on `$A17PATH`) and `chmod`ed an `a17bin/lclaude`
that did not exist. The --restart case went on passing only because it
picked up the first case's leftover file, made executable by its own
`chmod`.
What the --restart case actually lacked at afd5481 was not a directory:
`a17bin` holds no `lane`, so `$A17PATH_NOLANE` keeps it. It was the
`PCLAUDE` override 8326e68 exports for the picker cases, which
`lane-handoff --restart` honours before any PATH lookup — and 08dad90's
`env -u PCLAUDE` already removes it for that one invocation. So both
cases return to main's `a17bin` form and the only difference from main
here is that `env -u PCLAUDE`.
Lane: openRepoTools-3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Say what a published archive that will not render does: it refuses
d0310f6 made `archive_text` fail closed when origin holds the archive and
`git show` cannot render it — a local copy may lack a retired position the
published one holds — but the manual's Amendment 19(d) paragraph still
promised the fallback to this checkout's copy that the change removed.
Taken from the review of d0310f6 on #93 (docs/README-lanes.md:2490).
Lane: openRepoTools-3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Offer the sweep's reason back as one shell-quoted --reason= word
The dry run of `lane-end --retire-dormant` ends with the line that makes
it the act, and it rendered an accepted reason as `--reason "<why>"`. For
`--reason=--why` — the spelling the parser documents for a reason that
begins with a dash — that line is a dash-led value the same parser
refuses, so following the preview failed; and a reason carrying `"` or
`$(` came back as shell syntax the person would run. The line now carries
`--reason=%q`, which is the one-argument form the confirmed act already
forwards to the writer. Taken from the review of 44b77c3 on #93
(lane-end:697); two assertions pin it.
Lane: openRepoTools-3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Give the shell suite 5400 s and its liveness fixture 6000, because darwin crossed 3600
tests-macos on this PR has failed the same way twice, and not in #107's
class (the pty driver's 60 s SIGKILL, read as exit 137): the wrapper's own
bound killed the whole shell suite —
`subprocess.TimeoutExpired: … timed out after 3600 seconds` — at 08dad90
(`1 failed, 680 passed in 3961.39s`) and at 968ab5e (`1 failed, 693
passed in 3927.56s`), so neither run says which assertion was in flight.
Amendment 19 adds ~220 cases, and main at 69bf48d was already within
minutes of the bound (3741.66 s for the whole job's pytest). Since #119
`tests-macos` is the landing gate on the `ready` label, a bound the suite
cannot finish inside is a gate nothing passes and nobody can read.
This is the third raise for the same reason (900 -> 2400 -> 3600), dated
and measured beside the other two. The liveness fixture moves with it,
4200 -> 6000, because the pair is asserted: the wrapper refuses a sleep
that does not outlive its own timeout. The honest fix is still the
suite's duration, #77.
Lane: openRepoTools-3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Ask lane-start's window-name fence only of a NEW lane, not of a lane Amendment 18 hands over
At the #83 merge (7c1aef2) Linux `tests` went red on two of #83's own
Amendment 18 cases, both `lane-start` exiting 2:
FAIL …which it now does, with nothing refused expected [0], got [2]
FAIL `lane-start --dry-run --request-handoff` prints a plan instead of
refusing on a release it did not make expected [0], got [2]
Both lanes (repoBind-3 after a forced release, repoBind-12 bound in
another container) have their own window in the shared tmux server —
`cloudsess:4 @62 repoBind-3`, `cloudsess:6 @65 repoBind-12` — and the
server-wide window-name fence d0310f6 added to step 4 refused the second
place's bind because another window carried the name. Amendment 18 rules
that takeover in: a bench in another container keeps the lane's name
until that session hands off, and binding after a release or through
`--request-handoff` is the flow. main has no such fence and is green.
The fence keeps the case it was written for: a NEW lane (no row), whose
name on any other window is certainly foreign because nothing has bound
it. That is this branch's own assertion — `REPOA-8` on @99 still
refuses — and the manual's sentence now says which launches it asks.
Lane: openRepoTools-3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Let the sweep retire a no-log row whose cell already says RETIRED, as the listing says it may
The listing classes a row with no lane-kind log line DORMANT unless its
Amendment 13(a) cell names a state other than ENDED, RETIRED or MIGRATED,
so a no-log row whose phrase says RETIRED is dormant and the preview names
it. d0310f6 made `retire_rows` refuse that cell as "already retired", so
the act could not take a row its own preview offered, and because the
sweep is all-or-nothing that one row stopped every other retirement in the
repository. The row a migration leaves is that row: the phrase in the
cell, NOTED lines in the log.
A second retirement is a second RETIRED line in an append-only log, and
refusal 2 directly above already refuses that, reading the log. The cell
test goes back to accepting ENDED, RETIRED and MIGRATED, so the read and
the writer agree again, and a new case (`repo19ret-1`) pins it: dormant to
the listing, retired by the writer, one RETIRED line in its new log.
Taken from Copilot round 20 on #93 (lanes-edit.sh:10044).
Lane: openRepoTools-3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bring in #119, #103, #83, #139 and #93 so #61 lands last in the queue
on the main it will actually land on. One textual conflict:
- lanes-edit.sh, the `*)` arm's unknown-subcommand list: main added
`binding`, `request-handoff`, `register-row-local`, `retired-identity`
(#83) and `retire-rows`, `archive-rows` (#93); this branch added
`duplicate-holder`. Every name is kept, in main's order with
`duplicate-holder` after `forks` as before, and the list still equals
the dispatcher's arms exactly (test_repo_hygiene's derived check).
Checked against #61's code, nothing else needed a change: the suite's
fake-command block (tmux, claude, pgrep, ps, gh) is intact and no new
main code calls pgrep or ps; #93's columns 14-17 and #83's column 13
are in the `lanes` listing rows, which #61 never reads; LOG_AWK's
fields, live_holder's, transcript_holders' and lane_forks' output
formats are unchanged; #83's HANDOFF-REQUESTED is a lane-kind verb that
changes no state and holder_is_dead's five-verb whitelist skips it as
every last-line reader must; and lane-end's --retire-dormant sweep is
refused beside --retire and exits before the fork/duplicate retire path.
Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…18(b))
Copilot on 8ce6c9d, against the Amendment 18 that #83 landed on main:
holder_is_dead confirmed a terminal log with live_holder's 8 -- no
session record on THIS workstation holds the lane -- which proves nothing
about a lane whose last STARTED/RESUMED was written on another host or in
another container. Clause (b): liveness is pronounced only from inside
the binding's own host and container; from anywhere else it is UNKNOWN,
never dead. So claim --force could take over the open holds of a lane
still running somewhere this workstation cannot see.
The last binding is read off the last STARTED/RESUMED before the
terminal line, with clause (a)'s cutover rule (no host/container/os is a
pre-amendment line, matched on its own workstation), and judged by
binding_is_here, the file's one locality rule. Not here, clause (b)'s
one exception still applies -- a shared tmux server on which the
binding's window is gone is a dead binding (binding_window_state).
Anything else sets HOLDER_BOUND_ELSEWHERE: claim --force refuses it
naming the binding, ahead of the verb and age tests, as it refuses
HOLDER_TERMINAL_UNKNOWN; the takeover recheck abandons on it in
could-not-reconfirm words.
Suite: repoKr-1 ends RETIRED with no record here and its last STARTED on
elsewhere-host -- --force refuses with 2 naming elsewhere-host/none, and
the same takeover run with that host and container is granted.
Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* claude-current and claude-restart-check: launch only the newest published Claude Code, and tell a running session when its binary moved
For opensoft/openBenches#119, on Brett Heap's rulings of 2026-09-29:
the home decision ("this work is really for openRepoTools repo") puts
the resolver here, installed by `openRepoTools --install`, and point 2
("for 2, we can run update on every start, this ensures we have the
latest models") makes every launch run the check.
claude-current resolves the executable by ABSOLUTE PATH, never through
PATH: the newest native version, the user npm prefix, ~/.local/bin/claude,
then the image's copies, each read with --version and read once per
physical file. It asks npm for the published version with a bound, updates
the user-writable install under a lock when every candidate is behind
(claude update for a native install, else npm install -g --prefix <user>
plus the package's install.cjs that npm 12 skips), and hands back the path
whose version equals npm. An equal candidate beats a newer one, and a newer
one launches as ahead. Offline, it launches unverified. Still behind after
the update, it refuses (exit 2) unless CLAUDE_ALLOW_STALE=1.
--porcelain gives path/version/published/status, and --offline reads no npm.
claude-restart-check prints one green RESTART NEEDED line when the claude
process above it (at most three processes up) runs a binary since
replaced on disk (exe ends " (deleted)") or older than the installed one,
read from the native versions directory or the package's package.json. It
reads /proc and two small files, runs no binary, reaches no network, and
always exits 0.
Tests: tests/test_claude_current.py and tests/test_claude_restart_check.py,
both hermetic, and both commands held to test_repo_hygiene.py's bash rules.
The macOS job parses both with /bin/bash -n.
Refs opensoft/openBenches#119
Lane: openxfactory-5 (openXfactory-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* claude-current: the watchdog ends the whole tree, and one launch reaps a dead owner's lock
Two Copilot findings on #134, both in the fallbacks a
stock macOS takes, and a third wording fault found beside them:
- With no `timeout` and no `gtimeout`, the watchdog killed only the command.
A child the command had started (npm's lifecycle processes, a script's
`sleep`) kept the caller's command substitution open, so a 1 s bound took
as long as the child did. `kill_tree` now stops, walks (`pgrep -P`),
signals and continues the whole tree, as GNU `timeout` ends its process
group, and the watchdog's own `sleep` is ended the same way.
- With no `flock`, two launches that both read one dead owner each removed
"the stale lock", and the second removal could take the lock the first had
just made, so both updated at once. Only the launch holding the reaper
directory (`<lock>.d.reap`) removes it now, and only while the lock still
names the pid it saw dead. A reaper left by a launch killed in those lines
blocks the takeover, and the refusal names it.
- A lock directory that could not be created was reported as a lock that
"was not free within 330s". It is named as what it is.
tests/test_claude_current.py runs the two fallbacks on every host by hiding
`timeout`, `gtimeout` or `flock` from PATH. Against the previous head the
watchdog case takes the fake npm's full 30 s and the reaper case removes the
lock and updates; both pass here. 52 cases.
Refs opensoft/openBenches#119
Lane: openxfactory-5 (openXfactory-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* lane-start: an unset CLAUDE_BIN launches what claude-current names, and a stale one is refused before any write
opensoft/openBenches#119, Brett Heap 2026-09-29, verbatim "for 2, we can run
update on every start, this ensures we have the latest models". The hook is
minimal and touches three places only:
- Beside the CLAUDE_BIN default: CLAUDE_VERIFIED_VERSION is read once and
removed, so no session inherits it. CLAUDE_BIN unset, or equal to
CLAUDE_RESOLVED_BIN with no CLAUDE_VERIFIED_VERSION (an earlier launch's
answer inherited through a session's environment), is resolved. Any other
CLAUDE_BIN is a pin, launched as it is.
- At the foot of 5a, once the agent and the launch are known and before the
row, the object log and the handoff stamp are written: `claude-current
--porcelain`, found beside this command or in $OPENREPOTOOLS_BIN_DIR and
never on PATH. Exit 0 replaces the command's first word with the absolute
path and exports CLAUDE_BIN and CLAUDE_RESOLVED_BIN; exit 2 ends the run
with 2 and anything else with 1, nothing written. `--dry-run` plans the
call and `--no-launch` makes none. Only the `claude` agent asks. With no
claude-current installed, one note, and CLAUDE_BIN as before.
- The lane's STARTED/RESUMED payload gains `; claude <version>` when a
version is in hand, and only when it is x.y.z.
Step 4 is untouched. The bare path a `--confirm` answered No takes still
launches CLAUDE_BIN unresolved.
tests/test_lane_start_claude_current.py: 17 cases in a cut-down copy of the
lane suite's sandbox, with a stub resolver and a decoy one on PATH.
Refs opensoft/openBenches#119
Lane: openxfactory-5 (openXfactory-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* --install places claude-current and claude-restart-check, and their manual
`INSTALLABLES` gains the two commands at its END, so every index the suite
takes still names the file it did: FIFTEEN files and TWENTY-NINE artifacts,
with the usage text, the header and every in-body count moved with them
(`THIRTEEN SINCE AMENDMENT 16` and "the thirteen placements" stay: one is
history and the other a different count). The home ruling of 2026-09-29 is
why they are here: Brett Heap, verbatim "this work is really for
openRepoTools repo".
docs/README-claude-current.md is the manual: the candidates and their order,
the update and its lock, output, exit codes and variables, the hand-off
between launchers (CLAUDE_BIN, CLAUDE_RESOLVED_BIN, CLAUDE_VERIFIED_VERSION),
what `lane-start` does with it, and the restart check the status line calls.
README.md stays at 472 lines, the cap tests/test_repo_hygiene.py holds it
to: the counts are rewritten in place, and "THIRTEEN files" and
`13 of 13 placed`, which that test also asserts, survive as the history
sentence that says when they stopped being true. The doc is linked from the
existing line that names docs/README-lanes.md.
Refs opensoft/openBenches#119
Lane: openxfactory-5 (openXfactory-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* claude-current: a malformed bound exits 64, the usage status, and not 1
The workBenches side (opensoft/openBenches#121, 016-launch-current-claude)
reports claude-current's exit 1 as "Claude CLI not found.", which is what 1
means for every other cause. A CLAUDE_CURRENT_* bound that is not a whole
number of seconds is a typo in a setting, not a missing Claude Code, so it
exits 64 now, as an argument it does not know already did. The launcher there
already refuses on 64 and names the status.
`lane-start` ends with 1 for any failure but 2, as before, and its message
says "named no Claude Code to launch" so it reads true for 64 as well as 1.
The header, the usage text, the manual and both suites say the same.
Refs opensoft/openBenches#119
Lane: openxfactory-5 (openXfactory-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Copilot's second round on #134: SemVer pre-release order, the installed version across both families, and the bare --confirm launch resolves
Three findings, all real:
- Pre-release tags were compared as one string, so `2.1.284-beta.9` sorted
after `2.1.284-beta.10` and a launch could take an older pre-release as
ahead of npm. `precmp` compares the dot-separated identifiers by SemVer
2.0.0 rule 11: numbers as numbers and before words, words in byte order
(LC_ALL=C), fewer identifiers lower. It is in both commands byte for byte,
like `numcmp` and `vercmp`; the suite checks the identity and runs rule
11's own example chain.
- claude-restart-check read the installed version only from the running
binary's family, so an npm 2.1.283 session beside a native 2.1.284 said
nothing, though the next launch starts the native one. It now takes the
highest of the running install's own, the native versions directory
($CLAUDE_CURRENT_NATIVE_DIR) and the user npm prefix's package, which it
finds with claude-current's `npm_user_prefix`, byte for byte. No `$HOME`
reads only the running install. Its suite now runs with `$HOME` and the
npm prefix inside the fixture.
- lane-start's bare launch, the one a `--confirm` answered No takes, came
before the resolution and started `claude` from PATH. The resolution is
one function now, `lane_claude_resolve_launch`, called from that launch
and from the foot of 5a, and it refuses a stale copy in both. The bare
path gains one line; step 4 is untouched.
Also: the lane-start suite asks for the `claude <version>` sub-field by name
rather than at the end of the payload, because Amendment 18 (#83) appends
`host`, `os` and `container` after it (measured on a test-merge of #83).
Counts: tests/test_claude_current.py 70, tests/test_claude_restart_check.py
19, tests/test_lane_start_claude_current.py 20.
Refs opensoft/openBenches#119
Lane: openxfactory-5 (openXfactory-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Copilot's third round on #134: the lock without flock names its owner as it is taken, and the two commands borrow every per-file hygiene rule
- Without `flock`, the lock was a directory made first and given its
owner's pid a line later. A launch killed between the two left a lock with
no owner, which the takeover never removes (it reaps only a named dead
owner), so every later launch waited it out and refused. The lock is now a
symlink whose text is `pid=<pid>`: one `ln -s` makes it and names the
owner, so no ownerless lock can exist. The reaper is unchanged. A
directory standing at the lock path (where `ln` would put the link inside
it and succeed) is refused by name. The release removes the link only
while it still names this launch.
- The two commands are still not in test_repo_hygiene.py's `ALL_BASH`,
because that file is being changed by open PR #93 and this PR touches none
of #93's files. tests/test_claude_current.py now also borrows the
flag-spelling rule and runs the LF-index check for both. The follow-up
after #93 lands is to put `claude-current` in `SHIPPED_BASH` and
`claude-restart-check` in `HELPER_BASH`, then drop the borrowed list.
tests/test_claude_current.py, 72 cases:
- `test_the_link_lock_names_its_owner_while_it_is_held` reads the lock from
inside the update. It fails on the directory lock.
- `test_a_directory_at_the_lock_path_is_refused_not_taken` is new.
- The takeover, reaper and live-lock cases now work on the link.
Refs opensoft/openBenches#119
Lane: openxfactory-5 (openXfactory-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* claude-current: a copy ahead of npm that appears during the lock wait is ahead, not stale
Copilot's overview at 9ba858d, a finding in code unchanged since its
earlier rounds: after the wait for another launch's update, only a copy
EQUAL to npm was accepted. A copy newer than the published version that the
other launch installed meanwhile (npm moved on while this launch waited)
therefore went to another update, or, when the wait ran out, to the stale
refusal, although the same copy is `ahead` before any wait. The re-read
after the lock now gives the same two answers as the first read, in the same
order.
test_a_copy_ahead_of_npm_that_appears_during_the_wait_is_ahead: the other
launch installs 2.1.285 against a published 2.1.284 while it holds the lock
past this launch's wait. At 9ba858d this is exit 2; here it is `ahead`, and
this launch installs nothing. 73 cases.
Refs opensoft/openBenches#119
Lane: openxfactory-5 (openXfactory-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* claude-current and claude-restart-check: a relative npm prefix is taken under $HOME
Copilot's overview at c7e3e3c, a finding in code unchanged since earlier
rounds: npm takes a relative `--prefix` and installs there, but the
candidate read back from it is not an absolute path and is refused, so an
update that worked ended in "no runnable Claude Code" (exit 1). A relative
prefix from any source ($CLAUDE_CURRENT_NPM_PREFIX, $NPM_CONFIG_PREFIX,
$npm_config_prefix, the `prefix=` line of ~/.npmrc) is now anchored at
$HOME, in `npm_user_prefix`, which both commands carry byte for byte. npm
installs into that path, the resolver reads it back, and the restart check
reads it from whatever directory the status line runs in.
Both suites now run their command from inside the sandbox rather than the
checkout, so a relative path can never land in the repository. On the
previous head the three new cases fail: the resolver exits 1 for a relative
prefix from the environment and from ~/.npmrc, and the restart check never
reads the copy. They pass here.
Suites: tests/test_claude_current.py 78, tests/test_claude_restart_check.py 20.
Refs opensoft/openBenches#119
Lane: openxfactory-5 (openXfactory-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Copilot's overview at da83af3: KILL after a grace on every bound, a minified package.json, and one count
Three findings the overview lists as missed in unchanged code, all real:
- A command that ignored TERM, with its children, held a launch past every
bound, because TERM was the only signal. Every branch now sends KILL
KILL_GRACE (5) seconds after TERM: `timeout -k` and `gtimeout -k`, and in
the watchdog, the whole tree it signalled. The watchdog and run_bounded
settle who acts with one `mkdir` of a flag, whoever makes it first, so a
command that ends early is never signalled and a watchdog is never stopped
half way. A zombie counts as ended, because in a container whose first
process reaps nothing (py-bench's pid 1 is `sleep`) it is never reaped. A
137 from the KILL reads as the timeout's 124.
- claude-restart-check read `"version"` only from a line of its own, so a
minified package.json gave no installed version and no notice. The file is
cut at each brace and comma before the first "version" is taken, and a
missing or unreadable file is still silent.
- The comment above `place_skill_and_hook` said "thirteen placements" for
fourteen artifacts. It now says fourteen artifacts in thirteen writes (the
two hook entries go into settings.json in one write).
On da83af3 the three new cases fail (the TERM-ignoring npm on both
branches, and the minified package); they pass here. Suites:
tests/test_claude_current.py 80, tests/test_claude_restart_check.py 21.
Refs opensoft/openBenches#119
Lane: openxfactory-5 (openXfactory-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* claude-current: one symlink lock for every launch, whatever its PATH holds
Copilot's round three on #134 (claude-current:516): the lock was flock
where PATH had one and the symlink where it did not, so two launches
with different PATHs held two independent locks and both ran the same
npm update at once. Every launch now takes the one symlink lock at
claude-current.lock.l, and flock is never consulted.
The symlink names its owner as pid=<pid>@<place>, where <place> is the
host's name and, where /proc shows one, the pid namespace's inode.
Containers that share a home share this lock, and a pid read in another
namespace says nothing about its owner, which flock never needed to
know: a lock made at another place is waited on and never taken over,
and the refusal names its owner and place. A lock made here whose owner
has gone (a zombie included) is taken over under the reaper as before.
A cache directory the launch cannot write in, and a file that is not
this command's lock at the lock path, are named rather than waited out
in silence.
Tests: two real launches, one with a flock on PATH (a logging shim, on
every host) and one without, exclude each other in both orders, one
install, and flock is never called; red on b90f4dc in both orders.
Lane: openxfactory-5 (openXfactory-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* AGENTS.md: the installer places fifteen files
Copilot's round three on #134 (openRepoTools:286): INSTALLABLES now
holds fifteen names with claude-current and claude-restart-check, and
AGENTS.md still said thirteen. One word, line-neutral, so the file stays
at the 265-line cap; #93's AGENTS.md hunk is at line 254 and does not
touch this paragraph.
Lane: openxfactory-5 (openXfactory-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(claude): share relative cache locks across checkouts
* fix(claude): preflight lane launches and qualify newer-copy notices
* fix(lanes): approve handoffs before resolver preflight
Read the authoritative agent after handoff while resolving a possible Claude replacement before release.
Fixes#150
* fix(lanes): reject directories in resolved Claude paths
Fixes#151
* fix(claude): recheck bindings and reject watchdog timeout output
Recheck the binding immediately before window rename after resolver waits. A watchdog timeout stays 124 even when the command traps TERM and exits successfully.
Fixes#152
* fix(claude): contain timed-out children and preflight local fences
Run the watchdog command in a dedicated process group so TERM-handler children remain covered by the final KILL. Refuse known retired lanes and new-window name collisions before resolver work or a handoff, and recheck those fences after resolution before rename.
Validation: 463 focused tests passed, 411 deselected in 195.30s through tests/run.sh in py-bench.
Fixes#153.
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bring #97 onto a current base before the seam rework Brett Heap ruled on
2026-10-04 ("managed ledger owns enrolled lanes; #97 owns legacy — rework
both"). Main moved 14 commits past 957a26f, to daed209: #81 lane-rename,
#83 Amendment 18, #93 Amendment 19, #61 claim --force takeover, #146,
#119, #101-#103, #129, #131, #133, #135, #136, #139 and #134.
Four files conflicted, and in every one both intents survive:
- lanes-edit.sh, the function sections: both sides appended after
migrate_state_cells. Main's Amendment 19(c)/(d) sweep and archive stay
directly under it, because its header says "the shape
`migrate-state-cells` has one screen up"; #97's lifecycle and
inventory section follows them.
- lanes-edit.sh, the unknown-subcommand refusal: the union of both lists,
56 names, the same set as the dispatcher's 56 arms.
- lanes-edit.sh, the exit-code table, and the manual's copy of it: #97's
7 row ("another act got there first", in three verbs) is kept, and exit
9 now has two meanings, one per verb. #61 spent 9 on `claim --force`'s
abandoned takeover and #97 spent it on `lane-state`'s unreadable
snapshot, and each PR took it as unused. Neither verb can return the
other's 9, so the table names both and nothing is renumbered in a
merge. The manual's 9 row said "(`claim` only)", which the merge would
have made false; its 7 row now names #97's two fenced writers too, as
the code table already did. The next commit moves #97's 9 to 10.
- lane-handoff, the late-record restart line: #129's
`$lane_profile_word` (lclaude) with #97's lifecycle line under it.
- docs/README-lanes.md: Amendment 19's section, then #97's #91 section,
so the amendment sections stay in number order.
- tests/test_lane_helpers.sh: main's Amendment 19 and Amendment 18
sections run first, in main's order (A18's restores its fixtures), and
#97's self-contained #91 section follows them, before the
workstation-seam section, as it did on #97's branch.
lane-start auto-merged around #134's rewrite. #97's section 4a still
prints the reconciliation after Amendment 18's binding gate and before
section 5 and the STARTED/RESUMED write in 5b. AGENTS.md, README.md,
tests/test_repo_hygiene.py and skills/ are byte-identical to main, so
the 316- and 486-line caps already match the merged counts.
Locally, tests/run.sh -k 'lane_helpers_suite or repo_hygiene or
lane_start_claude_current or guard_launch_mode or install_skill_and_hook':
274 passed, 600 deselected, in 1560 s.
Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ess group when the wrapper gives up, so a timeout leaves no orphan `sleep`
subprocess.run(timeout=) killed the bash it started and nothing under it, and
the suite's EXIT trap does not run on a SIGKILL, so the liveness fixture's
`sleep` (longer than TIMEOUT_SECONDS on purpose, #83) and every later
fixture's were orphaned for up to 70 minutes. The suite now runs under
Popen(start_new_session=True), and any exception out of communicate() - the
timeout, and a Ctrl-C, which a session of its own no longer receives from the
terminal - kills the group with os.killpg before it propagates.
tests/test_lane_helpers_suite_timeout.py drives the wrapper's own test function
against a stand-in suite that starts a `sleep 600` and hangs: on the old
wrapper the timeout and interrupt tests fail with that sleep still running
(2 failed, 2 passed); the green-suite and red-suite tests pin that the
footer check and the transcript on failure are unchanged.
Refs #142 (item 3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…meout, interrupt, TERM or hangup, pin the launcher precedence of lane <name> with launchers the suite names, and say what the pty driver comment counts (#217)
* Say what the pty driver comment counts: six CI runs in #107, five on darwin and one on Linux whose two jobs both went red, not seven
Copilot read "five on darwin, then both Linux jobs of #61's `ready` run" as
seven runs. #107 names six: five `tests-macos` runs and one Linux run,
37124906535, in which both the `tests` and `tests-no-submodule` jobs failed.
The count was right and the wording was ambiguous; this changes a comment only.
Closes#147
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Pin the launcher precedence of `lane <name>` with launchers the suite names itself, so no host's lclaude can be picked
#143's eight false reds were already gone: c1bac0d exports PCLAUDE before the
`lane` cases, which pins the launcher seam to the suite's fake. What was never
added is a case for the rule that applies when the seam is unset, which is
`lclaude`, else `pclaude`. Three cases now, each on a PATH of its own:
* both launchers present, the pclaude directory FIRST, so the rule is shown to
prefer a name and not a position: lclaude runs, once;
* every directory holding an lclaude taken out of PATH (the a17 section's way
of making a word absent, and on a workstation the directory the real one is
in): pclaude runs with the same argv;
* PCLAUDE set explicitly, with an lclaude on PATH: PCLAUDE wins.
Each was checked against a throwaway slice of the suite on a host that has
workBenches' /usr/local/bin/lclaude, and each goes red under the matching
mutation of `lane`'s launcher() (order swapped, seam ignored, fallback dropped).
Closes#143
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* WIP: checkpoint before account swap; done: #147 comment reword, #143 launcher-precedence cases (harness + mutation checked), #142 item 3 test file written; left: apply the start_new_session/killpg fix in tests/test_lane_helpers_suite.py, run the new pytest file red-then-green under the lock, push CI watch, open the PR
The new tests/test_lane_helpers_suite_timeout.py is unrun: its first run was queued behind other lanes on the pytest lock and was stopped before it started. The wrapper in tests/test_lane_helpers_suite.py is still the unfixed subprocess.run.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Start the shell suite in a session of its own and kill its whole process group when the wrapper gives up, so a timeout leaves no orphan `sleep`
subprocess.run(timeout=) killed the bash it started and nothing under it, and
the suite's EXIT trap does not run on a SIGKILL, so the liveness fixture's
`sleep` (longer than TIMEOUT_SECONDS on purpose, #83) and every later
fixture's were orphaned for up to 70 minutes. The suite now runs under
Popen(start_new_session=True), and any exception out of communicate() - the
timeout, and a Ctrl-C, which a session of its own no longer receives from the
terminal - kills the group with os.killpg before it propagates.
tests/test_lane_helpers_suite_timeout.py drives the wrapper's own test function
against a stand-in suite that starts a `sleep 600` and hangs: on the old
wrapper the timeout and interrupt tests fail with that sleep still running
(2 failed, 2 passed); the green-suite and red-suite tests pin that the
footer check and the transcript on failure are unchanged.
Refs #142 (item 3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Stop the shell suite's group on a TERM or hangup to pytest as well as on the timeout, TERM before KILL, and clean up in the tests that watch it
Review of the session change found that moving the suite out of tests/run.sh's
process group opened a new way to orphan the fixture `sleep`: run.sh's
`kill -TERM -- -<pgid>` and a terminal's hangup now reach pytest alone, pytest
has no handler for either, and the suite's bash dies on its dead pipe with its
TERM trap never run. On main the suite shared the group and its trap cleaned up.
The wrapper's call is now run_in_own_group(): for as long as it waits it
installs a TERM and a HUP handler, where the disposition is still the default
(a nohup'd run keeps ignoring a hangup), which stops the suite's group and then
lets the signal take pytest as it would have. The timeout and a Ctrl-C stop the
group the same way. stop_group sends TERM first and waits up to ten seconds
(run.sh's own grace) for the suite's trap to remove its sandbox, then KILLs
what is left. The comment no longer claims the group is everything the suite
started: the pty driver's children are session leaders of their own.
The new tests signal the runner's own group the way run.sh does (TERM and HUP)
and assert no `sleep` survives, that the TERM trap ran, that a TERM-deaf group
is killed after the grace, and that an ignored hangup stays ignored. The
tests' own cleanup no longer depends on the wrapper working: a fixture kills a
leaked `sleep`, and the SIGINT sender is gated on an Event so it cannot fire
after the wrapper has returned.
Refs #142 (item 3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Add the claude-profile tier and the no-launcher refusal to the launcher-precedence cases, and check the exit code of the PCLAUDE case
The `lane` launcher is `$PCLAUDE`, else lclaude, else pclaude, else
claude-profile, else a refusal with 2. The cases covered the first three; two
more now cover the last two, on a PATH where the suite's own pclaude (in
fakebin, beside the fake tmux, ps and pgrep) is replaced by links to everything
else in that directory, and where every directory holding one of the three
words is taken out. The PCLAUDE case also asserts the exit code.
Checked in the throwaway slice of the suite, on a host with the real
/usr/local/bin/lclaude: dropping the claude-profile fallback, and letting
`claude` itself be found, each turn the matching cases red.
Refs #143
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Let the suite-timeout tests import where the signal module has no SIGHUP
The Windows job collected tests/test_lane_helpers_suite_timeout.py and died with
`AttributeError: module 'signal' has no attribute 'SIGHUP'`: the parametrize
decorator names the signal at import, before the module-wide Windows skip can
apply. SIGHUP is now read with getattr, and the module imports with the
attribute removed (checked by deleting it from `signal` and importing).
Refs #142 (item 3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Make the suite wrapper's stop survive a Ctrl-C during the TERM grace, finish inside run.sh's own, and note a TERM or hangup instead of raising from its handler
The delta check of the signal handling found two edges and a window.
* A SIGINT during the grace left child.wait() with a KeyboardInterrupt before
the KILL stage, so a suite that ignores TERM was orphaned: TERM then Ctrl-C,
and Ctrl-C twice, both ended the runner with the suite's `sleep` still
running. stop_group now ignores SIGINT until it is done, and the KILL is in a
`finally` as well.
* GROUP_TERM_GRACE equalled the ten seconds tests/run.sh's stop_group gives its
own group, which is really about 10.7 (fifty polls of 0.2 s and a `ps` each),
so a delay on pytest's side let run.sh KILL pytest mid-stop. It is five now,
and the comment says why.
* A handler that raised also raised inside Popen(), after the fork and before
`child` was bound, leaving the suite with nobody to stop it. The handlers now
only note the signal; the wait is made in half-second slices that look for
one, a signal that comes as the suite finishes is not swallowed, and the
signal is sent again with its default action after the group is stopped.
The new test sends a deaf suite's runner a TERM, or a Ctrl-C, and a Ctrl-C
inside the grace, and asserts no `sleep` survives. The tests' `ps` check is now
a probe that skips the module where `ps -o stat= -p` cannot answer, rather than
one that passes by finding nothing.
Refs #142 (item 3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Report the suite wrapper's real timeout bound, and pin that a Ctrl-C during the TERM grace lets a slow trap finish
The wrapper now waits in half-second slices, and the TimeoutExpired it re-raised
was the last slice's, so a 5400 s CI timeout would have read "timed out after
0.4 seconds". It is raised again with the bound that was passed and the output
and stderr gathered so far, and a test asserts the bound, the message and the
partial transcript.
Nothing pinned stop_group's SIGINT ignore: with it removed the KILL in the
`finally` still prevents an orphan, but a Ctrl-C in the grace cuts short the
cleanup of a suite that does answer TERM, slowly. A stand-in whose TERM trap
takes two seconds now has to finish writing its marker through a Ctrl-C.
Refs #142 (item 3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Give the slow-trap test a margin that survives a loaded bench: a one-second trap inside the three-second grace
The stand-in's TERM trap took two seconds against a three-second grace, and the
marker landed 2.1 to 2.5 s after the TERM, so about a second of scheduler delay
would have failed it. The trap now takes one second, which roughly doubles the
margin and still leaves the Ctrl-C landing mid-trap, and the comment says where
the Ctrl-C lands rather than where the trap is.
Refs #142 (item 3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brett Heap <service@opensoft.dev>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
readylanding gate: runs tests-macos once before the squash
2 participants
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tooling half of lane-collision-protocol Amendment 18 — ONE BINDING PER LANE. Ratified by Brett Heap 2026-09-14T13:15:18Z as revision 4, verbatim "merge 37 and 78, ratify revision 4"; brettheap/new-workstation#34/#35. Closes the acts of #38.
A pid does not cross a pid namespace, and this tooling pretended it did. Two bench containers on one host share the profile directory, so pyBench reads a session record cloudBench wrote and then
kill -0s a pid in cloudBench's namespace — which is either nothing or some other process. A lane live and writing in cloudBench read NOT LIVE from pyBench, andlane-startthere took the name: two bindings, one lane, one append-only log, reached through this protocol's own liveness read. The record could not say otherwise because it did not carry where a lane runs below the workstation's name.The acts
1 — the record says where the lane is.
write_event, the one writer every caller goes through, appendshost <name>; os <linux|macos|wsl|windows>; container <name|none>to everySTARTED,RESUMEDandPAUSED, read fromLANES_HOST/LANES_OS/LANES_CONTAINERwith clause (a)'s fallbacks —hostname -soutside a container, the Rule 10 workstation name inside one, the kernel probe for the OS, the container's ownhostnamefor its name. The three are read live at every write and never cached: measured 2026-09-15T12:17Z, when a bench container on this estate was recreated with a new hostname and its~/.local/binwent with it.lanes-edit.sh workstationgrows six columns, three values and three sources, after the two it has always printed.2 —
lanes-edit.sh binding <lane>.<host> <container> <window> <utc> <session> <os> <here|elsewhere> <live|gone|unknown>; 0 bound, 8 free, 1 the log could not be read. The seventh column is clause (b)'s whole point — liveness is pronounced only from inside the binding's own host and container — and the eighth is its one exception: one host's launcher mounts ONE tmux socket into every container it starts, so a window gone from that shared server is a pane no session can be in, which is openRepoTools#30's takeover path unchanged.3 —
HANDOFF-REQUESTED, andlanes-edit.sh request-handoff <lane>. A lane-kind verb that changes no state: every last-line reader enumerates the five state verbs by name and none of them sees it, solane-lastafter a request still answersSTARTED. The act is the clause's own order, and the order is load-bearing — the line is written and pushed first, so the bound session can read it from anywhere; then, and only where the bound pane is on this tmux server,/handoff --exit requested by <uuid>@<host>/<container>is typed into it (Amendment 12's M1, and only while that pane's current command isclaude); then the wait, polling the published log every fifteen seconds for a release newer than the request.4 —
lane-startandlanerefuse, then ask. A second fence rather than a replacement for step 3: where the binding is this host's and this container's,live_holderhas already pronounced and nothing changes; where it is not, the refusal stands whateverkill -0said. On a terminal both words ask, naming the binding;--request-handoffis thatywritten down for a caller whose stdin is not one;--force <why>is clause (e)'s override; passing both is refused, because two answers to one question are not an answer. Both doors call the one mechanism, so the line, the pane, the interval and the refusal cannot come to differ between them.--dry-runprints the plan.5 — the guard answers from the bound side. Clause (d): a request newer than this session's binding refuses that one prompt, names who asked and from where, and types the handoff into this pane. Clause (e): a
PAUSEDthis session did not write refuses every prompt from then on, naming the line and who forced it. Both are gated on the binding being this session's — by itssessionfield or by its window, which is the/clear, the usage reset and the profile switch.6 — the empty wait refuses, and only a word forces. Exit 2 with the facts: the binding, the request and its UTC, whether the pane was reachable and typed into, and
--force. That is a second invocation and never automatic; it writesPAUSED … → on behalf of <bound uuid>; forced by <uuid>@<host>/<container>; why <why>in the requester's own session field (openRepoTools#30's release-on-behalf precedent), and then binds.7 —
lane-handoff --exit— already landed in #47, withrequested by …as a why. Nothing was owed here; the guard types exactly that line and the flag was exercised against it.8 — the launcher's half is opensoft/openBenches#77 and is NOT in this PR. What it must export, into every session and every container it starts, beside the
LANES_WORKSTATIONit already exports:LANES_HOST(the machine's short hostname as it reads outside any container),LANES_OS(linux/macos/wsl/windows— andwindowsis the launcher's word alone, since this toolset is bash and runs in WSL2 there) andLANES_CONTAINER(the bench's name:py-bench,cloud-bench, …). This toolset only ever READS them; its fallbacks are what answer until that export reaches a workstation.9, 10 — the clause (i) attach line, tests and docs.
lanesprints a LIVE lane's one act filled in —tmux switch-client -t <session>:<@id>inside tmux,tmux attach -t <session>:<@id>outside, never a secondlane-start, because clause (h) is one live process per transcript. The choice is made in the read (column 10), so the listing and the picker cannot offer two different acts for one row.docs/README-lanes.mdgains the whole clause; AGENTS.md's lane paragraph gains the rule an assistant gets wrong if it is inferred rather than read.Three judgement calls, said rather than buried
ENDEDandRETIREDcarry none of the three. Amendment 18's Adoption list nameslane-end's two "for the record"; clause (a) — the RULE — namesSTARTED,RESUMEDandPAUSED, and clause (b) reads the binding out of the first two. Amendment 11(c) keepsENDED/RETIREDpayload-free as edit 1 of six (R-A11-15), andlane-endalready refuses to write one for exactly that reason: a payload there would be a seventh edit to in-force text. A verb that RELEASES a binding has none to describe, so nothing is lost by the omission and a ratified count is not quietly broken by it.A line written before the clause is matched on its HOST alone. Clause (a): "readers treat its binding as the window on the row's workstation, which is what they read today" — and today's reading has no container in it. Without that, every lane of an estate whose lines all predate this clause would read as another container's the moment the asker was in one — which is every bench session on a launcher-configured workstation, and a refusal of the whole estate on the day this lands. The suite caught it in
lane-start --dry-run repoA11 7, and the cutover is asserted in both directions.The forced release's why is a NAMED sub-field,
why <text>. Clause (e) spells it as a sub-field with no name; every reader in this file matches a sub-field by the word it opens with, so a why beginninghost is unreachablewould be read as that line'shost, refused by the writer's own check, and the person would be told about a field they did not write. Named, it can collide with nothing and the words are still exactly the ones they typed. The line's own three separators (,,;,—) are folded rather than refused, because the person typed a sentence and not a grammar.One boundary worth a word
The request line's
sessionfield is the requester's transcript uuid, and Amendment 7(b) admits nothing else there — not a placeholder, and never the literalunknown, which is in this estate's append-only log four times already with no later line able to correct any of them. A bare shell with no$CLAUDE_CODE_SESSION_IDand no live record for its window therefore cannot make a request: it is refused, naming--session <uuid>and the other way out (ask from inside the session that wants the lane). That is the honest reading of the grammar rather than a gap in the build; if it bites in practice it is a word for Brett Heap, not a fix to make quietly.Counts
lanes-edit.sh+~950 lines: the three fact readers and their fence,binding_subfields_add/_checkin the writer,lane_binding_scan,binding_is_here,binding_window_state,requester_uuid, thebindingandrequest-handoffarms, the guard's two reads, the LIVE attach line in column 10.lane-start+~150,lane+~170,lanes+12,AGENTS.md+12,docs/README-lanes.md+~250.tests/test_lane_helpers.sh+~440 lines, one new section: the three facts and each fallback under the container seam; the sub-fields written and read back and refused when a caller writes them wrong;ENDEDcarrying none; the binding read in every state including a pre-amendment line, from inside and outside a container; a bound lane refused from another host and from another container, and NOT refused where its window is gone or where the binding is this place's; the question on a fake terminal and the flag without one; the request line written FIRST and in the requester's own session field; the pane typed into only when the window resolves and only while it runsclaude; the wait ended by aPAUSEDthe second clone pushed; the empty wait refusing with the facts and releasing nothing;--forcewriting the on-behalfPAUSED; the guard refusing after a request and after a forced release, and the prompt after it; the LIVE lane's attach line inside and outside tmux.tests/test_repo_hygiene.py: the AGENTS.md cap 265 → 278, with the dated entry naming the rule the lines bought.🤖 Generated with Claude Code
Summary by Sourcery
Enforce one binding per lane by recording its execution context, requiring explicit handoffs before takeover, and attaching to already-live lanes.
New Features:
Bug Fixes:
Enhancements:
Documentation:
Tests: