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
Crash-consistent lane worktree recovery: a lane now says WHERE its session stopped — RUNNING → SWAPPING → SWAPPED with a generation/operation fence, a machine-readable worktree inventory taken at every handoff, and a resume reconciliation that reports and resets nothing - #97
This PR gives a legacy lane a crash-consistent lifecycle: RUNNING → SWAPPING → SWAPPED, plus CLOSED, with a generation/operation fence. It adds a machine-readable worktree inventory taken at every handoff, and a resume reconciliation that reports and resets nothing. Under the ruling, every act it adds or touches refuses a lane the managed ledger has enrolled before writing anything. It refuses a lane whose ownership cannot be read in the same way.
Branch 001's governance review is PROPOSED—NOT APPROVED. No Amendment 17 supersession is asserted.
exit 9 → 10 for "the lifecycle snapshot is there and could not be read"
64bcf5b
Amendment 19's sweep now closes the snapshot of each lane it retires
8f69e96
the "no sixth lane verb" claims are reworded. Amendment 18(g) added HANDOFF-REQUESTED, and design decision 12 is reworded to match.
28ed7c7
Amendment 18(b): a lane bound elsewhere reconciles as indeterminate, never as a crash
e9cd956
Amendment 16: rename-lane carries the lane's control root to its new name
39434ba
the seam: the ported reader, lane_is_managed_owned, managed-projection, and a refusal at every call site
673a81a
the lifecycle moves only on a legacy verdict and an unchanged pre-image
e959c3f
the manual, the proposal, design decisions 21–24, a spec requirement with three scenarios, and tasks section 8
a0cf37b, d869dcc, a98b0d8
the tests, and task 8.11 ticked
6e64660, ec9847e
lane-end's seam moved after its own Amendment 15(d) pair refusal, which the full suite caught (see Evidence)
7915745
the three findings Copilot left open on 64dfd97. Codex's P1 on ec9847e is the third of them (see Review)
7773267
Codex's four inventory findings on ec9847e (design decision 25, task 8.12)
d2d2eec
the manual says the reconciliation fetches the register first; the first deferred finding is #157
19df774
Copilot round 2: migrate-state-cells asks the seam, and one managed or unknown row refuses the whole migration (repoMG case 6)
eada5ff
docs only: the live-swap takeover is deferred to #157, the spec scenario says what the code guarantees, and the seam's call-site lists name the migration
07fb3ad
Copilot round 3, taken as seam completeness: every lane-kind line, request-handoff, archive-rows, append-session-id and add-row for an existing lane refuse a managed lane; three back doors refuse the marker's vocabulary; the refusal reads this checkout's row too
8c5958f
docs only: the spec, design decision 21, the manual and task 8.14 name every guarded arm
The merge: four conflicts, every intent of both sides kept
Unknown-subcommand refusal. It is now the union of both sides. With the seam's read verb added it lists 57 names, the same set as the dispatcher's 57 arms.
Exit-code table. It is a union, and the exit 9 collision is resolved in b5aedae, below.
Amendment 19's sweep bypassed the lifecycle.retire_rows appends RETIRED lines without write_event, so a swept lane kept its last snapshot. The sweep now takes each lane's pre-image under its lock and follows each line after it, the same way write_event does.
"No sixth lane verb" stopped being true. Amendment 18(g) added HANDOFF-REQUESTED. It changes no state, and every state reader skips it. The four claims now say "this change adds no lane-kind verb". Design decision 12 now rests on the reasons that remain: a transition must happen offline three times per handoff, and SWAPPING is a state.
Liveness was pronounced from outside a lane's binding. Amendment 18(b) says a binding is unknown, never dead, from anywhere but inside it. The reconcile now reads the binding through the same lane_binding_scan and binding_is_here as binding and holder_is_dead, and prints a BINDING line. Bound elsewhere, or a log that cannot be read, turns every verdict into indeterminate. Clause (b)'s one exception still holds: a window gone from this host's tmux means the local read decides.
Verified with nothing to do: #146's linear table_lookup and pty bound, since neither is used by #97's code. #134's claude-restart-check is about a replaced binary, not lane liveness. #97 adds no lanes column, so Amendment 19's columns 14–17 are untouched, and column 13 still prints none for a lane with a snapshot and no binding line (asserted).
The seam
The cell. It is the register row's state cell, and a valid marker takes one of two forms:
The rule is the ported reader's own: "no malformed marker may be downgraded to absence".
managed-projection <lane>
meaning
every legacy act
0 + owner
a valid marker
exit 2, names the owner, changes nothing
8
no managed-owner vocabulary: a legacy lane, which every lane is today
unchanged
1
the vocabulary is there and does not parse, the row is not 7 columns, or the register could not be read
exit 1, ownership UNKNOWN, changes nothing
64
usage
—
The call sites, each before the act's first write:
write_event, before its lock, for every lane-kind verb: STARTED, RESUMED, ENDED and RETIRED, whose verdict is handed to lane_state_follow (it writes only on 8), and PAUSED and HANDOFF-REQUESTED, which move no lifecycle. Object-kind lines (a claim, a release) are about the object, not the lane, and are not refused.
request-handoff, at its head after canon-lane, so --dry-run refuses too and no binding is read.
set-lane-state and set-lane-tree, before the control root and the lock.
retire-rows, in its scan. Any hit refuses the whole sweep.
migrate-state-cells (Amendment 13(e)), in its scan, for every row it would rewrite: after the 15(d) duplicate check and the phrase skip, and before the plan is built. Any hit refuses the whole migration, in the dry run and with --yes.
archive-rows (Amendment 19(d)), in its scan, for every RETIRED row it would move. Any hit refuses the whole move, in the dry run and with --yes.
append-session-id, and add-row for a lane that already has a row (named before the duplicate refusal that would refuse it anyway). A new lane has no row and is never asked.
Every refusal reads this checkout's row as well as the published one. Every legacy row writer rewrites this checkout's row, while the seam's first read is the published register. A local row that carries the vocabulary where the published one does not is unknown (1).
lane-reconcile is read-only. It prints MANAGED and VERDICT managed-owned, or indeterminate for unknown.
row_state_check, add-row, append-line, and the new text of replace-in-row, append-session-id and rename-lane's new name refuse the marker's vocabulary, so no legacy writer forges one.
set-row-state, replace-in-row and rename-lane refuse a managed lane. Each would replace the marker or orphan its bound-lane. These three go beyond the plan's list: they are the row writers branch 001 itself guards (b001:9064, 9096, 9613).
lane-handoff, right after its canonical lane= step and before the window rename. --late, --restart and --exit are all behind it, so SWAPPING and SWAPPED never run for a managed lane.
lane-start, at the head of section 3, before Amendment 18's binding gate and request-handoff.
lane-end, before its first act on each of its paths. For the --retire <pid> door that is the door's head, before any process is looked for or signalled. For the ending and --retire it is right after the command's own row refusals, so that Amendment 15(d)'s pair refusal is still the one a person reads through a helper that predates both reads.
Provenance, with an empty diff. The reader is 3c26041:lanes-edit.sh:776-898, the projection reader of branch 001-separate-swap-ctx-handoff (its T019), ported verbatim between two marker lines:
$ diff <(git show 3c26041:lanes-edit.sh | sed -n 776,898p) \
<(sed -n '/^# --- BEGIN ported from 3c26041/,/^# --- END ported from 3c26041/p' lanes-edit.sh | sed '1d;$d')
$ echo$?
0
Its four dependencies, row_split_state_cell, rstrip_spaces, lc and row_of_lane, are byte-identical on main. The wrapper lane_is_managed_owned closes the one gap the reader leaves: row_of_lane reads an unrendered published register as an empty one, so the reader would call every lane legacy, and the wrapper answers that as unknown. When 001's T024 lands, the ported block merges as "keep either" and the wrapper becomes its managed_legacy_check.
Risks, measured.
The hint matches anywhere in a row. Measured today: 0 of the 57 rows of the published register (brett-wip 225b9f90a). No legacy writer can now write the vocabulary.
LANES_NO_FETCH=1 callers miss a marker published since their last fetch, until 001's lease closes that window.
The lifecycle, as before (the earlier body's substance)
lanes-edit.sh has five lifecycle arms: lane-state, set-lane-state, lane-trees, set-lane-tree and lane-reconcile. There is also lane-tree-now, the one implementation of the observation. Exit 7 is the fence refusal, the number this file already spends on "another act got there first".
lane-handoff takes RUNNING → SWAPPING before it polls, records every polled worktree against that operation, and takes SWAPPING → SWAPPED only after the record, the row and the handoff file have all landed.
lane-start prints the reconciliation before it writes anything, for any verdict that is not running, resumable or closed.
docs/README-lanes.md describes the four words, the crash-kind table (now with the bound-elsewhere and managed-owned rows), the fence, the control root's rungs, the inventory, and the new "Managed-owned lanes" section.
Design decisions 9–20 stand. 12 is reworded, and 21 (the seam) and 22–24 (the merge fixes) are new.
Review: every thread answered and resolved
Round 3 was taken as seam completeness under the ruling, not as a cap exception for style. Copilot reviewed 19df774 and then eada5ff unasked, a third round. Its two findings on 19df774, and the same archive-rows finding again on eada5ff, are legacy writers still reaching a managed lane, against this PR's own requirement. A seam that lets a legacy writer stamp a managed lane would land Brett Heap's ruling broken. So the round was taken, and with it a sweep of every register and log writer arm, so that no arm is left for a fourth round. Any later finding that is not a seam leak is filed on #157.
thread
finding
outcome
Copilot on 64dfd97
lane_sidecar_schema_ok let a writer replace a dangling symlink
fixed7915745: [ -e ] || [ -L ], as the reader asks. repoRC-9 covers both writers.
Copilot on 64dfd97
an unreadable log became an empty coordinator directory, so both sweeps were skipped and the lane read resumable
fixed7915745: the read keeps its exit status, and anything but 0 or 8 makes the verdict indeterminate. Covered by repoRC-13.
Copilot on 64dfd97 and Codex P1 on ec9847e
a refused inventory write still let the lane reach SWAPPED
fixed7915745: the tree is named in lc_unfinished and the lane stays SWAPPING. Covered by repoRC-14.
Codex P2
lane-start was silent over a resumable lane whatever its trees held
fixed7773267: quiet only when TREES counts nothing needing attention. The report also names a binding that is not here or free. Covered by repoRC-15(d).
Codex P2
a partial observation was filed with dirty 0, unpushed 0
fixed7773267: refused as usage (64), writing nothing. Covered by repoRC-15(a). This is #114's second half.
Codex P2
tree ids were not injective: a+b and a-b folded to the same id
fixed7773267: a cksum of the whole path now prefixes every id. No migration is needed, because no released tooling has written a sidecar. Covered by repoRC-15(b). This is #116's first half.
Codex P2
an unreadable tree sidecar was skipped
fixed7773267: it is now a lane-trees row with schema <unreadable> and an unreadable-sidecar class. Covered by repoRC-15(c). This is #113's second read.
Copilot on ec9847e
lane-reconcilefetches before it reads
declined, filed as #157. The binding, alias table and marker it reads are published state that a stale ref misreports. Every read in lanes-edit.sh fetches the same way: bounded, and falling back to the local ref when offline. The fetch touches no lane tree, and lane-start already passes LANES_NO_FETCH=1. The manual now says this (d2d2eec).
Copilot round 2 on d2d2eec (lanes-edit.sh:11313)
migrate-state-cellsnever asked the seam. The shorthand MANAGED OWNER · <token> holds one ·, so it was not the phrase and --yes rewrote it to MIGRATED · …
fixed19df774: one managed (2) or unknown (1) row refuses the whole migration, as in Amendment 19's sweep. Covered by repoMG case 6 (zero change on the valid shorthand, a malformed one, and a marker only this checkout carries; 22 of its assertions fail without the fix).
Copilot round 2 on d2d2eec (lane-handoff:434)
a second swap supersedes a live one, where the spec said no new owner is minted
deferred to #157, its second finding. The takeover is design.md's settled answer for an interrupted swap. Telling a live operation from an interrupted one needs the handoff process's liveness, which the snapshot does not record and design.md rules out by PID alone. The fence bounds the harm: the superseded operation's finalizer and inventory writes are refused with 7 (repoRC-2, -3, -5). The scenario now says what the code guarantees (eada5ff).
Copilot round 3 on 19df774 (lanes-edit.sh:4947)
log PAUSED and request-handoff's HANDOFF-REQUESTED wrote to a managed lane's log: the seam covered four verbs
fixed07fb3ad: every lane-kind verb asks, and request-handoff refuses at its head (--dry-run included). Each is proved on all seven managed and unknown fixtures with zero change.
Copilot round 3 on 19df774 (spec.md:169)
archive-rows --yesmoved a RETIRED managed row out of the register
fixed07fb3ad: one managed or unknown RETIRED row refuses the whole move. Covered by repoMG case 7 (a valid and a malformed marker refused with zero change, and the same move landing once the row is gone).
Copilot on eada5ff (README-lanes.md:3114)
archive-rowsmissing from the seam's list
fixed: the same leak, closed in 07fb3ad. The manual names it (8c5958f).
Copilot on eada5ff (lanes-edit.sh:4949)
a hand-written log RESUMED takes a snapshot to RUNNING without the confirming act's proof
deferred to #157, its third finding. Design decision 9 makes the line the proof, "from any caller, over one implementation", and repoRC-3's recovery relies on it. Since 07fb3ad the seam refuses it for a managed or unknown lane.
Copilot on eada5ff (lanes-edit.sh:11867)
a multi-line printf would put trailing spaces into lane-trees fields
declined: no defect. Every continuation line starts at column 0, and a backslash-newline inside double quotes is removed whole. od -c of the construct shows no space, and repoRC-5 asserts the exact path on every CI run.
Copilot on 8c5958f (5 threads: lane-start:1435, lanes-edit.sh:10386, :11240, :11368, :11859)
a local-only marker reaching lane-start's transcript move; a sweep's capture commit before its refusal; two mis-shaped rows without vocabulary read as legacy; an unreadable trees/ directory read as no inventory
filed on #157, section 4, by the coordinator's rule (no fourth push). None lets a legacy writer stamp a managed lane under the published register. The lane-start fix is sketched there as a one-commit follow-up, and #157 also records the manual's "not seven columns" overclaim.
Evidence
$ openspec validate add-crash-consistent-lane-worktree-recovery --strict
Change 'add-crash-consistent-lane-worktree-recovery' is valid
Local runs, all through tests/run.sh, which takes the workstation lock:
The merge commit 682f855.tests/run.sh -k 'lane_helpers_suite or repo_hygiene or lane_start_claude_current or guard_launch_mode or install_skill_and_hook' gave 274 passed, 600 deselected in 1560 s. The bash suite's own footer is asserted by the wrapper to read passed, 0 failed.
Steps 1–5 at e9cd956.tests/run.sh -k lane_helpers_suite, run from an exported snapshot of that commit, gave 2 passed in 1581 s.
ec9847e. The same selection as the merge run gave 274 passed, 600 deselected in 1419 s. CI passed on every job that runs on a pull request: tests 18m02s, tests-no-submodule 17m45s, tests-windows, parse-macos (bash 3.2 parse), guard-launch-mode and SonarCloud. tests-macos is skipped, because it runs only with ready.
The first run of that selection, at a98b0d8. It gave 1 failed: the bash suite had 3928 passed and 3 failed. The failures were main's Amendment 15 case "lane-end with a helper that predates canon-lane still refuses the pair". The seam, placed right after canon-lane, called that old helper's unknown subcommand UNKNOWN and exited 1 before lane-end's own 15(d) refusal. 6e64660 fixes it by ordering, without weakening either refusal.
The round-2 fix, 19df774. A section harness (the suite's preamble, Amendment 13's migration section, whose legacy rows now pass through the seam, and the seam section's case 6) gave 72 passed, 0 failed in 11 s. The same harness against d2d2eec's lanes-edit.sh gave 22 failed. tests/run.sh -k repo_hygiene gave 152 passed on 19df774 and on eada5ff. openspec validate --strict passes on eada5ff.
The seam sweep, 07fb3ad + 8c5958f. The full tests/test_lane_helpers.sh, run under the workstation lock with the pytest wrapper's environment, gave 4167 passed, 0 failed at 07fb3ad. A section harness for cases 6 and 7 gave 90 passed in 8 s. openspec validate --strict passes on 8c5958f. tests/run.sh -k repo_hygiene passed 152 on 19df774 and eada5ff. For these two commits it was queued behind another project's pytest runs, which run.sh's workstation-wide poll waits on, so CI's tests job is its record. A static check of every added line against the locale and exit-code hygiene rules is clean.
The seam section has 452 assertions. At 410 assertions, a copy whose lane_is_managed_owned always answers "legacy" gave 339 failed, 91 of them zero-change assertions, so the assertions bite. Of #91's own section, the only removed line is the assertion of exit 9 that the move to 10 replaced. Every other assertion is unchanged, and nine were added.
lanes wall time on the live register (26 rows, LANES_NO_FETCH=1): 2.35 / 2.51 / 2.35 s on this branch against 2.58 / 2.54 / 2.20 s on main, with byte-identical output.
The coordinator base: the invariant, its shape-aware resolution, and its staged enforcement (tasks 1.2, 5.3).
Shape-governed feature worktrees are not indexed (task 2.2).
A snapshot/history divergence detector (task 1.4).
A duplicate-launch refusal of this change's own (task 4.2).
Cross-workstation replication of the snapshot. It is local by decision 10, and reconciliation now says indeterminate from outside a lane's binding rather than guessing.
The untested corners listed in tasks.md section 7.
… RUNNING → SWAPPING → SWAPPED snapshot, its generation/operation fence, and the machine-readable worktree inventory taken at the poll
A session can run out of tokens BEFORE the handoff, AFTER it began and before
it finished, or after it finished, and until now all three left the same
evidence: a last lane-kind line that is a `STARTED`/`RESUMED` (which is also
what a running lane looks like) or a `PAUSED` (which is also what a clean swap
looks like). The two crash kinds had no word, and the WRITERS section a handoff
leaves is prose — a person can read it, a recovery cannot.
This is the work so far, committed at a handoff and not finished:
* `lanes-edit.sh` gains a LOCAL lifecycle snapshot beside each lane — not in
the register (every write of it is a commit, a pull and a push) and not
inside a git worktree (metadata there dirties a checkout and disappears with
the directory whose loss it explains), but under a control root derived from
the lane's own recorded `dir`, else `$PROJECTS_ROOT`. Five subcommands:
`lane-state`, `set-lane-state`, `lane-trees`, `set-lane-tree` and
`lane-reconcile`. NO SIXTH LANE VERB IS ADDED to the append-only log —
Amendment 7's five stand and every reader of them is untouched.
* The fence: every transition carries a monotonic `generation` and a unique
`operation`, `--expect*` is the compare-and-swap, and exit 7 is the refusal
of a stale finalizer (one meaning on the number this file already spends on
`claim`'s CLAIM-LOST: you lost the race).
* `write_event` follows the line it wrote — a `STARTED`/`RESUMED` is the
confirming act of a new owner and takes the lane to `RUNNING`, an `ENDED` or
`RETIRED` to `CLOSED` — so the SessionStart hook keeps all three properties
that make it safe in front of every session (it never writes, never touches
the network, always exits 0; Amendment 8, R-A8-1).
* `lane-handoff` takes `SWAPPING` before it polls anything, records every
polled worktree's path, checkout, branch, HEAD, upstream, dirty and unpushed
counts and writer against that operation, and lands `SWAPPED` only after the
record, the row and the handoff file have all been written. The lifecycle
never refuses the swap (`R-A11-11`): a root it cannot derive or a fence
another act moved costs the lifecycle and never the record.
* Brett Heap's ideation packet and the OpenSpec change are committed with the
implementation, as he asked.
Still owed on this branch: `lane-reconcile`'s reporting surfaced on resume,
`docs/README-lanes.md`, the suite cases for each transition, each fence and
each crash kind, the review's deviations written into the design, and a rerun
of `openspec validate --strict`.
Part of #91
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
… — the reconciliation report, the manual, the suite cases, and every deviation written into the design rather than taken silently
The snapshot, the fence and the inventory landed in the commit before this one.
This is the half that makes them usable and honest.
* `lane-reconcile` is printed by `lane-start` BEFORE it writes anything, for
every verdict that is not `running`, `resumable` or `closed` — above section
5, because the `STARTED`/`RESUMED` line that run is about to write takes the
lane to `RUNNING`, so a report printed after it would describe this launch
rather than the crash it is recovering from. It is a REPORT and not a gate:
`resume` RESETS NOTHING and `park` CREATES NOTHING (AGENTS.md rule 1), and
every refusal this command makes is the one it already made.
* `docs/README-lanes.md` gains the four words, the two crash kinds as a table
(with `indeterminate` for a holder read nobody got), the fence, the three
rungs of the control root, the inventory and what a resumed session does.
* `tests/test_lane_helpers.sh` gains a section of its own: the cutover answer
of 8 with nothing backfilled; the confirming act writing RUNNING while the
SessionStart hook writes nothing (proved by the snapshot's bytes before and
after); both crash kinds with and without a live holder; the fence refusing
`--expect` that no longer matches and refusing a stale finalizer; a competing
resume advancing the generation so the interrupted swap's own finalizer is
refused when it returns; the handoff taking SWAPPING BEFORE the poll and
SWAPPED after; the inventory's full head and upstream; a lane left SWAPPING
when one of the three mandatory writes did not land; possible-loss against
missing, unmanaged, stale-registration and detached HEAD; that the read reset,
deleted and recreated nothing; an unknown schema failing closed; and the
report printed by a real `lane-start`.
THE DEVIATIONS ARE WRITTEN INTO THE CHANGE, as decisions 9 to 15 of design.md,
never taken silently:
9 `RUNNING` is written by the act that CONFIRMS the binding and never by the
SessionStart hook, which never writes, never touches the network and always
exits 0 (Amendment 8, `R-A8-1`). The spec is reworded to match.
10 The snapshot is local and is written where `R-A11-14` stops the register:
it is filed under NOTHING, so a container with no workstation still keeps a
lifecycle it can recover itself from.
11 The control root comes from the lane's own recorded `dir`, not from a
shape-governed Speckit worktree root — a lane's writers are not under one.
12 No sixth lane verb: Amendment 7's five stand and every reader is untouched.
13 `SWAPPED` needs three landed writes, and the swap still completes without
them (`R-A11-11`); the state stays SWAPPING and names the missing step.
14 The estate's `resume` is NAMED, never invoked, by a read.
15 An unreadable holder is `indeterminate` and never a crash (Amendment 7(d)).
`openspec validate add-crash-consistent-lane-worktree-recovery --strict` passes;
`openspec list` reads 20/28 tasks, and tasks.md's new section 7 names each of
the eight that are left and what stands in its place today.
Part of #91
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
… find for themselves — the control root is still a recorded path, and the report takes no lock — and the spec cites the issue at its head
Decision 11 said where the control root comes from and not what that still
leaves open. The brainstorm's "derivable lane root" asks for a root resolved
*without relying on a historical absolute path*, and rung 2 is exactly such a
path: Amendment 11(c)'s recorded `dir`. It is a RECORDED fact rather than a
guess or the caller's current directory, which is what makes it safe to act on,
but resolving the root from `home owner/repo` and the estate with no recorded
path at all is the coordinator-base work of tasks 1.2 and 5.3 and is not here.
Decision 14 said the estate's `resume` is named and not invoked, and left out
the other half: the brainstorm has resume ACQUIRE THE LANE LOCK before it
reconciles, and `lane-reconcile` does not. The lock here is the register's own
mutex, and taking it for a READ would serialize every launch on the workstation
behind every register write for the length of a `git worktree list` in each of a
lane's checkouts. It is taken where it decides something — around the read of
the fence and the replacement of the snapshot together, in `set-lane-state`.
And the capability specification cites `#91` at its head,
beside the sentence that sends a reader to the numbered decision wherever a
requirement is narrower than the brainstorm that proposed it.
Part of #91
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
…anaged tree, and fifteen assertions that would have passed on the wrong field
Two defects the first suite run and a reading of its output found.
`lane-reconcile` skipped the lane's own checkout from `git worktree list
--porcelain` by comparing the recorded `dir` to what git printed — and git
prints the PHYSICAL path, while a recorded `dir` is reached through the
estate's `projects` symlink on every workstation that has one. The lane's own
checkout was then a tree no sidecar names, reported as UNMANAGED at the head of
every report. `cd -P` is the portable resolver here for the reason
`lane-start`'s `real_of` gives: `readlink -f` is not in the stock macOS
userland.
And the suite's own reads: a TREE row is `TREE<US><id><US><class><US><path><US>
<detail>`, so the path is the FOURTH field and the case that counts a
discovered tree was matching the fifth. It went red, which is how it was found;
fifteen of its neighbours were `has …` against the whole report, where
`unmanaged`, `dirty` and `resumable` each appear in prose as well as in the
field that means them — green whatever the classification actually was. Each is
now an exact read of the field it is about: the `VERDICT` token, or the tree's
own class.
Part of #91
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m
This PR adds a local, crash-consistent lane state machine and generation/operation fence, records a shared machine-readable worktree inventory at handoff, and performs read-only resume reconciliation while preserving existing lane event semantics; documentation, OpenSpec artifacts, and extensive helper tests define the behavior and explicitly identify coordinator-base, shape-governed inventory, duplicate-launch, divergence, and cross-workstation replication work that remains.
Sequence diagram for fenced lane handoff and inventory
sequenceDiagram
participant H as lane-handoff
participant E as lanes-edit.sh
participant G as Git
participant R as Lane state
participant W as Handoff and row
H->>E: set-lane-state RUNNING to SWAPPING
E->>R: Compare-and-swap with generation and operation
alt fence mismatch
R-->>E: Reject with exit 7
E-->>H: Write nothing
else transition accepted
E-->>H: New generation and operation
H->>G: lane_tree_now for each worktree
H->>E: set-lane-tree for each observation
H->>W: Write record, row, and handoff
H->>E: set-lane-state SWAPPING to SWAPPED
E->>R: Verify generation and operation
R-->>E: Commit SWAPPED
E-->>H: READY under SWAPPED
end
Loading
State diagram for crash-consistent lane lifecycle
stateDiagram-v2
[*] --> RUNNING: confirming act writes STARTED or RESUMED
RUNNING --> SWAPPING: lane-handoff begins
SWAPPING --> SWAPPED: record, row, and handoff landed
SWAPPED --> RUNNING: confirming act accepts new binding
RUNNING --> CLOSED: ENDED or RETIRED
SWAPPING --> SWAPPING: interrupted handoff takeover
note right of RUNNING
No holder: ungraceful-stop
end note
note right of SWAPPING
No holder: interrupted-swap
end note
Loading
Flow diagram for read-only lane resume reconciliation
flowchart TD
A[lane-start] --> B[lane-reconcile]
B --> C[Read local lane state and live holder]
C --> D[Recompute each inventoried tree with lane_tree_now]
D --> E[Read Git registrations and lane worktree roots]
E --> F{Holder readable?}
F -->|No| G[VERDICT indeterminate]
F -->|Yes| H{State and holder combination}
H -->|RUNNING plus no holder| I[ungraceful-stop]
H -->|SWAPPING plus no holder| J[interrupted-swap]
H -->|SWAPPED plus no holder| K[resumable]
H -->|Live holder or inconsistency| L[Report and block competing recovery]
I --> M[Inspect trees; reset nothing]
J --> M
K --> M
L --> M
M --> N[Name estate resume or git worktree prune; invoke neither]
Loading
File-Level Changes
Change
Details
Files
Added a local, atomic lane lifecycle snapshot with generation and operation fencing while preserving the append-only event protocol.
Introduced RUNNING, SWAPPING, SWAPPED, and CLOSED snapshot states.
Added compare-and-swap state updates with stale-finalizer rejection via exit 7.
Derive control roots from explicit configuration, recorded lane checkout, or the projects root.
Update lifecycle state from confirming lane events rather than the read-only SessionStart hook.
Added machine-readable worktree inventory capture and non-destructive reconciliation for handoffs and resume.
Record path, branch, full HEAD, upstream, dirty/unpushed counts, writer, generation, and operation per tree.
Reuse a shared Git observation function for handoff polling and reconciliation.
Classify live, missing, possible-loss, unmanaged, stale-registration, detached, and inconsistent trees without resetting, deleting, pruning, or invoking estate resume.
Print recovery reports from lane-start before binding a replacement session.
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!
Lifecycle, inventory, fencing, and reconciliation commands.
Critical (3 votes): lifecycle snapshot updates are unsynchronized and unfenced. Critical (3 votes): transition writes bypass unknown-schema validation. Moderate (2 votes): tree schema is not validated. Critical (2 votes): tree writes do not validate generation and operation under the lock. Moderate (1 vote): unreadable session IDs are treated as no holder. Moderate (1 vote): repository identity is not persisted or validated. Moderate (1 vote): upstream lookup failures become none/0. Moderate (3 votes): later Git-read failures are converted to seemingly valid values. Moderate (1 vote): local lifecycle commands can fetch without forcing no-fetch mode.
lane-start
Pre-launch reconciliation reporting.
No final findings.
lane-handoff
Handoff transitions and worktree polling.
Moderate (2 votes): handoff duplicates the shared inventory helper, producing divergent observation and error handling.
A failed set-lane-tree write is only noted here, but lifecycle_finish gates SWAPPED on the record, row, and handoff file alone. The handoff can therefore publish SWAPPED with no machine-readable inventory for a writer; a later reconciliation reports that tree as unmanaged while the supposedly resumable state may suppress the recovery report. Inventory-write failures must keep the operation from reaching SWAPPED or otherwise be included in the completion fence.
>/dev/null 2>&1 ||
note "the worktree inventory entry for $pw_d could not be written; this handoff's WRITERS section still names it, and \`lane-reconcile\` will report it as unmanaged rather than as lost."
lanes-edit.sh:7949
Both session-ID reads have their errors discarded. If the register/session files are unreadable, lrc_ids becomes empty; live_holder then receives no IDs, repeats the read with || :, and returns 8, so the report prints HOLDER none and can classify RUNNING or SWAPPING as a crash. Preserve a read failure and return indeterminate rather than treating it as no holder.
The sidecar stores checkout as the recorded directory, and reconciliation only checks that the current path is some Git checkout; it never records or compares repository identity (or a stable relative path). If a different clone is later placed at the same path, the old sidecar is accepted as the same lane tree and no identity mismatch is reported. Persist and validate a repository identity before accepting the observation.
When a branch has an upstream configured but that remote-tracking ref is deleted or unreadable, both rev-parse @{u} and git log @{u}.. fail and these fallbacks turn the result into upstream none and 0 unpushed. A local commit can then be presented as clean/published (especially for a missing tree), even though its publication status is unknown. Distinguish “no upstream configured” from an upstream lookup failure and report the latter as indeterminate.
This follow-up writer also reads generation directly and then calls lane_state_put without checking the snapshot schema. A STARTED or ENDED event from an older helper can therefore replace a newer, unknown-schema snapshot even though lane_state_read is supposed to fail closed. Reject or preserve incompatible snapshots before deriving a generation.
lane-reconcile is documented as a local, read-only recovery report, but this arm calls log_sync without LANES_NO_FETCH=1. A direct invocation therefore fetches origin/$LANES_BRANCH and can block or fail on the network before reading local sidecars; the other four new arms repeat the same call. Force no-fetch for all five local lifecycle/inventory subcommands instead of relying on callers such as lane-handoff to set the environment.
The normative scenario says a second swap while SWAPPING must refuse or report the existing operation without minting another owner, but lane-handoff deliberately supersedes SWAPPING with a new generation and operation, and design.md says that takeover is settled. These acceptance artifacts conflict; update the specification to describe takeover or change the implementation before treating this capability as complete.
#### Scenario: Competing swap begins
- **WHEN** a second `/swap` attempts to start while the matching generation is already `SWAPPING`
- **THEN** the system refuses the competing operation or reports the existing operation without minting another owner
…them a hole in the first implementation — and the tree every symlinked workstation was reporting twice
A review round on #97 found six, and not one of them is cosmetic: they are the
compare-and-swap, the fail-closed schema and the single observation this change
is FOR, in the places the first implementation did not apply them.
* **The lifecycle follow-up wrote outside the mutex and outside any fence.**
`write_event` appends its line, commits it and pushes it BEFORE the snapshot
is moved, and a lane can be recovered by somebody else inside that window: a
`RUNNING` written out of an event that landed minutes ago then overwrote a
`SWAPPING` that began since — precisely the overwrite the generation exists to
refuse. The follow-up now reads the snapshot under the same mutex
`set-lane-state` takes and compares it with the PRE-IMAGE `write_event` took
before its own line existed. Equal is this write being the newest act; unequal
is another act having got there first, and NOTHING is written. The mutex is
taken without dying for it (`lock_try`, the non-fatal half of `acquire_lock`,
so the two are one implementation): the event is already on disk, so a lock
nobody could take costs the snapshot and says so, never the event.
* **Two writers walked past the fail-closed schema check the reader keeps.**
`lane_state_read` refuses a schema it does not know, and `set-lane-state`,
`set-lane-tree` and the follow-up each read the raw fields and renamed their
own file over the top — so an older helper meeting a newer tooling's record
destroyed what it could not read, which no later reader can undo. All three
ask `lane_sidecar_schema_ok` first and refuse with 1. It is 1 and not 7: 7
says a race was lost and invites a retry, and no retry makes an unknown schema
readable.
* **The inventory's own fence was written and compared with nothing.** Every
tree sidecar carried the generation and operation it was recorded under from
the first commit here, and no code read them, so a handoff that stalled while
a recovery advanced the lane filed its superseded poll straight over the
current one. `set-lane-tree` compares both with the lane's snapshot under the
mutex and refuses with 7, writing inside that mutex so nothing lands between
the compare and the record; and `lane-reconcile` now SAYS, on the tree's own
line, when an observation was taken under an earlier generation.
* **The handoff made a second observation of its own.** It computed branch,
head, upstream, dirty and unpushed for every writer it polled — a second
implementation of `lane_tree_now` with its own error handling, so the WRITERS
section a person reads and the sidecar a recovery reads could disagree about
what git said. `lanes-edit.sh lane-tree-now <path>` is that observation as one
read verb, and the handoff records what it answers; where it refuses, every
field is `?`, no sidecar is filed, and the section says so.
* **The inventory reader took every `*.yaml` at face value.** A sidecar written
by newer tooling was read field by field and reported as an ordinary
observation. `lane-trees` now prints such a record's id, path and schema and
not one other field of it, and `lane-reconcile` classes it `unknown-schema`
rather than recomputing git against fields it is guessing at. The row gained
its `generation`, `operation` and `schema` at the END, so a reader written
against the ten fields that were there first still reads those ten.
* **Every git read after the first became a clean-looking value.** `|| printf
'unknown'`, `|| printf 'none'` and a count that fell back to `0` meant a
partially unreadable repository produced a record that said CLEAN AND
PUBLISHED — and a later reconciliation comparing against it would call a tree
holding work `missing` rather than `possible-loss`. A failed read is now an
incomplete observation that prints nothing (exit 3 in the function, 1 in the
verb), which `set-lane-tree` refuses to file and `lane-reconcile` reports as
`unreadable`. Two states are answers rather than failures and are spelled:
`unborn` for a branch with no commit yet — `rev-parse --abbrev-ref` refuses
that exactly as it refuses a corrupt HEAD, so `symbolic-ref` is what tells
them apart — and, for a branch whose upstream is configured while its
remote-tracking ref is not in this checkout (the ordinary state after a merged
branch is deleted), that configured upstream with `unknown` unpushed, never
the `0` that reads as *everything here is published*.
AND THE FOUR RED CASES ON `tests-macos` AT `612ba5c`, WHICH WERE ONE DEFECT.
`expected [dirty], got [dirty unmanaged]`: the report reads three sources that
do not agree about spelling — a sidecar holds the path its poll was given,
`git worktree list --porcelain` answers with the PHYSICAL path, and the on-disk
sweep walks the recorded `dir`. On that runner `$TMPDIR` and `$HOME` are under
`/var`, which IS a symlink to `/private/var`, so EVERY tree was reported twice,
once as the tree it is and once as a tree nobody manages — and so it would be on
every estate that reaches its checkouts through a `projects` link. `612ba5c`
resolved the lane's own checkout for this very reason and left the trees under
it unresolved; `lane_real_path` is now applied on both sides of every
comparison, and a lane whose recorded directory goes through a link has a case
of its own so the defect is reproducible on every platform.
The suite gains a section for the round — the delayed follow-up (scheduled with
a `post-commit` hook, so it is a schedule and not a race), the schema refusals
against a file whose bytes are asserted unchanged, the inventory fence in both
directions, the handoff and the sidecar agreeing on a value only the shared
helper produces, the unknown-schema sidecar, the unreadable tree, the unborn
branch, and the linked path named once. `--expect`, `--expect-generation` and
`--expect-operation` also refuse an empty value in BOTH spellings, which is the
hygiene row that was red on all four jobs, and every other value-taking arm of
the two new writers with it.
Part of #91
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r
Review round on 612ba5c — the six threads, and the four macOS cases they sat beside
Every one of the six is the compare-and-swap, the fail-closed schema or the
single observation this change is FOR, in a place the first implementation did
not apply it. All six are taken, each with a suite case of its own; the replies
are on the threads.
thread
what it found
what landed
lanes-edit.sh:7775
the lifecycle follow-up read the generation and replaced the snapshot outside the mutex and outside any fence
write_event takes a PRE-IMAGE (state/generation/operation) before its line exists; the follow-up re-reads under the same mutex set-lane-state takes and writes only where the two still match — and takes that mutex without dying for it, because the event is already committed
lanes-edit.sh:9678
a writer bypassed lane_state_read's fail-closed schema check and replaced the file
lane_sidecar_schema_ok first, in all three writers; an unknown schema is refused with 1 and left byte for byte
lanes-edit.sh:9788
generation/operation were serialized into the tree file and compared with nothing
set-lane-tree compares both with the lane's snapshot under the mutex and refuses with 7; lane-reconcile now names the generation an observation was taken under where it is not the lane's
lane-handoff:820
the handoff computed the observation itself, bypassing lane_tree_now
lanes-edit.sh lane-tree-now <path> — one read verb — answers the five fields, and the handoff records what it answers, for the prose and the sidecar alike
lanes-edit.sh:7864
the inventory reader accepted every *.yaml without validating schema
such a record is printed with its id, path and schema and NOT ONE other field, and is classed unknown-schema rather than recomputed against
lanes-edit.sh:7909
only the first git failure was a non-checkout; later ones became unknown/0
a failed read is an incomplete observation that prints nothing; set-lane-tree refuses to file one and lane-reconcile reports unreadable. unborn and a configured-but-unresolvable upstream (unknown unpushed) are spelled, not guessed
And tests-macos's four red cases were one defect, not four. expected [dirty], got [dirty unmanaged]: the report reads three sources that do
not agree about spelling — a sidecar holds the path its poll was given, git worktree list --porcelain answers with the PHYSICAL path, and the on-disk
sweep walks the recorded dir. On that runner $TMPDIR and $HOME are under /var, which IS a symlink to /private/var, so every tree was reported twice,
once as the tree it is and once as a tree nobody manages — and so it would be on
every estate that reaches its checkouts through a projects link. 612ba5c
resolved the lane's own checkout for exactly this reason and left the trees under
it unresolved. lane_real_path is now applied on both sides of every comparison,
and a lane whose recorded directory goes through a link has a case of its own, so
the defect is reproducible on every platform rather than on one runner.
The hygiene row that was red on all four jobs — test_the_two_spellings_of_a_flag_refuse_the_same_empty_value[lanes-edit.sh] —
is fixed at the source: --expect, --expect-generation and --expect-operation
refuse an empty value in both spellings, and so does every other value-taking arm
of the two new writers.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved critical and moderate findings remain in lifecycle transitions, inventory writes and fencing, and malformed or unreadable state reconciliation.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (11)
lane-handoff:439
Every SWAPPING snapshot is treated as an interrupted handoff and superseded without checking whether its owner is still live. If a second /swap starts while the first is polling or refreshing, it mints a new generation and both processes can append pause records and rewrite the handoff; the competing-swap scenario requires reporting/refusing the live operation. Check the holder under the existing liveness contract and only take over when no holder is verified (with unreadable liveness treated conservatively).
# AN INTERRUPTED SWAP IS TAKEN OVER, NOT COMPETED WITH. A lane still
# reading `SWAPPING` when a new handoff starts is one whose previous
# operation never finished; this one supersedes it with a NEW generation,
# which is precisely what refuses the old finalizer if it ever wakes up.
# The old operation id is named rather than lost.
if [ "$lcb_now" = SWAPPING ]; then
lcb_old="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-state "$lane" 2>/dev/null \
| awk -F"\t" '$1 == "operation" { print $2; exit }')"
note "lane $lane is still recorded SWAPPING from operation ${lcb_old:-unknown}, which never finished: this handoff SUPERSEDES it with a new generation, and that is what refuses the old finalizer if it ever returns. Nothing of that operation is undone here."
fi
lcb_out=""; lcb_src=0
lcb_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" set-lane-state "$lane" SWAPPING \
--expect "$lcb_now" --owner "${uuid:-none}" --agent "$agent" \
--profile "${profile_name:-none}" --kind "$handoff_kind" 2>/dev/null)" || lcb_src=$?
lane-handoff:875
The handoff notes a failed set-lane-tree, but does not propagate that failure into lc_unfinished. lifecycle_finish only checks the record, row, and handoff flags, so a sidecar write failure can still move the lane to SWAPPED without the machine-readable inventory this change promises. Include inventory success in the finalization gate or keep the state SWAPPING.
lane_tree_now maps every non-directory to return 1, but lane_reconcile treats 1 as a missing path (8356–8368). If a regular file or other existing non-checkout occupies the expected worktree path, reconciliation will report missing/possible-loss and may direct the operator to rebuild it instead of reporting not-a-checkout, even though the path still exists. Distinguish an absent path from an existing non-directory before probing Git.
The parser only checks that these observation values are non-empty, although reconciliation treats dirty as a decimal and unpushed as decimal or unknown. A caller can therefore write --dirty nope; later [ "$lrc_nd" -gt 0 ] errors and leaves the tree effectively classified as clean. Validate dirty as a non-negative integer and unpushed as an integer or unknown before writing a current-schema sidecar.
--dirty) slt_d="${2-}"; [ "$#" -ge 2 ] && [ -n "$slt_d" ] || die "--dirty needs a value" 64; shift 2 ;;
--dirty=*) slt_d="${1#--dirty=}"; [ -n "$slt_d" ] || die "--dirty needs a value" 64; shift ;;
--unpushed) slt_n="${2-}"; [ "$#" -ge 2 ] && [ -n "$slt_n" ] || die "--unpushed needs a value" 64; shift 2 ;;
lanes-edit.sh:8324
The reconciliation input set never includes the latest handoff inventory: this function reads the sidecars, one checkout's Git registrations, and the two on-disk roots, but never resolves/parses the lane's handoff. A writer/path recorded in the handoff whose sidecar write was skipped (or whose directory is now gone) and which is not independently discovered is therefore invisible, although the design/spec require reconciling against the latest handoff before relaunch. Include handoff writer paths as expected evidence and report discrepancies without adopting or deleting them.
# 3. THE TREES — the inventory, recomputed. A stored value is a COMPARISON
# POINT and never current truth.
lrc_dir="$(lane_payload_field "$lrc_lane" dir 2>/dev/null || :)"
lrc_seen=""; lrc_n=0; lrc_recover=0; lrc_dirty=0
lanes-edit.sh:7933
lane_state_read opens the same sidecar separately for every field through lane_sidecar_field. Atomic rename makes each individual open complete, but without a read lock or a single-file snapshot a concurrent replacement can produce state from the old file and generation/operation from the new one. Reconciliation and lifecycle diagnostics can then act on an impossible fence tuple; parse one captured file image consistently.
for lsr_k in state generation operation owner agent profile workstation kind updated lane; do
printf '%s\t%s\n' "$lsr_k" "$(lane_sidecar_field "$lsr_f" "$lsr_k")"
done
lanes-edit.sh:8460
The on-disk sweep drops every candidate for which git rev-parse --git-dir fails. A corrupt or unreadable Git worktree beneath a lane root can therefore disappear instead of being reported as unreadable/not-a-checkout, and if it has no sidecar it is missed entirely. Surface the failed read as a preserved recovery finding rather than continuing silently.
The || : here collapses a failed lane-log read into the same empty dir used for a readable legacy lane. When $PROJECTS_ROOT exists, the resolver then falls through to rung 3 and can read or write a generic .lane-state/<lane> even though the recorded checkout could not be established. Propagate the read failure; only use the fallback root when the lane record was successfully read and simply has no dir.
lcr_dir="$(lane_payload_field "$lcr_lane" dir 2>/dev/null || :)"
lanes-edit.sh:8323
This also turns a failed read of the lane log into an empty recorded directory. Reconciliation then skips both the git worktree list sweep and the on-disk root sweep, so it can report only stale sidecars while missing live/unmanaged trees. A failure to read the directory provenance must make the report indeterminate, not mean there is no checkout to inspect.
lrc_dir="$(lane_payload_field "$lrc_lane" dir 2>/dev/null || :)"
lanes-edit.sh:8314
The snapshot's owner is parsed into lrc_owner but never added to the candidate IDs passed to live_holder; only register-row IDs are searched. If the row/object-log write was the step that failed after the lifecycle snapshot recorded a live owner, reconciliation cannot see that session and reports RUNNING with no holder as an ungraceful stop. Include the fenced owner (when valid) in the holder lookup, while still preserving unreadable-ID failures as indeterminate.
The sidecar schema does not record the repository/estate identity or a path relative to the resolved worktree root, despite the inventory contract requiring both. It stores only the lane and an absolute path/checkout, so a missing or moved tree cannot be identified from the inventory itself. Add these identity fields and expose them through the reader/reconciliation output.
…with the check written to catch it
The review pass over the rescued work found three defects, and the first is the
one that matters: `set-restart-intent` keeps every field a transition does not
name — which is what lets the supervisor write `starting` without blanking the
digest every later launch is fenced on — but five of those fields belong to ONE
OPERATION, and the readiness predicate is built on two of them. A second `/ctx`
inherited the last one's `new_transcript`, a uuid the new launch will never
mint, so readiness could only ever run to its deadline; and it inherited the
last one's `old_transcript`, which is the uuid the replacement must be DISTINCT
FROM. Being distinct from it is the check that catches openRepoTools#94 — a
pane that came back resuming the transcript the context clear had just paused —
so an inherited one let the measured defect through the predicate written for
it. `old_transcript`, `new_transcript`, `attempt`, `reason` and `created` now
fall back to nothing when the operation changes; a caller that names one still
wins, and the fields that belong to the LANE are kept exactly as before.
The other two are diagnostics that lie. `lane-start` treated a `2` from
`restart-intent` as a read that FAILED and printed a note, so every launch of
every lane on a workstation whose `lanes-edit.sh` predates this change would
nag about a state that is simply the old one — the estate's fence is `0` an
answer, `8` no answer and `2` a helper predating the read, and the last two fall
to the next rung. And `--restart-status` told a reader that `lanes-edit.sh` has
no `lane-state` when the lane merely has no snapshot, which would send somebody
hunting an installation problem the day #97 lands.
Brett Heap's brainstorm packet for this change is committed beside the
implementation, as #97 committed its own, and the proposal now carries a verdict
for each of its documents and for each OpenSpec file: what matched, what
deviated, and which numbered decision answers it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r
…t nobody could read was answered as a lane that has none, and an unfenced inventory write took no mutex at all
Automated review rounds are capped at TWO per pull request (Brett Heap's ruling
of 2026-09-16) and #97 is at round six, so that round is TRIAGED rather than
taken: these two are the findings this capability could not land with, and the
other twelve are filed as #113, #114, #115, #116, #117 and
#118, claimed by this lane, and named one by one — with what each costs and what
decides it — in the change's `tasks.md` section 7.
* **A snapshot that IS THERE and cannot be read was answered as a lane that has
none.** `lane_state_read`'s `[ -r ] || return 8` put a permission error, an
I/O error and a lane that never started under this capability behind one
number, and `lane_reconcile` mapped every non-zero read to `NONE` — whose
verdict is `no-state`, the cutover answer that tells a launcher there is
nothing here to recover. So the one report built to say WHERE a session
stopped answered *it never ran* about the lane nobody could look at, which is
fail-OPEN on a crash pronouncement — the class this change exists to close.
The read answers **9** now for a record that is there and could not be opened
(`[ -L ]` beside `[ -e ]`, because a dangling symlink is `-e` false and is
exactly the name-without-bytes case), `lane-state` exits 9 rather than the 8 a
launcher goes past, and `lane-reconcile` reports the state word `UNREADABLE`
with the verdict `indeterminate` — the same answer decision 15 already gives
an unreadable HOLDER, under the same rule (R22, Amendment 7(d)).
* **`set-lane-tree` took the lane mutex only when the caller named a fence.**
The atomic temp-file rename underneath it stops a reader seeing half a sidecar
and stops nothing else, so an UNFENCED observation — a person's, or any caller
that names no transition — could land after a newer fenced one and replace it,
leaving the inventory holding a reading older than the transition recorded
beside it. Every write of these files is serialized now, and the
generation/operation comparison is unchanged for the callers that name one.
The lock is taken AFTER the observation and not before it, for the reason
decision 14 keeps `lane-reconcile` out of it altogether: `lane_tree_now` runs
`git status` and `git rev-list` in somebody's checkout, and what must be
serialized is the compare and the write, not the reading of a repository.
Ten new assertions in the suite's own `#91` section, as its section 12 (four
more that need `timeout(1)` are skipped and named where there is none): the
unreadable snapshot is 9, `UNREADABLE` and `indeterminate` and never
`no-state`; the SAME lane with no snapshot at all is still 8 and still
`no-state`, so the two answers differ in nothing but whether the file is there;
and an unfenced `set-lane-tree` run while another process holds the mutex is
still waiting when `timeout` kills it (124) and has written nothing, then lands
the moment the lock is free. The fixture for an unreadable file is a DANGLING
SYMLINK and not a `chmod 000`: a suite run as root reads a mode-000 file, and
the case would go red on the one host shape it was written to be harmless on.
Exit **9** joins this file's own exit-code table and the five arms' table; the
manual's crash-kind table gains the row; the spec gains the two scenarios; and
design decisions 19 and 20 record both fixes, beside the twelve deferrals.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved critical and moderate findings affect fail-closed metadata handling, reconciliation, and inventory finalization.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
lane-handoff:846
When lane-tree-now cannot read a writer, this branch deliberately omits its inventory entry and continues the swap. Since lifecycle_finish has no signal for pw_orc != 0, the later handoff can still commit SWAPPED even though the mandatory inventory step did not succeed, leaving a live tree represented only by prose. Propagate this observation failure into the unfinished-step decision so the lifecycle remains SWAPPING.
else
note "the observation of $pw_d could not be made (\`$LANES_EDIT lane-tree-now\` exited $pw_orc), so this writer is listed with what could not be read rather than with a clean-looking reading of it, and no inventory entry is filed for it. The lines below are still printed from this checkout, and the swap goes on (\`R-A11-11\`)."
fi
lanes-edit.sh:10040
This contract is not true for the new arms: each one calls log_sync before its local read/write, and log_sync performs remote_has_branch plus git fetch unless LANES_NO_FETCH=1 is supplied. Direct lane-state, set-lane-state, lane-trees, set-lane-tree, and lane-reconcile can therefore block on or mutate the register's remote-tracking refs, contrary to the documented local/offline behavior. Either force the no-fetch mode for these arms or revise the contract and recovery guidance.
# ---------- openRepoTools#91: the lifecycle, the inventory, the reconcile
#
# ALL FIVE ANSWER OUT OF THE LOCAL CONTROL ROOT and none of them touches the
# register, the object log or the network. The two WRITERS are deliberately
lanes-edit.sh:7826
A lane_payload_field failure is swallowed as an empty directory, so an unreadable lane log falls through to the generic $PROJECTS_ROOT/.lane-state/<lane> rung. That can read or write lifecycle state without verifying the lane's recorded checkout, whereas the repository's fail-closed rule says a failed read is not the same as no recorded directory. Preserve a distinct read error instead of falling through.
lcr_dir="$(lane_payload_field "$lcr_lane" dir 2>/dev/null || :)"
Round 6 — two taken, twelve filed, fourteen threads answered and resolved
Automated review rounds are capped at two per pull request (Brett Heap's ruling of 2026-09-16) and this one is at six, so the round was TRIAGED rather than taken. Nothing of it is left as a comment thread and nothing is left unnamed.
Taken, in 3840c87 — the two findings this capability could not land with, both of them the same rule read in two places:
A snapshot that IS THERE and cannot be read was answered as a lane that has none.lane_state_read's [ -r ] || return 8 put a permission error, an I/O error and a lane that never started under this capability behind one number, and lane_reconcile mapped every non-zero read to NONE — whose verdict is no-state, the cutover answer that tells a launcher there is nothing here to recover. The one report built to say WHERE a session stopped answered it never ran about the lane nobody could look at. The read answers 9 now for a record that is there and could not be opened ([ -L ] beside [ -e ], because a dangling symlink is -e false and is exactly the name-without-bytes case), lane-state exits 9 rather than the 8 a launcher goes past, and lane-reconcile reports UNREADABLE / indeterminate — the answer decision 15 already gives an unreadable HOLDER, under the same rule (R22, Amendment 7(d)).
set-lane-tree took the lane mutex only for a FENCED write. The atomic rename underneath it stops a reader seeing half a sidecar and stops nothing else, so an unfenced observation could land after a newer fenced one and replace it. Every write of these files is serialized now; the generation/operation comparison is unchanged for the callers that name one, and the lock is taken AFTER the observation for the reason decision 14 keeps lane-reconcile out of it (what must be serialized is the compare and the write, not the reading of a repository).
Filed, claimed by lane openRepoTools-3, and named in tasks.md section 7 with what each costs and what decides it — one issue per theme, and each thread carries its number:
the four reads that still turn a failure into an answer — the holder ids, an unreadable tree sidecar, git config --get branch.<b>.remote, git worktree list --porcelain
the inventory fence accepts a poll from a finished operation (SWAPPED keeps generation AND operation by decision 13), and a partial observation is completed with clean-looking defaults
lane-handoff takes a CLOSED lane to SWAPPING, reopening a terminal lane
Design decisions 19 and 20 record the two that were taken; the spec gains the two scenarios; the manual's crash-kind table gains the unreadable-snapshot row; exit 9 joins this file's own exit-code table and the five arms' table.
The fourteen are suite section 12: the unreadable snapshot is 9 / UNREADABLE / indeterminate and never no-state, the SAME lane with no snapshot at all is still 8 / no-state — so the two answers differ in nothing but whether the file is there — and an unfenced set-lane-tree run while another process holds the mutex is still waiting when timeout kills it (124) with nothing written, then lands the moment the lock is free. The fixture for an unreadable file is a DANGLING SYMLINK and not a chmod 000: a suite run as root reads a mode-000 file, and the case would go red on the one host shape it was written to be harmless on.
CI on 64dfd97: tests (Linux) pass in 28m15s, tests-windows pass, SonarCloud pass; tests-no-submodule and tests-macos still running as this is written, and macOS is no longer a per-push gate. CI is the suite of record for this pull request, which supersedes the "tests/run.sh is running and its result is posted here when it lands" line in the body above.
Copilot round 2 on #97 (lanes-edit.sh:11313): migrate-state-cells rewrites
state cells and appends to logs, yet it never asked the seam. The shorthand
marker `MANAGED OWNER · <token>` holds one ` · `, so mig_cell_is_phrase does
not recognise it, and `--yes` rewrote a managed row to `MIGRATED · …`.
The scan now calls managed_seam_refuse for every row it would rewrite. The
call comes after the 15(d) duplicate check and the phrase skip, and before
the plan is built. One managed row (exit 2) or unknown row (exit 1) refuses
the whole migration, the same way one refuses Amendment 19's sweep, because
the migration is one commit. The migration rewrites this checkout's row
while the seam reads the published register. So a local row that carries
the vocabulary where the published one does not counts as unknown too.
The repoMG section gets case 6, in its own workspace. The valid shorthand
(in the dry run and with --yes), a malformed shorthand, and a marker that
only this checkout carries all refuse with zero change. The same register
with no marker migrates. Without the fix, 22 of the case's assertions fail.
Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot round 2 on #97 (lane-handoff:434) asked lane-handoff to refuse a
second swap while the first is still live. lifecycle_begin supersedes any
lane recorded SWAPPING, which is design.md's settled answer for an
interrupted swap. Telling a live operation from an interrupted one needs the
handoff process's liveness. The snapshot does not record that, and design.md
rules out a PID alone. The fence already bounds the harm: the superseded
operation's finalizer and inventory writes are refused with 7.
So the spec's "Competing swap begins" scenario now states what the code
guarantees, not "without minting another owner". The requirement names the
deferral. design.md's settled answer says what it cannot yet tell, and
tasks.md files the finding under #157 beside the first one.
The manual's and decision 21's lists of seam call sites now name
migrate-state-cells, which 19df774 added, and task 8.13 records that change.
Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This proposal says RUNNING is written when SessionStart confirms the owner, but the implementation's design decision 9 makes the SessionStart hook read-only; lane-start is the confirming act and write_event records RUNNING after it proves the binding. Please align this bullet with the implemented ownership boundary so the proposal does not describe a hook write that must never occur.
Copilot round 3 on #97 (lanes-edit.sh:4947, spec.md:169) found legacy
writers that still reach a managed lane. The spec says every log,
register-row and sweep act refuses one:
- write_event asked the seam for STARTED, RESUMED, ENDED and RETIRED only,
so `log PAUSED` and request-handoff's HANDOFF-REQUESTED wrote to a managed
lane's log. Every lane-kind verb now asks. The two new ones get no
lifecycle follow-up.
- request-handoff now refuses at its head, so --dry-run plans nothing.
- archive-rows moved a RETIRED managed row out of the register. Its scan now
asks after the RETIRED test, and one hit refuses the whole move.
The sweep of every writer arm closed four more gaps. append-session-id
rewrote a managed row's session cell. add-row now names the owner of an
existing managed lane before its duplicate refusal. append-line, the new
text of append-session-id and rename-lane's new name could all write the
marker's vocabulary into the register.
managed_seam_refuse now also reads this checkout's row. Every legacy row
writer rewrites that row, while the seam's first read is the published
one, so a local row that carries the vocabulary where the published one
does not is UNKNOWN (1). The migration's own copy of that check is now
this one.
The repoMG section proves each newly guarded arm on all seven managed and
unknown fixtures with the zero-change fingerprint. It also covers the
three back doors, the legacy controls, and case 7: archive-rows over a
valid and a malformed retired marker, then the same move landing once the
managed row is gone.
Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
07fb3ad closed the seam on every legacy writer arm. The documents now
list the same set:
- the spec requirement names lane-kind log lines, the archive, the
migration and this checkout's row, and says no legacy writer may write
the marker's vocabulary;
- design decision 21 and the manual list PAUSED, request-handoff,
archive-rows, append-session-id, add-row for an existing lane and the
three back doors;
- task 8.14 records the round.
Object-kind lines (a claim, a release) are stated as unchanged, because
they concern the object a lane holds and not the lane.
Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The proposal still says RUNNING is written when the SessionStart hook confirms ownership, but this implementation deliberately keeps that hook read-only and writes RUNNING from the lane-start confirming act (spec.md:42-55, design.md:118-135). This stale wording makes the proposal contradict the normative requirement and the shipped behavior; update it to name the lane-start confirming act.
This issue also appears on line 17 of the same file.
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.
Part of #91. Refs
openspec/changes/add-crash-consistent-lane-worktree-recovery.Brett Heap's ruling of 2026-10-04, verbatim: "managed ledger owns enrolled lanes; #97 owns legacy — rework both"
This PR gives a legacy lane a crash-consistent lifecycle: RUNNING → SWAPPING → SWAPPED, plus CLOSED, with a generation/operation fence. It adds a machine-readable worktree inventory taken at every handoff, and a resume reconciliation that reports and resets nothing. Under the ruling, every act it adds or touches refuses a lane the managed ledger has enrolled before writing anything. It refuses a lane whose ownership cannot be read in the same way.
Branch 001's governance review is PROPOSED—NOT APPROVED. No Amendment 17 supersession is asserted.
What this round did, commit by commit
682f855origin/mainatdaed209: 14 commits (#81, #83, #93, #61, #146, #119, #101–#103, #129, #131, #133, #135, #136, #139, #134). No rebase and no force-push.b5aedae64bcf5b8f69e96HANDOFF-REQUESTED, and design decision 12 is reworded to match.28ed7c7indeterminate, never as a crashe9cd956rename-lanecarries the lane's control root to its new name39434balane_is_managed_owned,managed-projection, and a refusal at every call site673a81ae959c3fa0cf37b,d869dcc,a98b0d86e64660,ec9847e791574564dfd97. Codex's P1 onec9847eis the third of them (see Review)7773267ec9847e(design decision 25, task 8.12)d2d2eec19df774migrate-state-cellsasks the seam, and one managed or unknown row refuses the whole migration (repoMG case 6)eada5ff07fb3adrequest-handoff,archive-rows,append-session-idandadd-rowfor an existing lane refuse a managed lane; three back doors refuse the marker's vocabulary; the refusal reads this checkout's row too8c5958fThe merge: four conflicts, every intent of both sides kept
lanes-edit.sh:migrate_state_cells, because their header says "one screen up" of it. Crash-consistent lane worktree recovery: a lane now says WHERE its session stopped — RUNNING → SWAPPING → SWAPPED with a generation/operation fence, a machine-readable worktree inventory taken at every handoff, and a resume reconciliation that reports and resets nothing #97's lifecycle section follows them.b5aedae, below.lane-handoff: the late-record restart line uses Use lclaude for lane restarts #129's$lane_profile_word(lclaude), with Crash-consistent lane worktree recovery: a lane now says WHERE its session stopped — RUNNING → SWAPPING → SWAPPED with a generation/operation fence, a machine-readable worktree inventory taken at every handoff, and a resume reconciliation that reports and resets nothing #97's lifecycle line under it.docs/README-lanes.md: the Amendment 19 section comes first, then Crash-consistent lane worktree recovery: canonical inventory and RUNNING → SWAPPING → SWAPPED lifecycle #91's, so the amendment sections stay in number order.tests/test_lane_helpers.sh: main's Amendment 19 and 18 sections run in main's order, and Crash-consistent lane worktree recovery: a lane now says WHERE its session stopped — RUNNING → SWAPPING → SWAPPED with a generation/operation fence, a machine-readable worktree inventory taken at every handoff, and a resume reconciliation that reports and resets nothing #97's self-contained section follows them.lane-startauto-merged around Launch current Claude Code and report stale running sessions #134's rewrite. Section 4a still prints the reconciliation after Amendment 18's binding gate and before the STARTED/RESUMED write.AGENTS.md,README.md,tests/test_repo_hygiene.pyandskills/are byte-identical to main, so the 316- and 486-line caps already match.What main's changes broke in #97, and what was done about each
claim --force's abandoned takeover, and Crash-consistent lane worktree recovery: a lane now says WHERE its session stopped — RUNNING → SWAPPING → SWAPPED with a generation/operation fence, a machine-readable worktree inventory taken at every handoff, and a resume reconciliation that reports and resets nothing #97 had taken it for an unreadable snapshot. Each called it unused. Crash-consistent lane worktree recovery: a lane now says WHERE its session stopped — RUNNING → SWAPPING → SWAPPED with a generation/operation fence, a machine-readable worktree inventory taken at every handoff, and a resume reconciliation that reports and resets nothing #97 moves to 10, which no shipped script spends. The code table and the manual get claim --force takes over a dead lane's hold; lane-end --retire ends a duplicate holder #61's 9 row back verbatim, plus a row for 10 that says it used to be 9.retire_rowsappends RETIRED lines withoutwrite_event, so a swept lane kept its last snapshot. The sweep now takes each lane's pre-image under its lock and follows each line after it, the same waywrite_eventdoes.HANDOFF-REQUESTED. It changes no state, and every state reader skips it. The four claims now say "this change adds no lane-kind verb". Design decision 12 now rests on the reasons that remain: a transition must happen offline three times per handoff, and SWAPPING is a state.lane_binding_scanandbinding_is_hereasbindingandholder_is_dead, and prints aBINDINGline. Boundelsewhere, or a log that cannot be read, turns every verdict intoindeterminate. Clause (b)'s one exception still holds: a window gone from this host's tmux means the local read decides.lane-renamemoves the row, the log, the handoff andlanes/aliases.tsvtogether, and the old name resolves for ever in every reader #81'srename-laneknew nothing of it. The rename now moves the root once its commit has landed, never over an existing one..lane-worktrees/<old>holds real git worktrees, so it is a person'sgit worktree moveand is not moved here.Verified with nothing to do: #146's linear
table_lookupand pty bound, since neither is used by #97's code. #134'sclaude-restart-checkis about a replaced binary, not lane liveness. #97 adds nolanescolumn, so Amendment 19's columns 14–17 are untouched, and column 13 still printsnonefor a lane with a snapshot and no binding line (asserted).The seam
The cell. It is the register row's state cell, and a valid marker takes one of two forms:
The rule is the ported reader's own: "no malformed marker may be downgraded to absence".
managed-projection <lane>The call sites, each before the act's first write:
write_event, before its lock, for every lane-kind verb: STARTED, RESUMED, ENDED and RETIRED, whose verdict is handed tolane_state_follow(it writes only on 8), and PAUSED and HANDOFF-REQUESTED, which move no lifecycle. Object-kind lines (a claim, a release) are about the object, not the lane, and are not refused.request-handoff, at its head aftercanon-lane, so--dry-runrefuses too and no binding is read.set-lane-stateandset-lane-tree, before the control root and the lock.retire-rows, in its scan. Any hit refuses the whole sweep.migrate-state-cells(Amendment 13(e)), in its scan, for every row it would rewrite: after the 15(d) duplicate check and the phrase skip, and before the plan is built. Any hit refuses the whole migration, in the dry run and with--yes.archive-rows(Amendment 19(d)), in its scan, for every RETIRED row it would move. Any hit refuses the whole move, in the dry run and with--yes.append-session-id, andadd-rowfor a lane that already has a row (named before the duplicate refusal that would refuse it anyway). A new lane has no row and is never asked.lane-reconcileis read-only. It printsMANAGEDandVERDICT managed-owned, orindeterminatefor unknown.row_state_check,add-row,append-line, and the new text ofreplace-in-row,append-session-idandrename-lane's new name refuse the marker's vocabulary, so no legacy writer forges one.set-row-state,replace-in-rowandrename-lanerefuse a managed lane. Each would replace the marker or orphan itsbound-lane. These three go beyond the plan's list: they are the row writers branch 001 itself guards (b001:9064, 9096, 9613).lane-handoff, right after its canonicallane=step and before the window rename.--late,--restartand--exitare all behind it, so SWAPPING and SWAPPED never run for a managed lane.lane-start, at the head of section 3, before Amendment 18's binding gate andrequest-handoff.lane-end, before its first act on each of its paths. For the--retire <pid>door that is the door's head, before any process is looked for or signalled. For the ending and--retireit is right after the command's own row refusals, so that Amendment 15(d)'s pair refusal is still the one a person reads through a helper that predates both reads.Provenance, with an empty diff. The reader is
3c26041:lanes-edit.sh:776-898, the projection reader of branch001-separate-swap-ctx-handoff(its T019), ported verbatim between two marker lines:Its four dependencies,
row_split_state_cell,rstrip_spaces,lcandrow_of_lane, are byte-identical on main. The wrapperlane_is_managed_ownedcloses the one gap the reader leaves:row_of_lanereads an unrendered published register as an empty one, so the reader would call every lane legacy, and the wrapper answers that as unknown. When 001's T024 lands, the ported block merges as "keep either" and the wrapper becomes itsmanaged_legacy_check.Risks, measured.
225b9f90a). No legacy writer can now write the vocabulary.LANES_NO_FETCH=1callers miss a marker published since their last fetch, until 001's lease closes that window.The lifecycle, as before (the earlier body's substance)
lanes-edit.shhas five lifecycle arms:lane-state,set-lane-state,lane-trees,set-lane-treeandlane-reconcile. There is alsolane-tree-now, the one implementation of the observation. Exit 7 is the fence refusal, the number this file already spends on "another act got there first".lane-handofftakes RUNNING → SWAPPING before it polls, records every polled worktree against that operation, and takes SWAPPING → SWAPPED only after the record, the row and the handoff file have all landed.lane-startprints the reconciliation before it writes anything, for any verdict that is notrunning,resumableorclosed.docs/README-lanes.mddescribes the four words, the crash-kind table (now with the bound-elsewhere and managed-owned rows), the fence, the control root's rungs, the inventory, and the new "Managed-owned lanes" section.Review: every thread answered and resolved
Round 3 was taken as seam completeness under the ruling, not as a cap exception for style. Copilot reviewed
19df774and theneada5ffunasked, a third round. Its two findings on19df774, and the samearchive-rowsfinding again oneada5ff, are legacy writers still reaching a managed lane, against this PR's own requirement. A seam that lets a legacy writer stamp a managed lane would land Brett Heap's ruling broken. So the round was taken, and with it a sweep of every register and log writer arm, so that no arm is left for a fourth round. Any later finding that is not a seam leak is filed on #157.64dfd97lane_sidecar_schema_oklet a writer replace a dangling symlink7915745:[ -e ] || [ -L ], as the reader asks. repoRC-9 covers both writers.64dfd97resumable7915745: the read keeps its exit status, and anything but 0 or 8 makes the verdictindeterminate. Covered by repoRC-13.64dfd97and Codex P1 onec9847eSWAPPED7915745: the tree is named inlc_unfinishedand the lane staysSWAPPING. Covered by repoRC-14.lane-startwas silent over aresumablelane whatever its trees held7773267: quiet only whenTREEScounts nothing needing attention. The report also names a binding that is not here or free. Covered by repoRC-15(d).dirty 0, unpushed 07773267: refused as usage (64), writing nothing. Covered by repoRC-15(a). This is #114's second half.a+banda-bfolded to the same id7773267: acksumof the whole path now prefixes every id. No migration is needed, because no released tooling has written a sidecar. Covered by repoRC-15(b). This is #116's first half.7773267: it is now alane-treesrow with schema<unreadable>and anunreadable-sidecarclass. Covered by repoRC-15(c). This is #113's second read.ec9847elane-reconcilefetches before it readslanes-edit.shfetches the same way: bounded, and falling back to the local ref when offline. The fetch touches no lane tree, andlane-startalready passesLANES_NO_FETCH=1. The manual now says this (d2d2eec).d2d2eec(lanes-edit.sh:11313)migrate-state-cellsnever asked the seam. The shorthandMANAGED OWNER · <token>holds one·, so it was not the phrase and--yesrewrote it toMIGRATED · …19df774: one managed (2) or unknown (1) row refuses the whole migration, as in Amendment 19's sweep. Covered by repoMG case 6 (zero change on the valid shorthand, a malformed one, and a marker only this checkout carries; 22 of its assertions fail without the fix).d2d2eec(lane-handoff:434)eada5ff).19df774(lanes-edit.sh:4947)log PAUSEDandrequest-handoff's HANDOFF-REQUESTED wrote to a managed lane's log: the seam covered four verbs07fb3ad: every lane-kind verb asks, andrequest-handoffrefuses at its head (--dry-runincluded). Each is proved on all seven managed and unknown fixtures with zero change.19df774(spec.md:169)archive-rows --yesmoved a RETIRED managed row out of the register07fb3ad: one managed or unknown RETIRED row refuses the whole move. Covered by repoMG case 7 (a valid and a malformed marker refused with zero change, and the same move landing once the row is gone).eada5ff(README-lanes.md:3114)archive-rowsmissing from the seam's list07fb3ad. The manual names it (8c5958f).eada5ff(lanes-edit.sh:4949)log RESUMEDtakes a snapshot toRUNNINGwithout the confirming act's proof07fb3adthe seam refuses it for a managed or unknown lane.eada5ff(lanes-edit.sh:11867)printfwould put trailing spaces intolane-treesfieldsod -cof the construct shows no space, and repoRC-5 asserts the exact path on every CI run.8c5958f(5 threads:lane-start:1435,lanes-edit.sh:10386,:11240,:11368,:11859)lane-start's transcript move; a sweep's capture commit before its refusal; two mis-shaped rows without vocabulary read as legacy; an unreadabletrees/directory read as no inventorylane-startfix is sketched there as a one-commit follow-up, and #157 also records the manual's "not seven columns" overclaim.Evidence
Local runs, all through
tests/run.sh, which takes the workstation lock:682f855.tests/run.sh -k 'lane_helpers_suite or repo_hygiene or lane_start_claude_current or guard_launch_mode or install_skill_and_hook'gave 274 passed, 600 deselected in 1560 s. The bash suite's own footer is asserted by the wrapper to readpassed, 0 failed.e9cd956.tests/run.sh -k lane_helpers_suite, run from an exported snapshot of that commit, gave 2 passed in 1581 s.ec9847e. The same selection as the merge run gave 274 passed, 600 deselected in 1419 s. CI passed on every job that runs on a pull request:tests18m02s,tests-no-submodule17m45s,tests-windows,parse-macos(bash 3.2 parse),guard-launch-modeand SonarCloud.tests-macosis skipped, because it runs only withready.7773267. The same selection gave 274 passed, 600 deselected in 1568 s. The four new repoRC-15 cases, together with Crash-consistent lane worktree recovery: canonical inventory and RUNNING → SWAPPING → SWAPPED lifecycle #91's section and the seam's, passed 641 of 641 on a section harness in 130 s.d2d2eecchanges only the manual andtasks.md;repo_hygienepassed 152 on it.a98b0d8. It gave 1 failed: the bash suite had 3928 passed and 3 failed. The failures were main's Amendment 15 case "lane-end with a helper that predates canon-lane still refuses the pair". The seam, placed right aftercanon-lane, called that old helper's unknown subcommand UNKNOWN and exited 1 before lane-end's own 15(d) refusal.6e64660fixes it by ordering, without weakening either refusal.19df774. A section harness (the suite's preamble, Amendment 13's migration section, whose legacy rows now pass through the seam, and the seam section's case 6) gave 72 passed, 0 failed in 11 s. The same harness againstd2d2eec'slanes-edit.shgave 22 failed.tests/run.sh -k repo_hygienegave 152 passed on19df774and oneada5ff.openspec validate --strictpasses oneada5ff.07fb3ad+8c5958f. The fulltests/test_lane_helpers.sh, run under the workstation lock with the pytest wrapper's environment, gave 4167 passed, 0 failed at07fb3ad. A section harness for cases 6 and 7 gave 90 passed in 8 s.openspec validate --strictpasses on8c5958f.tests/run.sh -k repo_hygienepassed 152 on19df774andeada5ff. For these two commits it was queued behind another project's pytest runs, whichrun.sh's workstation-wide poll waits on, so CI'stestsjob is its record. A static check of every added line against the locale and exit-code hygiene rules is clean.The seam section has 452 assertions. At 410 assertions, a copy whose
lane_is_managed_ownedalways answers "legacy" gave 339 failed, 91 of them zero-change assertions, so the assertions bite. Of #91's own section, the only removed line is the assertion of exit 9 that the move to 10 replaced. Every other assertion is unchanged, and nine were added.laneswall time on the live register (26 rows,LANES_NO_FETCH=1): 2.35 / 2.51 / 2.35 s on this branch against 2.58 / 2.54 / 2.20 s on main, with byte-identical output.What remains of #91
indeterminatefrom outside a lane's binding rather than guessing.tasks.mdsection 7.Lane: openRepoTools-3
🤖 Generated with Claude Code