diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 293b360..244e1ad 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1130,8 +1130,9 @@ else. | 4 | the mutex could not be taken within 60s | | 5 | an edit moved more than one line and was refused | | 6 | `git add` / `commit` / `push` failed | -| 7 | `CLAIM-LOST` — another lane's claim landed first (`claim` only) | +| 7 | another act got there first and this one wrote nothing: `claim`'s `CLAIM-LOST` (another lane's claim landed first), and `set-lane-state` / `set-lane-tree`'s lifecycle fence (openRepoTools#91, below) | | 9 | `CLAIM-LOST` — issue #30's own dead-lane verdict could not be reconfirmed before a `--force` takeover's push landed: the source lane resumed, a live session now backs it up, or that could not be read at all (`claim` only). Never 7 — that code is a RIVAL's claim, and this is the same lane the takeover was granted over | +| 10 | `lane-state`: the lane's lifecycle snapshot is there and could not be read (openRepoTools#91, below) — never the 8 that means it has none. It was 9 until #61 spent 9 on `claim` | | 8 | no record — and no other meaning | | 64 | `swapped`'s own usage error — never the dispatcher's 2 | @@ -2851,6 +2852,281 @@ either. **Rule 9 holds either way** — a row is one `git show` away — so the archive is for a register a person wants shorter, and never a requirement. +## Crash-consistent lane recovery (openRepoTools#91) + +**A lane is `RUNNING`, `SWAPPING`, `SWAPPED` or `CLOSED`, and which of those it +is WITH NO LIVE HOLDER is what says where its session stopped.** A session can +run out of tokens before the handoff, after the handoff began and before it +finished, or after it finished — and until this capability 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. + +Governed by `openspec/changes/add-crash-consistent-lane-worktree-recovery/` and +tracked on [opensoft/openRepoTools#91](https://github.com/opensoft/openRepoTools/issues/91). +It amends no protocol: **it adds no lane-kind verb to the append-only log**. +Amendment 7's five state verbs, `STARTED`, `PAUSED`, `RESUMED`, `ENDED` and +`RETIRED`, stand, and every reader of them — `swapped`, `lane-last`, +`lane-dir`, `who`, `lane-end` — is untouched. (Amendment 18(g) has since added a +sixth lane-kind verb, `HANDOFF-REQUESTED`, which changes no state; this change +adds none.) What is new is a SNAPSHOT beside that history. + +### The four words, and the two crashes + +```text +RUNNING --/handoff begins--> SWAPPING --record + row + handoff all landed--> SWAPPED + ^ | + +---------------- the act that confirms the next binding --------------—---+ +``` + +| the snapshot says | a live holder? | what it means | +|---|---|---| +| `RUNNING` | yes | the lane is running — do not launch a second coordinator or a second writer | +| `RUNNING` | **no** | **ungraceful stop**: the session died before any handoff began, so nothing was polled, refreshed or recorded | +| `SWAPPING` | yes | a handoff is in flight — do not compete with it | +| `SWAPPING` | **no** | **interrupted swap**: the handoff began and did not finish, so the record, the row and the handoff file may each be half done | +| `SWAPPED` | no | the swap completed; the lane is resumable once its trees are read | +| `SWAPPED` | yes | inconsistent — a swapped lane has no holder, and neither side is overwritten | +| `CLOSED` | — | the lane is finished; a dirty or unpushed tree under it is a closure inconsistency and no cleanup is made | +| any | **unreadable** | `indeterminate`. A holder that could not be established is NOT "no holder" (`R22`, Amendment 7(d)), and no crash is pronounced on a read nobody got. | +| **unreadable** | — | `indeterminate` again, and for the same rule read one file earlier: a snapshot that IS THERE and cannot be opened is not a lane that has none. `lane-state` exits **10** for it, never the **8** that means *this lane has no snapshot, go on*. | +| any | — and the lane is **bound elsewhere** | `indeterminate`. Amendment 18(b): liveness is pronounced only from inside the binding's own host and container, and from anywhere else a binding is UNKNOWN, never dead — and this workstation's snapshot is this workstation's alone. The one exception is clause (b)'s own: a binding whose window is gone from this host's tmux is dead, and the local read decides. | +| — | — and the lane is **managed-owned** | `managed-owned`, and nothing else is read or pronounced: see *Managed-owned lanes* below. | + +### The fence + +Every transition carries a monotonic **generation** and a unique **operation +id**, and `set-lane-state --expect …` is the compare-and-swap: + +```sh +lanes-edit.sh lane-state # state, generation, operation, owner, updated +lanes-edit.sh set-lane-state SWAPPING --expect RUNNING +lanes-edit.sh set-lane-state SWAPPED --expect SWAPPING \ + --expect-generation --expect-operation +``` + +A finalizer whose state, generation or operation no longer matches writes +NOTHING and exits **7** — the number this file already spends on `claim`'s +CLAIM-LOST, and one meaning on it: *you lost the race*. That is what stops a +`/handoff` that stalled for an hour from marking a lane `SWAPPED` after somebody +has recovered and resumed it. A handoff that finds the lane still `SWAPPING` +**takes it over** with a new generation and names the operation that never +finished; nothing of that operation is undone. + +`RUNNING` is written by the act that CONFIRMS the binding and never by the +SessionStart hook: `session-start` never writes, never touches the network and +always exits 0 (Amendment 8, `R-A8-1`), and a hook that writes is a hook that +can break the session it was meant to orient. So the snapshot follows the +`STARTED`/`RESUMED` line `lane-start` writes, in `lanes-edit.sh`'s own +`write_event`; `ENDED`/`RETIRED` become `CLOSED` there too. + +### Where it lives + +A LOCAL control root beside the lane's own checkouts — not the register (every +write of that is a commit, a pull and a push, and a transition happens three +times per handoff with no network), and not inside a git worktree (metadata +there dirties a checkout and disappears with the very directory whose loss it +explains). Three rungs, and never the caller's current directory: + +1. `$LANES_LANE_STATE_ROOT/` — the explicit override and the suite's seam; +2. `/.lane-state/` — the same parent + the lane's own `.lane-worktrees/` root sits in, and `dir` is Amendment + 11(c)'s recorded field rather than a guess; +3. `$PROJECTS_ROOT/.lane-state/`. + +No rung answering is **8**, *this lane has no control root* — the ordinary +answer for a lane that has not started under Amendment 11(c), and not a failure. +Nothing is backfilled (Amendment 7(i)). + +Because the snapshot is filed under NOTHING — never committed, never leaving the +machine that wrote it — `R-A11-14` does not reach it: a container with no +`$LANES_WORKSTATION` still keeps a lifecycle it can recover itself from, while +the register and object-log writes stop there exactly as they did. + +### The worktree inventory + +Every handoff polls the lane's writers in the two places a lane keeps them — +`/.claude/worktrees/` and +`/.lane-worktrees//` — and now records each one MACHINE +READABLY beside the lane as well as in the handoff's WRITERS section: + +```sh +lanes-edit.sh lane-trees +# +lanes-edit.sh lane-tree-now +# — what git says about one tree NOW +``` + +The full `head` and the `upstream` are why this is not the prose section one +more time: `%h` is an abbreviation that lengthens as a repository grows, and +`0 unpushed` cannot be told from *this branch tracks nothing at all* without the +upstream. Every field is an OBSERVATION and none of them is truth about git. + +**`lane-tree-now` is the one implementation of that observation**, and the +handoff, the sidecar and the reconciliation all go through it, so the WRITERS +section a person reads and the record a recovery reads can never be two +different readings. It does not convert a git read that FAILED into a +clean-looking value: `unknown`/`none`/`0` are answers, and a read that could not +be made exits **1** and prints nothing (`R22`, Amendment 7(d)). Two states are +answers rather than failures and are spelled as such — a branch with no commit +yet has `unborn` for its head, and a branch whose upstream is configured but +whose remote-tracking ref is not in this checkout (the ordinary state after a +merged branch is deleted) records that configured upstream with `unknown` +unpushed, never the `0` that reads as *everything here is published*. + +**A sidecar this helper cannot read is one it will not replace.** A snapshot or +a tree record carrying a schema this version does not write is reported as +`UNKNOWN-SCHEMA` by every reader and REFUSED by every writer (exit 1), rather +than overwritten by a record an older helper can understand — the one act no +later reader can undo. And `set-lane-tree --generation/--operation` is COMPARED +with the lane's own snapshot under the mutex before the record is filed, so a +poll taken under an operation a recovery has since superseded is refused with +**7** instead of being filed over the current inventory. + +**One path, one record.** A tree's record is named from its path and from +nothing else: `c-`, a `cksum` of the WHOLE absolute path +and then the path with every character outside the file-name alphabet folded to +`-`. The fold is for a person reading the directory; the checksum is what keeps +`…/a+b` and `…/a-b`, which fold alike, two records rather than one replacing the +other. And `set-lane-tree` takes a caller's observation **whole** — `--branch`, +`--head`, `--upstream`, `--dirty` and `--unpushed` together — or reads the tree +itself; a partial one is refused with **64** and nothing is written, because the +fields it lacks would be filed as a `dirty 0, unpushed 0` nobody observed. + +### The reconciliation, which resets nothing + +```sh +lanes-edit.sh lane-reconcile +``` + +It recomputes branch, HEAD, upstream, dirty and unpushed for every inventoried +tree, reads `git worktree list --porcelain` in the lane's checkout and the +directories under both lane roots, and prints one `TREE` line per tree with a +classification: `ok`, `dirty`, `unpushed`, `unpushed-unknown`, +`dirty+unpushed`, `dirty+unpushed-unknown`, `missing`, `possible-loss`, +`not-a-checkout`, `unreadable`, `unreadable-sidecar`, `unknown-schema`, +`unmanaged`, `stale-registration`. A `BINDING` line says where the lane is bound — `here`, +`free`, `gone` (bound on this host's tmux and its window is gone), `elsewhere` +(and where), or `unknown` (its log could not be read) — and `elsewhere` or +`unknown` turns every verdict into `indeterminate` (Amendment 18(b)). The last +line is the `VERDICT`. `lane-start` prints the report before it writes +anything, for any verdict that is not `running` or `closed` — and for +`resumable` as well whenever its `TREES` line counts a tree that is dirty or +unpushed, requires recovery, or is unmanaged or stale: `resumable` says the swap +completed, not that every tree it left is clean, published and where it was. + +A **renamed** lane keeps its snapshot and inventory: `rename-lane` moves its +control root to the new name once the rename's commit has landed, and never +over something already there (Amendment 16). Its `.lane-worktrees/` root +holds real git worktrees and is not moved — that is a `git worktree move`, and a +person's. A lane retired by Amendment 19's sweep is taken to `CLOSED` exactly as +a lane that ended itself is. + +**It reads the published register first**, as every read in `lanes-edit.sh` +does: the lane's name resolves through the register and Amendment 16's alias +table, and its binding and any managed-owner marker are read from them, so a +stale ref would pronounce from a binding that has since moved. The fetch goes +into the register checkout and touches no lane tree; it is bounded by +`LANES_GIT_TIMEOUT` and, when it fails, the ref is read as it stands. +`LANES_NO_FETCH=1` skips it — which is how `lane-start` calls it, having fetched +in its step 3 — at the cost of reading what was last fetched. Whether a recovery +read should be local by default is opensoft/openRepoTools#157. + +**It reports and it resets nothing.** `park` CREATES NOTHING and `resume` RESETS +NOTHING (`AGENTS.md` rule 1), so this read runs `git status`, `git log @{u}..`, +`git rev-parse` and `git worktree list --porcelain` and nothing else: + +* a **missing** tree that was clean and published names the estate's own + `resume ` as the only rebuild — and NAMES it rather than running it, + because that verb runs `make resume` across a whole estate and a report may + not do that as a side effect; +* a missing tree whose last observation held dirty or unpushed work is + **possible-loss** and is never claimed to be reconstructable — no metadata + reconstructs a file's contents; +* an **unmanaged** tree — one git registers, or one sitting under a lane root, + that no sidecar names — is reported and never deleted, adopted or overwritten: + which lane a tree belongs to is a person's to say. One tree is named ONCE + however many spellings of its path reach the report: 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`, so every comparison + resolves both sides — without which every tree of an estate that reaches its + checkouts through a `projects` symlink is reported twice, the second time as a + tree nobody manages; +* a **stale-registration** names the `git worktree prune` that clears it, and + prunes nothing itself; +* an **unreadable** tree is one git answers in and cannot be read through — + nothing is assumed about it, in either direction, and the line names the + `git -C status` a person runs; +* an **unreadable-sidecar** is a tree record that IS there and could not be + read — a permission, an I/O error, a dangling link. The tree it recorded is + NOT known, in any field; it counts toward recovery, the line names the file a + person reads by hand, and it is never skipped as though the lane owned one + tree fewer (`lane-trees` carries it as a row of its own whose schema is + ``); +* an **unknown-schema** tree is a sidecar written by a newer tooling: it is + named, and not one field of it is read, because a value taken out of a record + whose shape this reader is guessing at is worse than no value. + +### What a resumed session does with it + +Read the verdict first, then the trees, then `ListAgents` — the count is still +what decides whether a writer is live (Amendment 17 Addendum 1 (i), and (k): +one worktree, one writer). `ungraceful-stop` and `interrupted-swap` both mean +**inspect every tree before relaunching anything**; the difference is that under +`interrupted-swap` the record, the row and the handoff file may each be half +written, so check all three rather than trusting the handoff's top block. + +## Managed-owned lanes + +**Brett Heap's ruling of 2026-10-04, verbatim: *"managed ledger owns enrolled lanes; #97 owns legacy — rework both"*.** +A lane the managed ledger has enrolled carries a **managed-owner marker** in its +register row's state cell, written by that ledger's own writer: + +```text +LIVE · · managed-owner mode=managed daemon= generation= bound-lane= +MANAGED OWNER · (the historical shorthand) +``` + +Every lane without one is a **legacy** lane — every lane in the register today — +and the lane tooling here answers for legacy lanes only. The marker is read by +one reader, ported byte for byte from branch `001-separate-swap-ctx-handoff` +(`3c26041:lanes-edit.sh:776-898`), and asked through one read: + +```sh +lanes-edit.sh managed-projection +``` + +| exit | meaning | what every legacy act does | +|---|---|---| +| 0 | a valid marker; the owner is printed | **refuses with 2**, names the owner, writes nothing | +| 8 | no marker and no managed-owner vocabulary | runs exactly as it always has | +| 1 | managed-owner vocabulary that does not parse (a `generation=0`, an empty daemon, a `bound-lane` that is not this lane, an empty shorthand token, a `managed:` anywhere in the row), a row that is not seven columns, or a register that could not be read | **refuses with 1**: ownership is UNKNOWN, and an ownership nobody could establish is never read as legacy (Amendment 7(d)) | +| 64 | usage | — | + +The acts that ask, each before its first write: `lane-start` (at the head of its +section 3, before Amendment 18's binding gate and `--request-handoff`), +`lane-handoff` (before the window is renamed — so `--late`, `--restart` and +`--exit` never reach `SWAPPING` or `SWAPPED`), `lane-end` (the ending, +`--retire` and `--retire `), the object log's `STARTED`, `RESUMED`, `ENDED` +and `RETIRED`, a swap's `PAUSED`, `request-handoff` (its `--dry-run` too), +`set-lane-state`, `set-lane-tree`, `retire-rows` (one managed or unknown lane +refuses the whole sweep), `migrate-state-cells` (one managed or unknown row it +would rewrite refuses the whole migration), `archive-rows` (one such RETIRED +row refuses the whole move), `append-session-id`, `add-row` for a lane that +already has a row, `set-row-state`, `replace-in-row` and `rename-lane`. Each +reads this checkout's row as well as the published one, because this +checkout's row is the one a legacy writer rewrites: vocabulary in one copy and +not the other is UNKNOWN (1). A claim or a release is about the object, not the +lane, and is not refused. `lane-reconcile` reads nothing of a managed lane and prints +`VERDICT managed-owned` (and `indeterminate` where ownership is unknown). And no +legacy writer — `set-row-state`, `add-row`, `replace-in-row`, `append-line`, +`append-session-id`, `rename-lane`'s new name, the sweep — may write the +marker's vocabulary into the register at all, so a marker is never forged. + +What the managed ledger itself does with an enrolled lane is not this manual's: +branch `001-separate-swap-ctx-handoff`'s governance review is **PROPOSED — NOT +APPROVED**, and nothing here asserts that it supersedes Amendment 17. + ## Hand edits After **any** hand edit made with an allowed tool (python read/write, `sed -i diff --git a/ideation/README.md b/ideation/README.md new file mode 100644 index 0000000..105de3f --- /dev/null +++ b/ideation/README.md @@ -0,0 +1,9 @@ +# openRepoTools Ideation + +This directory holds non-normative brainstorm material. Documents under +`brainstorm/` describe possibilities for later specification and implementation; +they do not amend the lane collision protocol or the shipped command contracts. + +## Brainstorm packets + +- [Crash-Consistent Lane Worktree Recovery](brainstorm/lane-worktree-recovery-state-overview.md) — a proposed canonical lane worktree layout, two-phase swap state, and resume-time reconciliation model. diff --git a/ideation/brainstorm/lane-worktree-recovery-state-canonical-layout.md b/ideation/brainstorm/lane-worktree-recovery-state-canonical-layout.md new file mode 100644 index 0000000..1740a5e --- /dev/null +++ b/ideation/brainstorm/lane-worktree-recovery-state-canonical-layout.md @@ -0,0 +1,60 @@ +# Canonical Lane-Owned Worktree Layout — Brainstorm + +Status: brainstorm +Kind: architecture +Summary: Give each lane a derivable worktree root beneath its home repository while keeping the coordinator in the canonical base checkout. +Topics: lane-worktree-recovery-state, canonical-worktree-layout, lane-directory, worktree-ownership +Repository context: openRepoTools lane orchestration, with openRepoShape and Speckit worktree-layout compatibility as a boundary +Captured: 2026-09-15 + +## Possible feats + +- **Derivable lane root** — Resolve every lane-owned worktree without relying on a historical absolute path. +- **Coordinator-base invariant** — Require the lane coordinator to launch from its home repository's canonical base checkout. + +## Focus + +This document isolates where a lane and its worktrees live. Today `dir` is the exact directory from which a coordinator was launched; it can be a base checkout or a feature worktree, and legacy lanes may not record it at all. That makes recovery depend on historical paths and leaves the relationship between one coordinator and several writer worktrees implicit. + +## Proposed model + +Resolve a lane's stable repository identity from `home owner/repo` and `estate`. On each workstation, resolve that identity to the canonical base checkout. The coordinator always launches there. Lane-owned writer worktrees occupy a deterministic root derived from that checkout, conceptually: + +```text +/worktrees// +├── lane-state.yaml +├── tree-state/ +│ └── .yaml +└── trees/ + └── / +``` + +The exact path must remain compatible with the estate's `project.yaml`, `SPECKIT_GIT_WORKTREE_ROOT`, and openRepoShape's feature-first worktree contract. A naive `..` calculation is insufficient for a three-leg estate. The invariant is derivability from repository identity and shape, not this illustrative spelling. + +State files live beside the Git worktrees rather than inside them. That avoids making a checkout dirty, accidentally committing orchestration metadata, and losing the only record when a worktree directory disappears. + +One lane may own many worktrees, but its coordinator has one launch directory. Each worktree receives a stable tree identifier and records its own repository, role, branch, HEAD, upstream, writer, and lifecycle state. + +## Interfaces and boundaries + +The layout consumes canonical lane name, `home`, `estate`, and shape configuration. It emits derivable paths and sidecar locations. It does not decide whether work is safe, whether a writer is live, or whether a missing worktree can be rebuilt. + +Existing governed Speckit feature paths remain authoritative. Lane ownership may need to be an index over those paths rather than a second physical layout when moving them would violate the shape contract. + +## Alternatives and tensions + +- Keeping the coordinator in whichever worktree it last used preserves current flexibility but makes the lane's identity depend on mutable feature state. +- Putting `.lane-state` inside each worktree makes discovery local but dirties or disappears with the checkout unless special exclusion rules are reliable everywhere. +- Recording absolute paths is easy on one workstation but is not portable between host, container, and another workstation. +- Organizing physical worktrees by lane makes discovery simple; organizing them by feature preserves the existing Speckit contract. A sidecar lane index may reconcile both. + +## Open questions + +- Is the coordinator base the estate root, the lane's `home` repository checkout, or a declared coordination leg? +- Should lane-owned scratch worktrees and governed Speckit feature worktrees use different physical roots? +- What stable identifier names a writer tree when the branch is renamed? +- Is the sidecar local-only, workspace-replicated, or split between both? + +## Relationships + +The lifecycle written beneath this layout is defined in [Crash-Consistent Lane Lifecycle](lane-worktree-recovery-state-crash-consistent-lifecycle.md). Resume reconciles the layout against Git and processes as described in [Resume-Time Worktree Reconciliation](lane-worktree-recovery-state-resume-reconciliation.md). diff --git a/ideation/brainstorm/lane-worktree-recovery-state-crash-consistent-lifecycle.md b/ideation/brainstorm/lane-worktree-recovery-state-crash-consistent-lifecycle.md new file mode 100644 index 0000000..42edbe3 --- /dev/null +++ b/ideation/brainstorm/lane-worktree-recovery-state-crash-consistent-lifecycle.md @@ -0,0 +1,68 @@ +# Crash-Consistent Lane Lifecycle — Brainstorm + +Status: brainstorm +Kind: architecture +Summary: Persist RUNNING, SWAPPING, and SWAPPED as guarded transitions so token exhaustion before or during a handoff is distinguishable and recoverable. +Topics: lane-worktree-recovery-state, crash-consistent-lifecycle, swap, session-binding +Repository context: openRepoTools `/swap`, `lane-handoff`, `lane-start`, and SessionStart behavior +Captured: 2026-09-15 + +## Possible feats + +- **Two-phase swap** — Mark a swap in progress before preservation work and complete it only after every required handoff write succeeds. +- **Generation-fenced transition** — Prevent a delayed old process from overwriting the state of a newer resumed session. + +## Focus + +This document isolates lane-level lifecycle state and transition ordering. A session may exhaust tokens before `/swap`, during `/swap`, after a clean swap, or while a replacement is starting. Those cases must not collapse into one ambiguous `PAUSED` observation. + +## Proposed model + +Use an atomically replaced current-state sidecar plus the existing append-only lane event history: + +```text +RUNNING --/swap begins--> SWAPPING --handoff complete--> SWAPPED + ^ | + +------- confirmed replacement SessionStart ----------+ +``` + +`/swap` first performs a compare-and-swap from `RUNNING` to `SWAPPING`. It then inventories worktrees, polls writers, records dirty and unpushed state, refreshes the handoff, and lands the protocol's pause writes. Only after every required step succeeds may it compare-and-swap the same operation from `SWAPPING` to `SWAPPED`. + +Each transition carries at least: + +```yaml +state: swapping +generation: 42 +operation_id: 73989e7c-0000-0000-0000-000000000000 +session_id: +agent: claude +profile: team-01l +updated_at: +``` + +The finishing write must match both `generation` and `operation_id`. A stale `/swap` process therefore cannot mark a lane `SWAPPED` after another process has recovered or resumed it. + +The replacement must not write `RUNNING` merely because a launcher was invoked. `RUNNING` is written only after SessionStart confirms the new process, transcript, canonical lane, directory, and binding. An optional `RESUMING` state can make the launch interval explicit; without it, state remains `SWAPPED` until confirmation. + +## Interfaces and boundaries + +The lifecycle owns lane intent and transition authority. It consumes a verified session/binding identity and emits state changes. Liveness remains an observation from process/session records and must not be inferred from the state word alone. + +This lifecycle does not claim that work is durable. `SWAPPED` means the required swap procedure completed; durability assertions come from the worktree reconciliation checks. + +## Alternatives and tensions + +- Reusing only append-only `PAUSED` and `RESUMED` events preserves one source but makes an interrupted multi-step swap harder to distinguish without an explicit `SWAPPING` event. +- A mutable sidecar gives a cheap current-state read but needs atomic replacement, locking, and an append-only audit event to explain every change. +- A heartbeat can identify abandoned `RUNNING` and `SWAPPING` states sooner, but process identity and namespace boundaries remain the stronger liveness evidence. + +## Open questions + +- Is `RESUMING` worth a fourth persisted state, or should `SWAPPED` remain until SessionStart? +- What timeout, if any, changes an indeterminate holder into a recovery candidate? +- Which swap steps are mandatory before `SWAPPED`, and which may finish asynchronously? +- Is recovery allowed to complete an interrupted operation ID, or must it always create a new generation? + +## Relationships + +The state refers to worktrees placed by [Canonical Lane-Owned Worktree Layout](lane-worktree-recovery-state-canonical-layout.md). Its crash outcomes are interpreted by [Resume-Time Worktree Reconciliation](lane-worktree-recovery-state-resume-reconciliation.md). diff --git a/ideation/brainstorm/lane-worktree-recovery-state-overview.md b/ideation/brainstorm/lane-worktree-recovery-state-overview.md new file mode 100644 index 0000000..b7cf95d --- /dev/null +++ b/ideation/brainstorm/lane-worktree-recovery-state-overview.md @@ -0,0 +1,98 @@ +# Crash-Consistent Lane Worktree Recovery Overview — Brainstorm + +Status: brainstorm +Kind: reference +Summary: Make a multi-worktree lane resumable by deriving its worktree inventory, journaling swap transitions, and reconciling persisted intent with live Git and process evidence before relaunch. +Topics: lane-worktree-recovery-state, lane-management, worktree-recovery, swap, resume +Repository context: openRepoTools, with integration boundaries at the lane collision protocol, openRepoShape, Speckit, and estate park/resume +Captured: 2026-09-15 + +## Possible feats + +- **Structured lane recovery** — Deliver canonical lane worktree roots, crash-consistent swap state, and a guarded resume report as one capability. + +## Motivation + +A lane can coordinate several worktrees containing dirty files or unpushed commits. Today the lane launch record identifies one coordinator directory, while writer worktrees live primarily in the handoff and process context. Token exhaustion can happen before `/swap` or after `/swap` begins but before it completes, leaving the next session unable to distinguish a clean handoff from an interrupted one. + +Legacy lanes add another ambiguity: their human-readable handoffs may name a directory while their machine-readable event history does not. Asking the operator for `--dir` recovers the coordinator but does not discover or verify every worktree the lane owned. + +## Goals + +- Derive the complete expected worktree inventory for a lane on a workstation. +- Distinguish normal running, an in-progress swap, and a completed swap across process death. +- Detect token exhaustion before and during `/swap` without claiming a clean handoff. +- Prevent duplicate coordinators and writers through locked, generation-fenced transitions. +- Find and preserve dirty or unpushed worktrees before resuming. +- Delegate reconstructable feature worktrees to the existing estate `resume` mechanism. + +## Non-goals + +- Reconstruct missing uncommitted file contents from metadata. +- Delete unknown worktrees or stale paths automatically. +- Replace the estate parked record or hand-roll its Git operations. +- Treat a profile, absolute host path, or branch name as the lane's stable identity. +- Ratify the coordinator-base invariant merely by recording this brainstorm. + +## What the system delivers + +The coordinator starts from a canonical base checkout resolved from stable repository identity. Writer worktrees have deterministic or indexed locations and sidecar manifests. `/swap` uses a two-phase `RUNNING -> SWAPPING -> SWAPPED` transition. Resume compares those records with live session holders, Git worktree registrations, actual directories, branches, commits, dirty files, and upstream state before allowing a new SessionStart to return the lane to `RUNNING`. + +The result is an explainable outcome: attach to the existing owner, complete or recover an interrupted swap, resume a cleanly swapped lane, rebuild durable parked trees, or refuse with precise evidence of possible loss. + +## System model + +```text + /swap +confirmed session ----------------> transition journal + | RUNNING + | | + v v +canonical base ----> lane tree index --> SWAPPING + | | + v v + Git worktrees ------> SWAPPED + | | + +---- reconcile <--+ + | + attach / recover / refuse / resume + | + confirmed SessionStart + | + RUNNING +``` + +## Cluster map + +- [Resumable Lane Ownership](lane-worktree-recovery-state-synthesis-resumable-lanes.md) — joins physical discovery, transition authority, and evidence-based recovery. + +## How it fits + +The proposal extends rather than replaces existing roles. The lane event log remains the append-only audit history. The sidecar provides atomic current state and a bounded local inventory. The handoff remains the human and agent resume narrative. `git worktree list` and Git status remain filesystem truth. Estate `park`, `status`, and `resume` remain the only mechanism for durably recording and reconstructing governed feature worktrees. + +Implementation must account for the current openRepoShape worktree convention. If lane-first physical paths conflict with feature-first paths, the canonical lane root should index existing paths rather than move them. + +The governed change is [add-crash-consistent-lane-worktree-recovery](../../openspec/changes/add-crash-consistent-lane-worktree-recovery/proposal.md), tracked in [opensoft/openRepoTools#91](https://github.com/opensoft/openRepoTools/issues/91). The brainstorm remains non-normative; the OpenSpec proposal, capability specification, and design define the implementation boundary. + +## Key decisions and open questions + +- Proposed: `SWAPPING` is entered before any handoff work; `SWAPPED` is committed only after every mandatory step succeeds. +- Proposed: `RUNNING` is written only by a confirmed SessionStart. +- Proposed: every transition is locked and fenced by generation plus operation ID. +- Proposed: the coordinator launches from a canonical base checkout and feature changes live in writer worktrees. +- Open: whether `RESUMING` is a separate persisted state. +- Open: how the lane index coexists with Speckit's feature-first worktree paths. +- Open: which recovery discrepancies block only a writer versus the whole coordinator. +- Open: which metadata is local and which is replicated for another workstation. + +## Document map + +### Synthesis + +- [Resumable Lane Ownership](lane-worktree-recovery-state-synthesis-resumable-lanes.md) + +### Atomic concepts + +- [Canonical Lane-Owned Worktree Layout](lane-worktree-recovery-state-canonical-layout.md) +- [Crash-Consistent Lane Lifecycle](lane-worktree-recovery-state-crash-consistent-lifecycle.md) +- [Resume-Time Worktree Reconciliation](lane-worktree-recovery-state-resume-reconciliation.md) diff --git a/ideation/brainstorm/lane-worktree-recovery-state-resume-reconciliation.md b/ideation/brainstorm/lane-worktree-recovery-state-resume-reconciliation.md new file mode 100644 index 0000000..4d38413 --- /dev/null +++ b/ideation/brainstorm/lane-worktree-recovery-state-resume-reconciliation.md @@ -0,0 +1,66 @@ +# Resume-Time Worktree Reconciliation — Brainstorm + +Status: brainstorm +Kind: process +Summary: Resume a lane only after comparing its persisted intent with live holders, Git worktree registrations, filesystem state, branches, commits, and unpublished work. +Topics: lane-worktree-recovery-state, resume-reconciliation, git-worktree, loss-prevention +Repository context: openRepoTools lane restart behavior and the separate estate `park`, `resume`, and `status` mechanisms +Captured: 2026-09-15 + +## Possible feats + +- **Lane recovery report** — Classify every expected and discovered worktree before launching a replacement writer. +- **Safe rebuild delegation** — Recreate only worktrees whose commits and branch state are durably represented by the estate parked record or origin. + +## Focus + +This document isolates what resume must prove. A sidecar state records intent, but only live-process evidence, `git worktree list --porcelain`, filesystem inspection, and Git status reveal what survived a crash. + +## Proposed model + +Resume acquires the lane lock, resolves the canonical lane root, and reconciles three inventories: + +1. expected trees from the lane sidecars and latest handoff; +2. registered trees from each repository's `git worktree list --porcelain`; +3. directories actually present beneath the canonical lane root. + +For every tree it recalculates repository identity, branch or detached HEAD, current commit, upstream, dirty count, untracked files, and unpushed commits. It also checks the recorded writer/session against live holders. Stored `dirty`, `unpushed`, branch, and HEAD values are comparison points, never current truth. + +Suggested outcomes: + +| Persisted state | Observed holder | Outcome | +|---|---|---| +| RUNNING | live and matching | Attach or refuse a duplicate launch | +| RUNNING | absent | Ungraceful stop; preserve and inspect every tree | +| SWAPPING | live and matching | Swap still executing; do not compete | +| SWAPPING | absent | Interrupted swap; recovery required | +| SWAPPED | absent | Validate trees, then permit resume | +| SWAPPED | live | Inconsistent state; refuse until reconciled | +| CLOSED | any dirty or unpushed tree | Closure inconsistency; refuse cleanup | + +A missing worktree may be rebuilt only when its branch and commit are durably available and the existing estate `resume` contract authorizes reconstruction. A path that may have held uncommitted work is reported as possible loss; metadata cannot reconstruct missing file contents. + +Unknown worktrees beneath the lane root are never deleted. They are reported as orphan or unmanaged candidates and require adoption or an explicit operator cleanup act. + +## Interfaces and boundaries + +This process reads lane sidecars, lane event history, handoffs, session holders, Git registrations, and filesystem state. It may delegate reconstruction to the estate's `resume` command. It does not hand-roll WIP commits, force-add worktrees, reset branches, or delete unknown directories. + +The lane mechanism and estate mechanism remain distinct: lane resume restores orchestration identity; estate resume reconstructs previously parked feature worktrees from durable branch and commit records. + +## Alternatives and tensions + +- Automatically rebuilding every missing path is convenient but cannot distinguish a clean removed worktree from lost uncommitted work. +- Refusing on every discrepancy is safe but may make ordinary stale registrations expensive; classifications should include precise operator remedies. +- A lane-first physical directory makes enumeration cheap, while an index over shape-governed feature paths avoids changing the current worktree contract. + +## Open questions + +- Which discrepancies are warnings, and which must block the coordinator launch? +- Can the coordinator start while individual trees remain recovery-required? +- How are cross-repository writer worktrees represented under one lane root? +- Should a clean, pushed, unknown worktree be adoptable automatically or only on a person's word? + +## Relationships + +The expected inventory comes from [Canonical Lane-Owned Worktree Layout](lane-worktree-recovery-state-canonical-layout.md). The meaning of stale state comes from [Crash-Consistent Lane Lifecycle](lane-worktree-recovery-state-crash-consistent-lifecycle.md). diff --git a/ideation/brainstorm/lane-worktree-recovery-state-synthesis-resumable-lanes.md b/ideation/brainstorm/lane-worktree-recovery-state-synthesis-resumable-lanes.md new file mode 100644 index 0000000..edcb805 --- /dev/null +++ b/ideation/brainstorm/lane-worktree-recovery-state-synthesis-resumable-lanes.md @@ -0,0 +1,64 @@ +# Synthesis: Resumable Lane Ownership — Brainstorm + +Status: brainstorm +Kind: architecture +Summary: A canonical worktree inventory, generation-fenced swap lifecycle, and evidence-based reconciliation together make lane recovery deterministic after token exhaustion or process death. +Topics: lane-worktree-recovery-state, resumable-lanes, worktree-ownership, crash-recovery, synthesis +Repository context: openRepoTools lane lifecycle integrated with openRepoShape estate worktree recovery +Captured: 2026-09-15 + +## Possible feats + +- **Crash-explainable resume** — Report exactly where a lane stopped and what survived before any replacement process writes. +- **Recoverable multi-writer lane** — Restore the coordinator while preventing duplicate writers and preserving dirty or unpublished worktrees. + +## Members and their joints + +Atomic members: [Canonical Lane-Owned Worktree Layout](lane-worktree-recovery-state-canonical-layout.md), [Crash-Consistent Lane Lifecycle](lane-worktree-recovery-state-crash-consistent-lifecycle.md), and [Resume-Time Worktree Reconciliation](lane-worktree-recovery-state-resume-reconciliation.md). + +```text +stable home/estate + | + v +canonical lane root -----> expected worktree inventory + | | + v v +RUNNING -> SWAPPING -> SWAPPED -> reconcile Git + disk + holders + | + resume, attach, refuse, or recover +``` + +### Location makes recovery enumerable + +The canonical layout turns an open-ended filesystem search into a bounded lane inventory. Repository identity and shape determine where to look; sidecars explain what each path was intended to be. This supplies the reconciliation process with expected inputs without making the sidecars the truth about Git. + +### Transition state explains why the inventory was left + +The same worktree state has different meanings depending on when the coordinator died. Dirty work beneath `RUNNING` with no holder means no swap began. The same work beneath abandoned `SWAPPING` means preservation began but did not complete. `SWAPPED` means the handoff procedure reached its commit point, although Git is still rechecked. + +### Reconciliation controls the next transition + +Resume does not blindly change `SWAPPED` to `RUNNING`. It first proves there is no competing holder and classifies every tree. Only a confirmed SessionStart owns the transition to `RUNNING`. Generation and operation fencing prevent either the old swap or a second resume from overwriting the new owner. + +## Emergent behavior + +Together, the three mechanisms distinguish clean pause, pre-swap crash, mid-swap crash, failed launch, stale process, lost path, and durable rebuild. None of the three can provide that result alone: layout finds things, lifecycle explains intent, and reconciliation tests reality. + +## Tensions to hold + +- Lane-first layout improves discovery but must not contradict shape-governed feature paths. +- More persisted state improves diagnosis but creates more transition and migration obligations. +- A coordinator-base invariant simplifies identity but changes today's permission to launch a lane directly in a feature worktree. +- Automatic recovery should reduce routine work without masking potentially uncommitted loss. + +## Recombination opportunities + +- Add the reconciliation report to `lanes` and the SessionStart orientation block. +- Let `/swap` use the same inventory engine as resume, with different allowed transitions. +- Reuse estate `status` findings and `resume` reconstruction rather than duplicating Git worktree mechanics. + +## Open questions + +- Does the current openRepoShape worktree contract adopt lane ownership directly, or does openRepoTools maintain a sidecar index over it? +- What is the minimum state that must be replicated through the workspace repository for cross-workstation recovery? +- Which component owns garbage collection after `CLOSED`? diff --git a/lane-end b/lane-end index a56b2eb..0fec0ec 100755 --- a/lane-end +++ b/lane-end @@ -835,6 +835,32 @@ if [ "$cl_rc" = 0 ] && [ -n "$cl_out" ] && [ "$cl_out" != "$LANE" ]; then LANE="$cl_out" fi +# THE SEAM (ruling 2026-10-04): A MANAGED-OWNED LANE IS NOT THIS COMMAND'S. +# `lanes-edit.sh managed-projection` answers 0 with the owner where the lane's +# register row carries the managed ledger's marker, 8 for a legacy lane, and 1 +# where ownership could not be established. ANY other status — a helper that +# predates the verb included — is UNKNOWN as well, and refused the same way: +# a fence that opens because its read failed is no fence (Amendment 7(d)). +seam_refuse() { # + sr_out=""; sr_rc=0 + sr_out="$("$LANES_EDIT" managed-projection "$1" 2>/dev/null)" || sr_rc=$? + case "$sr_rc" in + 0) die "lane $1 is owned by the managed ledger (owner ${sr_out:-unnamed}), and $2 is a legacy lane act, refused here, once the lane is named and before a byte is written: no state cell, no ENDED or RETIRED line, no handoff fragment, and no process signalled. Brett Heap's ruling of 2026-10-04: \"managed ledger owns enrolled lanes; #97 owns legacy\". Act on this lane through the managed ledger." 2 ;; + 8) : ;; + *) die "whether the managed ledger owns lane $1 is UNKNOWN (\`$LANES_EDIT managed-projection $1\` exited $sr_rc): its register row carries managed-owner vocabulary that does not parse, or the read failed. An ownership nobody could establish is never read as legacy (Amendment 7(d)), so $2 is refused here, once the lane is named and before a byte is written: no state cell, no ENDED or RETIRED line, no handoff fragment, and no process signalled. Read it: $LANES_EDIT managed-projection $1" 1 ;; + esac +} +# CALLED TWICE BELOW, each time before the first thing its path does: at the +# head of the `--retire ` door, which signals processes, and right after +# "the row"'s own refusals on the ending path (the ending and `--retire`), +# before the state cell and the ENDED/RETIRED line. NOT HERE, ONCE: "the row" +# refuses a register that spells this lane two ways (Amendment 15(d)) out of its +# OWN read, so that the words a person needs arrive even through a helper that +# predates `canon-lane` — and such a helper predates `managed-projection` too, +# which this function rightly calls UNKNOWN. Both are refusals that write +# nothing; the pair's comes first so that it is still the one said. +# (`--retire-dormant` is above and is refused by `retire-rows` itself.) + # `--home` IS VALIDATED BEFORE ANYTHING IS WRITTEN. It is for a PRE-CUTOVER # lane, and `lanes-edit.sh` refuses it once the lane's own log records a home — # but the only place this script passes it is its LAST act, the ENDED/RETIRED @@ -920,6 +946,8 @@ fi if [ -n "$retire_target" ]; then [ "$force" = 0 ] || die "--force is for ending a lane that still holds something; it has no meaning for retiring a fork. Run: $prog $LANE --retire $retire_target" 2 [ -z "$state_was" ] || die "--state-was is for the state cell of a lane being ended; retiring a fork touches no cell. Run: $prog $LANE --retire $retire_target" 2 + # THE SEAM, before any process is looked for or signalled (ruling 2026-10-04). + seam_refuse "$LANE" "retiring a fork or a duplicate holder" # THE FORK IS PROVED BEFORE ANYTHING IS PRINTED, and a read that FAILED is # never "it is not a fork": `forks` answers 0 with rows, 8 with none and 1 @@ -1535,6 +1563,10 @@ case "$row_count" in die "the register has $row_count rows for lane $LANE, $rfl_why" 2 ;; esac +# THE SEAM, after the row's own refusals and before the state cell, the in-flight +# guard and every write (ruling 2026-10-04; see `seam_refuse` above). +seam_refuse "$LANE" "a lane end" + # The state cell is the row's SEVENTH column — awk field 8, because $1 is the # empty string before the leading pipe — and it runs to the trailing pipe, so a # cell that contains a literal `|` is rejoined rather than truncated. Counting diff --git a/lane-handoff b/lane-handoff index 777d921..2ecb755 100755 --- a/lane-handoff +++ b/lane-handoff @@ -392,6 +392,107 @@ $sr_why" 2 ;; eval "$sr_var=\$sr_out" } +# ---------------- the crash-consistent lifecycle (opensoft/openRepoTools#91) +# +# A LANE IS `RUNNING`, `SWAPPING`, `SWAPPED` OR `CLOSED`, and this command owns +# the two-phase transition in the middle of that: `SWAPPING` BEFORE any +# preservation work begins, `SWAPPED` only after every mandatory write has +# landed. What that buys is the one thing the `PAUSED` record alone could never +# say — WHERE a session that ran out of tokens stopped. `RUNNING` with no live +# holder is an ungraceful stop: nothing was polled, nothing was refreshed, +# nothing was recorded. `SWAPPING` with no live holder is an INTERRUPTED SWAP: +# some of those writes may have landed and some may not, and every worktree +# below is preserved until a person has read them. +# +# THE LIFECYCLE NEVER REFUSES THE SWAP. `R-A11-11` is that a swap is never left +# unwritten, and it binds this command: a control root that cannot be derived, +# a fence another act moved, a snapshot that cannot be written — each of them +# costs the LIFECYCLE and never the record. The handoff carries on and says +# what it could not keep, exactly as it does for a dropped sub-field. +lc_on=""; lc_gen=""; lc_op=""; lc_state=""; lc_inventory_missed="" + +lifecycle_begin() { + lc_on=""; lc_gen=""; lc_op=""; lc_state="" + lcb_now=""; lcb_rc=0 + lcb_now="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-state "$lane" 2>/dev/null \ + | awk -F"\t" '$1 == "state" { print $2; exit }')" || lcb_rc=$? + case "$lcb_rc" in + 0 | 8) : ;; + *) + note "the lane lifecycle could not be read (\`$LANES_EDIT lane-state $lane\` exited $lcb_rc), so this handoff records no RUNNING -> SWAPPING -> SWAPPED transition. The swap itself goes on (\`R-A11-11\`) and the PAUSED record below is unaffected." + return 0 ;; + esac + [ -n "$lcb_now" ] || lcb_now=none + # AN INTERRUPTED SWAP IS TAKEN OVER, NOT COMPETED WITH. A lane still + # reading `SWAPPING` when a new handoff starts is one whose previous + # 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=$? + case "$lcb_src" in + 0) + lc_gen="$(printf '%s\n' "$lcb_out" | awk -F"\t" '$1 == "generation" { print $2; exit }')" + lc_op="$(printf '%s\n' "$lcb_out" | awk -F"\t" '$1 == "operation" { print $2; exit }')" + # THE FENCE THIS RUN WILL FINISH WITH, READ BACK BEFORE IT IS TRUSTED. + # Every later call carries both — the finalizer as `--expect-generation` + # and `--expect-operation`, each tree sidecar as the operation that + # recorded it — and an empty one there is a flag with no value, which + # the writer refuses. Better to record no transition at all than to + # record trees against an operation nobody can match. + if [ -z "$lc_gen" ] || [ -z "$lc_op" ]; then + note "the lane was taken to SWAPPING and the helper named no generation or no operation back, so this handoff records no fence and takes no inventory. Read the state by hand: $LANES_EDIT lane-state $lane" + else + lc_on=1 + lc_state=SWAPPING + step "lifecycle: $lcb_now -> SWAPPING (generation $lc_gen, operation $lc_op)" + fi ;; + 7) + note "the lane lifecycle moved between reading it and taking it (it was $lcb_now and is not now), so this handoff records no transition and its worktree inventory carries no operation. Another process is acting on lane $lane: read it with \`$LANES_EDIT lane-reconcile $lane\` before relaunching anything. The swap itself goes on (\`R-A11-11\`)." ;; + 1) + note "lane $lane has no lifecycle control root (its record names no directory, and \$PROJECTS_ROOT is not a directory here), so this handoff records no RUNNING -> SWAPPING -> SWAPPED transition and crash recovery for it stays incomplete. The swap itself goes on." ;; + *) + note "the lane lifecycle could not be taken to SWAPPING (\`set-lane-state\` exited $lcb_src). The swap goes on and records no transition." ;; + esac + return 0 +} + +# `SWAPPED` IS THE COMMIT POINT AND IT IS FENCED ON THIS OPERATION. A handoff +# whose record, row and handoff file did not all land leaves the lane +# `SWAPPING` and SAYS WHICH STEP IS UNFINISHED — because those three are +# exactly what a resumed session needs to have found, and a lane called +# `SWAPPED` on two of them is a lane whose recovery would skip the third. +lifecycle_finish() { # + [ -n "$lc_on" ] || return 0 + if [ -n "${1-}" ]; then + lc_state=SWAPPING + note "the lane stays SWAPPING (generation $lc_gen, operation $lc_op): $1. A later session reads that as an INTERRUPTED SWAP and preserves every worktree, which is the honest record of what this act left." + return 0 + fi + lcf_rc=0 + LANES_NO_FETCH=1 "$LANES_EDIT" set-lane-state "$lane" SWAPPED \ + --expect SWAPPING --expect-generation "$lc_gen" --expect-operation "$lc_op" \ + --owner "${uuid:-none}" --agent "$agent" --profile "${profile_name:-none}" \ + --kind "$handoff_kind" >/dev/null 2>&1 || lcf_rc=$? + case "$lcf_rc" in + 0) lc_state=SWAPPED; step "lifecycle: SWAPPING -> SWAPPED (generation $lc_gen, operation $lc_op)" ;; + 7) + lc_state=superseded + note "this handoff is NOT the act that finishes lane $lane: the lifecycle fence moved under it (another handoff, or a resume that took the lane) and a stale finalizer must never overwrite a newer owner. The PAUSED record, the row and the handoff file are all written; only the SWAPPED transition was refused." ;; + *) + lc_state=SWAPPING + note "the lane could not be recorded SWAPPED (\`set-lane-state\` exited $lcf_rc), so it stays SWAPPING and a later session reads it as an interrupted swap." ;; + esac + return 0 +} + # ------------------------------------------------- 1. the identity triple # # THE WORKSTATION COMES FROM CONFIGURATION, NEVER FROM `hostname` (Amendment 11, @@ -508,6 +609,27 @@ case "$lane" in esac step "lane=$lane${window_ref:+ window=$window_ref} workstation=${ws:-}" +# THE SEAM (ruling 2026-10-04): A MANAGED-OWNED LANE IS NOT THIS COMMAND'S. +# `lanes-edit.sh managed-projection` answers 0 with the owner where the lane's +# register row carries the managed ledger's marker, 8 for a legacy lane, and 1 +# where ownership could not be established. ANY other status — a helper that +# predates the verb included — is UNKNOWN as well, and refused the same way: +# a fence that opens because its read failed is no fence (Amendment 7(d)). +seam_refuse() { # + sr_out=""; sr_rc=0 + sr_out="$("$LANES_EDIT" managed-projection "$1" 2>/dev/null)" || sr_rc=$? + case "$sr_rc" in + 0) die "lane $1 is owned by the managed ledger (owner ${sr_out:-unnamed}), and $2 is a legacy lane act, refused here, before the window is renamed and before a byte is written: no PAUSED, no row, no handoff file, no lifecycle transition, no inventory, and no restart. Brett Heap's ruling of 2026-10-04: \"managed ledger owns enrolled lanes; #97 owns legacy\". Act on this lane through the managed ledger." 2 ;; + 8) : ;; + *) die "whether the managed ledger owns lane $1 is UNKNOWN (\`$LANES_EDIT managed-projection $1\` exited $sr_rc): its register row carries managed-owner vocabulary that does not parse, or the read failed. An ownership nobody could establish is never read as legacy (Amendment 7(d)), so $2 is refused here, before the window is renamed and before a byte is written: no PAUSED, no row, no handoff file, no lifecycle transition, no inventory, and no restart. Read it: $LANES_EDIT managed-projection $1" 1 ;; + esac +} +# Here, once the lane's canonical name is known and before ANYTHING is done +# with it: every mode of this command — the handoff, `--late`, `--restart`, +# `--exit` — is behind this line, so `SWAPPING` and `SWAPPED` never run for a +# managed lane. +seam_refuse "$lane" "a handoff" + # THE OTHER TWO NAMES MADE TO MATCH IT, mechanically. The window is renamed # here; the SESSION's name is the lane's messaging address (Amendment 2) and # there is no API to rename a running session from outside it, so where the @@ -663,6 +785,19 @@ if [ "$do_late" = 1 ]; then fi fi +# ----------------- 2a. RUNNING -> SWAPPING, before any preservation work +# +# FIRST OF THE ACTS THAT CHANGE ANYTHING, and deliberately ahead of the poll, +# the refresh and the record: a session that dies from here on leaves the lane +# reading `SWAPPING`, which is the word for *the handoff began and did not +# finish*. Taken any later, the window in which token exhaustion is +# indistinguishable from an ungraceful stop is exactly the window in which the +# writers are being polled — the longest part of the act. +# +# IT IS BELOW THE `--late` PRECONDITIONS ON PURPOSE. Those refuse and write +# nothing at all, and a refusal must leave the lane exactly as it found it. +lifecycle_begin + # ------------------------------------------------ 3. poll the running writers # # Amendment 17(f): the top block *"LISTS EVERY WRITER THE LANE HAS RUNNING (its @@ -700,12 +835,42 @@ poll_writer() { # pw_d="$1" [ -d "$pw_d" ] || return 0 git -C "$pw_d" rev-parse --git-dir >/dev/null 2>&1 || return 0 - pw_branch="$(git -C "$pw_d" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')" + # THE OBSERVATION IS THE HELPER'S, AND THIS COMMAND MAKES NONE OF ITS OWN + # (Copilot round 5 on openRepoTools#97). `lanes-edit.sh lane-tree-now` is the + # one implementation of *what git says about this tree right now* — the same + # function `lane-reconcile` recomputes with — so the WRITERS section a person + # reads and the sidecar a recovery reads are ONE reading, with one error + # handling behind them. The five fields it answers with are the five the + # record carries: branch, full head, upstream, dirty and unpushed. + # + # `%h` IS NOT AMONG THEM, and that is the point of the full head: an + # abbreviation lengthens as a repository grows and is ambiguous across + # repositories, while the upstream is what tells `0 unpushed` from *this + # branch tracks nothing at all*. + # + # AND A READING NOBODY COULD MAKE IS SAID, NOT INVENTED. Where the helper + # refuses — git failing inside a checkout, or a lanes-edit.sh predating this + # read, which answers 2 for a subcommand it has never heard of — every field + # is `?`, no sidecar is filed, and the section says so. A `none` here and a + # `0` there is exactly the clean-looking record this round removed. + pw_obs=""; pw_orc=0 + pw_obs="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-tree-now "$pw_d" 2>/dev/null)" || pw_orc=$? + pw_branch='?'; pw_head=unknown; pw_up='?'; pw_dirty='?'; pw_ahead='?' + if [ "$pw_orc" = 0 ]; then + pw_branch="$(printf '%s' "$pw_obs" | awk -F'\037' '{print $1}')" + pw_head="$(printf '%s' "$pw_obs" | awk -F'\037' '{print $2}')" + pw_up="$(printf '%s' "$pw_obs" | awk -F'\037' '{print $3}')" + pw_dirty="$(printf '%s' "$pw_obs" | awk -F'\037' '{print $4}')" + pw_ahead="$(printf '%s' "$pw_obs" | awk -F'\037' '{print $5}')" + 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 + # The LISTING, which is prose and not the record: the actual lines a person + # reads under the counts above. `|| :` and not a failure, for the reason the + # note above gives — this is what is left to show when the reading refused. pw_last="$(git -C "$pw_d" log -1 --format='%h %s' 2>/dev/null || :)" pw_status="$(git -C "$pw_d" status --short 2>/dev/null || :)" pw_unpushed="$(git -C "$pw_d" log '@{u}..' --oneline 2>/dev/null || :)" - pw_dirty="$(printf '%s' "$pw_status" | grep -c . || :)" - pw_ahead="$(printf '%s' "$pw_unpushed" | grep -c . || :)" pw_brief="$(brief_for "$pw_d" || printf '')" writer_count=$((writer_count + 1)) say "" @@ -717,6 +882,27 @@ poll_writer() { # printf -- '- `%s` — branch `%s`, last commit `%s`, %s dirty, %s unpushed; brief: %s\n' \ "$pw_d" "$pw_branch" "${pw_last:-}" "$pw_dirty" "$pw_ahead" \ "${pw_brief:-(fill in: the brief this writer was given)}" >> "$writers_tmp" + # AND THE SAME OBSERVATION, MACHINE-READABLE, BESIDE THE LANE AND NOT INSIDE + # IT (openRepoTools#91). The WRITERS section above is what a person and the + # next session read; this is what `lane-reconcile` recomputes against after + # a crash, when there may be no session to read anything. It carries THIS + # handoff's generation and operation, so a tree recorded by a superseded + # operation is visible as one. It never fails the poll and never fails the + # swap: a lane with no control root simply keeps no inventory yet. + if [ -n "$lc_on" ] && [ "$pw_orc" = 0 ]; then + LANES_NO_FETCH=1 "$LANES_EDIT" set-lane-tree "$lane" "$pw_d" \ + --checkout "${dir:-unknown}" --branch "$pw_branch" --head "$pw_head" \ + --upstream "$pw_up" --dirty "$pw_dirty" --unpushed "$pw_ahead" \ + --writer "${uuid:-none}" --generation "$lc_gen" --operation "$lc_op" \ + >/dev/null 2>&1 || { + # AND THE SWAP IS NOT COMPLETE WITHOUT IT (Copilot on 64dfd97, PR + # #97): an inventory missing a polled writer is not the record a + # recovery reads as a finished swap, so the tree is named here and + # `lifecycle_finish` leaves the lane SWAPPING for it. + lc_inventory_missed="${lc_inventory_missed:+$lc_inventory_missed }$pw_d" + note "the worktree inventory entry for $pw_d could not be written — the helper refused it, and a refusal of 7 there is this handoff's own fence: the lane's generation or operation moved while this poll was being taken, so a superseded reading was not filed over the current one. This handoff's WRITERS section still names the tree, the lane stays SWAPPING for it, and \`$LANES_EDIT lane-reconcile $lane\` says what the inventory holds." + } + fi return 0 } @@ -928,12 +1114,23 @@ if [ "$do_late" = 1 ]; then if LANES_LANE="$lane" LANES_SESSION="$uuid" "$LANES_EDIT" log PAUSED "lane:$lane" \ '→' "$late_payload" "$late_why" --utc "$late_at"; then step "late PAUSED written, dated $late_at (before the relaunch, which is the only place it can go)" + # THE LATE RECORD'S MANDATORY SET IS TWO AND NOT THREE: the row is not + # flipped by this branch at all (it exits below), so requiring it would + # leave every late record reading as an interrupted swap for ever. + lc_unfinished="" + [ "$handoff_written" = 1 ] || lc_unfinished="the handoff file was not refreshed" + [ -z "$lc_inventory_missed" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the worktree inventory was not recorded for $lc_inventory_missed" + lifecycle_finish "$lc_unfinished" else + lifecycle_finish "the late PAUSED record was refused by the helper" die "the late PAUSED was NOT written (the helper refused it — its own message is above). Nothing else was done." 2 fi say "" say "LATE RECORD WRITTEN for lane $lane, dated $late_at. Now start the lane:" say " $lane_profile_word ${profile_name:-}" + if [ -n "$lc_on" ]; then + say " lifecycle: $lc_state (generation $lc_gen, operation $lc_op) — lanes-edit.sh lane-reconcile $lane" + fi exit 0 fi @@ -1026,6 +1223,27 @@ else [ -z "$row_write_refused" ] || note "the row's state cell was NOT set to PAUSED (the write was refused — run it by hand to see what it says: lanes-edit.sh set-row-state $lane \"PAUSED · $hs_line\"). The restart line below carries --lane for that reason." fi +# ------------------------- 4a. SWAPPING -> SWAPPED, the operation's commit point +# +# THE THREE WRITES THAT MUST HAVE LANDED, and they are the same three +# `--restart` and `--exit` already gate on: the lane's `PAUSED` record (what +# every launcher and every later reader takes its state from), the row (what +# every OTHER lane reads) and the handoff file (whose top block is literally +# the next session's first prompt). A lane called `SWAPPED` on two of those is +# a lane whose recovery would skip the third, so the transition is made only on +# all three and the state otherwise STAYS `SWAPPING` — which a later session +# reads as an interrupted swap and answers by preserving every worktree. +# +# THE SWAP IS STILL REPORTED AND STILL RESTARTABLE (`R-A11-11`, *a swap is +# never left unwritten*): this changes what the lane's lifecycle SAYS, never +# whether the act completes. +lc_unfinished="" +[ "$record_written" = 1 ] || lc_unfinished="the lane's PAUSED record did not land" +[ -z "$row_write_refused" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the row's state cell was not flipped" +[ "$handoff_written" = 1 ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the handoff file was not refreshed" +[ -z "$lc_inventory_missed" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the worktree inventory was not recorded for $lc_inventory_missed" +lifecycle_finish "$lc_unfinished" + # ------------------------------------------- 5. the restart line — one command if [ -n "$row_write_refused" ]; then if [ "$lane_profile_word" = lclaude ]; then @@ -1042,6 +1260,10 @@ say "" say "READY — restart with: $restart_cmd$restart_note" say " record: PAUSED lane:$lane → $payload" say " agent $agent, transcript $transcript (lane-start --agent $agent resumes it)" +if [ -n "$lc_on" ]; then + say " lifecycle: $lc_state (generation $lc_gen, operation $lc_op) — lanes-edit.sh lane-reconcile $lane" + say " inventory: $writer_count worktree(s) recorded — lanes-edit.sh lane-trees $lane" +fi # -------------------------------------------- Amendment 17(f) / 18(d): the end # diff --git a/lane-start b/lane-start index 346be23..0b7afd0 100755 --- a/lane-start +++ b/lane-start @@ -1414,6 +1414,26 @@ step " its record refs are $record_window" # -------------------------------------------------------- 3. liveness check +# THE SEAM (ruling 2026-10-04): A MANAGED-OWNED LANE IS NOT THIS COMMAND'S. +# `lanes-edit.sh managed-projection` answers 0 with the owner where the lane's +# register row carries the managed ledger's marker, 8 for a legacy lane, and 1 +# where ownership could not be established. ANY other status — a helper that +# predates the verb included — is UNKNOWN as well, and refused the same way: +# a fence that opens because its read failed is no fence (Amendment 7(d)). +seam_refuse() { # + sr_out=""; sr_rc=0 + sr_out="$("$LANES_EDIT" managed-projection "$1" 2>/dev/null)" || sr_rc=$? + case "$sr_rc" in + 0) die "lane $1 is owned by the managed ledger (owner ${sr_out:-unnamed}), and $2 is a legacy lane act, refused here, before the binding is read, before a handoff is requested of anybody and before a byte is written: no row, no log line, no stamp, no window name, no launch. Brett Heap's ruling of 2026-10-04: \"managed ledger owns enrolled lanes; #97 owns legacy\". Act on this lane through the managed ledger." 2 ;; + 8) : ;; + *) die "whether the managed ledger owns lane $1 is UNKNOWN (\`$LANES_EDIT managed-projection $1\` exited $sr_rc): its register row carries managed-owner vocabulary that does not parse, or the read failed. An ownership nobody could establish is never read as legacy (Amendment 7(d)), so $2 is refused here, before the binding is read, before a handoff is requested of anybody and before a byte is written: no row, no log line, no stamp, no window name, no launch. Read it: $LANES_EDIT managed-projection $1" 1 ;; + esac +} +# AT THE TOP OF SECTION 3, and so BEFORE Amendment 18's binding gate and its +# `request-handoff`: asking a managed lane's place to hand off is itself a +# legacy act on that lane. +seam_refuse "$LANE" "a lane start" + # The row, READ (never written) with the same rule lanes-edit.sh uses to find # one: the row's FIRST backticked token is the lane name. # AMENDMENT 15 — `tolower` ON BOTH SIDES, exactly as `row_line` now compares. @@ -3038,6 +3058,60 @@ if [ "$AGENT" != claude ]; then stamp_ok=1 fi +# ------------- 4a. what the last session left (opensoft/openRepoTools#91) +# +# THE RECONCILIATION IS PRINTED BEFORE ANYTHING IS WRITTEN, and it REFUSES +# NOTHING. A lane recorded `RUNNING` with no live holder stopped ungracefully — +# nothing was polled, refreshed or recorded — and one recorded `SWAPPING` with +# no live holder was interrupted MID-HANDOFF, where some of the three writes +# may have landed and some may not. Either way there may be worktrees carrying +# dirty or unpushed work, and the one thing this command must not do is start a +# session that writes over them in ignorance. +# +# IT IS HERE, ABOVE SECTION 5, because the `STARTED`/`RESUMED` line this 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); this prints what differs and names the act, and +# every refusal this command makes is the one it already made. A helper that +# predates the read (exit 2) and a lane with no snapshot (exit 8) are both +# silent — Amendment 7(i)'s cutover rule: nothing is backfilled. +if (( ! dry_run )); then + lrec_out=""; lrec_rc=0 + lrec_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-reconcile "$LANE" 2>/dev/null)" || lrec_rc=$? + case "$lrec_rc" in + 0) + lrec_verdict="$(printf '%s\n' "$lrec_out" | awk -F'\037' '$1 == "VERDICT" { print $2 " — " $3; exit }')" || lrec_verdict="" + # A RESUMABLE LANE IS QUIET ONLY WHEN ITS TREES ARE (Codex on ec9847e, + # PR #97): `resumable` says the swap completed, not that every tree it + # left is clean, published and where it was. So a resumable lane whose + # TREES line counts anything dirty or unpushed, needing recovery, or + # unmanaged or stale is reported like any other verdict. + lrec_attn="$(printf '%s\n' "$lrec_out" | awk -F'\037' '$1 == "TREES" { split($3, a, " "); split($4, b, " "); split($5, c, " "); print a[1] + b[1] + c[1]; exit }')" || lrec_attn="" + lrec_quiet=0 + case "$lrec_verdict" in + running*|closed*|'') lrec_quiet=1 ;; + resumable*) + if [ "${lrec_attn:-0}" = 0 ]; then lrec_quiet=1 + else lrec_verdict="$lrec_verdict — and its trees below need reading before a writer is relaunched" + fi ;; + esac + if [ "$lrec_quiet" = 0 ]; then + note "LANE RECOVERY: $lrec_verdict" + printf '%s\n' "$lrec_out" | awk -F'\037' ' + $1 == "STATE" { printf " state %s (%s, %s)\n", $2, $3, $4 } + $1 == "HOLDER" { printf " holder %s %s\n", $2, $3 } + $1 == "BINDING" && $2 != "here" && $2 != "free" { printf " binding %s %s\n", $2, $3 } + $1 == "TREE" && $3 != "ok" { printf " tree %-18s %s\n", $3, $5 } + $1 == "TREES" { printf " trees %s, %s, %s, %s\n", $2, $3, $4, $5 }' >&2 || : + note "Nothing here was reset, recreated or deleted. Read it in full with: $LANES_EDIT lane-reconcile $LANE" + fi ;; + 2 | 8) : ;; + *) note "the lane recovery read failed (\`$LANES_EDIT lane-reconcile $LANE\` exited $lrec_rc). That is NOT 'this lane stopped cleanly' — a read that failed is never an answer (Amendment 7(d)). This launch goes on; run it by hand to see what it says." ;; + esac +fi + # ------------------------------------------------------------- 5. the row utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)" diff --git a/lanes-edit.sh b/lanes-edit.sh index dc852c3..3c9bf5f 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -321,6 +321,79 @@ # `into main` clause, and keying those on the repository they name # reported 232 phantom merge holds where one was real. # +# openRepoTools#91 — THE CRASH-CONSISTENT LANE LIFECYCLE AND THE INVENTORY +# lanes-edit.sh lane-state +# lanes-edit.sh set-lane-state \ +# [--expect ] [--expect-generation ] \ +# [--expect-operation ] [--operation ] \ +# [--owner ] [--agent ] [--profile ] \ +# [--kind ] +# lanes-edit.sh lane-trees +# lanes-edit.sh set-lane-tree [--checkout ] \ +# [--branch ] [--head ] [--upstream ] \ +# [--dirty ] [--unpushed ] [--writer ] \ +# [--generation ] [--operation ] +# lanes-edit.sh lane-tree-now +# lanes-edit.sh lane-reconcile +# +# A lane is RUNNING, SWAPPING, SWAPPED or CLOSED, and WHICH OF THOSE IT IS +# WITH NO LIVE HOLDER is what says where its session stopped: `RUNNING` with +# no holder is an UNGRACEFUL STOP (nothing was handed off) and `SWAPPING` +# with no holder is an INTERRUPTED SWAP (the handoff began and did not +# finish). Every transition carries a monotonic `generation` and a unique +# `operation`, and `--expect*` is the compare-and-swap that refuses a stale +# finalizer with exit 7 rather than letting it overwrite a newer owner. +# +# NO LANE-KIND VERB IS ADDED TO THE APPEND-ONLY LOG BY THIS CHANGE. Amendment +# 7's five state verbs stand and every reader of them is untouched (Amendment +# 18(g) has since added a sixth lane-kind verb, `HANDOFF-REQUESTED`, which +# changes no state; this adds none). This is a SNAPSHOT beside that +# history, local to the workstation, replaced atomically, and never committed +# — see the section above the dispatcher for where it lives and why it is +# neither in the register nor inside a git worktree. +# +# `set-lane-tree` records ONE worktree of the lane — its path, checkout, +# branch, head, upstream, dirty and unpushed counts and writer — as an +# OBSERVATION; `lane-reconcile` recomputes every one of them and REPORTS the +# difference. It resets nothing, deletes nothing and creates nothing: a +# missing tree's only rebuild is the estate's own `resume `, and a +# missing tree that last held uncommitted work is reported as possible loss, +# because no metadata reconstructs a file's contents. +# +# `lane-tree-now` IS THAT OBSERVATION ON ITS OWN, and it is here so that the +# handoff's WRITERS section, the sidecar it files and the reconciliation that +# recomputes against it are one implementation with one error handling. A git +# read that FAILED is never converted into a clean-looking value by any of the +# three: `unknown`, `none` and `0` are answers, and the reads that could not +# be made are said instead (R22, Amendment 7(d)). +# +# THE TWO WRITERS REFUSE A SIDECAR THEY CANNOT READ. A snapshot or a tree +# record carrying a schema this helper does not write is never replaced — a +# reader that fails closed and a writer that walks past it protect nothing — +# and `set-lane-tree`'s `--generation`/`--operation` are COMPARED with the +# lane's own snapshot under the mutex before the record is filed, so a poll +# from an operation a recovery has superseded cannot be filed over the +# current one. +# +# THE SEAM — A MANAGED-OWNED LANE IS NOT THIS TOOLING'S (Brett Heap's ruling of +# 2026-10-04, verbatim: "managed ledger owns enrolled lanes; #97 owns legacy — +# rework both") +# lanes-edit.sh managed-projection +# +# 0 with the owner on stdout where the lane's register row carries a valid +# managed-owner marker, 8 where it carries none (a LEGACY lane, which is +# every lane today), 1 where it carries managed-owner vocabulary that does +# not parse as a marker or the register could not be read (ownership +# UNKNOWN), 64 usage. Every legacy act that would write for a lane asks this +# first and refuses a managed lane with 2 and an unknown one with 1, before +# a byte is written: the lane-kind lines `log` writes (STARTED, RESUMED, +# ENDED, RETIRED), `set-lane-state`, `set-lane-tree`, `retire-rows`, +# `set-row-state`, `replace-in-row` and `rename-lane`, the entry of +# `lane-start` and `lane-handoff`, and `lane-end` before its first act on +# each of its paths. `lane-reconcile` pronounces +# nothing on a managed lane (`VERDICT managed-owned`), and no legacy writer +# may write the marker's vocabulary into a row at all. +# # EXIT CODES — every subcommand, one table, no two meanings on one number # 0 done # 1 environment (no register, no writer) @@ -383,14 +456,15 @@ # 5 an edit moved more than one line and was refused — or, Amendment 15, a # lane's object log could not be renamed to the row's own spelling # 6 git add / commit / push failed -# 7 CLAIM-LOST — another lane's claim landed on main first (`claim` only) -# 9 CLAIM-LOST — issue #30's own dead-lane verdict could not be -# reconfirmed before this takeover's push landed: the source lane's own -# log moved past its terminal line, a live session on this workstation -# now backs it up, or that could not be read at all (`claim` only, -# `--force` over a dead lane's hold). NEVER coerced to 7: that code -# means a RIVAL's claim landed first, and this is the same lane the -# takeover was granted over, alive again rather than beaten to main. +# 7 ANOTHER ACT GOT THERE FIRST, and this one wrote nothing. `claim`: +# CLAIM-LOST, another lane's claim landed on main first. +# `set-lane-state`: the lifecycle fence did not match — the state, the +# generation or the operation id moved under this process — so a stale +# finalizer cannot overwrite a newer owner (openRepoTools#91). +# `set-lane-tree`: the `--generation`/`--operation` this observation was +# taken under is no longer the lane's, so a superseded poll cannot be filed +# over the current inventory. ONE MEANING ON THE NUMBER, in three places: +# you lost the race. # 8 no record — `who` found nothing; `lane-objects` has no log file for the # lane; `live-holder` READ this workstation's session records and none of # them holds it; `swapped` found no lane swapped on the workstation; @@ -399,6 +473,23 @@ # not be performed is never 8 (R22). `session-start` never exits 8, or # anything but 0: it is a hook. `guard` never exits 8 either — it is a hook # too, and a BLOCKING one, so its two codes are 0 and 2 (see 2 above). +# 9 CLAIM-LOST — issue #30's own dead-lane verdict could not be +# reconfirmed before this takeover's push landed: the source lane's own +# log moved past its terminal line, a live session on this workstation +# now backs it up, or that could not be read at all (`claim` only, +# `--force` over a dead lane's hold). NEVER coerced to 7: that code +# means a RIVAL's claim landed first, and this is the same lane the +# takeover was granted over, alive again rather than beaten to main. +# 10 THE RECORD IS THERE AND COULD NOT BE READ, which is the other half of 8 +# and never 8 itself (Copilot round 6 on openRepoTools#97). `lane-state` +# spends it for a lifecycle snapshot that EXISTS at the control root and +# cannot be opened — a permission, an I/O error, a name whose bytes are +# gone. 8 says *this lane has no snapshot*, which a launcher answers by +# going on; 10 says *this lane may be mid-crash and nobody could look*, +# which it answers by reading the lane by hand. One number could not carry +# both, and the one that was carrying both was 8 (R22, Amendment 7(d)). +# IT WAS 9 until main's #61 landed with 9 as `claim --force`'s abandoned +# takeover; each PR had taken 9 as unused, and the later one moved. # # --no-sweep (DEFAULT, added 2026-09-09 after 0d84d34/a1f2438 swept another # lane's uncommitted hand edit into an unrelated commit): every mutating @@ -1104,7 +1195,18 @@ lock_steal_if_dead() { return 0 } -acquire_lock() { +# THE MUTEX, TAKEN WITHOUT DYING FOR IT — 0 taken, 1 not taken, and no exit +# either way. `acquire_lock` below is this with the refusal on the end, so the +# `mkdir` mutex, the pid test and the age-out are written ONCE and every taker +# of the lock obeys the same three. +# +# THE NON-FATAL FORM IS WHAT THE LIFECYCLE FOLLOW-UP TAKES (openRepoTools#91). +# It runs at the foot of `write_event`, AFTER the event line has landed and been +# committed, and a `die` there would abort a caller whose write is already on +# disk — so the snapshot that could not be taken under the mutex is left alone +# and SAID, exactly as a snapshot that could not be written is. +lock_try() { # [] + lt_max="${1:-60}" lock_steal_if_dead # Stale lock (>10 min) is removed: a helper run never takes that long. The # age test is now the FALLBACK — the pid test above is the real one. @@ -1115,17 +1217,22 @@ acquire_lock() { rmdir -- "$LOCK" 2>/dev/null || : fi fi - i=0 - while [ "$i" -lt 60 ]; do + lt_i=0 + while [ "$lt_i" -lt "$lt_max" ]; do if mkdir -- "$LOCK" 2>/dev/null; then LOCK_HELD=1 printf '%s\n' "$$" > "$LOCK/pid" 2>/dev/null || : return 0 fi lock_steal_if_dead # it may have died while we were waiting - i=$((i + 1)) + lt_i=$((lt_i + 1)) sleep 1 done + return 1 +} + +acquire_lock() { + lock_try 60 && return 0 die "could not acquire $LOCK after 60s — another lanes-edit run is active (holder pid $(cat -- "$LOCK/pid" 2>/dev/null || printf 'unrecorded'))" 4 } @@ -1332,6 +1439,12 @@ row_state_check() { # " · " if [ "${ROW_STATE_LINE//[$'\n\r']/}" != "$ROW_STATE_LINE" ]; then die "the line is ONE line: a newline in it would split the row in two and every row after it would be read as a lane" 2 fi + # THE MARKER'S VOCABULARY IS NOT A LEGACY WRITER'S TO WRITE (ruling + # 2026-10-04). The projection reader matches it anywhere in a row, so a + # phrase carrying it would make this lane read as managed, or as UNKNOWN. + if managed_projection_hint "$rst_in"; then + die "the phrase carries managed-owner vocabulary ('managed owner', 'managed binding', 'mode=managed' or 'managed:'), and only the managed ledger's own writer writes a managed-owner marker, and a legacy writer that wrote its vocabulary into a row would forge that lane's ownership (ruling 2026-10-04: \"managed ledger owns enrolled lanes; #97 owns legacy\"). Reword it: '$rst_in'" 2 + fi # ${#s} COUNTS CHARACTERS and the cap is O1's 240 of them — these lines are # full of `·`, `—` and `→`, so a byte count would refuse a line that is inside # the cap and accept one that is not. @@ -4818,6 +4931,29 @@ write_event() { # refused HERE: before the lock, before the capture and before anything is # created, so that a refusal leaves the checkout exactly as it found it. refuse_dirty_checkout "write $we_verb" "${we_paths[@]}" "$we_lp" "$LANES_PATH" + # THE LIFECYCLE SNAPSHOT AS IT STANDS BEFORE THIS LINE EXISTS (openRepoTools#91, + # Copilot round 5 on #97). It is read HERE — before the lock, before the append + # and before the commit — because it is the pre-image the follow-up at the foot + # of this function compares against: a `RUNNING` written out of an event that + # landed an hour ago must not overwrite a `SWAPPING` that began since, and the + # only evidence of which came first is what the snapshot said when this write + # started. Only the four verbs that move the lifecycle pay for the read. + we_pre=""; we_seam="" + # THE SEAM, BEFORE THE LOCK AND BEFORE A BYTE (ruling 2026-10-04): EVERY + # lane-kind line — the four that move a lifecycle, a swap's `PAUSED`, and + # Amendment 18(g)'s `HANDOFF-REQUESTED` (Copilot round 3 on #97) — is a legacy + # act on the lane itself, and a managed-owned lane, or one whose ownership + # could not be read, refuses it here. Object-kind lines (a claim, a release) + # are about the object a lane holds and are not the lane's ownership. + is_lane_verb "$we_verb" && managed_seam_refuse "$we_lane" "a $we_verb line in its object log" + case "$we_verb" in + STARTED|RESUMED|ENDED|RETIRED) + # Past the seam the lane is legacy, and that verdict is what the lifecycle + # follow-up at this function's foot is handed. `PAUSED` and + # `HANDOFF-REQUESTED` move no lifecycle and are handed nothing. + we_seam=8 + we_pre="$(lane_state_preimage "$we_lane" "$we_pay")" ;; + esac acquire_lock capture_register_edit "${we_paths[@]}" handle_preexisting "${we_paths[@]}" @@ -4850,6 +4986,21 @@ write_event() { we_rc=$? state_events_flush release_lock + # THE LIFECYCLE SNAPSHOT FOLLOWS THE LINE THAT WAS WRITTEN (openRepoTools#91), + # and it is here — in the writer, after the lock — for the reason + # `pause_subfields_check` is here: from ANY caller, over one implementation, + # rather than in each of the commands that write a lane-kind line. A + # `STARTED` or a `RESUMED` is the confirming act of a new owner and takes the + # lane to `RUNNING`; an `ENDED` or a `RETIRED` takes it to `CLOSED`; a + # `PAUSED` moves nothing, because the two-phase transition around it is + # `lane-handoff`'s and lands `SWAPPED` only once every mandatory write has. + # AFTER `release_lock`, because `acquire_lock` is a `mkdir` mutex and not a + # reentrant one — and the follow-up takes that same mutex for itself, around + # the read of the pre-image and the replacement of the snapshot together. It + # never fails the event and it is silent for a lane with no control root, which + # is every lane that has not started under Amendment 11(c) — the cutover rule + # of Amendment 7(i), not a failure. + lane_state_follow "$we_lane" "$we_verb" "$we_pay" "$we_uuid" "$we_pre" "$we_seam" return "$we_rc" } @@ -10216,6 +10367,23 @@ EOF *) msc_plain=$((msc_plain + 1)); continue ;; esac if mig_cell_is_phrase "$msc_cell"; then msc_phrase=$((msc_phrase + 1)); continue; fi + # THE SEAM (Brett Heap's ruling of 2026-10-04; Copilot round 2 on + # openRepoTools#97). This act rewrites a row's state cell and appends to + # its lane's log, so a lane the managed ledger has enrolled is not a row it + # may take — and the historical shorthand `MANAGED OWNER · ` holds + # ONE ` · `, so `mig_cell_is_phrase` does not recognise it and, unasked, + # the row was planned as a diary and rewritten to `MIGRATED · …`. + # ONE SUCH ROW REFUSES THE WHOLE MIGRATION, exactly as one refuses the + # whole of Amendment 19's sweep: clause (e) makes this act ONE commit, and + # a register migrated but for its managed rows is not that commit. Asked + # HERE and not at the top of the loop: a row skipped above (15(d)'s + # duplicate pair, a row that is not seven columns, one word, the phrase) is + # never rewritten and has nothing to refuse, and asking first would turn + # today's skips into refusals. Both modes ask, because the dry run reports + # exactly what the act would do. `die` releases the lock the act holds. + # The seam reads this checkout's row as well as the published one, and + # this checkout's row is the one the migration rewrites. + managed_seam_refuse "$msc_lane" "migrate-state-cells (the WHOLE migration, which Amendment 13(e) makes one commit)" fi if [ -z "$msc_why" ]; then # The row's own columns, walked from the LEFT out of the head this split @@ -10582,9 +10750,14 @@ retire_rows() { # … [--reason ""] [--writer ] rr_tmp="$(mktemp -d)" : > "$rr_tmp/plan"; : > "$rr_tmp/report" rr_n=0; rr_names="" + rr_follow="" rr_seen="" for rr_lane in $rr_lanes; do rr_l="$(canon_lane "$rr_lane")" || exit 2 # Amendment 15 + # THE SEAM (ruling 2026-10-04). The sweep is ONE commit, so one managed or + # unknown lane in it refuses all of it, here in the scan, before any line or + # cell is written; `die` releases the lock this sweep holds. + managed_seam_refuse "$rr_l" "retire-rows (Amendment 19's sweep)" # A LANE NAMED TWICE IS A REFUSAL AND NOT A SECOND LINE. The log is # append-only: a duplicate in the argument list would put the same `RETIRED` # line into it twice, in one commit, and no later line could take it back. @@ -10747,6 +10920,14 @@ $(session_ids_local_of_lane "$rr_l" 2>/dev/null || :)" printf '%s%s%s%s%s%s%s%s%s\n' "$rr_ln" "$US" "$rr_l" "$US" "$rr_new" "$US" "$RSS_HEAD" "$US" "$RSS_TAIL" >> "$rr_tmp/plan" printf ' %-26s %s\n' "$rr_l" "$rr_new" >> "$rr_tmp/report" rr_names="$rr_names $rr_l" + # THE LIFECYCLE PRE-IMAGE, TAKEN UNDER THE LOCK THIS SWEEP ALREADY HOLDS + # (openRepoTools#91). These `RETIRED` lines are appended by this function + # and never by `write_event`, so the follow-up that moves a lane's snapshot + # to `CLOSED` for every other `RETIRED` would never run for them, and a + # swept lane would keep whatever its snapshot last said. The pre-image is + # what the follow-up compares against, exactly as `write_event` takes it. + rr_follow="$rr_follow$rr_l$US$(lane_state_preimage "$rr_l" "")${US}8 +" rr_n=$((rr_n + 1)) done @@ -10784,6 +10965,15 @@ EOF commit_push "$rr_msg" "${rr_paths[@]}" rr_crc=$? release_lock + # AND EACH SWEPT LANE'S SNAPSHOT FOLLOWS ITS LINE, after the lock, for the + # reason `write_event` gives at its own foot: the follow-up takes the mutex + # for itself and never fails the act whose lines have already landed. + while IFS="$US" read -r rr_fl rr_fpre rr_fseam; do + [ -n "${rr_fl:-}" ] || continue + lane_state_follow "$rr_fl" RETIRED "" "$rr_uuid" "$rr_fpre" "$rr_fseam" + done < <1 = the act, 0 = the dry run> fi mig_trim "$RSS_CELL" [ "$(mig_lead_state "$MIG_TRIM")" = RETIRED ] || continue + # THE SEAM (Copilot round 3 on #97). This act takes a row out of the + # register, and a RETIRED row may carry the managed ledger's marker. One + # managed or unknown row refuses the WHOLE move, as one refuses the whole + # of the sweep, because the move is one commit. Asked in a subshell so this + # function removes its own staging directory before it exits, as each + # refusal above does; `die` there has already said why. + ar_mrc=0 + ( managed_seam_refuse "$ar_lane" "archive-rows $ar_repo (the WHOLE move, which Amendment 19(d) makes one commit)" ) || ar_mrc=$? + if [ "$ar_mrc" != 0 ]; then + rm -rf -- "$ar_tmp" + exit "$ar_mrc" + fi printf '%s\n' "$ar_row" >> "$ar_tmp/rows" printf '%s\n' "$ar_num" >> "$ar_tmp/nums" printf '%s\n' "$ar_lane" >> "$ar_tmp/names" @@ -10949,6 +11151,1196 @@ EOF return "$ar_crc" } +# ============================================================================ +# THE SEAM: A MANAGED-OWNED LANE IS NOT THIS TOOLING'S +# ============================================================================ +# +# Brett Heap's ruling of 2026-10-04, verbatim: "managed ledger owns enrolled +# lanes; #97 owns legacy — rework both". A lane the managed ledger has enrolled +# carries a MANAGED-OWNER MARKER in its register row's state cell, written by +# that ledger's own writer; every lane without one is a LEGACY lane, and the +# legacy lane tooling in this file — the lifecycle below, the object log's +# lane-kind lines, the sweep, the row writers — answers for legacy lanes only. +# +# THE RULE, which is the ported reader's own ("no malformed marker may be +# downgraded to absence"): +# * a VALID marker is a managed lane: every legacy write refuses it with 2, +# names the owner, and writes nothing; +# * managed-owner vocabulary that does NOT parse — a malformed marker, a row +# that is not seven columns, an empty owner — or a register that could not +# be read is UNKNOWN: refused with 1, and nothing is written; +# * no vocabulary at all is a legacy lane, and everything here runs exactly +# as it did. +# +# PROVENANCE. The block between the two markers below is +# `3c26041:lanes-edit.sh:776-898` — the projection reader of branch +# `001-separate-swap-ctx-handoff` (its T019) — ported VERBATIM: not one byte of +# it differs, so that when that branch's T024 lands, the merge of the two is +# "keep either", and this file's `lane_is_managed_owned` becomes that branch's +# `managed_legacy_check`. The proof is an empty diff: +# 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') +# Its four dependencies — `row_split_state_cell`, `rstrip_spaces`, `lc` and +# `row_of_lane` — are byte-identical in this file and in that one. +# --- BEGIN ported from 3c26041:lanes-edit.sh:776-898 --- +MANAGED_PROJECTION_MODE="" +MANAGED_PROJECTION_DAEMON="" +MANAGED_PROJECTION_GENERATION="" +MANAGED_PROJECTION_LANE="" + +managed_projection_component_valid() { # + local mpc_value="${1-}" + case "$mpc_value" in + '' | .* | -* | *[!A-Za-z0-9._-]*) return 1 ;; + esac + [ "${#mpc_value}" -le 128 ] || return 1 + return 0 +} + +managed_projection_generation_valid() { # + local mpg_value="${1-}" + case "$mpg_value" in + '' | *[!0-9]* | 0) return 1 ;; + esac + while [ "${mpg_value#0}" != "$mpg_value" ]; do + mpg_value="${mpg_value#0}" + done + [ -n "$mpg_value" ] || return 1 + MANAGED_PROJECTION_GENERATION="$mpg_value" + return 0 +} + +managed_projection_hint() { # ; 0 means a managed marker is visible + printf '%s\n' "${1-}" | awk ' + { s = tolower($0) + if (s ~ /managed[ _-](owner|binding)/ || + s ~ /mode[ _-]*=[ _-]*managed/ || + s ~ /managed[ _-]*:/) found = 1 + } + END { exit(found ? 0 : 8) }' +} + +managed_projection_parse_row() { # ; 0 valid marker, 8 absent, other unknown + local mppr_row="${1-}" mppr_split mppr_cell mppr_text mppr_line mppr_fields mppr_hint mppr_row_lane + MANAGED_PROJECTION_MODE="" + MANAGED_PROJECTION_DAEMON="" + MANAGED_PROJECTION_GENERATION="" + MANAGED_PROJECTION_LANE="" + mppr_hint=8 + if managed_projection_hint "$mppr_row"; then + mppr_hint=0 + else + mppr_hint=$? + fi + mppr_split=0 + row_split_state_cell "$mppr_row" || mppr_split=$? + case "$mppr_split" in + 0) : ;; + 2) [ "$mppr_hint" = 0 ] && return 1; return 8 ;; + *) [ "$mppr_hint" = 0 ] && return 1; return 8 ;; + esac + # A row without any managed-owner vocabulary is an ordinary legacy row and + # is the confirmed-absent projection result. Once that vocabulary appears, + # however, every table/state delimiter and every owner field is evidence that + # must parse; no malformed marker may be downgraded to absence. The split + # above intentionally runs first so projection writers retain RSS_HEAD and + # RSS_TAIL when replacing an ordinary row. + [ "$mppr_hint" = 0 ] || { [ "$mppr_hint" = 8 ] && return 8; return 1; } + mppr_cell="$(rstrip_spaces "$RSS_CELL")" + case "$mppr_cell" in + 'MANAGED OWNER · '* | 'managed owner · '*) + # Keep the historical shorthand, but never treat an empty owner token + # as a valid durable binding. + mppr_text="${mppr_cell#* · }" + case "$mppr_text" in + '' | *[!A-Za-z0-9._-]*) return 1 ;; + esac + return 0 + ;; + *' · '*) mppr_text="${mppr_cell#* · }" ;; + *) return 1 ;; + esac + case "$mppr_text" in + *' · '*) mppr_line="${mppr_text#* · }" ;; + *) + return 1 ;; + esac + case "$mppr_line" in + managed-owner\ *) : ;; + *) return 1 ;; + esac + mppr_fields="$(printf '%s\n' "$mppr_line" | sed -n \ + 's/^managed-owner mode=\([^[:space:]]*\) daemon=\([^[:space:]]*\) generation=\([^[:space:]]*\) bound-lane=\([^[:space:]]*\)$/\1|\2|\3|\4/p')" + [ -n "$mppr_fields" ] || return 1 + IFS='|' read -r MANAGED_PROJECTION_MODE \ + MANAGED_PROJECTION_DAEMON MANAGED_PROJECTION_GENERATION \ + MANAGED_PROJECTION_LANE <; 0 marker, 8 absent, other unknown + local mpr_lane="${1-}" mpr_row mpr_rc + if mpr_row="$(row_of_lane "$mpr_lane" 2>/dev/null)"; then + : + else + mpr_rc=$? + return "${mpr_rc:-1}" + fi + # `row_of_lane` is an awk pipeline and therefore exits 0 even when it + # printed no row. An empty result is the published register's confirmed + # absence (8), not an unreadable projection; callers need this distinction + # so a new legacy lane remains compatible when the optional helper is absent. + [ -n "$mpr_row" ] || return 8 + managed_projection_parse_row "$mpr_row" +} + +managed_projection_check() { # ; 0 conflict, 8 absent, other unknown + managed_projection_read "${1-}" +} +# --- END ported from 3c26041:lanes-edit.sh:776-898 --- + +# THE ONE READ EVERY CALLER ASKS: 0 with the owner on stdout, 8 a legacy lane, +# 1 UNKNOWN. It wraps `managed_projection_read` and maps every other status of +# it to 1, and it closes the one gap the ported reader leaves open on purpose: +# `row_of_lane` is an awk pipeline over `register_text`, which answers an EMPTY +# register — not a failure — where the published one could not be rendered, so +# the reader would call every lane legacy. An empty register is not a register +# with no managed lane in it, and it is 1 here (Amendment 7(d)). +# THE OWNER is the marker's `daemon=` for the full form and its token for the +# historical `MANAGED OWNER · ` shorthand, read from the same state cell +# the reader has just validated. +lane_is_managed_owned() { # + limo_lane="${1-}"; limo_rc=0 + [ -n "$limo_lane" ] || return 1 + limo_reg="$(register_text)" + [ -n "$limo_reg" ] || return 1 + managed_projection_read "$limo_lane" || limo_rc=$? + case "$limo_rc" in + 0) + if [ -n "$MANAGED_PROJECTION_DAEMON" ]; then + printf '%s\n' "$MANAGED_PROJECTION_DAEMON" + else + limo_cell="$(rstrip_spaces "$RSS_CELL")" + printf '%s\n' "${limo_cell#* · }" + fi + return 0 ;; + 8) return 8 ;; + *) return 1 ;; + esac +} + +# THE REFUSAL, in one place, so every legacy act says the same two sentences. +# Returns 0 for a legacy lane; never returns otherwise. Called BEFORE the act +# writes anything — before its lock where it takes one, and where the lock is +# already held (the sweeps), before its first write — so a refusal changes +# nothing; `die` releases a held lock on the way out. +# +# AND THIS CHECKOUT'S ROW, NOT ONLY THE PUBLISHED ONE (Copilot round 2 and 3 on +# openRepoTools#97). The read above is the published register's (R19), and every +# legacy row writer rewrites the row in THIS checkout: one ahead of origin, or +# holding an edit `handle_preexisting` is about to capture, can carry managed-owner +# vocabulary the published row does not. Two copies that disagree on ownership +# are UNKNOWN, never legacy (Amendment 7(d)). A checkout with no register file +# has no row here to rewrite, and the published read has already answered. +managed_seam_refuse() { # + msr_lane="${1-}"; msr_act="${2-this act}"; msr_rc=0; msr_owner="" + msr_owner="$(lane_is_managed_owned "$msr_lane")" || msr_rc=$? + case "$msr_rc" in + 0) die "lane $msr_lane is owned by the managed ledger (owner ${msr_owner:-unnamed}, from the managed-owner marker in its register row), and $msr_act is a legacy lane act: Brett Heap's ruling of 2026-10-04 — \"managed ledger owns enrolled lanes; #97 owns legacy\". Nothing was written. Act on this lane through the managed ledger." 2 ;; + 8) : ;; + *) die "whether the managed ledger owns lane $msr_lane is UNKNOWN: its register row carries managed-owner vocabulary that does not parse as a marker, or the register could not be read — and an ownership nobody could establish is never read as 'legacy' (Amendment 7(d)). $msr_act was refused and nothing was written. Read it: lanes-edit.sh managed-projection $msr_lane" 1 ;; + esac + [ -f "$LANES_FILE" ] || return 0 + msr_hint=8; msr_loc="" + msr_loc="$(row_of_lane_local "$msr_lane" 2>/dev/null)" || msr_hint=1 + if [ "$msr_hint" = 8 ] && [ -n "$msr_loc" ]; then + msr_hint=0; managed_projection_hint "$msr_loc" || msr_hint=$? + fi + [ "$msr_hint" = 8 ] || + die "whether the managed ledger owns lane $msr_lane is UNKNOWN: this checkout's row for it carries managed-owner vocabulary (or could not be read) where the published register's does not, and a legacy act rewrites THIS checkout's row — an ownership the two copies disagree on is never read as 'legacy' (Amendment 7(d)). $msr_act was refused and nothing was written. Publish or undo this checkout's change to that row first: git -C $LANES_REPO log origin/$LANES_BRANCH..HEAD -- $LANES_PATH" 1 + return 0 +} + +# ============================================================================ +# THE CRASH-CONSISTENT LANE LIFECYCLE AND THE WORKTREE INVENTORY +# (openspec/changes/add-crash-consistent-lane-worktree-recovery, +# opensoft/openRepoTools#91) +# ============================================================================ +# +# **A LANE IS `RUNNING`, `SWAPPING`, `SWAPPED` OR `CLOSED`, and which of those +# it is, with no live holder, is what says where it stopped.** A session can +# run out of tokens BEFORE the handoff, AFTER it began and before it finished, +# or after it finished — and until this section those three left the same +# evidence: a lane whose last lane-kind line was a `STARTED`/`RESUMED` (which +# is also what a lane that is running looks like) or a `PAUSED` (which is also +# what a clean swap looks like). The two crash kinds had no word. +# +# WHAT IS NEW AND WHAT IS NOT. Nothing about the append-only log changes: its +# five STATE verbs are Amendment 7's and this section adds no lane-kind verb at +# all (Amendment 18(g)'s `HANDOFF-REQUESTED` is the sixth lane-kind verb, and it +# changes no state), so `swapped_candidates`, `lane_row_facts`, +# `lane_payload_field`, `lane-last`, `who` and `lane-end` read exactly what they +# read before. What is added is a +# SNAPSHOT beside that history — one small file per lane, replaced atomically +# under this file's own mutex — carrying the state word, a monotonic +# GENERATION, the OPERATION ID of the transition in flight, and the owner. The +# log stays the provenance; the snapshot is the cheap current-state read that +# an append-only file cannot give a compare-and-swap. +# +# WHERE IT LIVES, AND WHY IT IS NOT IN THE REGISTER AND NOT IN A WORKTREE. +# * NOT IN THE REGISTER. `lanes/LANES.md` is one file shared by every lane on +# every workstation and every write of it is a commit, a pull --rebase and +# a push (Amendment 5). A transition is taken three times per handoff and +# must be able to happen with no network at all. +# * NOT INSIDE A GIT WORKTREE. Orchestration metadata written into a checkout +# dirties it, is committed by accident, and disappears with the very +# directory whose loss it is meant to explain. +# * SO: a LOCAL control root beside the lane's own checkouts, derived in this +# order and never from the caller's current directory — +# 1. `$LANES_LANE_STATE_ROOT/`, the explicit override and the +# suite's seam; +# 2. `/.lane-state/` — the +# same parent the lane's own `.lane-worktrees/` root sits in, +# and `dir` is Amendment 11(c)'s recorded field, not a guess; +# 3. `$PROJECTS_ROOT/.lane-state/`, for a lane whose record names +# no directory yet. +# No answer is 8, "this lane has no control root", exactly as `lane-dir` +# answers 8 for a lane that has not started under Amendment 11 — and the +# pre-cutover lane is the ordinary case, not a failure (Amendment 7(i)). +# +# IT IS WRITTEN WHERE `R-A11-14` STOPS THE REGISTER AND THE OBJECT LOG. That +# rule refuses a record FILED UNDER A WORKSTATION where no workstation is +# configured, because such a record is unfindable by every restart of every +# workstation. The snapshot is filed under nothing: it is local to the machine +# that wrote it, it is never committed and it never leaves. So a container with +# no `$LANES_WORKSTATION` still keeps a lifecycle it can recover itself from, +# and these two writers are deliberately NOT in the dispatcher's workstation +# guard above. +# +# THE FENCE. Every transition carries `generation` (monotonic) and +# `operation` (unique to one handoff). `set-lane-state --expect` names the +# state, and optionally the generation and the operation, the caller believes +# it is moving from; the write happens only where all three still match, and +# otherwise NOTHING is written and the exit is 7 — the estate's "another act +# got there first", which is what `claim` already spends it on. That is the +# whole of what stops a `/handoff` that stalled for an hour from marking a lane +# `SWAPPED` after somebody has recovered and resumed it. +# +# WHO WRITES `RUNNING`. The confirming act, and never the SessionStart hook: +# `session-start` NEVER WRITES, never touches the network and ALWAYS EXITS 0 +# (Amendment 8, R-A8-1), which is what makes it safe in front of every session +# on the workstation. The act that actually proves lane, transcript, agent, +# directory and binding is `lane-start` (with or without `--no-launch`), and +# the proof it leaves is its `STARTED`/`RESUMED` line — so the snapshot follows +# THAT line, in `write_event`, from any caller. `ENDED`/`RETIRED` become +# `CLOSED` by the same rule. A `PAUSED` moves nothing: the two-phase transition +# around it belongs to `lane-handoff`, which takes `SWAPPING` before it polls +# anything and `SWAPPED` only after every mandatory write has landed. + +LANE_STATE_SCHEMA=1 + +# The four words, and nothing else is one. +lane_state_word_ok() { # + case "${1-}" in RUNNING|SWAPPING|SWAPPED|CLOSED) return 0 ;; esac + return 1 +} + +# THE CONTROL ROOT — the three rungs above, in order. 0 with the path (which +# need not exist yet), 8 where no rung answers. +lane_control_root() { # [] + lcr_lane="${1-}"; lcr_pay="${2-}"; lcr_dir=""; lcr_par="" + [ -n "$lcr_lane" ] || return 8 + if [ -n "${LANES_LANE_STATE_ROOT:-}" ]; then + printf '%s/%s\n' "${LANES_LANE_STATE_ROOT%/}" "$lcr_lane" + return 0 + fi + [ -n "$lcr_pay" ] && lcr_dir="$(payload_subfield "$lcr_pay" dir)" + if [ -z "$lcr_dir" ]; then + lcr_dir="$(lane_payload_field "$lcr_lane" dir 2>/dev/null || :)" + fi + case "$lcr_dir" in + /*) lcr_par="${lcr_dir%/*}" ;; + *) lcr_par="" ;; + esac + if [ -n "$lcr_par" ] && [ -d "$lcr_par" ]; then + printf '%s/.lane-state/%s\n' "$lcr_par" "$lcr_lane" + return 0 + fi + if [ -n "${PROJECTS_ROOT:-}" ] && [ -d "$PROJECTS_ROOT" ]; then + printf '%s/.lane-state/%s\n' "${PROJECTS_ROOT%/}" "$lcr_lane" + return 0 + fi + return 8 +} + +# ONE FIELD OUT OF ONE OF THESE FILES. They are flat `key: value` lines and +# nothing else — no nesting, no lists — so one `awk` reads every one of them +# and a malformed file answers empty rather than half a value. +lane_sidecar_field() { # + [ -r "${1-}" ] || return 1 + awk -v k="${2-}" ' + BEGIN { k = k ": " } + substr($0, 1, length(k)) == k { + v = substr($0, length(k) + 1) + sub(/^[ \t]+/, "", v); sub(/[ \t]+$/, "", v) + print v; exit }' "$1" +} + +# THE SCHEMA EVERY WRITER ASKS ABOUT BEFORE IT REPLACES ANYTHING (Copilot round +# 5 on openRepoTools#97). `lane_state_read` fails closed for a snapshot version +# it does not know — but a READER failing closed protects nobody if the WRITER +# beside it reads the raw fields and renames a file of its own over the top: an +# older helper meeting a `schema: 999` snapshot would then destroy a record it +# could not even read, and no later reader can undo that. So every writer of a +# sidecar in this section asks this first. +# +# ABSENT IS FINE — the first snapshot of a lane destroys nothing — and the +# CURRENT version is fine. Everything else, INCLUDING A FILE THAT EXISTS AND +# CANNOT BE READ, is refused: a schema nobody could read is not a schema this +# helper knows (R22, Amendment 7(d)). +# AND A DANGLING SYMLINK IS NOT ABSENT (Copilot on 64dfd97, PR #97): `-e` is +# false for one, so this guard used to wave every writer through to rename its +# own file over a record `lane_state_read` itself calls present-and-unreadable. +# `[ -L ]` beside `[ -e ]`, exactly as the reader asks it. +lane_sidecar_schema_ok() { # + [ -e "${1-}" ] || [ -L "${1-}" ] || return 0 + lss_v="$(lane_sidecar_field "$1" schema 2>/dev/null || :)" + [ "$lss_v" = "$LANE_STATE_SCHEMA" ] +} + +# THE SNAPSHOT AS IT STOOD, IN ONE STRING — the pre-image a fenced write +# compares against. `//`, and `none/0/none` for a +# lane that has no snapshot at all, so that "there was nothing here" and "there +# was something here" are two different answers rather than one empty string. +lane_state_fingerprint() { # + [ -e "${1-}" ] || { printf 'none/0/none\n'; return 0; } + printf '%s/%s/%s\n' \ + "$(lane_sidecar_field "$1" state 2>/dev/null || :)" \ + "$(lane_sidecar_field "$1" generation 2>/dev/null || :)" \ + "$(lane_sidecar_field "$1" operation 2>/dev/null || :)" +} + +# A VALUE IS ONE LINE OF `key: value`, so a newline or a leading space in one +# would make the file unreadable by the reader above. Both are flattened here +# rather than refused, because a value this can spoil is a branch name or a +# path and losing the WHOLE record over one is the worse trade. +lane_sidecar_value() { # + lsv="${1-}" + lsv="${lsv//$'\r'/ }" + lsv="${lsv//$'\n'/ }" + printf '%s' "$lsv" +} + +# THE ATOMIC REPLACE. Written beside the target and renamed over it, so a +# reader never sees half a snapshot and a full disk leaves the old one intact. +lane_sidecar_put() { # ; the whole body on stdin + lsp_f="${1-}"; lsp_d="${lsp_f%/*}" + [ -n "$lsp_f" ] || return 1 + mkdir -p -- "$lsp_d" 2>/dev/null || return 1 + lsp_t="$lsp_f.tmp.$$" + cat > "$lsp_t" 2>/dev/null || { rm -f -- "$lsp_t" 2>/dev/null; return 1; } + mv -- "$lsp_t" "$lsp_f" 2>/dev/null || { rm -f -- "$lsp_t" 2>/dev/null; return 1; } + return 0 +} + +# AN OPERATION ID: unique to one transition, and shaped like the manifest key +# every other sub-field of this estate is — letters, digits, `.`, `_`, `-` — so +# that it can be carried in a payload, a filename and a commit subject without +# a quoting rule of its own. There is no `uuidgen` on every workstation this +# runs on, and a timestamp with the pid and two `$RANDOM`s behind it is unique +# among the handfuls of transitions one lane takes in a day. +lane_op_id() { + printf 'op-%s-%s-%s%s\n' "$(date -u +%Y%m%dT%H%M%SZ)" "$$" "$RANDOM" "$RANDOM" +} + +# THE SNAPSHOT, READ. `` lines, which is what every caller +# here parses with one `awk`. 0 with the fields, 8 where the lane has no +# snapshot at all (the pre-cutover lane, and not a failure), 10 where a snapshot +# IS there and could not be read, 1 where the control root could not be derived. +# +# THE 10 IS THE POINT OF THIS ROUND (Copilot round 6 on openRepoTools#97, where +# it was 9 — main's #61 has since spent 9 on `claim`, so this one moved). A bare +# `[ -r ] || return 8` answered *this lane has no snapshot* for a file that +# exists and cannot be opened, and `lane_reconcile` maps every non-zero read to +# `NONE` — so a permission or an I/O error came out of the report as `no-state`, +# the verdict that tells a launcher this lane was never migrated and there is +# nothing to recover. That is fail-OPEN on a crash pronouncement, which is the +# whole class this capability exists to close: a read that failed is never an +# answer (R22, Amendment 7(d)), and the two cases are told apart here so that +# every caller above can tell them apart too. +# +# `[ -L ]` BESIDE `[ -e ]`, because a DANGLING SYMLINK is `-e` false: the name +# is there and the bytes are not, which is exactly *present and unreadable* and +# would otherwise fall through to the 8. +lane_state_read() { # + lsr_lane="${1-}"; lsr_root=""; lsr_rc=0 + lsr_root="$(lane_control_root "$lsr_lane")" || lsr_rc=$? + [ "$lsr_rc" = 0 ] || return 1 + lsr_f="$lsr_root/lane-state.yaml" + if [ ! -r "$lsr_f" ]; then + if [ -e "$lsr_f" ] || [ -L "$lsr_f" ]; then return 10; fi + return 8 + fi + # AN UNKNOWN SCHEMA FAILS CLOSED (design decision: "conservative fail-closed + # behaviour for unknown versions"), through the ONE test every writer of these + # files takes too. A newer tooling's snapshot read by an older reader must not + # be reported as a lane in a state this reader knows — and must not be + # replaced by one either, which is what `lane_sidecar_schema_ok` is for. + if ! lane_sidecar_schema_ok "$lsr_f"; then + lsr_schema="$(lane_sidecar_field "$lsr_f" schema 2>/dev/null || :)" + printf 'state\tUNKNOWN-SCHEMA\n'; printf 'schema\t%s\n' "${lsr_schema:-}" + printf 'file\t%s\n' "$lsr_f"; return 0 + fi + 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 + printf 'file\t%s\n' "$lsr_f" + return 0 +} + +# One field of it, for the callers that want exactly one. +lane_state_of() { # + lso_out=""; lso_rc=0 + lso_out="$(lane_state_read "${1-}")" || lso_rc=$? + [ "$lso_rc" = 0 ] || return "$lso_rc" + printf '%s\n' "$lso_out" | awk -F'\t' -v k="${2-}" '$1 == k { print $2; exit }' +} + +# THE SNAPSHOT, WRITTEN — the ONE writer, and every caller reaches it through +# `set-lane-state`. `lsw_*` are its fields; an empty one is written as `none`, +# which is an ANSWER and not a gap, on the same argument Amendment 17(b) gives +# its `transcript` sub-field. +lane_state_put() { # + lsw_root="$1"; lsw_lane="$2"; lsw_state="$3"; lsw_gen="$4"; lsw_op="$5" + lsw_owner="$6"; lsw_agent="$7"; lsw_prof="$8"; lsw_ws="$9"; shift 9; lsw_kind="${1-}" + { printf 'schema: %s\n' "$LANE_STATE_SCHEMA" + printf 'lane: %s\n' "$(lane_sidecar_value "$lsw_lane")" + printf 'state: %s\n' "$lsw_state" + printf 'generation: %s\n' "$lsw_gen" + printf 'operation: %s\n' "${lsw_op:-none}" + printf 'owner: %s\n' "${lsw_owner:-none}" + printf 'agent: %s\n' "${lsw_agent:-none}" + printf 'profile: %s\n' "${lsw_prof:-none}" + printf 'workstation: %s\n' "${lsw_ws:-none}" + printf 'kind: %s\n' "${lsw_kind:-none}" + printf 'updated: %s\n' "$(utc_now)" + } | lane_sidecar_put "$lsw_root/lane-state.yaml" +} + +# THE SNAPSHOT AS IT STOOD BEFORE THE LINE WAS WRITTEN. `write_event` takes this +# BEFORE it appends anything and hands it to the follow-up below, which is the +# only way that follow-up can tell *I am the newest act on this lane* from *I am +# a delayed act whose lane has moved on since*. A lane with no control root +# answers the same `none/0/none` a lane with no snapshot does: nothing to +# compare, and nothing to overwrite either. +lane_state_preimage() { # + lsp_root=""; lsp_prc=0 + lsp_root="$(lane_control_root "${1-}" "${2-}")" || lsp_prc=$? + [ "$lsp_prc" = 0 ] || { printf 'none/0/none\n'; return 0; } + lane_state_fingerprint "$lsp_root/lane-state.yaml" +} + +# THE LINE THAT WAS WRITTEN IS WHAT MOVES THE SNAPSHOT, and this is where the +# two are kept from disagreeing: it is called by `write_event`, from any +# caller, after the log line has landed. It NEVER fails the event: a lane with +# no control root is silent, because a lane that has not started under Amendment +# 11 has no directory to derive one from and that is the ordinary pre-cutover +# case. +# +# IT IS SERIALIZED AND IT IS FENCED (Copilot round 5 on openRepoTools#97). What +# a confirmed `STARTED`/`RESUMED` is entitled to overwrite is the state THIS +# WRITE SAW — advancing the generation over a `SWAPPING` it superseded is the +# whole point of writing it — and what it is never entitled to overwrite is a +# state that arrived AFTER it. The two are the same act read at different +# moments, so they are told apart the only way they can be: the snapshot is read +# under the same mutex `set-lane-state` takes, and compared with the pre-image +# `write_event` took before this event's own line landed. Unequal means another +# act moved the lane while this one was being written — a recovery, a handoff, +# an `ENDED` from elsewhere — and a delayed `RUNNING` or `CLOSED` written over +# it would be exactly the overwrite the generation exists to refuse. Nothing is +# written then, and the lane is NAMED so a person can read it. +# +# THE MUTEX IS TAKEN WITHOUT DYING FOR IT. `write_event` has already released it +# and its line is already committed: a `die` here would abort a caller whose +# work is on disk, so a mutex nobody could take within 20s costs the snapshot +# and says so, never the event. +# +# AND IT WRITES ONLY FOR A LEGACY LANE, ON EVIDENCE IT WAS HANDED (ruling +# 2026-10-04, design decision 21). Three things must all hold before the +# snapshot moves — `RUNNING` for a `STARTED`/`RESUMED`, `CLOSED` for an +# `ENDED`/`RETIRED`: +# * the verb is one of those four; +# * the caller's seam read said 8 — this lane carries no managed-owner +# marker and no vocabulary of one — because a managed lane's lifecycle is +# the managed ledger's and an unknown one is nobody's to write; +# * a pre-image was taken before the line was written and the snapshot still +# matches it under the mutex. +# A missing verdict or a missing pre-image is NOT a pass: it writes nothing, +# because a caller that did not ask is not a caller that was told "legacy". +lane_state_follow() { # + lsf_lane="${1-}"; lsf_verb="${2-}"; lsf_pay="${3-}"; lsf_uuid="${4-}"; lsf_pre="${5-}" + lsf_seam="${6-}" + lsf_new="" + case "$lsf_verb" in + STARTED|RESUMED) lsf_new=RUNNING ;; + ENDED|RETIRED) lsf_new=CLOSED ;; + *) return 0 ;; + esac + [ "$lsf_seam" = 8 ] || return 0 + [ -n "$lsf_pre" ] || return 0 + lsf_root=""; lsf_rc=0 + lsf_root="$(lane_control_root "$lsf_lane" "$lsf_pay")" || lsf_rc=$? + [ "$lsf_rc" = 0 ] || return 0 + lsf_f="$lsf_root/lane-state.yaml" + lsf_own=0 + if [ "$LOCK_HELD" != 1 ]; then + if lock_try 20; then + lsf_own=1 + else + note "the lane lifecycle snapshot for $lsf_lane was NOT moved to $lsf_new: $LOCK is held by another lanes-edit run and this follow-up will not wait behind an event that has already landed. The $lsf_verb line itself is written; the snapshot is one act behind until the next transition, and \`lanes-edit.sh lane-reconcile $lsf_lane\` says what it holds." + return 0 + fi + fi + # A SNAPSHOT THIS HELPER CANNOT READ IS ONE IT MUST NOT REPLACE. + if ! lane_sidecar_schema_ok "$lsf_f"; then + if [ "$lsf_own" = 1 ]; then release_lock; fi + note "the lane lifecycle snapshot at $lsf_f records a schema this helper does not write, so the $lsf_verb line landed and NOTHING was written over that file: a record an older helper cannot read is one it cannot safely replace. Upgrade this workstation's lanes-edit.sh, or read the file by hand." + return 0 + fi + lsf_seen="$(lane_state_fingerprint "$lsf_f")" + if [ "$lsf_seen" != "$lsf_pre" ]; then + if [ "$lsf_own" = 1 ]; then release_lock; fi + note "the lane lifecycle moved under this $lsf_verb: lane $lsf_lane read '$lsf_pre' (state/generation/operation) when this write began and reads '$lsf_seen' now, so the snapshot is LEFT AS IT IS and no $lsf_new was written over it. Another act got there first — a recovery, or a second handoff — and a delayed write is precisely what the generation exists to refuse. The $lsf_verb line itself landed: read the lane with \`lanes-edit.sh lane-reconcile $lsf_lane\` before relaunching anything." + return 0 + fi + lsf_gen="$(lane_sidecar_field "$lsf_f" generation 2>/dev/null || :)" + case "$lsf_gen" in ''|*[!0-9]*) lsf_gen=0 ;; esac + lsf_gen=$((lsf_gen + 1)) + lsf_agent="$(payload_subfield "$lsf_pay" agent)" + lsf_prof="$(payload_subfield "$lsf_pay" profile)" + if lane_state_put "$lsf_root" "$lsf_lane" "$lsf_new" "$lsf_gen" "$(lane_op_id)" \ + "$lsf_uuid" "${lsf_agent:-}" "${lsf_prof:-}" "$WS" ""; then + if [ "$lsf_own" = 1 ]; then release_lock; fi + return 0 + fi + if [ "$lsf_own" = 1 ]; then release_lock; fi + note "the lane lifecycle snapshot at $lsf_f could NOT be written (the $lsf_verb line itself landed). Crash recovery for lane $lsf_lane is incomplete until it can be: see \`lanes-edit.sh lane-reconcile $lsf_lane\`" + return 0 +} + +# A RENAMED LANE KEEPS ITS LIFECYCLE (Amendment 16, openRepoTools#91). The +# control root is keyed by the lane's NAME — `/.lane-state/` on +# every rung — and `rename-lane` moves the row, the log, the handoff and the +# alias table but knew nothing of this directory, so a renamed lane's snapshot +# and inventory were left under a name every reader now resolves away from, and +# its next reconciliation read `no-state`: the one answer a launcher goes past. +# The directory is MOVED, never copied (two snapshots of one lane would be two +# answers), only after the rename's commit returned 0, and never over anything +# already at the new name, which is reported for a person rather than merged. +# The `lane:` line inside the files keeps the old name until the next write +# replaces it; no reader of these files keys on it. +lane_state_rename() { # + lsn_old="${1-}"; lsn_new="${2-}" + [ -n "$lsn_old" ] && [ -n "$lsn_new" ] || return 0 + [ -e "$lsn_old" ] || [ -L "$lsn_old" ] || return 0 + lsn_to="${lsn_old%/*}/$lsn_new" + [ "$lsn_to" != "$lsn_old" ] || return 0 + # A RENAME BY CASE ALONE is the same directory on a case-insensitive file + # system (macOS's default), where the target "exists" because it is the + # source; it goes through a temporary name so it lands on every file system. + if [ "$(lc "${lsn_old##*/}")" = "$(lc "$lsn_new")" ]; then + lsn_tmp="$lsn_old.rename.$$" + if mv -- "$lsn_old" "$lsn_tmp" 2>/dev/null && mv -- "$lsn_tmp" "$lsn_to" 2>/dev/null; then + note "the lane lifecycle snapshot and inventory moved with the rename: $lsn_old → $lsn_to" + else + note "the lane lifecycle snapshot and inventory could NOT be moved from $lsn_old to $lsn_to (the rename itself has landed). Move it by hand; until then \`lanes-edit.sh lane-reconcile $lsn_new\` reads no snapshot for this lane." + fi + return 0 + fi + if [ -e "$lsn_to" ] || [ -L "$lsn_to" ]; then + note "the lane lifecycle snapshot and inventory were NOT moved: $lsn_old is the old name's and $lsn_to already exists for the new one, and two records of one lane are not merged here. Read both and keep one by hand; the rename itself has landed." + return 0 + fi + if mv -- "$lsn_old" "$lsn_to" 2>/dev/null; then + note "the lane lifecycle snapshot and inventory moved with the rename: $lsn_old → $lsn_to" + else + note "the lane lifecycle snapshot and inventory could NOT be moved from $lsn_old to $lsn_to (the rename itself has landed). Move it by hand; until then \`lanes-edit.sh lane-reconcile $lsn_new\` reads no snapshot for this lane." + fi + return 0 +} + +# ------------------------------------------------- the worktree inventory +# +# A TREE ID IS DERIVED FROM ITS PATH AND FROM NOTHING ELSE, so that a branch +# renamed under a writer does not rename the record of the tree it is on: a +# `cksum` of the WHOLE absolute path, then the path with every character outside +# the manifest-key set folded to `-`, which a reader recognises. The fold alone +# is NOT one name per path (Codex on ec9847e, PR #97): `…/a+b` and `…/a-b` fold +# to the same name, and recording the second would replace the first tree's +# sidecar and lose its last observation without a word. The checksum in front +# of EVERY id is what keeps two paths two records; the fold is for a person. +tree_id_for() { # + tif_p="$(printf '%s' "${1-}" | tr -c 'A-Za-z0-9._-' '-' | tr -s '-')" + while [ "${tif_p#-}" != "$tif_p" ]; do tif_p="${tif_p#-}"; done + while [ "${tif_p%-}" != "$tif_p" ]; do tif_p="${tif_p%-}"; done + tif_c="$(printf '%s' "${1-}" | cksum | awk '{print $1}')" + # AND IT CAN NEVER OUTGROW A FILE NAME. A path deep enough to make this + # longer than the 255 bytes most filesystems take is a tree whose sidecar + # could not be created at all — silently, since the failure would be the + # shell's `>` and not this function's. The tail is what a reader recognises, + # so the head is what is dropped. + if [ "${#tif_p}" -gt 180 ]; then + tif_p="$(printf '%s' "$tif_p" | tail -c 180)" + fi + printf 'c%s-%s\n' "$tif_c" "$tif_p" +} + +# ONE TREE'S SIDECAR. Every field is an OBSERVATION and none of them is truth +# about git: `lane-reconcile` recomputes all of them and reports the +# difference, which is the whole of design decision 6. +lane_tree_put() { # + ltp_root="$1"; ltp_lane="$2"; ltp_path="$3"; ltp_co="$4"; ltp_branch="$5" + ltp_head="$6"; ltp_up="$7"; ltp_dirty="$8"; ltp_unp="$9"; shift 9 + ltp_writer="${1-}"; ltp_gen="${2-}"; ltp_op="${3-}" + ltp_id="$(tree_id_for "$ltp_path")" + [ -n "$ltp_id" ] || return 1 + { printf 'schema: %s\n' "$LANE_STATE_SCHEMA" + printf 'lane: %s\n' "$(lane_sidecar_value "$ltp_lane")" + printf 'tree: %s\n' "$ltp_id" + printf 'path: %s\n' "$(lane_sidecar_value "$ltp_path")" + printf 'checkout: %s\n' "$(lane_sidecar_value "${ltp_co:-unknown}")" + printf 'branch: %s\n' "$(lane_sidecar_value "${ltp_branch:-unknown}")" + printf 'head: %s\n' "$(lane_sidecar_value "${ltp_head:-unknown}")" + printf 'upstream: %s\n' "$(lane_sidecar_value "${ltp_up:-none}")" + printf 'dirty: %s\n' "${ltp_dirty:-0}" + printf 'unpushed: %s\n' "${ltp_unp:-0}" + printf 'writer: %s\n' "${ltp_writer:-none}" + printf 'generation: %s\n' "${ltp_gen:-0}" + printf 'operation: %s\n' "${ltp_op:-none}" + printf 'observed: %s\n' "$(utc_now)" + } | lane_sidecar_put "$ltp_root/trees/$ltp_id.yaml" +} + +# EVERY RECORDED TREE, ONE PER LINE, US-separated: +# +# 0 with rows, 8 with none, 1 where no control root could be derived. +# +# THE LAST THREE ARE NEW AND THEY ARE AT THE END (Copilot round 5 on +# openRepoTools#97): the fence the observation was RECORDED UNDER, and the +# schema the sidecar itself carries. A reader written against the ten fields +# that were here first still reads those ten. The fence is here because it was +# written into the file and read by nothing — so a poll filed by an operation a +# recovery has since superseded looked exactly like the current one. +# +# AND A SIDECAR THIS READER DOES NOT KNOW IS SAID, NEVER PARSED. Until this +# round every `*.yaml` under `trees/` was read field by field whatever it +# claimed to be, which is the fail-closed contract `lane_state_read` keeps for +# the snapshot broken for the inventory beside it: a record written by a newer +# tooling would have been reported as an ordinary observation of a tree, in +# fields this reader was guessing at. Its id, its path and its schema are +# printed — the three a person needs to find it — and every other field is +# EMPTY, which `lane_reconcile` reports as `unknown-schema` rather than +# recomputing against. +lane_trees_list() { # + ltl_root=""; ltl_rc=0; ltl_n=0 + ltl_root="$(lane_control_root "${1-}")" || ltl_rc=$? + [ "$ltl_rc" = 0 ] || return 1 + [ -d "$ltl_root/trees" ] || return 8 + for ltl_f in "$ltl_root"/trees/*.yaml; do + [ -e "$ltl_f" ] || [ -L "$ltl_f" ] || continue + ltl_n=$((ltl_n + 1)) + # A SIDECAR THAT IS THERE AND CANNOT BE READ IS NOT AN ABSENT ONE (Codex on + # ec9847e, PR #97). It was skipped, so a permission or an I/O error — or a + # dangling link — made the only record of a tree's location and its dirty + # state vanish, and a lane whose one sidecar it was read as having no + # inventory at all. It is a row of its own, named by its file and carrying + # no field, which `lane_reconcile` reports as `unreadable-sidecar`. + if [ ! -r "$ltl_f" ]; then + ltl_id="${ltl_f##*/}"; ltl_id="${ltl_id%.yaml}" + printf '%s\n' "$ltl_id$US$US$US$US$US$US$US$US$US$US$US$US" + continue + fi + ltl_s="$(lane_sidecar_field "$ltl_f" schema 2>/dev/null || :)" + ltl_id="$(lane_sidecar_field "$ltl_f" tree 2>/dev/null || :)" + ltl_p="$(lane_sidecar_field "$ltl_f" path 2>/dev/null || :)" + if [ "$ltl_s" != "$LANE_STATE_SCHEMA" ]; then + # The file name is the id where the record cannot be trusted to name it: + # a row with no id at all is one `lane_reconcile` skips, and a sidecar + # nobody can account for is the opposite of what this round is about. + [ -n "$ltl_id" ] || { ltl_id="${ltl_f##*/}"; ltl_id="${ltl_id%.yaml}"; } + printf '%s\n' "$ltl_id$US$ltl_p$US$US$US$US$US$US$US$US$US$US$US${ltl_s:-}" + continue + fi + printf '%s\n' "$ltl_id$US$ltl_p\ +$US$(lane_sidecar_field "$ltl_f" branch)\ +$US$(lane_sidecar_field "$ltl_f" head)\ +$US$(lane_sidecar_field "$ltl_f" upstream)\ +$US$(lane_sidecar_field "$ltl_f" dirty)\ +$US$(lane_sidecar_field "$ltl_f" unpushed)\ +$US$(lane_sidecar_field "$ltl_f" writer)\ +$US$(lane_sidecar_field "$ltl_f" observed)\ +$US$(lane_sidecar_field "$ltl_f" checkout)\ +$US$(lane_sidecar_field "$ltl_f" generation)\ +$US$(lane_sidecar_field "$ltl_f" operation)\ +$US$ltl_s" + done + [ "$ltl_n" -gt 0 ] || return 8 + return 0 +} + +# ------------------------------------------------ resume-time reconciliation +# +# IT REPORTS AND IT RESETS NOTHING. AGENTS.md rule 1 is the estate's: *"`park` +# CREATES NOTHING and `resume` RESETS NOTHING"*, and never a `git reset`, a +# `git stash`, a `git checkout -f` or a `worktree add --force` to make the next +# run succeed. So this read runs `git status`, `git log @{u}..`, `git rev-parse` +# and `git worktree list --porcelain` and NOTHING ELSE, names the act a person +# takes, and leaves every tree exactly as it found it — a missing tree included, +# whose rebuild is the estate's own `resume ` and is a person's to run. + +# ONE PATH, ONE SPELLING — the physical one, or the path itself where it cannot +# be resolved (a path that is gone still has to be comparable). +# +# THE REPORT READS THREE SOURCES AND THEY DO NOT AGREE ABOUT SPELLING. A sidecar +# holds the path the poll was given, `git worktree list --porcelain` answers with +# the PHYSICAL path, and the on-disk sweep walks the recorded `dir`. Every estate +# with a `projects` symlink reaches its checkouts through it — and on macOS +# `$TMPDIR` and `$HOME` are under `/var`, which IS a symlink to `/private/var`, +# which is how four cases of this section went red on that runner alone at +# `612ba5c` (`expected [dirty], got [dirty unmanaged]`): every tree was reported +# TWICE, once as the tree it is and once as a tree nobody manages. `612ba5c` +# resolved the lane's OWN checkout for exactly this reason and left the trees +# under it unresolved. `cd -P` is the portable resolver, for the reason +# `lane-start`'s `real_of` gives: `readlink -f` is not in the stock macOS +# userland. +lane_real_path() { # + lrp_p="${1-}" + [ -n "$lrp_p" ] || return 0 + lrp_r="$( CDPATH=''; cd -P -- "$lrp_p" 2>/dev/null && pwd -P )" || lrp_r="" + printf '%s\n' "${lrp_r:-$lrp_p}" +} + +# THE LANE'S TWO WORKTREE ROOTS — the same two `lane-handoff` polls, and they +# are here rather than there so the poll and the reconciliation cannot come to +# disagree about where a lane keeps its writers. +lane_worktree_roots() { # + lwr_lane="${1-}"; lwr_dir="${2-}" + [ -n "$lwr_dir" ] || return 0 + printf '%s/.claude/worktrees\n' "$lwr_dir" + printf '%s/.lane-worktrees/%s\n' "${lwr_dir%/*}" "$lwr_lane" + return 0 +} + +# What git says about one tree, NOW — +# `` — and it is the ONE +# implementation of that sentence: `lane-reconcile` recomputes with it, +# `set-lane-tree` records through it and `lane-handoff` polls its writers with +# it, over the `lane-tree-now` arm below, so the WRITERS section a person reads +# and the sidecar a recovery reads can never be two different readings. +# +# 0 the five fields +# 1 there is no directory at that path +# 2 the path is there and git does not answer in it at all +# 3 GIT ANSWERED AND ONE OF THE READS FAILED, and NOTHING is printed +# +# THE 3 IS THE WHOLE POINT OF THIS ROUND (Copilot round 5 on openRepoTools#97). +# Every read below used to end in `|| printf 'unknown'`, `|| printf 'none'` or +# an empty count that became `0` — so a repository whose object store is +# unreadable, whose index is locked or whose HEAD is corrupt produced a RECORD +# THAT LOOKED CLEAN AND PUBLISHED, and a later reconciliation comparing against +# it would call a tree holding work `missing` rather than `possible-loss`. A +# read that failed is never an answer (R22, Amendment 7(d)), so it is no longer +# converted into one: the caller is told the observation could not be made. +# +# TWO THINGS ARE ANSWERS AND NOT FAILURES, and they are named rather than +# guessed. A branch with NO COMMIT YET has `unborn` for a head — HEAD is a +# symbolic ref to a branch that does not exist, which is an ordinary state and +# not a broken repository. And a branch with NO UPSTREAM CONFIGURED has `none`, +# which is exactly the distinction the inventory records an upstream for. A +# branch whose upstream IS configured and whose remote-tracking ref is not here +# — the ordinary state of a branch whose remote was deleted after its merge — is +# the third: the configured spelling is recorded, and the count against a ref +# this checkout does not have is `unknown` rather than the `0` that reads as +# *everything is published*. +lane_tree_now() { # + ltn_p="${1-}" + [ -d "$ltn_p" ] || return 1 + git -C "$ltn_p" rev-parse --git-dir >/dev/null 2>&1 || return 2 + ltn_raw=""; ltn_b=""; ltn_h=""; ltn_u=none; ltn_d=""; ltn_n=0 + ltn_s=""; ltn_up=""; ltn_cfg=""; ltn_rem=""; ltn_crc=0 + # THE BRANCH, AND THE UNBORN ONE `rev-parse` CANNOT ANSWER FOR. A branch with + # no commit yet is HEAD as a symbolic ref to a ref that does not exist, which + # `rev-parse --abbrev-ref` refuses exactly as it refuses a corrupt HEAD — + # `symbolic-ref` is what tells the two apart, and it is asked only where the + # first read failed, so an ordinary tree costs one git process as before. + if ltn_raw="$(git -C "$ltn_p" rev-parse --abbrev-ref HEAD 2>/dev/null)" && [ -n "$ltn_raw" ]; then + : + elif ltn_raw="$(git -C "$ltn_p" symbolic-ref --short -q HEAD 2>/dev/null)" && [ -n "$ltn_raw" ]; then + : + else + return 3 + fi + ltn_b="$ltn_raw"; [ "$ltn_b" = HEAD ] && ltn_b=detached + if ltn_h="$(git -C "$ltn_p" rev-parse --verify --quiet HEAD 2>/dev/null)"; then + [ -n "$ltn_h" ] || return 3 + elif git -C "$ltn_p" symbolic-ref -q HEAD >/dev/null 2>&1; then + ltn_h=unborn + else + return 3 + fi + ltn_s="$(git -C "$ltn_p" status --short 2>/dev/null)" || return 3 + ltn_d="$(printf '%s\n' "$ltn_s" | awk 'NF { n = n + 1 } END { print n + 0 }')" + if ltn_up="$(git -C "$ltn_p" rev-parse --abbrev-ref '@{u}' 2>/dev/null)" && [ -n "$ltn_up" ]; then + ltn_u="$ltn_up" + ltn_n="$(git -C "$ltn_p" rev-list --count '@{u}..HEAD' 2>/dev/null)" || return 3 + case "$ltn_n" in ''|*[!0-9]*) return 3 ;; esac + elif [ "$ltn_b" != detached ]; then + # `git config --get` is 0 for found and 1 for NOT FOUND; anything else is + # the read itself failing, and that is a 3 like any other. + ltn_cfg="$(git -C "$ltn_p" config --get "branch.$ltn_raw.merge" 2>/dev/null)" || ltn_crc=$? + case "$ltn_crc" in 0|1) : ;; *) return 3 ;; esac + if [ -n "$ltn_cfg" ]; then + ltn_rem="$(git -C "$ltn_p" config --get "branch.$ltn_raw.remote" 2>/dev/null || :)" + ltn_u="${ltn_rem:-.}/${ltn_cfg#refs/heads/}" + ltn_n=unknown + fi + fi + printf '%s%s%s%s%s%s%s%s%s\n' "$ltn_b" "$US" "$ltn_h" "$US" "${ltn_u:-none}" "$US" \ + "${ltn_d:-0}" "$US" "$ltn_n" + return 0 +} + +lane_reconcile() { # + lrc_lane="${1-}" + # 0. THE SEAM (ruling 2026-10-04): a managed-owned lane's lifecycle is the + # managed ledger's, so this legacy reconciliation reads nothing of it and + # pronounces nothing on it — no crash, no clearance, no closure. A lane whose + # ownership could not be read gets the same silence, as `indeterminate`. + lrc_mrc=0; lrc_mown="" + lrc_mown="$(lane_is_managed_owned "$lrc_lane")" || lrc_mrc=$? + case "$lrc_mrc" in + 0) + printf 'MANAGED%s%s\n' "$US" "${lrc_mown:-unnamed}" + printf 'VERDICT%smanaged-owned%slane %s is owned by the managed ledger (owner %s): its lifecycle and its worktrees are that ledger'"'"'s, and this legacy reconciliation pronounces nothing on them (ruling 2026-10-04: "managed ledger owns enrolled lanes; #97 owns legacy")\n' \ + "$US" "$US" "$lrc_lane" "${lrc_mown:-unnamed}" + return 0 ;; + 8) : ;; + *) + printf 'MANAGED%sunknown\n' "$US" + printf 'VERDICT%sindeterminate%swhether the managed ledger owns lane %s is UNKNOWN — its register row carries managed-owner vocabulary that does not parse, or the register could not be read — so nothing is pronounced on it (Amendment 7(d)). Read it: lanes-edit.sh managed-projection %s\n' \ + "$US" "$US" "$lrc_lane" "$lrc_lane" + return 0 ;; + esac + lrc_root=""; lrc_rc=0 + lrc_root="$(lane_control_root "$lrc_lane")" || lrc_rc=$? + if [ "$lrc_rc" != 0 ]; then + note "lane $lrc_lane has no control root: its record names no directory (Amendment 11(c)) and \$PROJECTS_ROOT is not a directory here, so there is nothing to reconcile against. This is the pre-cutover answer, not a failure — the lane's next start or handoff creates one." + return 8 + fi + + # 1. THE SNAPSHOT. + lrc_state=""; lrc_gen=""; lrc_op=""; lrc_owner=""; lrc_upd="" + lrc_snap=""; lrc_srrc=0 + lrc_snap="$(lane_state_read "$lrc_lane")" || lrc_srrc=$? + if [ "$lrc_srrc" = 0 ]; then + lrc_state="$(printf '%s\n' "$lrc_snap" | awk -F'\t' '$1 == "state" { print $2; exit }')" + lrc_gen="$(printf '%s\n' "$lrc_snap" | awk -F'\t' '$1 == "generation" { print $2; exit }')" + lrc_op="$(printf '%s\n' "$lrc_snap" | awk -F'\t' '$1 == "operation" { print $2; exit }')" + lrc_owner="$(printf '%s\n' "$lrc_snap" | awk -F'\t' '$1 == "owner" { print $2; exit }')" + lrc_upd="$(printf '%s\n' "$lrc_snap" | awk -F'\t' '$1 == "updated" { print $2; exit }')" + elif [ "$lrc_srrc" = 8 ]; then + lrc_state=NONE + else + # A SNAPSHOT THAT IS THERE AND COULD NOT BE READ IS NOT A LANE THAT HAS + # NONE (Copilot round 6 on openRepoTools#97). Every non-zero read used to + # land on `NONE` here, and `NONE` is the verdict `no-state` — *this lane + # never started under this capability, there is nothing to recover* — which + # a launcher answers by going straight on. That is fail-OPEN on a crash + # pronouncement, the one class this capability exists to close. A read + # nobody got is `indeterminate` below, exactly as an unreadable HOLDER is. + lrc_state=UNREADABLE + fi + printf 'ROOT%s%s\n' "$US" "$lrc_root" + printf 'STATE%s%s%sgeneration %s%soperation %s%sowner %s%supdated %s\n' \ + "$US" "$lrc_state" "$US" "${lrc_gen:-0}" "$US" "${lrc_op:-none}" "$US" \ + "${lrc_owner:-none}" "$US" "${lrc_upd:-never}" + + # 2. THE HOLDER. `live_holder`'s three answers are the contract every other + # caller here reads: 0 a holder, 8 none, anything else THE RECORDS COULD NOT + # BE READ — which is never "no holder" (R22, Amendment 7(d)). A holder that + # could not be established fails CLOSED: the verdict is `indeterminate` and + # no crash is pronounced on a read nobody got. + lrc_ids="$( { session_ids_of_lane "$lrc_lane" 2>/dev/null || : + session_ids_local_of_lane "$lrc_lane" 2>/dev/null || :; } | awk 'NF && !seen[$0]++')" + lrc_hold=""; lrc_hrc=0 + lrc_hold="$(live_holder "$lrc_lane" "$lrc_ids" 2>/dev/null)" || lrc_hrc=$? + case "$lrc_hrc" in + 0) printf 'HOLDER%slive%s%s\n' "$US" "$US" "$(printf '%s' "$lrc_hold" | tr "$US" ' ')" ;; + 8) printf 'HOLDER%snone\n' "$US" ;; + *) printf 'HOLDER%sunknown%s%s\n' "$US" "$US" "${SESSION_FILES_ERR:-the session records of this workstation could not be read}" ;; + esac + + # 2b. THE BINDING (Amendment 18(b)): *"Liveness is pronounced only from + # INSIDE the binding's own `host` and `container`, where the pid namespace is + # the record's: from anywhere else a binding is UNKNOWN, never dead."* The + # holder read above is THIS workstation's session records, so it says nothing + # about a lane whose last STARTED/RESUMED was written on another host or in + # another container — and this workstation's snapshot is this workstation's + # alone (design decision 10). Read through the same scan and the same + # locality rule `binding` and `holder_is_dead` use, with clause (b)'s one + # exception: a binding whose window is GONE from a shared tmux server is a + # dead binding, and then the local read above decides, as it does there. + # here the binding is this place's, or the lane's lines predate it + # free no binding is open (released, or never started) + # gone bound elsewhere on this host's tmux, and its window is gone + # elsewhere bound in a place this one cannot pronounce on + # unknown the object log could not be read for it (Amendment 7(d)) + lrc_bwhere=free; lrc_bat="" + lrc_bout=""; lrc_brc=0 + lrc_bout="$(lane_binding_scan "$lrc_lane")" || lrc_brc=$? + if [ "$lrc_brc" != 0 ]; then + lrc_bwhere=unknown + else + IFS="$US" read -r lrc_bst lrc_bhost lrc_bcont lrc_bwin lrc_butc lrc_bsess lrc_bos \ + lrc_brutc lrc_brsess lrc_brpay lrc_bxverb lrc_bxutc lrc_bxsess lrc_bxpay lrc_blegacy \ + lrc_bfverb lrc_bfutc lrc_bfsess lrc_bfpay </dev/null)" || lrc_drc=$? + [ "$lrc_drc" = 0 ] || lrc_dir="" + lrc_seen=""; lrc_n=0; lrc_recover=0; lrc_dirty=0 + while IFS="$US" read -r lrc_id lrc_p lrc_b lrc_h lrc_u lrc_d lrc_np lrc_w lrc_ob lrc_co lrc_tg lrc_to lrc_sch; do + [ -n "${lrc_id:-}" ] || continue + lrc_n=$((lrc_n + 1)) + # BOTH SPELLINGS JOIN THE SEEN SET, because the two sweeps below compare + # against it with git's physical answer and with the recorded `dir`'s own, + # and a tree named under one spelling and skipped under neither is a tree + # reported twice. + [ -n "${lrc_p:-}" ] && lrc_seen="$lrc_seen $lrc_p $(lane_real_path "$lrc_p") " + # A SIDECAR THIS READER DOES NOT KNOW IS REPORTED AND NEVER RECOMPUTED + # AGAINST. `lane_trees_list` empties every field of one, because a value + # read out of a record whose shape this reader is guessing at is worse than + # no value: comparing git's answer to it would print a difference that means + # nothing. It counts as wanting recovery, because a person has to say what + # wrote it. + if [ "${lrc_sch:-}" = "" ]; then + printf 'TREE%s%s%sunreadable-sidecar%s%s%sits sidecar is there and could not be read, so the tree it records — where it is, and whether it held uncommitted or unpublished work — is NOT known, and nothing is assumed about it; read %s/trees/%s.yaml by hand before relaunching a writer\n' \ + "$US" "$lrc_id" "$US" "$US" "" "$US" "$lrc_root" "$lrc_id" + lrc_recover=$((lrc_recover + 1)) + continue + fi + if [ "${lrc_sch:-}" != "$LANE_STATE_SCHEMA" ]; then + printf 'TREE%s%s%sunknown-schema%s%s%sits sidecar records schema %s and this reader writes %s, so none of its fields is read and nothing is compared against them; the tree itself is untouched\n' \ + "$US" "$lrc_id" "$US" "$US" "${lrc_p:-}" "$US" "${lrc_sch:-}" "$LANE_STATE_SCHEMA" + lrc_recover=$((lrc_recover + 1)) + continue + fi + lrc_now=""; lrc_nrc=0 + lrc_now="$(lane_tree_now "$lrc_p")" || lrc_nrc=$? + if [ "$lrc_nrc" = 3 ]; then + # GIT ANSWERS THERE AND ONE OF ITS READS FAILED. Nothing is assumed: not + # clean, not dirty, not published. The tree is left exactly as it is and + # the person is sent to it. + printf 'TREE%s%s%sunreadable%s%s%sgit answers in this path and one of the reads an observation is made of failed, so NOTHING is assumed about it — it was branch %s head %s with %s dirty and %s unpushed at %s; read it by hand (git -C %s status) before relaunching a writer onto it\n' \ + "$US" "$lrc_id" "$US" "$US" "$lrc_p" "$US" "$lrc_b" "$lrc_h" "${lrc_d:-0}" "${lrc_np:-0}" "$lrc_ob" "$lrc_p" + lrc_recover=$((lrc_recover + 1)) + continue + fi + if [ "$lrc_nrc" = 1 ]; then + # THE PATH IS GONE. Whether that is a tidy removal or a loss is decided + # by what the sidecar last SAW there, and metadata can reconstruct no + # file's contents: a tree that held uncommitted or unpublished work is + # POSSIBLE LOSS and is never claimed to be rebuildable. + case "${lrc_d:-0}${lrc_np:-0}" in + 00) printf 'TREE%s%s%smissing%s%s%swas branch %s head %s; clean and published at %s; the estate parked record and `resume ` are the only rebuild\n' \ + "$US" "$lrc_id" "$US" "$US" "$lrc_p" "$US" "$lrc_b" "$lrc_h" "$lrc_ob" ;; + *) printf 'TREE%s%s%spossible-loss%s%s%swas branch %s head %s with %s dirty and %s unpushed at %s; NOTHING here can reconstruct uncommitted files — do not recreate this path\n' \ + "$US" "$lrc_id" "$US" "$US" "$lrc_p" "$US" "$lrc_b" "$lrc_h" "$lrc_d" "$lrc_np" "$lrc_ob" ;; + esac + lrc_recover=$((lrc_recover + 1)) + continue + fi + if [ "$lrc_nrc" = 2 ]; then + printf 'TREE%s%s%snot-a-checkout%s%s%sthe path exists and git does not answer in it; it is left exactly as it is\n' \ + "$US" "$lrc_id" "$US" "$US" "$lrc_p" "$US" + lrc_recover=$((lrc_recover + 1)) + continue + fi + lrc_nb="$(printf '%s' "$lrc_now" | awk -F"$US" '{print $1}')" + lrc_nh="$(printf '%s' "$lrc_now" | awk -F"$US" '{print $2}')" + lrc_nu="$(printf '%s' "$lrc_now" | awk -F"$US" '{print $3}')" + lrc_nd="$(printf '%s' "$lrc_now" | awk -F"$US" '{print $4}')" + lrc_nn="$(printf '%s' "$lrc_now" | awk -F"$US" '{print $5}')" + lrc_class=ok + [ "${lrc_nd:-0}" -gt 0 ] && lrc_class=dirty + # `unpushed` IS A COUNT OR IT IS THE WORD `unknown`, and the word is what a + # branch whose configured upstream is not in this checkout answers. It is + # never compared as a number, and it counts as work that may not be + # published rather than as nothing to publish. + case "${lrc_nn:-0}" in + ''|0) : ;; + *[!0-9]*) + if [ "$lrc_class" = dirty ]; then lrc_class=dirty+unpushed-unknown; else lrc_class=unpushed-unknown; fi ;; + *) + if [ "$lrc_class" = dirty ]; then lrc_class=dirty+unpushed; else lrc_class=unpushed; fi ;; + esac + [ "$lrc_class" = ok ] || lrc_dirty=$((lrc_dirty + 1)) + lrc_moved="" + [ "$lrc_nb" = "$lrc_b" ] || lrc_moved="$lrc_moved; branch was $lrc_b and is $lrc_nb" + [ "$lrc_nh" = "$lrc_h" ] || lrc_moved="$lrc_moved; head was $lrc_h and is $lrc_nh" + [ "${lrc_nu:-none}" = "${lrc_u:-none}" ] || lrc_moved="$lrc_moved; upstream was ${lrc_u:-none} and is ${lrc_nu:-none}" + # AND WHICH TRANSITION TOOK THE OBSERVATION, where it is not this lane's + # current one. The pair was written into every sidecar and read by nothing + # until this round, so a poll filed by an operation a recovery has since + # superseded read exactly like the current one. + if [ -n "${lrc_tg:-}" ] && [ "$lrc_tg" != 0 ] && [ "$lrc_tg" != "${lrc_gen:-0}" ]; then + lrc_moved="$lrc_moved; observed under generation $lrc_tg (operation ${lrc_to:-none}) and this lane is at generation ${lrc_gen:-0}" + fi + printf 'TREE%s%s%s%s%s%s%sbranch %s head %s upstream %s %s dirty %s unpushed; observed %s at %s%s\n' \ + "$US" "$lrc_id" "$US" "$lrc_class" "$US" "$lrc_p" "$US" \ + "$lrc_nb" "$lrc_nh" "${lrc_nu:-none}" "${lrc_nd:-0}" "${lrc_nn:-0}" \ + "${lrc_d:-0}/${lrc_np:-0}" "$lrc_ob" "$lrc_moved" + done </dev/null || :) +EOF + + # 4. WHAT IS THERE AND IS IN NO SIDECAR — from git's own registrations and + # from the two lane roots on disk. It is REPORTED and never adopted, deleted + # or overwritten: which lane a tree belongs to is a person's to say. + lrc_unmanaged=0 + if [ -n "$lrc_dir" ] && [ -d "$lrc_dir" ]; then + # THE CHECKOUT'S OWN ROW IS NOT AN UNMANAGED TREE, AND git ANSWERS WITH THE + # PHYSICAL PATH. A recorded `dir` reached through a symlink — which is how + # every estate with a `projects` link spells it — would otherwise not match + # the first row of `worktree list` and the lane's own checkout would be + # reported as a tree nobody manages. `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. + lrc_dirp="$(lane_real_path "$lrc_dir")" + while IFS= read -r lrc_wl; do + case "$lrc_wl" in worktree\ *) : ;; *) continue ;; esac + lrc_wp="${lrc_wl#worktree }" + [ "$lrc_wp" = "$lrc_dir" ] && continue + [ -n "$lrc_dirp" ] && [ "$lrc_wp" = "$lrc_dirp" ] && continue + # AND THE SIDECARS ARE ASKED UNDER BOTH SPELLINGS, for the same reason. + lrc_wpr="$(lane_real_path "$lrc_wp")" + case "$lrc_seen" in *" $lrc_wp "*|*" $lrc_wpr "*) continue ;; esac + if [ -d "$lrc_wp" ]; then + printf 'TREE%s%s%sunmanaged%s%s%sgit registers it in %s and no sidecar of this lane names it; it is left exactly as it is\n' \ + "$US" "$(tree_id_for "$lrc_wp")" "$US" "$US" "$lrc_wp" "$US" "$lrc_dir" + else + printf 'TREE%s%s%sstale-registration%s%s%sgit registers it in %s and the directory is gone; `git -C %s worktree prune` is a person'\''s act\n' \ + "$US" "$(tree_id_for "$lrc_wp")" "$US" "$US" "$lrc_wp" "$US" "$lrc_dir" "$lrc_dir" + fi + # NAMED ONCE. The on-disk sweep below walks the same two roots git + # registers these in, so a path reported here joins the seen set — under + # both spellings, because that sweep walks the recorded `dir` and this one + # answered with the physical path — or a reader is told about one tree + # twice under two different reasons. + lrc_seen="$lrc_seen $lrc_wp $lrc_wpr " + lrc_unmanaged=$((lrc_unmanaged + 1)) + done </dev/null || :) +EOF + fi + while IFS= read -r lrc_wr; do + [ -n "$lrc_wr" ] || continue + [ -d "$lrc_wr" ] || continue + for lrc_c in "$lrc_wr"/*; do + [ -d "$lrc_c" ] || continue + lrc_cr="$(lane_real_path "$lrc_c")" + case "$lrc_seen" in *" $lrc_c "*|*" $lrc_cr "*) continue ;; esac + git -C "$lrc_c" rev-parse --git-dir >/dev/null 2>&1 || continue + printf 'TREE%s%s%sunmanaged%s%s%sit sits under this lane'\''s worktree root and no sidecar names it; it is left exactly as it is\n' \ + "$US" "$(tree_id_for "$lrc_c")" "$US" "$US" "$lrc_c" "$US" + lrc_seen="$lrc_seen $lrc_c $lrc_cr " + lrc_unmanaged=$((lrc_unmanaged + 1)) + done + done < \"\" \"\" [\"\"]" 2 lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 + # THE SEAM (ruling 2026-10-04): a managed lane's row is the ledger's, and + # no legacy edit may write the marker's vocabulary into any row. + managed_seam_refuse "$lane" "replace-in-row" + if managed_projection_hint "$new"; then + die "the replacement text carries managed-owner vocabulary ('managed owner', 'managed binding', 'mode=managed' or 'managed:'), and only the managed ledger's own writer writes a managed-owner marker, and a legacy writer that wrote its vocabulary into a row would forge that lane's ownership (ruling 2026-10-04: \"managed ledger owns enrolled lanes; #97 owns legacy\"). Nothing was written." 2 + fi acquire_lock; handle_preexisting n="$(row_line "$lane")" || exit 2 row="$(sed -n -e "${n}p" "$LANES_FILE")" @@ -11167,7 +12569,11 @@ Nothing was written." 2 case "$add" in *"|"*) die "append-session-id's appended text may not contain '|': it would forge a cell boundary in the row. Got '$add'." 2 ;; esac + if managed_projection_hint "$add"; then + die "append-session-id's appended text carries managed-owner vocabulary ('managed owner', 'managed binding', 'mode=managed' or 'managed:'), and only the managed ledger's own writer writes it into a row. Nothing was written." 2 + fi lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 + managed_seam_refuse "$lane" "append-session-id" # Copilot round 3 on #97 acquire_lock; handle_preexisting n="$(row_line "$lane")" || exit 2 row="$(sed -n -e "${n}p" "$LANES_FILE")" @@ -11185,6 +12591,12 @@ Nothing was written." 2 append-line) text="${1-}"; [ -n "$text" ] || die "usage: append-line \"\"" 2 + # NO MARKER BY THE BACK DOOR (Copilot round 3 on #97): `add-row` refuses a + # row in the marker's vocabulary, and this appends any line at all to the + # register — a row-shaped one included. + if managed_projection_hint "$text"; then + die "the line carries managed-owner vocabulary ('managed owner', 'managed binding', 'mode=managed' or 'managed:'), and only the managed ledger's own writer writes it into the register. Nothing was written." 2 + fi lane_tag="${LANES_LANE:-}" [ -z "$lane_tag" ] || lane_tag="$(canon_lane "$lane_tag")" || exit 2 if [ -z "$lane_tag" ]; then @@ -11236,6 +12648,9 @@ Nothing was written." 2 esac pipes="$(count_occurrences "$row" "|")" || exit 2 [ "$pipes" -ge 8 ] || die "a 7-column row needs at least 8 '|' characters, found $pipes" 2 + if managed_projection_hint "$row"; then + die "the new row carries managed-owner vocabulary ('managed owner', 'managed binding', 'mode=managed' or 'managed:'), and only the managed ledger's own writer writes a managed-owner marker, and a legacy writer that wrote its vocabulary into a row would forge that lane's ownership (ruling 2026-10-04: \"managed ledger owns enrolled lanes; #97 owns legacy\"). Nothing was written." 2 + fi lane_new="$(printf '%s' "$row" | cut -d'`' -f2)" # AMENDMENT 15(a) — A ROW UNDER ANY CASE IS A ROW, AND THE REFUSAL NAMES IT. # This is the act the 2026-09-13 incident got past: `lane-start openxfactory @@ -11285,6 +12700,10 @@ Nothing was written." 2 ar_n="$(printf '%s' "$ar_all" | grep -c . || :)" ar_ln="$(printf '%s' "$ar_hits" | grep -c . || :)" if [ "$ar_n" -gt 0 ]; then + # A LANE THE MANAGED LEDGER OWNS IS SAID TO BE ONE (Copilot round 3 on + # #97) before the duplicate refusal below, which would refuse it anyway + # without naming the owner. A new lane has no row and is never asked. + managed_seam_refuse "$lane_new" "add-row" ar_more="" [ "$ar_n" -gt 1 ] && ar_more=" Those $ar_n rows differ only by case and are themselves the refusal: merge them into one row first (Amendment 15(d))." ar_where="Use set-row-state / replace-in-row on the row that is there." @@ -11415,6 +12834,9 @@ Nothing was written." 2 || die "usage: rename-lane [\"\"] [--verbatim] [--no-github]" 2 check_lane_name "$rl_old" check_lane_name "$rl_new" + if managed_projection_hint "$rl_new"; then + die "the new name '$rl_new' carries managed-owner vocabulary ('managed-owner' or 'managed-binding'), so the row it is written into would read as a malformed managed-owner marker — ownership UNKNOWN for every act. Choose another name. Nothing was written." 2 + fi # THE REASON IS FREE TEXT ON AN APPEND-ONLY LINE, so it takes the writer's # own two rules before anything else is read (`write_event` states both): a # third ` — ` leaves the line ambiguous to its own parser, and a newline @@ -11451,6 +12873,13 @@ Nothing was written." 2 66) die "${LANES_ALIASES_PATH:-lanes/aliases.tsv} could not be read ($(lane_alias_err)), and a rename whose alias table cannot be read is a rename whose old name may stop resolving — which is the one thing clause (e) promises for ever, so this fails closed. Nothing was written." 1 ;; *) exit 2 ;; esac + # THE OLD NAME'S LIFECYCLE CONTROL ROOT, read while the old name still has + # its log (openRepoTools#91; `lane_state_rename` moves it after the commit). + rl_lsr_old="$(lane_control_root "$rl_old" 2>/dev/null)" || rl_lsr_old="" + # THE SEAM (ruling 2026-10-04). A managed lane's marker names its lane as + # `bound-lane=`, so renaming the row under it would turn a valid marker into + # an unparseable one; the rename is the ledger's, and refused before the lock. + managed_seam_refuse "$rl_old" "rename-lane" # ---- THE MUTEX IS TAKEN BEFORE THE CHECKS, NOT BETWEEN THEM AND THE WRITE # (Copilot round 6 on openRepoTools#81). Every refusal below reads a row, a @@ -12035,6 +13464,7 @@ EOF RL_ACTIVE=0 state_events_flush lane_alias_flush + [ "$rl_rc" != 0 ] || lane_state_rename "$rl_lsr_old" "$rl_new" release_lock [ "$rl_rc" = 0 ] || exit "$rl_rc" @@ -12866,6 +14296,11 @@ EOF check_lane_name "$lane" log_sync lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 + # THE SEAM (Copilot round 3 on #97): asking a lane's session to hand off is + # a handoff act on that lane, refused for a managed one before the binding + # is read — so `--dry-run` refuses too, rather than planning an act that + # could not be taken. + managed_seam_refuse "$lane" "request-handoff (Amendment 18(c))" rh_out=""; rh_rc=0 rh_out="$(lane_binding_scan "$lane")" || rh_rc=$? [ "$rh_rc" = 0 ] || die "request-handoff could not read lane $lane's object log (exit $rh_rc). That is NOT 'this lane is free' (Amendment 7(d)), and nothing was written." 1 @@ -13704,7 +15139,365 @@ EOF printf '%s\n' "$rh_out" ;; + # ---------- THE SEAM: is this lane the managed ledger's? (ruling 2026-10-04) + # + # 0 a valid managed-owner marker: the owner is printed + # 8 no marker and no managed-owner vocabulary: a legacy lane + # 1 vocabulary that does not parse, or a register that could not be read: + # ownership UNKNOWN, which a caller refuses on exactly as it refuses 0 + # 64 usage + managed-projection) + lane="${1-}"; [ -n "$lane" ] || die "usage: managed-projection " 64 + [ "$#" -le 1 ] || die "managed-projection takes one lane: managed-projection " 64 + check_lane_name "$lane" + log_sync + lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 + mpj_out=""; mpj_rc=0 + mpj_out="$(lane_is_managed_owned "$lane")" || mpj_rc=$? + case "$mpj_rc" in + 0) printf '%s\n' "$mpj_out" ;; + 8) exit 8 ;; + *) die "lane $lane's register row carries managed-owner vocabulary that does not parse as a marker, or the register could not be read: whether the managed ledger owns it is UNKNOWN, and that is never read as 'legacy' (Amendment 7(d))." 1 ;; + esac + ;; + + # ---------- 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 + # absent from the dispatcher's workstation guard above (`R-A11-14` is about a + # record FILED UNDER A WORKSTATION; this one is filed under nothing and never + # leaves the machine that wrote it), and they take the same 64-for-usage + # contract every read Amendment 11 added takes, because they sit in front of + # a launch exactly as those do. + # + # 0 done + # 1 no control root could be derived, or it could not be written + # 2 a refusal of the arguments + # 7 THE FENCE DID NOT MATCH — another act got there first, which is what + # this file already spends 7 on (`claim`'s CLAIM-LOST). Nothing was + # written and the current state is printed. + # 8 there is no such record (no snapshot, no tree) + # 10 THE RECORD IS THERE AND COULD NOT BE READ, which is never 8 (Copilot + # round 6 on #97). 8 tells a launcher *this lane was never migrated, go + # on*; 10 tells it *this lane may be mid-crash and nobody could look*. + # (It was 9 until main's #61 spent 9 on `claim`'s abandoned takeover.) + # 64 a usage error of this subcommand's own + + lane-state) + lane="${1-}"; [ -n "$lane" ] || die "usage: lane-state " 64 + [ "$#" -le 1 ] || die "lane-state takes one lane: lane-state " 64 + check_lane_name "$lane" + log_sync + lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 + lst_out=""; lst_rc=0 + lst_out="$(lane_state_read "$lane")" || lst_rc=$? + case "$lst_rc" in + 0) : ;; + 8) exit 8 ;; + 10) die "lane $lane HAS a lifecycle snapshot at its control root and it could not be read. That is NOT 'this lane has no snapshot' — 8 says that, and a launcher answers an 8 by going on, which over an unreadable record would be a launch made in ignorance of a crash nobody could look at (R22, Amendment 7(d)). Read it by hand: $(lane_control_root "$lane" 2>/dev/null || printf '')/lane-state.yaml" 10 ;; + *) die "lane $lane has no lifecycle control root: its record names no directory (Amendment 11(c)) and \$PROJECTS_ROOT is not a directory here. That is NOT 'this lane is in no state' — a read that could not be made is never an answer (Amendment 7(d))." 1 ;; + esac + printf '%s\n' "$lst_out" + ;; + + set-lane-state) + lane="${1-}"; [ -n "$lane" ] || die "usage: set-lane-state [--expect ] [--expect-generation ] [--expect-operation ] [--operation ] [--owner ] [--agent ] [--profile ] [--kind ]" 64 + shift + sls_state="${1-}"; [ -n "$sls_state" ] || die "set-lane-state needs the state to move to: RUNNING, SWAPPING, SWAPPED or CLOSED" 64 + shift + sls_exp=""; sls_expg=""; sls_expo=""; sls_op=""; sls_owner=""; sls_agent=""; sls_prof=""; sls_kind="" + while [ $# -gt 0 ]; do + case "$1" in + --expect) sls_exp="${2-}"; [ -n "$sls_exp" ] || die "--expect needs a state word, or 'none'" 64; shift 2 ;; + --expect=*) sls_exp="${1#--expect=}"; [ -n "$sls_exp" ] || die "--expect needs a value" 64; shift ;; + --expect-generation) sls_expg="${2-}"; [ -n "$sls_expg" ] || die "--expect-generation needs a number" 64; shift 2 ;; + --expect-generation=*) sls_expg="${1#--expect-generation=}"; [ -n "$sls_expg" ] || die "--expect-generation needs a value" 64; shift ;; + --expect-operation) sls_expo="${2-}"; [ -n "$sls_expo" ] || die "--expect-operation needs an operation id" 64; shift 2 ;; + --expect-operation=*) sls_expo="${1#--expect-operation=}"; [ -n "$sls_expo" ] || die "--expect-operation needs a value" 64; shift ;; + --operation) sls_op="${2-}"; [ -n "$sls_op" ] || die "--operation needs an operation id" 64; shift 2 ;; + --operation=*) sls_op="${1#--operation=}"; [ -n "$sls_op" ] || die "--operation needs a value" 64; shift ;; + --owner) sls_owner="${2-}"; [ "$#" -ge 2 ] && [ -n "$sls_owner" ] || die "--owner needs a value" 64; shift 2 ;; + --owner=*) sls_owner="${1#--owner=}"; [ -n "$sls_owner" ] || die "--owner needs a value" 64; shift ;; + --agent) sls_agent="${2-}"; [ "$#" -ge 2 ] && [ -n "$sls_agent" ] || die "--agent needs a value" 64; shift 2 ;; + --agent=*) sls_agent="${1#--agent=}"; [ -n "$sls_agent" ] || die "--agent needs a value" 64; shift ;; + --profile) sls_prof="${2-}"; [ "$#" -ge 2 ] && [ -n "$sls_prof" ] || die "--profile needs a value" 64; shift 2 ;; + --profile=*) sls_prof="${1#--profile=}"; [ -n "$sls_prof" ] || die "--profile needs a value" 64; shift ;; + --kind) sls_kind="${2-}"; [ "$#" -ge 2 ] && [ -n "$sls_kind" ] || die "--kind needs a value" 64; shift 2 ;; + --kind=*) sls_kind="${1#--kind=}"; [ -n "$sls_kind" ] || die "--kind needs a value" 64; shift ;; + --) shift ;; + *) die "unknown option '$1' for set-lane-state" 64 ;; + esac + done + lane_state_word_ok "$sls_state" || die "'$sls_state' is not a lane lifecycle state: RUNNING, SWAPPING, SWAPPED and CLOSED are the four, and a fifth word in this file would be a state no reader of it knows" 64 + [ -z "$sls_exp" ] || [ "$sls_exp" = none ] || lane_state_word_ok "$sls_exp" || + die "--expect takes one of the four states, or 'none' for a lane that has no snapshot yet; '$sls_exp' is neither" 64 + case "$sls_expg" in + '') : ;; + *[!0-9]*) die "--expect-generation takes a number; '$sls_expg' is not one" 64 ;; + esac + check_lane_name "$lane" + log_sync + lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 + managed_seam_refuse "$lane" "set-lane-state" # ruling 2026-10-04 + sls_root=""; sls_rrc=0 + sls_root="$(lane_control_root "$lane")" || sls_rrc=$? + [ "$sls_rrc" = 0 ] || + die "lane $lane has no lifecycle control root, so there is nowhere to record that it is $sls_state: its record names no directory (Amendment 11(c)) and \$PROJECTS_ROOT is not a directory here. Set \$LANES_LANE_STATE_ROOT, or start the lane through lane-start so that its record carries a dir." 1 + # THE FENCE, read under the same mutex the write takes, so that two + # transitions cannot both read the state before either writes it. + acquire_lock + # AND THE SCHEMA IS ASKED ABOUT BEFORE ANY TRANSITION FIELD IS READ OR + # WRITTEN (Copilot round 5 on #97). `lane_state_read` fails closed for a + # version it does not know; this writer used to walk past that check into + # the raw fields and rename its own file over the top, so an older helper + # meeting a newer tooling's snapshot destroyed a record it could not read — + # the one act no later reader can undo. It is a 1 and not a 7: 7 says a race + # was lost and invites the caller to re-read and try again, and no re-read + # makes this file readable by this helper. + if ! lane_sidecar_schema_ok "$sls_root/lane-state.yaml"; then + sls_sch="$(lane_sidecar_field "$sls_root/lane-state.yaml" schema 2>/dev/null || :)" + release_lock + die "lane $lane's lifecycle snapshot at $sls_root/lane-state.yaml records schema ${sls_sch:-} and this helper writes schema $LANE_STATE_SCHEMA. NOTHING was read out of it and nothing was written over it: a record this helper cannot read is one it cannot safely replace. Upgrade this workstation's lanes-edit.sh; if you know what wrote that file, move it aside by hand and re-run." 1 + fi + sls_now=""; sls_nowg=""; sls_nowo="" + if [ -r "$sls_root/lane-state.yaml" ]; then + sls_now="$(lane_sidecar_field "$sls_root/lane-state.yaml" state)" + sls_nowg="$(lane_sidecar_field "$sls_root/lane-state.yaml" generation)" + sls_nowo="$(lane_sidecar_field "$sls_root/lane-state.yaml" operation)" + fi + sls_fence="" + [ -z "$sls_exp" ] || [ "$sls_exp" = "${sls_now:-none}" ] || sls_fence="state is ${sls_now:-none} and --expect named $sls_exp" + [ -z "$sls_expg" ] || [ "$sls_expg" = "${sls_nowg:-0}" ] || sls_fence="${sls_fence:+$sls_fence; }generation is ${sls_nowg:-0} and --expect-generation named $sls_expg" + [ -z "$sls_expo" ] || [ "$sls_expo" = "${sls_nowo:-none}" ] || sls_fence="${sls_fence:+$sls_fence; }operation is ${sls_nowo:-none} and --expect-operation named $sls_expo" + if [ -n "$sls_fence" ]; then + release_lock + printf 'state\t%s\ngeneration\t%s\noperation\t%s\n' "${sls_now:-none}" "${sls_nowg:-0}" "${sls_nowo:-none}" + die "lane $lane did not move to $sls_state: $sls_fence. Another act got there first — a resume that advanced the generation, or a second handoff — and nothing was written. Re-read the state (lanes-edit.sh lane-state $lane) before deciding what this process should do; a stale finalizer must never overwrite a newer owner." 7 + fi + case "$sls_nowg" in ''|*[!0-9]*) sls_nowg=0 ;; esac + # A TRANSITION THAT DOES NOT NAME A FIELD KEEPS IT, and does not blank it. + # `SWAPPED` is the same operation's commit point and `CLOSED` the end of a + # lane that was owned by somebody: writing `owner none` there would lose + # the one fact a later reconciliation compares a live holder against, out of + # a call that was only ever about the state word. + [ -n "$sls_owner" ] || sls_owner="$(lane_sidecar_field "$sls_root/lane-state.yaml" owner 2>/dev/null || :)" + [ -n "$sls_agent" ] || sls_agent="$(lane_sidecar_field "$sls_root/lane-state.yaml" agent 2>/dev/null || :)" + [ -n "$sls_prof" ] || sls_prof="$(lane_sidecar_field "$sls_root/lane-state.yaml" profile 2>/dev/null || :)" + [ -n "$sls_kind" ] || sls_kind="$(lane_sidecar_field "$sls_root/lane-state.yaml" kind 2>/dev/null || :)" + # WHICH TRANSITION ADVANCES THE GENERATION. A new owner or a new operation + # does (`RUNNING`, `SWAPPING`, `CLOSED`); the FINISH of an operation + # already in flight does not, because `SWAPPED` is the same operation + # reaching its commit point and a fence that moved under it would refuse + # the very finalizer that is entitled to write. + case "$sls_state" in + SWAPPED) sls_gen="$sls_nowg"; [ -n "$sls_op" ] || sls_op="${sls_nowo:-none}" ;; + *) sls_gen=$((sls_nowg + 1)); [ -n "$sls_op" ] || sls_op="$(lane_op_id)" ;; + esac + if lane_state_put "$sls_root" "$lane" "$sls_state" "$sls_gen" "$sls_op" \ + "$sls_owner" "$sls_agent" "$sls_prof" "$WS" "$sls_kind"; then + release_lock + printf 'state\t%s\ngeneration\t%s\noperation\t%s\n' "$sls_state" "$sls_gen" "$sls_op" + else + release_lock + die "lane $lane's lifecycle snapshot could not be written under $sls_root (the shell's own error is above). Nothing was changed: the snapshot is replaced atomically, so the one that was there is the one that is there." 1 + fi + ;; + + lane-trees) + lane="${1-}"; [ -n "$lane" ] || die "usage: lane-trees " 64 + [ "$#" -le 1 ] || die "lane-trees takes one lane: lane-trees " 64 + check_lane_name "$lane" + log_sync + lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 + ltr_out=""; ltr_rc=0 + ltr_out="$(lane_trees_list "$lane")" || ltr_rc=$? + case "$ltr_rc" in + 0) : ;; + 8) exit 8 ;; + *) die "lane $lane has no lifecycle control root, so its worktree inventory could not be read. That is NOT 'this lane owns no worktree' (Amendment 7(d))." 1 ;; + esac + printf '%s\n' "$ltr_out" + ;; + + set-lane-tree) + lane="${1-}"; [ -n "$lane" ] || die "usage: set-lane-tree [--checkout ] [--branch ] [--head ] [--upstream ] [--dirty ] [--unpushed ] [--writer ] [--generation ] [--operation ]" 64 + shift + slt_path="${1-}"; [ -n "$slt_path" ] || die "set-lane-tree needs the worktree's path" 64 + shift + case "$slt_path" in /*) : ;; *) die "set-lane-tree takes an ABSOLUTE path: a relative one has no meaning to any later reader of this record, which is a different process in a different directory" 64 ;; esac + slt_co=""; slt_b=""; slt_h=""; slt_u=""; slt_d=""; slt_n=""; slt_w=""; slt_g=""; slt_op="" + while [ $# -gt 0 ]; do + case "$1" in + --checkout) slt_co="${2-}"; [ "$#" -ge 2 ] && [ -n "$slt_co" ] || die "--checkout needs a value" 64; shift 2 ;; + --checkout=*) slt_co="${1#--checkout=}"; [ -n "$slt_co" ] || die "--checkout needs a value" 64; shift ;; + --branch) slt_b="${2-}"; [ "$#" -ge 2 ] && [ -n "$slt_b" ] || die "--branch needs a value" 64; shift 2 ;; + --branch=*) slt_b="${1#--branch=}"; [ -n "$slt_b" ] || die "--branch needs a value" 64; shift ;; + --head) slt_h="${2-}"; [ "$#" -ge 2 ] && [ -n "$slt_h" ] || die "--head needs a value" 64; shift 2 ;; + --head=*) slt_h="${1#--head=}"; [ -n "$slt_h" ] || die "--head needs a value" 64; shift ;; + --upstream) slt_u="${2-}"; [ "$#" -ge 2 ] && [ -n "$slt_u" ] || die "--upstream needs a value" 64; shift 2 ;; + --upstream=*) slt_u="${1#--upstream=}"; [ -n "$slt_u" ] || die "--upstream needs a value" 64; shift ;; + --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 ;; + --unpushed=*) slt_n="${1#--unpushed=}"; [ -n "$slt_n" ] || die "--unpushed needs a value" 64; shift ;; + --writer) slt_w="${2-}"; [ "$#" -ge 2 ] && [ -n "$slt_w" ] || die "--writer needs a value" 64; shift 2 ;; + --writer=*) slt_w="${1#--writer=}"; [ -n "$slt_w" ] || die "--writer needs a value" 64; shift ;; + --generation) slt_g="${2-}"; [ "$#" -ge 2 ] && [ -n "$slt_g" ] || die "--generation needs a value" 64; shift 2 ;; + --generation=*) slt_g="${1#--generation=}"; [ -n "$slt_g" ] || die "--generation needs a value" 64; shift ;; + --operation) slt_op="${2-}"; [ "$#" -ge 2 ] && [ -n "$slt_op" ] || die "--operation needs a value" 64; shift 2 ;; + --operation=*) slt_op="${1#--operation=}"; [ -n "$slt_op" ] || die "--operation needs a value" 64; shift ;; + --) shift ;; + *) die "unknown option '$1' for set-lane-tree" 64 ;; + esac + done + check_lane_name "$lane" + log_sync + lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 + managed_seam_refuse "$lane" "set-lane-tree" # ruling 2026-10-04 + slt_root=""; slt_rrc=0 + slt_root="$(lane_control_root "$lane")" || slt_rrc=$? + [ "$slt_rrc" = 0 ] || + die "lane $lane has no lifecycle control root, so its worktree inventory has nowhere to go: its record names no directory (Amendment 11(c)) and \$PROJECTS_ROOT is not a directory here." 1 + # WHAT THE CALLER SAW BEATS WHAT THIS PROCESS SEES, because a poll and a + # record written from it are ONE observation and a second reading of the + # same tree a moment later is a different one. Where the caller passed + # nothing, the tree is read HERE through the same `lane_tree_now` + # `lane-reconcile` recomputes with, so the two cannot disagree. + # + # AND AN OBSERVATION NOBODY COULD MAKE IS NOT COMPLETED WITH CLEAN-LOOKING + # VALUES (Copilot round 5 on #97). Until this round a `lane_tree_now` that + # failed left every field empty and `lane_tree_put`'s defaults wrote them as + # `unknown` / `none` / `0` — a record saying *branch unknown, nothing dirty, + # nothing unpushed* for a tree this process never read, which a later + # reconciliation would compare against and call published. + if [ -z "$slt_b$slt_h$slt_u$slt_d$slt_n" ]; then + slt_now=""; slt_nrc=0 + slt_now="$(lane_tree_now "$slt_path")" || slt_nrc=$? + case "$slt_nrc" in + 0) + slt_b="$(printf '%s' "$slt_now" | awk -F"$US" '{print $1}')" + slt_h="$(printf '%s' "$slt_now" | awk -F"$US" '{print $2}')" + slt_u="$(printf '%s' "$slt_now" | awk -F"$US" '{print $3}')" + slt_d="$(printf '%s' "$slt_now" | awk -F"$US" '{print $4}')" + slt_n="$(printf '%s' "$slt_now" | awk -F"$US" '{print $5}')" ;; + 1) die "no worktree was recorded for lane $lane: there is no directory at $slt_path and this command was given no observation of its own to file. A record of a tree nobody read would say 'branch unknown, 0 dirty, 0 unpushed', which a later reconciliation reads as clean and published. Pass what you saw (--branch/--head/--upstream/--dirty/--unpushed), or record the tree while it is there." 1 ;; + 2) die "no worktree was recorded for lane $lane: $slt_path exists and git does not answer in it, so there is no observation to file and this command invents none. The path itself was not touched." 1 ;; + *) die "no worktree was recorded for lane $lane: git answers in $slt_path and one of the reads an observation is made of FAILED, so nothing was written — a branch, a head, a status or an upstream that could not be read is never recorded as a clean tree (Amendment 7(d)). Read it by hand: git -C $slt_path status" 1 ;; + esac + # AND A CALLER'S OBSERVATION IS TAKEN WHOLE OR NOT AT ALL (Codex on ec9847e, + # PR #97). One flag alone used to skip the reading above and let the rest + # reach the record as `unknown`, `none` and — the dangerous two — `dirty 0` + # and `unpushed 0`, which a later reconciliation of a vanished tree reads as + # clean and published although nobody observed either. + elif [ -z "$slt_b" ] || [ -z "$slt_h" ] || [ -z "$slt_u" ] || [ -z "$slt_d" ] || [ -z "$slt_n" ]; then + die "set-lane-tree takes the caller's observation WHOLE — --branch, --head, --upstream, --dirty and --unpushed together — or none of them, when the tree is read here. A partial one would be filed with values nobody observed, and 'dirty 0, unpushed 0' is the record a later reconciliation reads as clean and published. Nothing was written." 64 + fi + # THE FENCE, AND IT IS THE LANE'S OWN (Copilot round 5 on #97). A caller + # that names the generation and the operation it is recording under is + # saying *this observation belongs to that transition* — and until this + # round the pair was serialized into the sidecar and compared with nothing, + # so a handoff that stalled while a recovery advanced the lane filed its + # superseded poll straight over the current inventory. The pair is compared + # with the lane's own snapshot under the SAME mutex the transition takes, + # and the write happens inside that mutex, so a transition cannot land + # between the compare and the record. A caller that names NEITHER is making + # an observation of its own — a person, or the read above — and has no fence + # to fail. + # + # AND THE MUTEX IS TAKEN WHETHER OR NOT THERE IS A FENCE TO CHECK (Copilot + # round 6 on #97). It used to be taken only for a FENCED write, which left + # the unfenced ones — a person's `set-lane-tree`, a caller that named no + # transition — racing every other writer of the same sidecar: the atomic + # rename below stops a reader seeing half a file and stops nothing else, so + # an unfenced observation could land after a newer fenced one and replace + # it, and the inventory would then hold a reading older than the transition + # recorded beside it. One mutex over every write of these files, and the + # generation/operation comparison stays what it was for the callers that + # name one. + # + # IT IS TAKEN HERE AND NOT ABOVE THE OBSERVATION, because the observation + # runs `git status` and `git rev-list` in somebody's checkout and this lock + # is the whole workstation's (design decision 14, and the same argument that + # keeps `lane-reconcile` out of it): what must be serialized is the compare + # and the write, not the reading of a repository. + acquire_lock + if [ -n "$slt_g" ] || [ -n "$slt_op" ]; then + if ! lane_sidecar_schema_ok "$slt_root/lane-state.yaml"; then + release_lock + die "the worktree $slt_path was NOT recorded for lane $lane: its lifecycle snapshot records a schema this helper does not write, so the generation and operation this observation names cannot be compared with anything. Nothing was written. Upgrade this workstation's lanes-edit.sh." 1 + fi + slt_ng=""; slt_no="" + if [ -r "$slt_root/lane-state.yaml" ]; then + slt_ng="$(lane_sidecar_field "$slt_root/lane-state.yaml" generation)" + slt_no="$(lane_sidecar_field "$slt_root/lane-state.yaml" operation)" + fi + slt_fence="" + [ -z "$slt_g" ] || [ "$slt_g" = "${slt_ng:-0}" ] || slt_fence="generation is ${slt_ng:-0} and --generation named $slt_g" + [ -z "$slt_op" ] || [ "$slt_op" = "${slt_no:-none}" ] || slt_fence="${slt_fence:+$slt_fence; }operation is ${slt_no:-none} and --operation named $slt_op" + if [ -n "$slt_fence" ]; then + release_lock + die "the worktree $slt_path was NOT recorded for lane $lane: $slt_fence. The lane moved while this observation was being made — a recovery advanced it, or a second handoff did — so filing this poll now would put a superseded reading where the current one belongs, and nothing downstream could tell. Nothing was written and the tree itself was not touched. Re-read the lane (lanes-edit.sh lane-reconcile $lane) before recording anything under this operation." 7 + fi + fi + # AND THE SIDECAR THAT IS THERE IS NOT WALKED OVER EITHER, for the same + # reason the snapshot is not: a record written by a newer tooling is one + # this helper cannot read, so it is not one this helper may replace. + if ! lane_sidecar_schema_ok "$slt_root/trees/$(tree_id_for "$slt_path").yaml"; then + release_lock + die "the worktree $slt_path was NOT recorded for lane $lane: its sidecar under $slt_root/trees records a schema this helper does not write, and a record it cannot read is one it cannot safely replace. Nothing was written. Upgrade this workstation's lanes-edit.sh." 1 + fi + if lane_tree_put "$slt_root" "$lane" "$slt_path" "$slt_co" "$slt_b" "$slt_h" \ + "$slt_u" "$slt_d" "$slt_n" "$slt_w" "$slt_g" "$slt_op"; then + release_lock + printf '%s\n' "$(tree_id_for "$slt_path")" + else + release_lock + die "the sidecar for $slt_path could not be written under $slt_root/trees (the shell's own error is above). The tree itself was not touched: this command reads worktrees and writes only its own record of them." 1 + fi + ;; + + # THE OBSERVATION, FROM THE ONE IMPLEMENTATION OF IT (Copilot round 5 on #97). + # `lane-handoff` polls the worktrees this reconciliation recomputes, and until + # this arm it made that observation ITSELF — a second implementation with its + # own error handling, in which a failed upstream lookup became `none` and a + # failed `log @{u}..` became `0`. One function answers both now: the handoff + # records what this prints, for its WRITERS section and for the sidecar alike, + # and `lane-reconcile` recomputes with the same code. It touches no lane, no + # register and no lock — it reads one path with git. + # + # 0 + # 8 no checkout there: the path is gone, or git does not answer in it + # 1 git ANSWERED there and one of the reads FAILED, so nothing is printed + # 64 a usage error of this subcommand's own + lane-tree-now) + ltna="${1-}"; [ -n "$ltna" ] || die "usage: lane-tree-now " 64 + [ "$#" -le 1 ] || die "lane-tree-now takes one path: lane-tree-now " 64 + case "$ltna" in /*) : ;; *) die "lane-tree-now takes an ABSOLUTE path: a relative one means whatever the calling process's directory happens to be, and that is never the tree being asked about" 64 ;; esac + ltna_out=""; ltna_rc=0 + ltna_out="$(lane_tree_now "$ltna")" || ltna_rc=$? + case "$ltna_rc" in + 0) printf '%s\n' "$ltna_out" ;; + 1|2) exit 8 ;; + *) die "git answers in $ltna and one of the reads an observation is made of FAILED, so NOTHING is printed: a branch, a head, a status or an upstream that could not be read is never reported as a clean tree (R22, Amendment 7(d)). Read it by hand: git -C $ltna status" 1 ;; + esac + ;; + + lane-reconcile) + lane="${1-}"; [ -n "$lane" ] || die "usage: lane-reconcile " 64 + [ "$#" -le 1 ] || die "lane-reconcile takes one lane: lane-reconcile " 64 + check_lane_name "$lane" + log_sync + lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 + lane_reconcile "$lane"; lrec_rc=$? + case "$lrec_rc" in + 0) : ;; + 8) exit 8 ;; + *) die "lane $lane could not be reconciled (exit $lrec_rc)" 1 ;; + esac + ;; + *) - die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|retire-rows|archive-rows|migrate-state-cells|commit|rename-lane|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|binding|request-handoff|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|duplicate-holder|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|register-row-local|retired-identity|canon-lane|resolve-home)" 2 + die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|retire-rows|archive-rows|migrate-state-cells|commit|rename-lane|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|binding|request-handoff|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|duplicate-holder|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|register-row-local|retired-identity|canon-lane|resolve-home|managed-projection|lane-state|set-lane-state|lane-trees|set-lane-tree|lane-tree-now|lane-reconcile)" 2 ;; esac diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/.openspec.yaml b/openspec/changes/add-crash-consistent-lane-worktree-recovery/.openspec.yaml new file mode 100644 index 0000000..96db9a4 --- /dev/null +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-15 diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md new file mode 100644 index 0000000..369b936 --- /dev/null +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -0,0 +1,521 @@ +## Context + +Lane identity is stable across Claude profile rotation, but the process running a lane has one checkout directory while its writers may occupy several linked Git worktrees. Current records can resume a transcript and, for newer lanes, recover the coordinator directory. They do not provide a complete machine-readable worktree inventory or a crash-consistent distinction between token exhaustion before `/swap`, interruption during `/swap`, and a completed handoff. + +The existing systems retain separate authority: + +- the lane event log is append-only history and binding evidence; +- the handoff is the human/agent resume narrative and current writer inventory; +- Git and the filesystem are truth about surviving worktrees and unpublished work; +- estate `park`, `status`, and `resume` own durable cross-workstation reconstruction; +- openRepoShape/Speckit owns governed feature-worktree placement. + +The design must run in Bash 3.2 environments, avoid unguarded shared-register rewrites, preserve legacy lanes, and never delete, reset, or overwrite an unknown worktree. + +## Goals / Non-Goals + +**Goals:** + +- Make every worktree owned by a lane discoverable after its coordinator dies. +- Distinguish `RUNNING`, `SWAPPING`, and `SWAPPED` across every token-exhaustion window. +- Make swap completion and resume ownership atomic, exclusive, and generation-fenced. +- Recalculate worktree and publication state before launching replacement writers. +- Keep profile rotation independent from lane and worktree identity. +- Reuse the estate reconstruction mechanism for work that was durably parked. + +**Non-Goals:** + +- Reconstructing uncommitted file contents after their directory is lost. +- Automatically deleting unknown paths or stale worktree registrations. +- Replacing the lane event log, handoff, or estate parked record. +- Moving existing shape-governed feature worktrees merely to satisfy a lane-first visual layout. +- Treating an absolute path or profile as stable lane identity. + +## Decisions + +### 1. Separate stable identity, coordinator checkout, and writer trees + +The stable lane record carries canonical lane name, `home owner/repo`, and estate. The workstation resolves those values to a canonical primary checkout. The coordinator SHALL launch from that base after migration to this capability; mutable feature work SHALL occur in writer worktrees. + +This makes the coordinator location derivable and prevents a profile rotation from silently selecting a feature checkout. Existing lanes that were deliberately launched from a worktree require explicit migration rather than automatic rebinding. + +Alternative considered: preserve arbitrary coordinator directories indefinitely. This retains flexibility but makes a feature path part of lane identity and prevents deterministic recovery when that worktree is removed. + +### 2. Use a lane control root with sidecars outside Git worktrees + +The shape-resolved worktree root gains a lane namespace conceptually equivalent to: + +```text +/.lanes// +├── lane-state.yaml +└── trees/ + └── .yaml +``` + +Each tree sidecar records repository identity, role, path relative to the resolved worktree root, branch or detached state, HEAD, upstream, writer agent/session, lifecycle state, and the last observed dirty and unpushed counts. + +The sidecar is an index: a governed Speckit worktree remains at its shape-prescribed feature-first path. A lane-specific scratch tree may use a lane-derived physical path only where that does not violate the owning repository's worktree contract. + +Sidecars do not live inside worktrees because orchestration metadata must not dirty a checkout or disappear with it. + +### 3. Pair an atomic current-state snapshot with append-only events + +`lane-state.yaml` is atomically replaced under the same lane lock used to authorize transitions. Every successful transition also appends an event to the existing lane history. The snapshot makes current-state reads cheap; the event log preserves provenance and explains how the snapshot arose. + +The state document carries `generation`, `operation_id`, owner transcript/session, agent, profile, binding, and update time. Finalization uses compare-and-swap semantics over state, generation, and operation ID. + +Alternative considered: derive everything from append-only events. That preserves one source but makes exclusive multi-step transitions and stale-finalizer rejection substantially harder for every reader. + +### 4. Make `/swap` a two-phase transition + +`/swap` performs these ordered acts: + +1. lock the lane and compare-and-swap `RUNNING` to `SWAPPING`, minting an operation ID and next generation; +2. enumerate and snapshot every expected and discovered writer tree; +3. poll live writers and require their existing commit/push or explicit unresolved-work outcome; +4. refresh and commit the handoff, including the reconciled writer inventory; +5. write the protocol's `PAUSED`/swap records; +6. compare-and-swap the same operation from `SWAPPING` to `SWAPPED`. + +Any refusal, token exhaustion, or process death before step 6 leaves `SWAPPING`. The operation is not reported ready to swap until `SWAPPED` lands. + +An old finalizer whose generation or operation ID no longer matches is refused. This prevents a delayed `/swap` from overwriting a recovered or newly running generation. + +### 5. Let confirmed SessionStart own `RUNNING` + +Resume does not mark a lane running when the launcher is invoked. State remains `SWAPPED` while the process starts. The SessionStart path changes `SWAPPED` to `RUNNING` only after it confirms canonical lane, transcript/session, agent, directory, and exclusive binding. + +The first implementation does not persist `RESUMING`; an idempotent retry remains possible while state is `SWAPPED`. A future `RESUMING` state may be added if concurrent launch reservation cannot be expressed by the binding lock alone. + +### 6. Reconcile intent with evidence before resume + +Resume compares: + +- lane and tree sidecars; +- the latest handoff inventory; +- live holder/session evidence; +- `git worktree list --porcelain` for every owning repository; +- actual paths beneath the resolved worktree root; +- current branch or detached HEAD, commit, upstream, dirty state, and unpushed commits. + +Stored Git values are prior observations, not truth. Unknown or inconsistent paths are reported and preserved. A matching live holder blocks a duplicate; an absent holder under `RUNNING` or `SWAPPING` creates a recovery-required result. + +### 7. Rebuild only through durable estate records + +A missing tree may be reconstructed only when the estate parked record and its normal `resume` command authorize it. Lane tooling does not hand-roll `git worktree add`, WIP commits, soft resets, or force operations. A missing tree that may have held uncommitted work is a possible-loss refusal. + +### 8. Treat profile as launch metadata only + +Profile remains selectable on every launch. It is stored for diagnostics and default relaunch behavior but does not participate in lane identity, worktree paths, or generation ownership. A profile change resumes the same lane and validated worktree inventory. + +## Decisions taken in implementation (opensoft/openRepoTools#91) + +The eight decisions above were written before the openRepoTools lane tooling was +read against them. Each of the following records where the implementation +DEVIATES from one of them, and why; none of them is a silent departure, and +every one names the ratified rule or the AGENTS.md ruling it answers to. + +### 9. `RUNNING` is written by the confirming act, not by the SessionStart hook + +Decision 5 gives `RUNNING` to "the SessionStart path". In this estate that path +is `lanes-edit.sh session-start`, whose three properties are ratified and +load-bearing: **it never writes, it never touches the network, and it always +exits 0** (lane-collision-protocol Amendment 8, `R-A8-1`) — which is what makes +it safe to put in front of every session on the workstation. A hook that writes +is a hook that can break the session it was meant to orient, and a hook that +fetches puts two network round-trips in front of every session start. + +The act that actually proves canonical lane, transcript, agent, directory and +exclusive binding is `lane-start` (with or without `--no-launch`), and the proof +it leaves is its `STARTED`/`RESUMED` line. So `RUNNING` follows THAT line, in +`write_event` — the register's single writer, which already validates the +session field and Amendment 17(b)'s sub-fields there for the same reason: from +any caller, over one implementation. `ENDED`/`RETIRED` become `CLOSED` by the +same rule. The hook still only reports, and the capability specification is +worded as *the confirming act* rather than *SessionStart* for this reason. + +### 10. The lifecycle snapshot is local, and it is written where `R-A11-14` stops the register + +Decision 3 pairs a snapshot with the append-only history. The snapshot is NOT +in the register: `lanes/LANES.md` is one file shared by every lane on every +workstation and every write of it is a commit, a `pull --rebase` and a push +(Amendment 5), while a transition is taken three times per handoff and must work +with no network at all. Nor is it inside a git worktree, for decision 2's own +reason. + +It follows that Amendment 11's `R-A11-14` — no record is FILED UNDER A +WORKSTATION where none is configured, because such a record is unfindable by +every restart of every workstation — does not reach it. The snapshot is filed +under nothing, never committed and never leaves the machine that wrote it, so a +container with no `$LANES_WORKSTATION` still keeps a lifecycle it can recover +itself from. The two writers are therefore deliberately absent from +`lanes-edit.sh`'s dispatcher workstation guard, and the register and object-log +writes stop there exactly as they did. + +### 11. The control root is derived from the lane's own checkout, not from a shape-governed worktree root + +Decision 2 places the control root under "the shape-resolved worktree root". In +openRepoTools a lane keeps its writers in two places — `/.claude/ +worktrees/` and `/.lane-worktrees//` — and NEITHER +is under `$SPECKIT_GIT_WORKTREE_ROOT`, which is where openRepoShape puts +governed FEATURE worktrees and where `AGENTS.md` forbids this tooling to +hand-roll anything at all. + +So the control root is resolved in three rungs, none of them the caller's +current directory: `$LANES_LANE_STATE_ROOT/`; else `/.lane-state/`, the same parent the lane's own +`.lane-worktrees/` root sits in, where `dir` is Amendment 11(c)'s recorded +field; else `$PROJECTS_ROOT/.lane-state/`. No answer is the honest answer +`8` — a lane that has not started under Amendment 11(c) has no directory to +derive one from, and Amendment 7(i)'s cutover rule is that nothing is +backfilled. This keeps decision 2's actual rule — *the sidecar is an index over +shape-governed paths, which are never moved* — and spells it for this +repository. + +**And it is only half of the brainstorm's "derivable lane root".** That document +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 in this implementation. + +### 12. This change adds no lane-kind verb to the append-only log + +Decision 3's alternative (derive everything from events) was rejected there for +the reasons it gives. As first written, this decision also argued that a sixth +lane-kind verb would change what `swapped_candidates`, `lane_row_facts`, +`lane_payload_field`, `lane-last`, `who` and `lane-end` each mean by *a lane's +last lane-kind line*. **Amendment 18(g) has since added a sixth lane-kind verb, +`HANDOFF-REQUESTED`, without that effect**: it changes no state, and every +reader that decides a lane's state enumerates the five state verbs by name and +skips it. So that argument no longer holds as stated, and the decision now +rests on two others: + +- **A transition is local and frequent, and a log line is neither.** The + lifecycle moves three times per handoff and must move with no network at all, + while every log line is a commit, a `pull --rebase` and a push (Amendment 5) + in a file every workstation shares. +- **`SWAPPING` is a state, and `HANDOFF-REQUESTED` is not.** A state verb in + the log is exactly what the readers that enumerate the five state verbs would + have to learn, in a file nothing rewrites, on every workstation until adoption + reaches it. + +`SWAPPING` is therefore a snapshot state and never a log line, and every +existing reader is untouched. + +### 13. `SWAPPED` needs three landed writes, and the swap still completes without them + +Decision 4 says the operation "is not reported ready to swap until `SWAPPED` +lands". Amendment 11 Addendum 4 ruling 11 (`R-A11-11`) says a swap is never left +unwritten, and `lane-handoff` implements it: the restart line is printed even +where the row flip or the handoff refresh was refused, because a person whose +session is about to die needs the command either way. + +Both are kept by separating them. The lifecycle commits `SWAPPED` only when all +three of the lane's `PAUSED` record, the row's state cell and the handoff file +have landed — the same three `--restart` and `--exit` already gate on — and +otherwise the state STAYS `SWAPPING` and the output names the unfinished step. +The READY line still prints, and now names the state it is printing under. A +lane called `SWAPPED` on two of the three is a lane whose recovery would skip +the third. + +### 14. Reconciliation reports; `resume` remains the only rebuild, and it is NAMED, not invoked + +Decision 7 says the system "names or invokes the estate `resume` path". It +names it. `AGENTS.md` rule 1 is that **`park` CREATES NOTHING and `resume` +RESETS NOTHING**, and `resume ` runs that estate's own `make resume` +across every member of it — which is not a thing a read may do as a side effect +of a report. `lane-reconcile` runs `git status`, `git log @{u}..`, +`git rev-parse` and `git worktree list --porcelain` and nothing else; it never +deletes, resets, force-adds or prunes, and a stale worktree registration is +reported with the `git worktree prune` that clears it rather than pruned. + +**Nor does the report take the lane lock.** The brainstorm has resume *"acquire +the lane lock"* before it reconciles; 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. The lock is taken where it decides something — around the +read of the fence and the replacement of the snapshot together, in +`set-lane-state`, so two transitions cannot both read the state before either +writes it. A report that raced a transition would print a state one moment +stale, which is what the `VERDICT` line is for. + +### 15. An unreadable holder is `indeterminate`, and never a crash + +The crash kinds are the lifecycle word crossed with the live-holder read, and +that read has three answers, not two: `0` a holder, `8` none, and anything else +**the records could not be read** — which is never "no holder" (`R22`, and +Amendment 7(d): a read that failed is never an answer). A holder that could not +be established therefore yields the verdict `indeterminate` and no crash is +pronounced on a read nobody got. `RESUMING` remains unpersisted, as decision 5 +proposes. + +## Decisions taken in the review round (Copilot round 5 on opensoft/openRepoTools#97) + +### 16. EVERY lifecycle write is serialized and fenced, the follow-up included + +The first implementation fenced `set-lane-state` and left the follow-up at the +foot of `write_event` unfenced *deliberately* — a confirmed `STARTED`/`RESUMED` +is a new owner arriving, and advancing the generation over the `SWAPPING` it +supersedes is exactly what the fence is for. What that argument missed is that +the two cases are the same act read at different moments. `write_event` appends +the 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 would then overwrite a `SWAPPING` that began +since, which is precisely the overwrite the generation exists to refuse. + +So the follow-up 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 — state, generation and operation in one string. Equal means this +write is the newest act on the lane and it proceeds; unequal means another act +got there first and NOTHING is written, with the lane named so a person can read +it. The mutex is taken **without dying for it**: the event line is already on +disk, so a lock nobody could take within 20 seconds costs the snapshot and says +so, never the event (`R-A11-11`). + +### 17. A sidecar this helper cannot read is one it must not replace + +`lane_state_read` fails closed for a schema version it does not know, and both +writers beside it read the raw fields and renamed their own file over the top — +so an older helper meeting a newer tooling's snapshot destroyed a record it +could not even read, and nothing later can undo that. The fail-closed contract +therefore binds the WRITERS too: `set-lane-state`, `set-lane-tree` and the +`write_event` follow-up each ask the schema first and refuse (exit 1) rather +than replace. The refusal is 1 and not 7 — 7 says a race was lost and invites +the caller to re-read and try again, and no re-read makes an unknown schema +readable. The inventory READER gained the same rule: `lane-trees` reports an +unknown sidecar by its id, path and schema and reads not one other field of it, +and `lane-reconcile` classifies it `unknown-schema` rather than recomputing git +against fields it is guessing at. + +**And the inventory's own fence is compared rather than merely recorded.** Every +tree sidecar carried the generation and operation it was written under from the +first commit of this change, and nothing read them: a handoff that stalled while +a recovery advanced the lane filed its superseded poll straight over the current +one. `set-lane-tree` now compares both with the lane's snapshot under the mutex +and refuses with **7**; and where an observation is legitimately older than the +lane's current generation, `lane-reconcile` says so on the tree's own line. + +### 18. One implementation of the observation, and an incomplete one is never completed + +`lane-handoff` computed its own branch, head, upstream, dirty and unpushed for +every writer it polled and handed all five to `set-lane-tree` — 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. The observation is now one function behind one read verb, +`lanes-edit.sh lane-tree-now `, and the handoff records what it answers. + +That one function no longer converts a git read which FAILED into a value. +Every read after the first `rev-parse --git-dir` used to end in +`|| printf 'unknown'`, `|| printf 'none'` or a count that fell back to `0`, so a +partially unreadable repository produced a record that looked 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, 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, 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 — the +configured upstream with `unknown` unpushed, never the `0` that reads as +*everything here is published*. + +## Decisions taken in the sixth review round (capped at two rounds; see `tasks.md` section 7) + +### 19. An unreadable snapshot is `indeterminate`, exactly as an unreadable holder is + +Decision 15 gave the HOLDER read three answers and made the third +`indeterminate`. The SNAPSHOT read one file earlier had two: `lane_state_read` +answered 8 — *this lane has no snapshot* — for a record that exists and cannot +be opened, and `lane_reconcile` mapped every non-zero read to `NONE`, whose +verdict is `no-state`: the cutover answer that tells a launcher this lane never +ran under this capability and there is nothing to recover. A permission or an +I/O error therefore came out of the report as a clearance, which is fail-OPEN on +a crash pronouncement — the one class this change exists to close. + +So the two cases are told apart at the read: **10** where the record is there and +could not be opened, 8 where there is none. `lane-state` spends the same 10 at +the CLI (8 stays *there is none*, which every launcher answers by going on), and +`lane-reconcile` reports the state word `UNREADABLE` with the verdict +`indeterminate`. `[ -L ]` sits beside `[ -e ]` in that test because a dangling +symlink is `-e` false: the name is there and the bytes are not, which is +present-and-unreadable and not absent. + +### 20. Every sidecar write is serialized, fence or no fence + +`set-lane-tree` took the lane mutex only when the caller named a +`--generation`/`--operation` to compare. The atomic temp-file rename underneath +it stops a reader seeing half a file and stops nothing else, so an UNFENCED +observation — a person's, or a 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. The mutex is now taken for every +write of these files, with the generation/operation comparison unchanged for the +callers that name one. + +It is taken AFTER the observation and not before it, for the reason decision 14 +keeps `lane-reconcile` out of the lock entirely: `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. + +## Decisions taken in the rework of 2026-10-04 + +### 21. The managed ledger owns enrolled lanes; this change owns legacy lanes only + +Brett Heap's ruling of 2026-10-04, verbatim: "managed ledger owns enrolled lanes; #97 owns legacy — rework both". + +**The seam is the register row's state cell.** A lane the managed ledger has +enrolled carries a managed-owner marker there, in the long form +` · · managed-owner mode=managed daemon= generation= bound-lane=` +or the historical shorthand `MANAGED OWNER · `. The reader is +`3c26041:lanes-edit.sh:776-898` — branch `001-separate-swap-ctx-handoff`'s +projection reader (its T019) — **ported verbatim**, with an empty diff as the +proof, so that branch's T024 merges as "keep either". Its four dependencies +(`row_split_state_cell`, `rstrip_spaces`, `lc`, `row_of_lane`) are +byte-identical on `main`. One wrapper, `lane_is_managed_owned`, answers 0 with +the owner, 8 for a legacy lane and 1 for unknown, and closes the one gap the +ported reader leaves: an unrendered published register reads as an empty one, +which the wrapper answers as unknown. `managed-projection ` is that +answer as a read verb (64 for usage). + +**The rule is the reader's own — "no malformed marker may be downgraded to +absence".** A valid marker refuses with 2, naming the owner; managed-owner +vocabulary that does not parse, a row that is not seven columns or a register +that cannot be read refuses with 1 (unknown); no vocabulary is a legacy lane and +nothing changes. Every refusal comes before the act's first write: `write_event` +for every lane-kind verb (the four that move a lifecycle, `PAUSED`, and +`HANDOFF-REQUESTED`, which `request-handoff` writes and which refuses at its +head, `--dry-run` included), `set-lane-state`, `set-lane-tree`, +`retire-rows` (any hit refuses the whole sweep), `migrate-state-cells` (any +row it would rewrite refuses the whole migration, which is one commit), +`archive-rows` (any RETIRED row it would move refuses the whole move), +`append-session-id`, `add-row` for a lane that already has a row, +`set-row-state`, +`replace-in-row`, `rename-lane`, the entry of `lane-start` (before Amendment +18's binding gate) and of `lane-handoff` (before every mode), and `lane-end` +(at the head of its `--retire ` door, and on the ending path right after +its 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). +`lane-reconcile` is read-only and answers `managed-owned`. `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 a marker. The refusal reads this +checkout's row as well as the published one, because every legacy row writer +rewrites this checkout's row: vocabulary in one copy and not the other is +unknown (1). Object-kind lines (a claim, a release) concern the object a lane +holds and are not refused (Copilot rounds 2 and 3 on #97). + +**`RUNNING` is written only on a legacy verdict.** `lane_state_follow` moves a +snapshot only when the verb is `STARTED`/`RESUMED` (to `RUNNING`) or +`ENDED`/`RETIRED` (to `CLOSED`), the caller's seam read was 8, and a pre-image +was taken and still matches; a missing verdict or pre-image writes nothing. A +later change may own `RUNNING` for an operation it holds — the +`SWAPPED → RUNNING` readiness write of a supervised restart, for one — and no +stub of that ships here. + +**What is not asserted.** Branch 001's governance review is PROPOSED — NOT +APPROVED; nothing here adopts it, and no supersession of Amendment 17 is +asserted. When 001's T024 lands, the ported block resolves "keep either" and +`lane_is_managed_owned` gives way to its `managed_legacy_check`. + +### 22. An unreadable snapshot is exit 10, because `main` spent 9 + +Decision 19 gave an unreadable snapshot exit 9. `main`'s #61 landed first with 9 +as `claim --force`'s abandoned takeover; each change took 9 as unused, and the +one that had not landed moved. 10 is spent nowhere else in the shipped scripts. + +### 23. A lane bound elsewhere is `indeterminate` (Amendment 18(b)) + +Amendment 18(b): *"Liveness is pronounced only from inside the binding's own +`host` and `container` … from anywhere else a binding is UNKNOWN, never dead."* +`lane-reconcile` decided its verdict from this workstation's snapshot and this +workstation's session records alone, so a lane bound on another host read as an +ungraceful stop or as resumable. It now reads the binding through the same +`lane_binding_scan` and `binding_is_here` rule as `binding` and +`holder_is_dead`, prints a `BINDING` line, and turns every verdict into +`indeterminate` where the binding is elsewhere or its log cannot be read — +keeping clause (b)'s one exception, a window gone from this host's tmux. + +### 24. Every writer of a lane-kind line moves the lifecycle, and a rename carries it + +Amendment 19's sweep appends `RETIRED` lines without `write_event`, so the +follow-up of decision 9 never ran for a lane it retired; the sweep now takes each +lane's pre-image in its scan and follows each line after its lock, as +`write_event` does. Amendment 16's `rename-lane` moved the row, the log, the +handoff and the alias table but not the control root, which is keyed by the +lane's name, so a renamed lane read `no-state`; it now moves that directory once +its commit has landed, never over an existing one. + +## Decisions taken on Codex's review of `ec9847e` + +### 25. The inventory says what it could not read, keeps one record per path, and takes an observation whole + +Four findings, each a tree a recovery would misread, and each a part of a +follow-up this change had filed and deferred: + +- **An unreadable sidecar was skipped** (`[ -r ] || continue`), so a record + nobody could read and no record at all were one silence. It is now a row of + `lane-trees` named by its file, with no field and the schema ``, + and `lane-reconcile` reports it as `unreadable-sidecar` and counts it toward + recovery (#113's second read). +- **The tree id was not injective.** The fold to the file-name alphabet makes + `…/a+b` and `…/a-b` one name, so the second tree's record replaced the + first's. The `cksum` of the whole path, which only an over-long id carried, + now prefixes EVERY id. No migration: no released tooling has written a + sidecar (#116's first half). +- **A partial observation was completed with clean-looking defaults.** One flag + skipped the reading and the rest were filed as `unknown`, `none`, `dirty 0` + and `unpushed 0`. It is now a usage refusal, 64, and nothing is written — the + answer decision 18 gives the other path (#114's second half). +- **`lane-start` stayed silent over a `resumable` lane** whatever its trees + held. `resumable` says the swap completed; it is now quiet only when the + `TREES` line counts nothing dirty or unpushed, nothing requiring recovery and + nothing unmanaged or stale, and the report it prints names the binding when + it is neither here nor free. + +## Risks / Trade-offs + +- **[Risk] Lane-first discovery conflicts with feature-first Speckit paths** → Use a sidecar index over shape-governed paths; do not move governed trees. +- **[Risk] Snapshot and append-only history diverge** → Write under one lane lock, verify the event append, and surface disagreement as recovery-required. +- **[Risk] A stale PID appears live in another namespace** → Use the existing binding/session provenance contract, not PID alone. +- **[Risk] `RUNNING` remains after a process dies normally without `/swap`** → Treat `RUNNING` plus no verified holder as ungraceful and inspect all trees. +- **[Risk] A swap records `SWAPPING` and then permanently stalls** → Permit an explicit recovery/takeover operation that advances the generation while retaining the interrupted operation in history. +- **[Risk] Sidecars claim work is clean when Git changed later** → Recalculate all Git observations on resume and before `SWAPPED`. +- **[Risk] Coordinator-base enforcement disrupts legacy lanes** → Introduce explicit migration and warning stages before enforcing the invariant. +- **[Risk] Concurrent tooling versions interpret new state differently** → Version the sidecar schema and retain conservative fail-closed behavior for unknown versions or states. +- **[Risk] The managed-owner hint over-matches** → The reader matches its vocabulary anywhere in a row, so a legacy row carrying `managed:` reads as unknown and is refused. Measured on 2026-10-04 at 0 of the 57 rows of the published register (brett-wip `225b9f90a`, the hint's own three patterns); no legacy writer can now write the vocabulary, so the count cannot grow from this side. +- **[Risk] A marker published since the last fetch is missed** → A caller running with `LANES_NO_FETCH=1` reads the register as last fetched, until branch 001's lease closes the window. + +## Migration Plan + +1. Add read-only discovery and reporting for the new control root and state schema. +2. On an explicitly named lane start or successful `/swap`, create sidecars from verified repository identity, current binding, Git registrations, and the handoff; never scrape arbitrary prose silently. +3. Continue accepting legacy lanes with no sidecar through the existing explicit `--dir` path, while reporting that crash recovery is incomplete. +4. Add `SWAPPING` and `SWAPPED` writes behind compatibility detection so older installed readers fail closed rather than misclassify them. +5. Enable resume reconciliation before coordinator-base enforcement. +6. After existing lanes have been migrated, require the coordinator base and structured writer registration for newly created trees. + +Rollback disables new writes but preserves sidecars and append-only events for diagnosis. It must not rewrite or remove lane history, worktrees, or handoffs. + +## Open Questions + +- ~~Which existing lane lock becomes the single transition lock?~~ **Settled by + the implementation**: `lanes-edit.sh`'s own mutex, taken around the read of + the fence and the replacement of the snapshot together, so two transitions + cannot both read the state before either writes it. How it is shared across a + host/container boundary remains open — the snapshot is local to a machine by + decision 10, so today it is not shared at all. +- ~~Does recovery complete an interrupted operation ID or always supersede it?~~ + **Settled**: it supersedes. A handoff that finds the lane still `SWAPPING` + takes it over with a NEW generation and names the operation that never + finished; that new generation is precisely what refuses the old finalizer if + it ever wakes. Nothing of the interrupted operation is undone. + **What it cannot yet tell** is an interrupted operation from one that is + still running (Copilot round 2 on #97). That takes the handoff process's + liveness, which the snapshot does not record, and a PID alone does not + establish it (Risks). So a second handoff begun during a live one supersedes + it too. The fence bounds the harm: every later finalizer and inventory write + of the superseded operation is refused with 7 and changes nothing. Refusing a + live competitor is deferred to opensoft/openRepoTools#157. +- Which tree-level discrepancies block the entire coordinator versus only that writer's relaunch? +- What subset of sidecar data is replicated through the workspace repository for another workstation? +- How should lane-specific scratch worktrees be named without colliding with Speckit feature identifiers? diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/proposal.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/proposal.md new file mode 100644 index 0000000..f4556f2 --- /dev/null +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/proposal.md @@ -0,0 +1,39 @@ +## Why + +A lane may coordinate several Git worktrees containing dirty or unpushed work, but its current launch record identifies only one directory and cannot distinguish a clean swap from token exhaustion before or during `/swap`. Resume therefore needs a structured, crash-consistent inventory that can find every lane-owned worktree and explain whether the previous session stopped cleanly before another process writes to it. + +**Scope, by ruling.** Brett Heap's ruling of 2026-10-04, verbatim: "managed ledger owns enrolled lanes; #97 owns legacy — rework both". This change governs **legacy lanes only**. A lane the managed ledger has enrolled — one whose register row carries a valid managed-owner marker — is that ledger's, and every act of this change refuses it before writing anything; a lane whose ownership cannot be read is refused the same way. See design decision 21. + +## What Changes + +- Give each lane a structured worktree root derived from its stable repository/estate identity and canonical lane name, with one sidecar record per lane-owned tree. +- Record repository, branch, HEAD, upstream, writer/session identity, and last-observed dirty and unpushed state for each tree without placing orchestration files inside Git worktrees. +- Add a guarded lane lifecycle: `RUNNING` when SessionStart confirms the owner, `SWAPPING` before `/swap` begins preservation work, and `SWAPPED` only after every required handoff and pause write succeeds. +- Fence transitions with a generation and operation ID so a delayed swap or competing resume cannot overwrite a newer owner. +- Reconcile sidecar intent against live holders, `git worktree list --porcelain`, filesystem paths, branches, commits, dirty files, and unpushed commits before resuming. +- Classify `RUNNING` or `SWAPPING` without a matching live owner as recovery-required rather than treating it as a clean swap. +- Delegate reconstruction of durably parked feature worktrees to the existing estate `resume` mechanism; never recreate over unknown paths or claim that missing uncommitted files can be recovered. +- Migrate legacy lanes on an explicit, verified start or swap rather than inferring paths from prose or the caller's current directory. +- **BREAKING**: lane launch and swap become state transitions requiring exclusive ownership; ambiguous or inconsistent lane/worktree state blocks a competing launch instead of silently starting. + +## Capabilities + +### New Capabilities + +- `lane-worktree-recovery`: Canonical lane worktree discovery, crash-consistent swap state, and evidence-based resume reconciliation for multi-writer lanes. + +### Modified Capabilities + +None. This repository has no existing OpenSpec capability specifications; current behavior is documented by the lane collision protocol and command manual. + +## Impact + +Tracked by [opensoft/openRepoTools#91](https://github.com/opensoft/openRepoTools/issues/91), with the existing `openRepoTools-3` Claude lane as the active implementation owner. + +**Delivered in the first implementation**: the lifecycle snapshot with its generation and operation fence (`lane-state`, `set-lane-state`), the machine-readable worktree inventory taken at every handoff (`set-lane-tree`, `lane-trees`), the resume reconciliation that reports and resets nothing (`lane-reconcile`, printed by `lane-start` before it writes anything), the two-phase `/swap`, and `RUNNING` written by the act that confirms the binding. Design decisions 9 to 15 record where each of these departs from the decisions above and why. + +**Delivered in the rework of 2026-10-04**: the seam that keeps every act of this change off a managed-owned lane (design decision 21), and four fixes the merge of `main` made necessary — the unreadable-snapshot exit moved from 9 to 10, a lane retired by Amendment 19's sweep closes its snapshot, a lane bound elsewhere is `indeterminate` under Amendment 18(b), and a renamed lane keeps its snapshot (decisions 22 to 24). + +**Not yet delivered**: the coordinator-base invariant and its staged enforcement, the inventory of shape-governed feature worktrees beyond the two roots a lane already owns, and any replication of this state to a second workstation. Each remains a task in `tasks.md`, and the lifecycle is local to one machine until they land. + +The change affects `lane`, `lanes`, `lane-start`, `lane-handoff`, `/swap`/`/handoff`, the SessionStart integration, `lanes-edit.sh`, lane status rendering, and lane helper tests. It integrates with—but does not replace—the workspace register, handoff documents, `git worktree` plumbing, openRepoShape/Speckit worktree conventions, and estate `park`, `status`, and `resume` commands. Existing lane records and ad hoc writer worktrees require an explicit compatibility and migration path. diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/specs/lane-worktree-recovery/spec.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/specs/lane-worktree-recovery/spec.md new file mode 100644 index 0000000..d2bf185 --- /dev/null +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/specs/lane-worktree-recovery/spec.md @@ -0,0 +1,197 @@ +## ADDED Requirements + +Capability `lane-worktree-recovery`, tracked in [opensoft/openRepoTools#91](https://github.com/opensoft/openRepoTools/issues/91). Where a requirement below is narrower than the brainstorm that proposed it, the reason is a numbered decision in `design.md`. + +### Requirement: Canonical lane worktree inventory +The system SHALL maintain a structured inventory for every worktree owned by a lane, rooted or indexed beneath the worktree root derived from the lane's stable repository and estate identity. The inventory SHALL remain discoverable without parsing handoff prose or trusting the caller's current directory. + +#### Scenario: Lane owns several worktrees +- **WHEN** one lane coordinates writers in multiple registered worktrees +- **THEN** the lane inventory identifies every tree by stable tree ID, repository, path, branch or detached state, HEAD, upstream, and writer identity + +#### Scenario: Shape governs the physical path +- **WHEN** a Speckit or openRepoShape contract prescribes a feature-first worktree path +- **THEN** the lane inventory references that path without moving it into a conflicting lane-first layout + +#### Scenario: Two paths fold to the same record name +- **WHEN** two worktree paths differ only in characters outside the record-name alphabet +- **THEN** the lane inventory keeps two records, each with its own observation, and neither replaces the other + +#### Scenario: A caller supplies part of an observation +- **WHEN** a caller records a tree with some but not all of branch, HEAD, upstream, dirty state and unpushed count +- **THEN** the system refuses the write as a usage error and records nothing, rather than completing the observation with values nobody read + +### Requirement: Coordinator base checkout +The system SHALL resolve the lane coordinator's checkout from canonical lane, home repository, estate, and workstation shape. After migration, the coordinator SHALL launch from the canonical base checkout while mutable feature work occurs in inventoried writer worktrees. + +#### Scenario: Profile rotation +- **WHEN** an operator resumes a migrated lane under a different valid profile +- **THEN** the system uses the same canonical coordinator checkout and worktree inventory + +#### Scenario: Legacy coordinator directory is ambiguous +- **WHEN** a legacy lane has no verified base-checkout record +- **THEN** the system requires an explicit directory or migration act and does not infer one from the current directory or handoff prose + +### Requirement: Sidecar metadata remains outside worktrees +The system SHALL store lane and tree lifecycle metadata outside Git worktree directories. + +#### Scenario: State update does not dirty work +- **WHEN** the system updates lane or tree state +- **THEN** no tracked or untracked orchestration file is added inside the associated Git worktree + +### Requirement: Running ownership is confirmed +The system SHALL write `RUNNING` only from the act that confirms the canonical lane, transcript or session, agent, coordinator directory, and exclusive binding, and SHALL NOT write it from a read-only session-start report. See design decision 9: in this repository the confirming act is the lane start that records the binding, and the SessionStart hook never writes. + +#### Scenario: Launcher dies before the binding is recorded +- **WHEN** a resume command begins but no replacement binding is confirmed and recorded +- **THEN** the system does not record the lane as `RUNNING` + +#### Scenario: A read-only session-start report runs +- **WHEN** the session-start hook reports on a lane +- **THEN** it writes no lifecycle state, makes no network call, and exits successfully + +#### Scenario: Confirmed replacement starts +- **WHEN** the confirming act proves the replacement owns the expected lane and binding +- **THEN** the system atomically records `RUNNING` with that owner and a newly advanced generation + +### Requirement: Swap uses a two-phase lifecycle +The system SHALL transition a running lane to `SWAPPING` before `/swap` performs preservation work and SHALL transition it to `SWAPPED` only after every mandatory inventory, writer, handoff, and pause-record step succeeds. + +#### Scenario: Token exhaustion before swap +- **WHEN** the running process disappears before invoking `/swap` +- **THEN** persisted state remains `RUNNING` and resume classifies the lane as an ungraceful stop + +#### Scenario: Token exhaustion during swap +- **WHEN** the process disappears after `SWAPPING` is recorded but before all mandatory swap steps complete +- **THEN** persisted state remains `SWAPPING` and resume classifies the lane as an interrupted swap + +#### Scenario: Graceful swap completes +- **WHEN** every mandatory swap step succeeds +- **THEN** the same operation atomically records `SWAPPED` before printing that the lane is ready to resume + +### Requirement: Transitions are generation-fenced +Every ownership transition SHALL carry a monotonically advancing generation and unique operation ID, and a transition finalizer SHALL succeed only when its expected state, generation, and operation ID still match. Every write that follows a recorded act SHALL be serialized under the one transition lock and refused when the lane has moved past the state that write observed. A swap that begins while the lane is recorded `SWAPPING` SHALL supersede the recorded operation, as it does an interrupted swap (design.md, Open Questions). The snapshot records no liveness for the handoff process, so a live operation cannot yet be told from an interrupted one. Refusing a live competitor instead is deferred to opensoft/openRepoTools#157. + +#### Scenario: A lifecycle write is overtaken while it lands +- **WHEN** the lane's state, generation, or operation changes between the moment a lifecycle-moving event is recorded and the moment its current-state snapshot is updated +- **THEN** the snapshot is left exactly as the later act wrote it, the recorded event still stands, and the overtaken write reports what it found + +#### Scenario: An observation is filed under a superseded operation +- **WHEN** an inventory write names a generation or operation the lane has already moved past +- **THEN** the system refuses the write, changes no recorded observation, and directs the caller to re-read the lane + +#### Scenario: Two writers record the same tree at once +- **WHEN** an inventory observation is written while another run holds the lane transition lock +- **THEN** the write waits for that lock before replacing the sidecar, whether or not it names a generation and operation to compare + +#### Scenario: Delayed swap finalizer +- **WHEN** an old `/swap` process attempts to record `SWAPPED` after another recovery or resume has advanced the generation +- **THEN** the system refuses the stale finalizer without changing current state + +#### Scenario: Competing swap begins +- **WHEN** a second `/swap` starts while the lane is already recorded `SWAPPING` +- **THEN** the system names the recorded operation and supersedes it with a new generation and operation +- **AND** every later finalizer or inventory write of the superseded operation is refused without changing current state + +### Requirement: Resume reconciles records with live evidence +Before launching replacement writers, the system SHALL compare lane and tree sidecars with the latest handoff, verified live holders, Git worktree registrations, filesystem paths, branches, commits, dirty files, and unpushed commits. + +#### Scenario: Existing writer remains live +- **WHEN** reconciliation finds a verified live writer for an inventoried worktree +- **THEN** the system does not launch a second writer into that worktree + +#### Scenario: Running state has no holder +- **WHEN** persisted state is `RUNNING` and no verified owner remains live +- **THEN** the system reports an ungraceful stop and inspects every inventoried and discovered worktree before permitting recovery + +#### Scenario: Liveness cannot be established +- **WHEN** the holder records cannot be read at all +- **THEN** the system reports that liveness is not established and pronounces neither crash kind, because a read that failed is not an answer + +#### Scenario: The lifecycle snapshot cannot be read +- **WHEN** a lane's persisted state record exists at its control root and cannot be read +- **THEN** the system reports the state as unreadable and the outcome as indeterminate, and never as a lane that has no persisted state + +#### Scenario: The lane is bound elsewhere +- **WHEN** a lane's last binding names another host or container and its window is not known to be gone from this host +- **THEN** the system reports the binding as elsewhere and the verdict as indeterminate, and pronounces no crash, clearance or closure from outside that binding + +#### Scenario: Swapping state has no holder +- **WHEN** persisted state is `SWAPPING` and no verified owner remains live +- **THEN** the system reports the interrupted operation ID and preserves all trees for recovery + +#### Scenario: Stored Git observations are stale +- **WHEN** current branch, HEAD, dirty state, or unpushed commits differ from the sidecar +- **THEN** the system reports current Git evidence and does not treat the stored observation as truth + +#### Scenario: Unknown tree is discovered +- **WHEN** Git or the filesystem contains a worktree beneath the lane root that is absent from the inventory +- **THEN** the system reports it as unmanaged and does not delete, overwrite, or automatically assign it + +#### Scenario: A tree cannot be read +- **WHEN** Git answers in a tree's path and one of the reads an observation is made of fails +- **THEN** the system reports the tree as unreadable, records no observation of it, and assumes neither clean nor dirty state for it + +#### Scenario: A tree record cannot be read +- **WHEN** a tree's inventory record exists at the lane's control root and cannot be read +- **THEN** the system reports the record as unreadable, counts it toward recovery, and never reads the lane as owning one tree fewer + +#### Scenario: A resumable lane's trees need reading +- **WHEN** a swap completed and the reconciliation counts a tree that is dirty, unpushed, requires recovery, or is unmanaged or stale +- **THEN** the launcher reports the reconciliation before it writes anything, rather than starting the lane in silence + +### Requirement: Missing worktrees are rebuilt only from durable records +The lane system SHALL delegate worktree reconstruction to the existing estate resume mechanism and SHALL not hand-roll worktree creation, WIP commits, resets, or force operations. + +#### Scenario: Parked worktree is missing +- **WHEN** an inventoried worktree is absent and its last observation recorded no dirty or unpushed work +- **THEN** the system reports it as missing and NAMES the estate `resume` path as the only rebuild, without running it (design decision 14) + +#### Scenario: A stale worktree registration is discovered +- **WHEN** a repository registers a worktree whose directory is gone +- **THEN** the system reports the stale registration and names the `git worktree prune` that clears it, and prunes nothing itself + +#### Scenario: Potentially uncommitted worktree is missing +- **WHEN** a missing worktree's last evidence indicates dirty or unpublished work without a durable parked commit +- **THEN** the system reports possible loss and refuses to claim that the worktree can be reconstructed + +### Requirement: Closed and swapped states remain distinguishable +The system SHALL distinguish a lane or tree that completed a temporary swap from one whose work is permanently closed. + +#### Scenario: Swapped lane resumes +- **WHEN** a `SWAPPED` lane passes reconciliation and SessionStart confirms its replacement +- **THEN** the lane returns to `RUNNING` without changing the identity of its worktrees + +#### Scenario: Closed tree is unexpectedly dirty +- **WHEN** a tree marked `CLOSED` contains dirty files or unpushed commits +- **THEN** the system reports a closure inconsistency and refuses automatic cleanup + +### Requirement: Legacy lifecycle refuses a managed-owned lane +The system SHALL govern legacy lanes only (Brett Heap's ruling of 2026-10-04, verbatim: "managed ledger owns enrolled lanes; #97 owns legacy — rework both"). Before its first write, every lifecycle, inventory, lane-kind log line, register-row, sweep, archive, migration, start, handoff and end act SHALL read the lane's managed-owner marker, in the published register and in the checkout whose row it rewrites. It SHALL refuse a valid marker, refuse an unreadable or malformed one (or two copies that disagree) as unknown, and run unchanged for a lane with none. No legacy writer SHALL write the marker's vocabulary into the register. Object-kind lines (a claim or a release of an issue or pull request) concern the object a lane holds, not the lane's ownership, and are unchanged. + +#### Scenario: A lane carries a valid managed-owner marker +- **WHEN** any legacy act is invoked for a lane whose register row carries a valid managed-owner marker +- **THEN** the act exits 2 naming the owner, and the register, the lane's object log, its lifecycle control root and its tmux window are unchanged, and the reconciliation reports `managed-owned` without pronouncing anything else + +#### Scenario: A managed-owner marker cannot be read +- **WHEN** a lane's register row carries managed-owner vocabulary that does not parse, this checkout's row carries vocabulary the published row does not, or the register cannot be read +- **THEN** the act exits 1 reporting ownership as unknown, and nothing is changed + +#### Scenario: A legacy lane is acted on +- **WHEN** a lane's register row carries no managed-owner vocabulary +- **THEN** every act behaves exactly as it did before the seam, and the lifecycle moves only on that legacy verdict and an unchanged pre-image + +### Requirement: Legacy migration is explicit and non-destructive +The system SHALL create structured state for a legacy lane only from an explicitly named start, swap, or migration using verified repository and Git evidence. + +#### Scenario: Legacy lane is migrated during swap +- **WHEN** a legacy lane successfully completes `/swap` from a verified coordinator checkout +- **THEN** the system creates its structured lane and tree inventory without rewriting historical handoff or lane events + +#### Scenario: Migration encounters an unknown path +- **WHEN** legacy evidence names a path that cannot be verified against the expected repository +- **THEN** the system refuses to adopt the path and leaves existing files unchanged + +#### Scenario: A sidecar records a schema this tooling does not write +- **WHEN** a lane snapshot or a tree sidecar records a schema version this tooling does not write +- **THEN** every reader reports it as unknown without interpreting any other field of it, and every writer refuses to replace it rather than overwriting a record it cannot read diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md new file mode 100644 index 0000000..6def619 --- /dev/null +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md @@ -0,0 +1,211 @@ +## 1. Structured State Model + +- [x] 1.1 Define and version the lane-state and tree-sidecar schemas, including canonical lane, repository/estate identity, relative path, Git observations, owner, state, generation, operation ID, and timestamps. +- [ ] 1.2 Implement shape-aware resolution of the coordinator base, worktree root, and lane control root without deriving identity from profile or current directory. +- [x] 1.3 Implement atomic sidecar reads and writes under one lane transition lock, with conservative refusal for unknown schema versions and malformed state. +- [ ] 1.4 Append an auditable lane event for every successful current-state transition and detect disagreement between the snapshot and event history. + +## 2. Worktree Inventory + +- [x] 2.1 Implement registration and update of lane-owned tree sidecars without placing metadata inside Git worktrees. +- [ ] 2.2 Inventory governed feature worktrees by reference to their shape-prescribed paths rather than moving them into a conflicting lane-first layout. +- [x] 2.3 Reconcile expected sidecars, latest handoff writers, `git worktree list --porcelain`, and directories present beneath the resolved worktree root. +- [x] 2.4 Recalculate repository identity, branch or detached HEAD, commit, upstream, dirty files, untracked files, and unpushed commits for every discovered tree. + +## 3. Crash-Consistent Swap + +- [x] 3.1 Change `/swap` and `lane-handoff` to compare-and-swap `RUNNING` to `SWAPPING` before inventory or preservation work begins. +- [x] 3.2 Carry one generation and operation ID through writer polling, handoff refresh, lane pause records, and finalization. +- [x] 3.3 Finalize `SWAPPING` to `SWAPPED` only after every mandatory step succeeds, and print readiness only after that transition lands. +- [x] 3.4 Refuse stale or competing finalizers whose expected state, generation, operation ID, or owner no longer matches. +- [x] 3.5 Preserve `SWAPPING` on every refusal or interrupted path and report the unfinished operation and completed steps. + +## 4. Guarded Resume + +- [x] 4.1 Classify `RUNNING` without a verified owner as an ungraceful stop and `SWAPPING` without one as an interrupted swap. +- [ ] 4.2 Refuse duplicate coordinator or writer launches when a verified holder remains live. +- [x] 4.3 Produce a resume reconciliation report that distinguishes safe, dirty, unpushed, unmanaged, missing, stale-registration, and possible-loss trees. +- [x] 4.4 Keep state `SWAPPED` during launch and transition to `RUNNING` only after SessionStart confirms lane, transcript/session, agent, directory, and exclusive binding. +- [x] 4.5 Delegate eligible missing-tree reconstruction to estate `resume` and refuse hand-rolled recreation or recovery claims for missing unpublished work. + +## 5. Compatibility and Migration + +- [x] 5.1 Add a read-only compatibility path for lanes with no sidecar, preserving the existing explicit `--dir` refusal and launch behavior. +- [x] 5.2 Create structured state for a legacy lane only during an explicitly named, repository-verified start, swap, or migration. +- [ ] 5.3 Introduce coordinator-base enforcement in a staged mode that first reports legacy feature-worktree launches before making them refusals. +- [x] 5.4 Keep profile selection independent of lane identity and verify that profile rotation resumes the same coordinator and worktree inventory. + +## 6. Verification and Delivery + +- [x] 6.1 Add tests for token exhaustion before `/swap`, during every mandatory swap step, after `SWAPPED`, and before replacement SessionStart. +- [ ] 6.2 Add race tests for competing swaps, competing resumes, stale finalizers, and live duplicate writers. +- [ ] 6.3 Add worktree tests for dirty and unpushed trees, missing parked trees, missing unpublished trees, unknown trees, stale registrations, detached HEAD, and shape-governed paths. +- [ ] 6.4 Add migration tests for legacy lanes with no directory, verified explicit directories, invalid repository identities, and repeated idempotent migration. +- [x] 6.5 Update the lane manual and installed `/swap`/handoff guidance with the state meanings, recovery output, profile-switch sequence, and non-destructive boundaries. +- [x] 6.6 Run `tests/run.sh`, capture the exact acceptance evidence, and link the implementation PR and final behavior back to this OpenSpec change before archive. + +## 7. What the first implementation left (opensoft/openRepoTools#91) + +The unticked items above are not oversights; each is named here with what +stands in its place today. + +- **1.2 / 5.3 — the coordinator base.** Only the lane CONTROL ROOT is resolved + (design decision 11). The coordinator-base invariant and its staged + enforcement are not implemented, so a lane launched from a feature worktree is + neither refused nor reported as one. +- **1.4 — snapshot/history disagreement.** Every transition that follows a + lane-kind line is audited by that line, and the swap's own `PAUSED` is the + event for the handoff; `SWAPPING` has no log event by design decision 12, and + there is no detector that compares the two and reports a divergence. +- **2.2 — shape-governed feature worktrees.** The inventory covers the two roots + a lane already owns. Worktrees at Speckit's feature-first paths are not + indexed, and moving them is forbidden either way. +- **4.2 — duplicate refusal.** `lane-reconcile` REPORTS a live holder; + `lane-start`'s existing duplicate refusals are unchanged, and no new refusal + is added by this change. +- **6.2 — races.** Competing swaps, competing resumes, stale finalizers, a + lifecycle write overtaken between its event line and its snapshot, and an + inventory write filed under a superseded operation each have a case; a live + duplicate WRITER on one worktree does not. +- **6.3 — worktrees.** Dirty, unpushed, missing-and-clean, missing-with-work, + unknown, stale-registration, detached HEAD, a tree git answers in and cannot + be read through, and a sidecar whose schema this tooling does not write each + have a case; shape-governed paths do not, because 2.2 does not. +- **6.4 — migration.** A lane with no snapshot answering 8 everywhere, and a + snapshot created by the first transition that runs, both have cases; an + invalid repository identity and a repeated idempotent migration do not. +- **Cross-workstation replication** remains the open question the design states. + The snapshot is local to one machine by decision 10. + +### The sixth review round, and where each of its fourteen findings went + +Automated review rounds are capped at **two per pull request** (Brett Heap's +ruling of 2026-09-16), and this change is at round six. That round was therefore +TRIAGED rather than taken: the two findings that are safety holes this capability +could not land with are in the branch, and the other twelve are filed as issues, +claimed by this lane, and named below with what each costs and what decides it. +Nothing is left as a comment thread and nothing is left unnamed. + +**Taken on this branch.** + +- **An unreadable lifecycle snapshot is no longer read as a lane that has none.** + `lane_state_read` answers **10** for a record that IS there and cannot be + opened, `lane-state` exits 10 rather than the 8 a launcher goes past, and + `lane-reconcile` reports `UNREADABLE` / `indeterminate`. Fail-OPEN on a crash + pronouncement is the one class this change exists to close (`R22`, + Amendment 7(d)). +- **Every inventory write is serialized under the lane mutex.** `set-lane-tree` + took it only for a FENCED write; the atomic rename underneath stops a reader + seeing half a file and stops nothing else, so an unfenced observation could + land after a newer fenced one and replace it. + +**Filed, claimed, and deferred.** + +- **`opensoft/openRepoTools#113` — the four reads that still turn a failure into + an answer.** The holder ids (`|| :`, so an unreadable register reads as *no + holder* and a crash is pronounced on liveness nobody established), an + unreadable tree sidecar (`[ -r ] || continue`, so a record nobody can read and + no record at all are one silence — and one such sidecar alone answers *this + lane owns no worktree*), `git config --get branch..remote` (`|| :`, so a + failed read fabricates the local upstream `./`), and `git worktree list + --porcelain` (`|| :`, so a checkout whose registrations cannot be read looks + exactly like one with none and the report can still say `resumable`). Each + costs a wrong verdict or an omitted tree on exactly the machine a recovery is + running on; what decides them is whether `indeterminate` is the answer at + every one of these reads, as it already is at the holder's — a decision for + the round that also settles how `lane-trees` carries a row it could not read. + *The unreadable sidecar is taken in 8.12* — a row of its own, and the + `unreadable-sidecar` class; the other three reads remain. +- **`opensoft/openRepoTools#114` — the inventory fence, and the partial + observation.** `SWAPPING -> SWAPPED` keeps the generation AND the operation by + design (decision 13), so `set-lane-tree --generation G --operation O` still + succeeds after that operation was finalized and a delayed poll overwrites the + completed inventory; and the *all five empty* guard means `--dirty 1` alone + files `upstream none, 0 unpushed` for a tree nobody read, which is the + clean-and-published shape decision 18 removed from the other path. The cost is + a superseded or invented reading that nothing downstream can tell from a + current one; what decides the first is whether the lifecycle STATE joins the + compare-and-swap (at minimum `SWAPPING`) or the operation id is invalidated at + finalization, and the second is whether a partial observation is a usage + refusal or is completed from one `lane_tree_now`. *The second is taken in + 8.12*, as a usage refusal (64); the fence remains. +- **`opensoft/openRepoTools#115` — validation beyond the `schema:` line.** + `lane_sidecar_schema_ok` asks one question, so a truncated `schema: 1` file + with no state and no generation passes it: readers emit empty fields and + writers replace it, which is the act decision 17 refuses for an UNKNOWN schema + performed against a known one whose content is not valid. The cost is the one + loss no later reader can undo; what decides it is which keys, types and + identity each kind of sidecar requires, and whether a malformed record of a + known schema takes the same refusal path an unknown schema takes. +- **`opensoft/openRepoTools#116` — the tree's identity.** `tree_id_for` folds + every character outside the manifest-key set to `-` and squeezes repeats, so + `/tmp/a/b` and `/tmp/a-b` share one sidecar and a lane with two valid trees + loses one from the inventory and from every classification; and the recorded + `checkout` is never compared with the repository git reports, so a path + occupied by a different checkout on the same branch at the same commit reads + `ok` and a recovery relaunches a writer into it. Both cost a tree that is + silently the wrong tree; what decides them is an injective id — or the `cksum` + prefix on every path rather than on long ones only — WITH a migration for the + sidecars already on disk, and a repository identity that is stable across a + clone and answerable for a worktree. *The `cksum` prefix on every path is + taken in 8.12*, with no migration, because no released tooling has written a + sidecar; the `checkout` comparison remains. +- **`opensoft/openRepoTools#117` — what the READY line and the report claim.** + `writer_count` rises before the observation and the sidecar write are known to + have worked, so the READY line says *N worktree(s) recorded* for trees + `lane-trees` does not carry; and the seen-set is seeded from every sidecar + before the registration sweep, so a tree that WAS inventoried and whose + directory is gone is reported `missing`/`possible-loss` and never + `stale-registration`, with the `git worktree prune` remedy omitted and the + estate's `resume` left to fail on it. The cost is a completeness signal a + recovery operator should not trust; what decides the second is the tension + with *one tree, one row* (`612ba5c`) — whether one row can carry both facts, + or the registered-but-gone case is decided before the path is deduplicated. +- **`opensoft/openRepoTools#118` — `lane-handoff` takes a `CLOSED` lane to + `SWAPPING`.** `--expect` asks only *has the lane moved since I read it*, so + every state is a legal source for the swap transition and a terminal lane is + reopened, polled, and finished at `SWAPPED` with a `PAUSED` record appended. + The cost is a lifecycle that says a swap is what last happened to a lane that + was ended; what decides it is a ruling rather than a guard, because + `R-A11-11` binds this command — *a swap is never left unwritten* — so refusing + the handoff would be the first time the lifecycle stopped the swap, and + declining the transition silently would leave a `PAUSED` record beside a + `CLOSED` snapshot. +- **`opensoft/openRepoTools#157` — the lifecycle reads fetch first.** + `lane-reconcile`, `lane-state`, `lane-trees` and `managed-projection` run + `log_sync` before they read, as every read in `lanes-edit.sh` does, so a + direct call offline waits up to `LANES_GIT_TIMEOUT` before reading the ref as + it stands. Kept here because the binding, the alias table and the marker they + read are published state a stale ref misreports; `LANES_NO_FETCH=1` is the + opt-out `lane-start` already uses. What decides it is which default an + operator's recovery read during an outage should have. +- **`opensoft/openRepoTools#157`, its second finding — a second swap supersedes + a live one.** `lifecycle_begin` (`lane-handoff:426-435`) takes over any lane + still recorded `SWAPPING`, which is design.md's settled answer for an + interrupted swap. It cannot tell a live operation from an interrupted one: + that takes the handoff process's liveness, which the snapshot does not + record, and design.md's risks rule out a PID alone. The fence bounds the + harm: the superseded operation's finalizer and inventory writes are refused + with 7 (repoRC-2, repoRC-3, repoRC-5). The spec's "Competing swap begins" + scenario now says that, rather than promising no new owner is minted. What + decides it is a liveness record for the handoff process that a second + handoff can read and trust; 6.2 stays open for it. + +## 8. The rework of 2026-10-04 — managed ledger owns enrolled lanes; this change owns legacy + +Brett Heap's ruling of 2026-10-04, verbatim: "managed ledger owns enrolled lanes; #97 owns legacy — rework both". + +- [x] 8.1 Merge `origin/main` (`daed209`) into the branch with every intent of both sides kept, and no rebase. +- [x] 8.2 Move the unreadable-snapshot exit from 9 (spent by `main`'s #61 on `claim --force`) to 10 (design decision 22). +- [x] 8.3 Close the snapshot of a lane retired by Amendment 19's sweep, which appends its lines without `write_event` (decision 24). +- [x] 8.4 Stop claiming the log keeps five lane-kind verbs; Amendment 18(g) added `HANDOFF-REQUESTED` (decision 12 reworded). +- [x] 8.5 Report a lane bound elsewhere as `indeterminate`, never a crash or a clearance (Amendment 18(b), decision 23). +- [x] 8.6 Carry a renamed lane's control root to its new name (Amendment 16, decision 24). +- [x] 8.7 Port `3c26041:lanes-edit.sh:776-898` verbatim; add `lane_is_managed_owned` and `managed-projection` (decision 21). +- [x] 8.8 Refuse a managed lane (2) and an unknown one (1) before the first write of every act decision 21 lists, and refuse the marker's vocabulary in every legacy row writer. +- [x] 8.9 Move the lifecycle only on a legacy verdict and an unchanged pre-image. +- [x] 8.10 Document the seam in the manual, the proposal, this design and the spec. +- [x] 8.11 Prove each act on a valid long marker, a valid shorthand, a legacy row and five malformed rows, with zero change on every refusal, and leave every existing assertion of this change's suite section unchanged. +- [x] 8.12 Take Codex's four inventory findings on `ec9847e` (decision 25): `lane-start` reports a `resumable` lane whose trees need reading; `set-lane-tree` refuses a partial observation with 64 (#114's second half); every tree id carries a `cksum` of its whole path (#116's first half); and an unreadable tree sidecar is a row of `lane-trees` and an `unreadable-sidecar` class of the reconciliation (#113's second read). +- [x] 8.13 Refuse a managed or unknown row in Amendment 13(e)'s `migrate-state-cells` before its plan is built, aborting the whole migration (Copilot round 2 on #97), with zero change proved in the seam section's case 6. +- [x] 8.14 Close the seam on every legacy writer arm (Copilot round 3 on #97, taken as seam completeness under the ruling): every lane-kind log line, `request-handoff` (`--dry-run` included), `archive-rows`, `append-session-id` and `add-row` for an existing lane refuse a managed or unknown lane. `append-line`, `append-session-id`'s text and `rename-lane`'s new name refuse the marker's vocabulary. The refusal reads this checkout's row as well as the published one. Each arm is proved on every managed and unknown fixture with zero change. diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 2c9f493..90f1718 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -8302,8 +8302,24 @@ is "a Rule 6 line naming the OLD lane is appended" "$rc" 0 has "…and ATTRIBUTED to the lane it is now" "$(git -C "$WIP" log --oneline -1)" "LANES(repoRen-4@$WS_S): append line" # ---------------------------------------------- a chain resolves to its end +# AND THE LANE'S LIFECYCLE SNAPSHOT GOES WITH IT (openRepoTools#91): the control +# root is keyed by the lane's name, so a rename that left it behind would leave +# the renamed lane reading `no-state` — the answer a launcher goes past. +run env LANES_NO_FETCH=1 "$E" set-lane-state repoRen-4 RUNNING --expect none --owner "$REN_ID" +is "a lane about to be renamed records a lifecycle snapshot" "$rc" 0 +ren_snap_old="$(printf '%s\n' "$out" | awk -F'\t' '$1 == "operation" { print $2 }')" +run "$E" lane-state repoRen-4 +ren_snap_file="$(printf '%s\n' "$out" | awk -F'\t' '$1 == "file" { print $2 }')" run env LANES_SESSION="$REN_ID" "$E" rename-lane repoRen-4 repoRen-7 "again" --no-github is "a second rename of the same lane exits 0" "$rc" 0 +has "…saying the lifecycle snapshot moved with it" "$err" "moved with the rename" +run "$E" lane-state repoRen-7 +is "…and the NEW name reads the same snapshot" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "operation" { print $2 }')" "$ren_snap_old" +has "…from a control root under the new name" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "file" { print $2 }')" "/repoRen-7/lane-state.yaml" +is "…and nothing is left under the old one" \ + "$( [ -e "$ren_snap_file" ] && echo left || echo moved )" "moved" run "$E" canon-lane repoRen-1 is "…and the FIRST name resolves through the chain to its end" "$out" "repoRen-7" run "$E" canon-lane repoRen-4 @@ -11984,6 +12000,16 @@ is "…and the three rows are all of them" \ "$(grep -c '| RETIRED · ' "$A19_WIP/lanes/LANES.md" || :)" 3 is "the parked lane's row is untouched" \ "$(grep -c 'PAUSED · 2026-09-11T00:00:00Z · swapped for the night' "$A19_WIP/lanes/LANES.md" || :)" 1 +# THE SWEEP'S RETIRED LINES MOVE THE LIFECYCLE TOO (openRepoTools#91). They are +# appended by `retire_rows` and not by `write_event`, so until this round the +# follow-up that takes every other RETIRED lane to CLOSED never ran for them. +run a19 "$E" lane-state repo19-4 +is "a swept lane's lifecycle snapshot is CLOSED" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "state" { print $2 }')" "CLOSED" +is "…owned by the sweeping lane's transcript, the session the RETIRED line names" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "owner" { print $2 }')" "$A19_ID" +run a19 "$E" lane-state repo19-2 +is "…and the parked lane that did the sweeping has no snapshot made for it" "$rc" 8 # A SECOND RUN IS A NO-OP THAT SAYS SO — nothing to do is said, not done. A19_HEAD1="$(git -C "$A19_WIP" rev-parse HEAD)" @@ -13197,6 +13223,1258 @@ export FAKE_TMUX_WINDOW_NAME="$BIND_SAVE_NAME" export FAKE_TMUX_WINDOW_INDEX="$BIND_SAVE_INDEX" unset LANES_HOST LANES_CONTAINER +echo "== openRepoTools#91: the crash-consistent lifecycle and the worktree inventory ==" + +# A LANE IS `RUNNING`, `SWAPPING`, `SWAPPED` OR `CLOSED`, AND WHICH OF THOSE IT +# IS WITH NO LIVE HOLDER IS WHAT SAYS WHERE ITS SESSION STOPPED. Governed by +# `openspec/changes/add-crash-consistent-lane-worktree-recovery/` and tracked on +# opensoft/openRepoTools#91. +# +# EVERYTHING THIS SECTION ADDS IS ITS OWN — its own repository, its own +# worktrees, its own lanes and its own handoffs — so that it can be read, moved +# or merged in one piece. It reuses `$HANDOFF_CMD` from the Amendment 17 +# section above (the same installed copy) and nothing else of it. + +RC_ID="91aa0001-1111-4000-8000-91aa00011111" +RC_ID2="91aa0002-2222-4000-8000-91aa00022222" +RC_LIVE="91aa0003-3333-4000-8000-91aa00033333" + +RC_DIR="$HOME/projects/repoRC" +mkdir -p "$RC_DIR" +git init -q -b main "$RC_DIR" +git -C "$RC_DIR" config user.email "test@example.invalid" +git -C "$RC_DIR" config user.name "lane helper tests" +git -C "$RC_DIR" remote add origin "https://github.com/opensoft/repoRC.git" +printf 'seed\n' > "$RC_DIR/a.txt" +git -C "$RC_DIR" add -A >/dev/null 2>&1 +git -C "$RC_DIR" commit -q -m "the lane's own first commit" + +# ONE WRITER WORKTREE, A REAL ONE — `git worktree add` in the lane's own +# checkout, which is what the reconciliation reads back out of +# `git worktree list --porcelain`. +mkdir -p "$RC_DIR/.claude/worktrees" +git -C "$RC_DIR" worktree add -q -b feat/rc1 "$RC_DIR/.claude/worktrees/w1" >/dev/null 2>&1 +printf 'uncommitted\n' > "$RC_DIR/.claude/worktrees/w1/b.txt" + +mkdir -p "$WIP/handoffs/repoRC" +rc_seed_handoff() { # + { printf 'Lane: %s (team-01a, session %s) — single-use resume prompt: stamp RESUMED-by before acting (lane-collision-protocol rule 3)\n' "$1" "$RC_ID" + printf '\n' + printf 'Seeded for the openRepoTools#91 cases.\n' + } > "$WIP/handoffs/repoRC/$1.md" +} +rc_row() { # [] + "$E" add-row "| \`$1\` | $2 | Eagle / test / brett | 2026-09-15T00:00Z | none | ${3:-handoffs/repoRC/$1.md} | ACTIVE |" >/dev/null 2>&1 +} +rc_seed_log() { # […] + rc_l="$1"; shift + { printf '# lane %s — object log (lane-collision-protocol Amendment 7)\n' "$rc_l" + printf 'STARTED — lane %s, session %s@Eagle, 2026-09-15T00:00:00Z, lane:%s → home opensoft/repoRC; dir %s; profile team-01a\n' \ + "$rc_l" "$RC_ID" "$rc_l" "$RC_DIR" + for rc_x in "$@"; do printf '%s\n' "$rc_x"; done + } > "$LOGD/$rc_l.md" + git -C "$WIP" add -- "lanes/log/$rc_l.md" + git -C "$WIP" commit -q -m "LOG($rc_l@Eagle): seed" + git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : + git -C "$WIP" push -q origin main + return 0 +} +for rc_l in repoRC-1 repoRC-2 repoRC-3 repoRC-4 repoRC-5 repoRC-6; do + rc_seed_handoff "$rc_l" +done +git -C "$WIP" add -- handoffs/repoRC >/dev/null 2>&1 +git -C "$WIP" commit -q -m "seed the openRepoTools#91 handoffs" +git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : +git -C "$WIP" push -q origin main +rc_row repoRC-1 "harness \`$RC_ID\`" +rc_row repoRC-2 "harness \`$RC_ID\`" +rc_row repoRC-3 "harness \`$RC_ID\`" +rc_row repoRC-4 "harness \`$RC_LIVE\`" +rc_row repoRC-5 "harness \`$RC_ID\`" +# THE LANE WHOSE HANDOFF CANNOT BE FOUND — its row names a path that is not +# there, which is how the "one mandatory write did not land" case is made +# without breaking anything else. +rc_row repoRC-6 "harness \`$RC_ID\`" "handoffs/repoRC/nowhere-at-all.md" +for rc_l in repoRC-1 repoRC-2 repoRC-3 repoRC-4 repoRC-5 repoRC-6; do + rc_seed_log "$rc_l" +done + +RC_STATE_ROOT="$HOME/projects/.lane-state" + +# ------------------------------------- 1. nothing is backfilled, and 8 says so + +run "$E" lane-state repoRC-1 +is "a lane that has never transitioned has no snapshot, and that is 8 and not a failure" "$rc" 8 +run "$E" lane-trees repoRC-1 +is "…and no inventory either" "$rc" 8 +run "$E" lane-reconcile repoRC-1 +is "lane-reconcile still answers for it" "$rc" 0 +is "…with the verdict that names the cutover rule rather than guessing a crash" "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "no-state" +is "…and nothing was created on disk by a read" \ + "$( [ -e "$RC_STATE_ROOT/repoRC-1" ] && echo made || echo none )" none + +# ---------------------- 2. the confirming act writes RUNNING, and the hook never does + +run env LANES_LANE=repoRC-1 LANES_SESSION="$RC_ID" "$E" log RESUMED lane:repoRC-1 '→' "dir $RC_DIR; profile team-01a" "relaunched" +is "a RESUMED is written" "$rc" 0 +run "$E" lane-state repoRC-1 +is "…and the lane is RUNNING: the confirming act writes it, not the SessionStart hook (Amendment 8, R-A8-1)" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "state" { print $2 }')" RUNNING +is "…at generation 1, because a new owner advances the fence" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "generation" { print $2 }')" 1 +is "…owned by the session the line names" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "owner" { print $2 }')" "$RC_ID" +is "…and the profile the payload carried" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "profile" { print $2 }')" team-01a + +rc_snap_sum="$(cksum < "$RC_STATE_ROOT/repoRC-1/lane-state.yaml")" +printf '{"session_id":"%s","source":"startup","cwd":"%s"}' "$RC_ID" "$RC_DIR" | "$E" session-start >/dev/null 2>&1 +is "the SessionStart hook writes NO lifecycle state — it never writes at all (R-A8-1)" \ + "$(cksum < "$RC_STATE_ROOT/repoRC-1/lane-state.yaml")" "$rc_snap_sum" + +run env LANES_LANE=repoRC-1 LANES_SESSION="$RC_ID" "$E" log ENDED lane:repoRC-1 "finished" +run "$E" lane-state repoRC-1 +is "an ENDED closes the lifecycle too" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "state" { print $2 }')" CLOSED + +# --------------------------------------------------- 3. the two crash kinds + +run "$E" set-lane-state repoRC-2 RUNNING --owner "$RC_ID" --agent claude --profile team-01a +is "set-lane-state writes the first snapshot for a lane that has none" "$rc" 0 +run "$E" lane-reconcile repoRC-2 +is "RUNNING with no live holder is an UNGRACEFUL STOP — the session died before any handoff began" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "ungraceful-stop" +has "…and the report says so in the words a resumed session acts on" "$out" "before any handoff began" +run "$E" set-lane-state repoRC-2 SWAPPING --expect RUNNING +is "…the lane moves to SWAPPING" "$rc" 0 +rc_gen="$(printf '%s\n' "$out" | awk -F'\t' '$1 == "generation" { print $2 }')" +rc_op="$(printf '%s\n' "$out" | awk -F'\t' '$1 == "operation" { print $2 }')" +run "$E" lane-reconcile repoRC-2 +is "SWAPPING with no live holder is an INTERRUPTED SWAP — the handoff began and did not finish" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "interrupted-swap" +has "…naming the operation that never finished" "$out" "$rc_op" +has "…and saying the three writes may each be half done" "$out" "may each be half done" + +# THE HOLDER IS THE OTHER HALF OF EVERY VERDICT, and a record this workstation +# really has is what makes one live. +write_record_ns "$sessions_dir/live-rc91.json" "$RC_LIVE" "$LIVE_PID" "$live_start" "rcsess:@31.%31" "repoRC-4" "user" "busy" +run "$E" set-lane-state repoRC-4 RUNNING --owner "$RC_LIVE" --agent claude +run "$E" lane-reconcile repoRC-4 +is "RUNNING with a live holder is a lane that is RUNNING, and not a crash" "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "running" +hasnt "…and never an ungraceful stop" "$out" "ungraceful-stop" +run "$E" set-lane-state repoRC-4 SWAPPING --expect RUNNING +run "$E" lane-reconcile repoRC-4 +is "SWAPPING with a live holder is a swap in flight, not an interrupted one" "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "swap-in-progress" +hasnt "…and is never reported as interrupted" "$out" "interrupted-swap" + +# ------------------------------------------------------------- 4. the fence + +run "$E" set-lane-state repoRC-2 SWAPPING --expect none +is "a transition whose --expect no longer matches is refused with 7, the estate's 'another act got there first'" "$rc" 7 +has "…naming what it found against what was expected" "$err" "state is SWAPPING and --expect named none" +run "$E" lane-state repoRC-2 +is "…and nothing was written: the generation is where it was" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "generation" { print $2 }')" "$rc_gen" + +run "$E" set-lane-state repoRC-2 SWAPPED --expect SWAPPING --expect-generation "$rc_gen" --expect-operation "op-no-such-operation" +is "a STALE FINALIZER — the right state and generation, another operation — is refused" "$rc" 7 +has "…and says a stale finalizer must never overwrite a newer owner" "$err" "must never overwrite a newer owner" +run "$E" set-lane-state repoRC-2 SWAPPED --expect SWAPPING --expect-generation "$rc_gen" --expect-operation "$rc_op" +is "the operation that owns the transition finishes it" "$rc" 0 +is "…and SWAPPED keeps the generation, because it is the same operation reaching its commit point" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "generation" { print $2 }')" "$rc_gen" +run "$E" lane-state repoRC-2 +is "…and a transition that named no owner KEPT the one that was there" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "owner" { print $2 }')" "$RC_ID" +run "$E" lane-reconcile repoRC-2 +is "a swapped lane with no holder is resumable" "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "resumable" + +# A COMPETING RESUME ADVANCES THE GENERATION, and that is what refuses the old +# swap's finalizer if it ever wakes — the whole point of the fence. +run "$E" set-lane-state repoRC-3 RUNNING --owner "$RC_ID" +run "$E" set-lane-state repoRC-3 SWAPPING --expect RUNNING +rc3_gen="$(printf '%s\n' "$out" | awk -F'\t' '$1 == "generation" { print $2 }')" +rc3_op="$(printf '%s\n' "$out" | awk -F'\t' '$1 == "operation" { print $2 }')" +run env LANES_LANE=repoRC-3 LANES_SESSION="$RC_ID2" "$E" log RESUMED lane:repoRC-3 '→' "dir $RC_DIR; profile team-01b" "somebody recovered it" +is "a recovery resumes the lane while a swap is still recorded in flight" "$rc" 0 +run "$E" lane-state repoRC-3 +is "…taking it RUNNING under a NEW generation" \ + "$( [ "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "generation" { print $2 }')" -gt "$rc3_gen" ] && echo advanced || echo stuck )" advanced +run "$E" set-lane-state repoRC-3 SWAPPED --expect SWAPPING --expect-generation "$rc3_gen" --expect-operation "$rc3_op" +is "…so the interrupted swap's own finalizer is refused when it returns" "$rc" 7 +run "$E" lane-state repoRC-3 +is "…and the lane it would have marked SWAPPED is still RUNNING" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "state" { print $2 }')" RUNNING + +# ------------------- 5. the handoff: SWAPPING before the poll, SWAPPED after + +run "$E" set-lane-state repoRC-5 RUNNING --owner "$RC_ID" --agent claude --profile team-01a +run env LANE_HANDOFF_NO_TMUX=1 LANES_EDIT="$E" CLAUDE_CODE_SESSION_ID="$RC_ID" \ + CLAUDE_PROFILE_NAME=team-01a "$HANDOFF_CMD" --lane repoRC-5 clear +is "lane-handoff exits 0" "$rc" 0 +rc5_err="$err" +has "…taking the lane RUNNING -> SWAPPING" "$rc5_err" "lifecycle: RUNNING -> SWAPPING" +has "…and SWAPPING -> SWAPPED once every mandatory write has landed" "$rc5_err" "lifecycle: SWAPPING -> SWAPPED" +is "…in that order, and SWAPPING BEFORE the writers are polled: a session that dies during the poll must not read as an ungraceful stop" \ + "$(printf '%s\n' "$rc5_err" | awk '/lifecycle: RUNNING -> SWAPPING/ { s = NR } /writers polled:/ { p = NR } END { print (s > 0 && p > 0 && s < p) ? "before" : "not-before" }')" "before" +run "$E" lane-state repoRC-5 +is "the lane is SWAPPED" "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "state" { print $2 }')" SWAPPED +is "…and the record's kind is on the snapshot too" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "kind" { print $2 }')" unknown + +run "$E" lane-trees repoRC-5 +is "the handoff took a machine-readable inventory" "$rc" 0 +rc5_tree="$(printf '%s\n' "$out" | head -n 1)" +is "…naming the writer's worktree" "$(printf '%s' "$rc5_tree" | awk -F'\037' '{print $2}')" "$RC_DIR/.claude/worktrees/w1" +is "…its branch" "$(printf '%s' "$rc5_tree" | awk -F'\037' '{print $3}')" "feat/rc1" +is "…its FULL head, which an abbreviated %h is not (it lengthens as a repository grows)" \ + "$(printf '%s' "$rc5_tree" | awk -F'\037' '{print $4}')" \ + "$(git -C "$RC_DIR/.claude/worktrees/w1" rev-parse HEAD)" +is "…its upstream, without which 0 unpushed cannot be told from 'tracks nothing'" \ + "$(printf '%s' "$rc5_tree" | awk -F'\037' '{print $5}')" none +is "…its dirty count" "$(printf '%s' "$rc5_tree" | awk -F'\037' '{print $6}')" 1 +is "…its unpushed count" "$(printf '%s' "$rc5_tree" | awk -F'\037' '{print $7}')" 0 +is "…the writer that held it" "$(printf '%s' "$rc5_tree" | awk -F'\037' '{print $8}')" "$RC_ID" +is "…and the checkout it belongs to" "$(printf '%s' "$rc5_tree" | awk -F'\037' '{print $10}')" "$RC_DIR" +is "the sidecar is BESIDE the lane and never inside the worktree it describes" \ + "$( [ -e "$RC_DIR/.claude/worktrees/w1/lane-state.yaml" ] || [ -e "$RC_DIR/.claude/worktrees/w1/.lane-state" ] && echo inside || echo beside )" beside +is "…so the worktree it describes is no dirtier for having been recorded" \ + "$(git -C "$RC_DIR/.claude/worktrees/w1" status --short | grep -c .)" 1 + +# THE FOURTH WINDOW: the replacement has not started yet, so the lane stays +# SWAPPED and nothing has written RUNNING on a launcher's behalf. +run "$E" lane-reconcile repoRC-5 +is "between the handoff and the next session the lane is resumable, not running" "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "resumable" +is "…and the dirty writer worktree is reported as dirty" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "TREE" && $4 ~ /worktrees\/w1$/ { print $3 }')" "dirty" +run env LANES_LANE=repoRC-5 LANES_SESSION="$RC_ID2" "$E" log RESUMED lane:repoRC-5 '→' "dir $RC_DIR; profile team-09z" "the next session" +run "$E" lane-state repoRC-5 +is "…and only the confirming act takes it back to RUNNING" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "state" { print $2 }')" RUNNING +is "PROFILE ROTATION IS NOT LANE IDENTITY: the new profile is recorded" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "profile" { print $2 }')" team-09z +run "$E" lane-trees repoRC-5 +is "…and the same worktree inventory comes back under it, at the same control root" \ + "$(printf '%s\n' "$out" | awk -F'\037' '{print $2}' | head -n 1)" "$RC_DIR/.claude/worktrees/w1" + +# ---------- 6. one mandatory write missing leaves the lane SWAPPING, honestly + +run "$E" set-lane-state repoRC-6 RUNNING --owner "$RC_ID" --agent claude --profile team-01a +run env LANE_HANDOFF_NO_TMUX=1 LANES_EDIT="$E" CLAUDE_CODE_SESSION_ID="$RC_ID" \ + CLAUDE_PROFILE_NAME=team-01a "$HANDOFF_CMD" --lane repoRC-6 clear +is "a handoff whose handoff file cannot be found still completes (R-A11-11: a swap is never left unwritten)" "$rc" 0 +has "…and still prints the restart line" "$out" "READY — restart with:" +run "$E" lane-state repoRC-6 +is "…but the lane stays SWAPPING, because one of the three mandatory writes did not land" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "state" { print $2 }')" SWAPPING +run "$E" lane-reconcile repoRC-6 +is "…which a later session reads as an interrupted swap and not as a clean handoff" "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "interrupted-swap" + +# ------------------------------------ 7. what the reconciliation classifies + +# A tree that is GONE and was last seen holding work is POSSIBLE LOSS, and a +# tree that is gone and was clean and published is MISSING: no metadata +# reconstructs a file's contents, and the two must never read the same. +run "$E" set-lane-tree repoRC-5 "$RC_DIR/.claude/worktrees/lost" --checkout "$RC_DIR" \ + --branch feat/lost --head 0123456789abcdef0123456789abcdef01234567 --upstream origin/feat/lost --dirty 4 --unpushed 2 +is "set-lane-tree records a tree from the caller's own observation" "$rc" 0 +run "$E" set-lane-tree repoRC-5 "$RC_DIR/.claude/worktrees/tidy" --checkout "$RC_DIR" \ + --branch feat/tidy --head 89abcdef0123456789abcdef0123456789abcdef --upstream origin/feat/tidy --dirty 0 --unpushed 0 +# An UNMANAGED tree: git registers it and no sidecar of this lane names it. +git -C "$RC_DIR" worktree add -q -b feat/rc-unmanaged "$RC_DIR/.claude/worktrees/unmanaged" >/dev/null 2>&1 +# A STALE REGISTRATION: git still holds the path and the directory is gone. +git -C "$RC_DIR" worktree add -q -b feat/rc-stale "$RC_DIR/.claude/worktrees/stale" >/dev/null 2>&1 +rm -rf "$RC_DIR/.claude/worktrees/stale" +# A DETACHED HEAD, which a branch name cannot describe. +git -C "$RC_DIR" worktree add -q --detach "$RC_DIR/.claude/worktrees/detached" >/dev/null 2>&1 +run "$E" set-lane-tree repoRC-5 "$RC_DIR/.claude/worktrees/detached" --checkout "$RC_DIR" + +run "$E" lane-reconcile repoRC-5 +rc5_rep="$out" +is "a missing tree that last held dirty or unpushed work is POSSIBLE LOSS" \ + "$(printf '%s\n' "$rc5_rep" | awk -F'\037' '$1 == "TREE" && $4 ~ /worktrees\/lost$/ { print $3 }')" "possible-loss" +has "…and says plainly that nothing here reconstructs uncommitted files" "$rc5_rep" "NOTHING here can reconstruct uncommitted files" +is "a missing tree that was clean and published is MISSING, and not a possible loss" \ + "$(printf '%s\n' "$rc5_rep" | awk -F'\037' '$1 == "TREE" && $4 ~ /worktrees\/tidy$/ { print $3 }')" "missing" +has "…naming the estate's own resume as the only rebuild, rather than running it" \ + "$rc5_rep" "the estate parked record and \`resume \` are the only rebuild" +is "a tree git registers that no sidecar names is UNMANAGED" \ + "$(printf '%s\n' "$rc5_rep" | awk -F'\037' '$1 == "TREE" && $4 ~ /worktrees\/unmanaged$/ { print $3 }')" "unmanaged" +has "…and is left exactly as it is" "$rc5_rep" "it is left exactly as it is" +is "a registration whose directory is gone is a STALE REGISTRATION" \ + "$(printf '%s\n' "$rc5_rep" | awk -F'\037' '$1 == "TREE" && $4 ~ /worktrees\/stale$/ { print $3 }')" "stale-registration" +has "…naming the prune that clears it, which is a person's act" "$rc5_rep" "worktree prune" +is "a detached HEAD is recorded and reported as detached, not as a branch" \ + "$(printf '%s\n' "$rc5_rep" | awk -F'\037' '$1 == "TREE" && $4 ~ /worktrees\/detached$/ { print $5 }' | awk '{print $2}')" "detached" +has "the stored observation is a COMPARISON POINT and the report says what git says NOW" "$rc5_rep" "observed" +is "every discovered tree is named ONCE, whichever of the two sweeps found it" \ + "$(printf '%s\n' "$rc5_rep" | awk -F'\037' '$1 == "TREE" && $4 ~ /worktrees\/unmanaged$/' | grep -c .)" 1 + +# AND IT RESET NOTHING. This is the claim the whole read exists under: +# `park` CREATES NOTHING and `resume` RESETS NOTHING (AGENTS.md rule 1). +is "the dirty writer's uncommitted file survived the reconciliation" \ + "$( [ -f "$RC_DIR/.claude/worktrees/w1/b.txt" ] && echo kept || echo gone )" kept +is "…the unmanaged worktree was not deleted" \ + "$( [ -d "$RC_DIR/.claude/worktrees/unmanaged" ] && echo kept || echo gone )" kept +is "…the stale registration was not pruned" \ + "$(git -C "$RC_DIR" worktree list --porcelain | grep -c 'worktrees/stale$')" 1 +is "…and no missing path was recreated" \ + "$( [ -e "$RC_DIR/.claude/worktrees/lost" ] && echo made || echo none )" none + +# ------------------------------- 8. a schema this reader does not know is closed + +printf 'schema: 999\nstate: WONDERLAND\n' > "$RC_STATE_ROOT/repoRC-3/lane-state.yaml" +run "$E" lane-state repoRC-3 +has "a snapshot written by a newer tooling is UNKNOWN-SCHEMA and never a state this reader acts on" "$out" "UNKNOWN-SCHEMA" +run "$E" lane-reconcile repoRC-3 +is "…and the verdict says nothing is assumed about it" "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "unknown-state" + +# ------------------------------------------ 9. the usage contract of the five + +run "$E" lane-state +is "lane-state with no lane is 64, the code every read in front of a launch spends" "$rc" 64 +run "$E" set-lane-state repoRC-5 NOWHERE +is "a fifth state word is refused" "$rc" 64 +has "…naming the four there are" "$err" "RUNNING, SWAPPING, SWAPPED and CLOSED are the four" +run "$E" set-lane-tree repoRC-5 relative/path +is "a relative worktree path is refused: no later reader shares this process's directory" "$rc" 64 +run "$E" lane-reconcile repoRC-5 extra-argument +is "lane-reconcile takes one lane" "$rc" 64 + +# ------------- 10. lane-start prints the recovery report before it writes anything + +run "$E" set-lane-state repoRC-2 RUNNING --owner "$RC_ID" --agent claude --profile team-01a +run "$START" repoRC 2 --no-launch +is "lane-start on a lane whose last session stopped ungracefully still starts it" "$rc" 0 +has "…and says so first: the report is printed BEFORE the row and the log are written" "$err" "LANE RECOVERY" +has "…with the crash kind" "$err" "ungraceful-stop" +has "…and the line that says nothing was touched" "$err" "Nothing here was reset, recreated or deleted" +run "$E" lane-state repoRC-2 +is "…and the lane it just bound is RUNNING under the act that confirmed it" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "state" { print $2 }')" RUNNING + + +# ---- 11. the six fences of the review round (Copilot round 5 on #97) +# +# EACH OF THESE IS ONE OF THE FENCES THIS CAPABILITY EXISTS FOR, and every one +# of them was a hole in the first implementation: a follow-up that read the +# generation and replaced the snapshot outside the mutex and outside any fence +# of its own; two writers that walked straight past the fail-closed schema check +# the reader beside them keeps; an inventory whose generation and operation were +# written into every sidecar and compared with nothing; a handoff that made a +# second observation of its own, with its own error handling; an inventory +# reader that read every `*.yaml` under `trees/` whatever it claimed to be; and +# an observation that turned a git read which FAILED into `unknown`, `none` or a +# `0` that reads as *everything here is published*. +# +# The cases below write object-log lines, and `lanes-edit.sh` refuses a write on +# a checkout it cannot rebase. The sections above leave refreshed handoffs +# uncommitted on purpose, because those cases are about them; this one is about +# the fences, so the checkout is committed first and that refusal is out of the +# way — the same thing the workstation-seam section below does, for the same +# reason. +git -C "$WIP" add -A >/dev/null 2>&1 +git -C "$WIP" commit -q -m "commit the sandbox's pending handoffs before the fence cases" >/dev/null 2>&1 || : + +# (a) A SNAPSHOT THIS HELPER CANNOT READ IS NEVER REPLACED BY ONE IT CAN. +# `repoRC-3` carries the `schema: 999` file section 8 wrote. A reader that fails +# closed beside a writer that reads the raw fields and renames its own file over +# the top protects nothing: the record an older helper could not read is exactly +# the record it would destroy, and no later reader can undo that. +rc3_sum="$(cksum < "$RC_STATE_ROOT/repoRC-3/lane-state.yaml")" +run "$E" set-lane-state repoRC-3 RUNNING --owner "$RC_ID" +is "a writer meeting a snapshot whose schema it does not write REFUSES" "$rc" 1 +has "…naming what it found" "$err" "records schema 999" +is "…and the file it would have replaced is byte for byte what it was" \ + "$(cksum < "$RC_STATE_ROOT/repoRC-3/lane-state.yaml")" "$rc3_sum" +run env LANES_LANE=repoRC-3 LANES_SESSION="$RC_ID" "$E" log RESUMED lane:repoRC-3 '→' "dir $RC_DIR; profile team-01a" "a resume that meets a snapshot it cannot read" +is "…the event line itself still lands, because the lifecycle never fails the event" "$rc" 0 +has "…and the follow-up says why it wrote nothing" "$err" "schema this helper does not write" +is "…having left that snapshot alone too" \ + "$(cksum < "$RC_STATE_ROOT/repoRC-3/lane-state.yaml")" "$rc3_sum" + +# (b) A FOLLOW-UP WHOSE LANE MOVED UNDER IT WRITES NOTHING. The window is real +# and it is not small: `write_event` appends the line, commits it and pushes it +# BEFORE the snapshot is moved, and a lane can be recovered by somebody else +# inside it. The `post-commit` hook below is that somebody — it moves the +# snapshot by hand, with no helper and no mutex, in the middle of the write — so +# this case is a SCHEDULE and not a race. +rc_seed_handoff repoRC-7 +git -C "$WIP" add -- handoffs/repoRC >/dev/null 2>&1 +git -C "$WIP" commit -q -m "seed repoRC-7's handoff" >/dev/null 2>&1 || : +rc_row repoRC-7 "harness \`$RC_ID\`" +rc_seed_log repoRC-7 +run "$E" set-lane-state repoRC-7 RUNNING --owner "$RC_ID" --agent claude --profile team-01a +is "the lane a delayed write will be about is RUNNING" "$rc" 0 +mkdir -p "$WIP/.git/hooks" +cat > "$WIP/.git/hooks/post-commit" < "$RC_STATE_ROOT/repoRC-7/lane-state.yaml" +exit 0 +HOOK +chmod +x "$WIP/.git/hooks/post-commit" +: > "$SANDBOX/rc7-interleave" +run env LANES_LANE=repoRC-7 LANES_SESSION="$RC_ID2" "$E" log RESUMED lane:repoRC-7 '→' "dir $RC_DIR; profile team-09z" "a resume overtaken while it was being written" +rc7_err="$err" +is "the delayed RESUMED's own line still lands" "$rc" 0 +has "…and the follow-up says the lane moved under it" "$rc7_err" "the lane lifecycle moved under this RESUMED" +run "$E" lane-state repoRC-7 +is "…the lane another act took to SWAPPING mid-write is STILL SWAPPING" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "state" { print $2 }')" SWAPPING +is "…at the generation that act gave it, never overwritten by a write that began before it" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "generation" { print $2 }')" 9 +rm -f "$WIP/.git/hooks/post-commit" + +# (c) AN OBSERVATION FILED UNDER AN OPERATION THE LANE HAS MOVED PAST IS +# REFUSED. The pair was written into every sidecar from the first commit of this +# capability and read by nothing, so a handoff that stalled while a recovery +# advanced the lane filed its superseded poll straight over the current one. +run "$E" lane-state repoRC-5 +rc5_gen="$(printf '%s\n' "$out" | awk -F'\t' '$1 == "generation" { print $2 }')" +rc5_op="$(printf '%s\n' "$out" | awk -F'\t' '$1 == "operation" { print $2 }')" +rc5_w1head="$(git -C "$RC_DIR/.claude/worktrees/w1" rev-parse HEAD)" +run "$E" set-lane-tree repoRC-5 "$RC_DIR/.claude/worktrees/w1" --checkout "$RC_DIR" \ + --branch feat/rc1 --head 0000000000000000000000000000000000000000 --upstream none \ + --dirty 0 --unpushed 0 --generation "$rc5_gen" --operation op-a-superseded-handoff +is "an inventory write whose operation is not the lane's is refused with 7" "$rc" 7 +has "…saying a superseded reading is not filed where the current one belongs" "$err" "superseded reading" +run "$E" lane-trees repoRC-5 +is "…and the reading that was there is the one that is there" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$2 ~ /worktrees\/w1$/ { print $4 }')" "$rc5_w1head" +run "$E" set-lane-tree repoRC-5 "$RC_DIR/.claude/worktrees/w1" --checkout "$RC_DIR" \ + --branch feat/rc1 --head "$rc5_w1head" --upstream none --dirty 1 --unpushed 0 \ + --generation "$rc5_gen" --operation "$rc5_op" +is "…while the operation the lane IS on records its observation" "$rc" 0 + +# (d) ONE IMPLEMENTATION OF THE OBSERVATION, and the proof is a value only that +# one produces. A branch whose upstream is CONFIGURED and whose remote-tracking +# ref is not in this checkout — the ordinary state of a branch whose remote was +# deleted after its merge — has `unknown` unpushed, never the `0` that a +# `git log @{u}.. | grep -c .` of the caller's own answers and that reads as +# *everything here is published*. +git -C "$RC_DIR/.claude/worktrees/w1" config branch.feat/rc1.remote origin +git -C "$RC_DIR/.claude/worktrees/w1" config branch.feat/rc1.merge refs/heads/feat/rc1 +run "$E" lane-tree-now "$RC_DIR/.claude/worktrees/w1" +is "lane-tree-now answers for a real checkout" "$rc" 0 +is "…naming the upstream that IS configured rather than 'none'" \ + "$(printf '%s' "$out" | awk -F'\037' '{print $3}')" "origin/feat/rc1" +is "…and 'unknown' for a count against a ref this checkout does not have" \ + "$(printf '%s' "$out" | awk -F'\037' '{print $5}')" "unknown" +run "$E" lane-tree-now "$RC_DIR/.claude/worktrees/there-is-nothing-here" +is "…8 where there is no checkout at the path" "$rc" 8 +run "$E" lane-tree-now relative/path +is "…and 64 for a relative path, which names whatever directory the caller happens to be in" "$rc" 64 +# A BRANCH WITH NO COMMIT YET IS A STATE, NOT A BROKEN REPOSITORY — and it is +# the one `rev-parse --abbrev-ref HEAD` refuses exactly as it refuses a corrupt +# HEAD, so `symbolic-ref` is what tells the two apart. Outside both lane roots, +# so it joins nobody's sweep. +mkdir -p "$HOME/projects/rc-unborn" +git init -q -b main "$HOME/projects/rc-unborn" +run "$E" lane-tree-now "$HOME/projects/rc-unborn" +is "a branch with no commit yet is answered, not refused" "$rc" 0 +is "…with 'unborn' for the head no commit has given it" \ + "$(printf '%s' "$out" | awk -F'\037' '{print $2}')" "unborn" +is "…and the branch it is on all the same" \ + "$(printf '%s' "$out" | awk -F'\037' '{print $1}')" "main" + +run env LANE_HANDOFF_NO_TMUX=1 LANES_EDIT="$E" CLAUDE_CODE_SESSION_ID="$RC_ID2" \ + CLAUDE_PROFILE_NAME=team-09z "$HANDOFF_CMD" --lane repoRC-7 clear +is "a handoff over a lane another act left SWAPPING still completes" "$rc" 0 +has "…and its WRITERS section carries the helper's own reading of that writer" "$out" "unknown unpushed" +run "$E" lane-trees repoRC-7 +is "…which is the reading its sidecar carries too, because the two are ONE observation" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$2 ~ /worktrees\/w1$/ { print $7 }')" "unknown" +run env LANES_LANE=repoRC-7 LANES_SESSION="$RC_ID" "$E" log RESUMED lane:repoRC-7 '→' "dir $RC_DIR; profile team-01a" "the next session" +run "$E" lane-reconcile repoRC-7 +has "…and an inventory taken under an earlier generation SAYS so, rather than reading as current" \ + "$out" "observed under generation" +is "…while the tree whose unpushed count cannot be made is classed by that, never as published" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "TREE" && $4 ~ /worktrees\/w1$/ { print $3 }')" \ + "dirty+unpushed-unknown" + +# (e) A TREE SIDECAR THIS READER DOES NOT KNOW IS SAID AND NEVER PARSED. Until +# this round every `*.yaml` under `trees/` was read field by field whatever it +# claimed to be — so a record written by a newer tooling was reported as an +# ordinary observation, in fields this reader was guessing at. +run "$E" lane-trees repoRC-5 +rc5_w1_id="$(printf '%s\n' "$out" | awk -F'\037' '$2 ~ /worktrees\/w1$/ { print $1 }')" +is "the writer's sidecar has an id derived from its path" \ + "$( [ -n "$rc5_w1_id" ] && echo yes || echo no )" "yes" +printf 'schema: 999\ntree: %s\npath: %s\nbranch: feat/wonderland\ndirty: 0\nunpushed: 0\n' \ + "$rc5_w1_id" "$RC_DIR/.claude/worktrees/w1" > "$RC_STATE_ROOT/repoRC-5/trees/$rc5_w1_id.yaml" +run "$E" lane-reconcile repoRC-5 +is "a tree sidecar written by a newer tooling is UNKNOWN-SCHEMA, not an observation" \ + "$(printf '%s\n' "$out" | awk -F'\037' -v id="$rc5_w1_id" '$1 == "TREE" && $2 == id { print $3 }')" "unknown-schema" +hasnt "…and none of its fields is read, so nothing is compared against them" "$out" "feat/wonderland" +rc5w1_sum="$(cksum < "$RC_STATE_ROOT/repoRC-5/trees/$rc5_w1_id.yaml")" +run "$E" set-lane-tree repoRC-5 "$RC_DIR/.claude/worktrees/w1" --checkout "$RC_DIR" \ + --branch feat/rc1 --head "$rc5_w1head" --upstream none --dirty 1 --unpushed 0 +is "…and the writer refuses to replace a record it cannot read" "$rc" 1 +is "…so that file is byte for byte what it was" \ + "$(cksum < "$RC_STATE_ROOT/repoRC-5/trees/$rc5_w1_id.yaml")" "$rc5w1_sum" + +# (f) A GIT READ THAT FAILED IS NEVER COMPLETED WITH A CLEAN-LOOKING VALUE. The +# fixture is a real worktree whose HEAD git can no longer resolve: `rev-parse +# --git-dir` still answers there — which is all the first implementation asked — +# and every read after it fails, which used to produce `branch unknown, head +# unknown, upstream none, 0 dirty, 0 unpushed`: a record a later reconciliation +# reads as clean and published. +git -C "$RC_DIR" worktree add -q -b feat/rc-broken "$RC_DIR/.claude/worktrees/broken" >/dev/null 2>&1 +printf 'ref: refs/heads/\n' > "$RC_DIR/.git/worktrees/broken/HEAD" +run "$E" lane-tree-now "$RC_DIR/.claude/worktrees/broken" +is "an observation git could not complete is 1" "$rc" 1 +is "…and prints NOTHING, rather than a branch and two zero counts" "$out" "" +has "…naming the act a person takes" "$err" "git -C $RC_DIR/.claude/worktrees/broken status" +run "$E" set-lane-tree repoRC-5 "$RC_DIR/.claude/worktrees/broken" --checkout "$RC_DIR" +is "…and nothing is recorded for a tree this process could not read" "$rc" 1 +run "$E" lane-trees repoRC-5 +is "…so the inventory gained no row for it" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$2 ~ /worktrees\/broken$/' | grep -c .)" 0 +run "$E" set-lane-tree repoRC-5 "$RC_DIR/.claude/worktrees/broken" --checkout "$RC_DIR" \ + --branch feat/rc-broken --head 2222222222222222222222222222222222222222 \ + --upstream none --dirty 3 --unpushed 1 +is "a caller's OWN observation of it is still recorded: the refusal is about INVENTING one" "$rc" 0 +run "$E" lane-reconcile repoRC-5 +is "…and the reconciliation reports it UNREADABLE, neither clean nor missing" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "TREE" && $4 ~ /worktrees\/broken$/ { print $3 }')" "unreadable" +has "…assuming nothing about what it holds" "$out" "NOTHING is assumed about it" +is "…and it was left exactly as it is, like every other tree this read names" \ + "$( [ -d "$RC_DIR/.claude/worktrees/broken" ] && echo kept || echo gone )" "kept" + +# (g) ONE TREE, ONE ROW, THROUGH HOWEVER MANY SPELLINGS OF ITS PATH. 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`. Every estate with a +# `projects` symlink reaches its checkouts through it — and on macOS `$TMPDIR` +# and `$HOME` live under `/var`, which IS a symlink to `/private/var`, which is +# how the four cases of section 7 above went red on that runner alone at +# `612ba5c`: every tree was reported TWICE, once as the tree it is and once as a +# tree nobody manages. `612ba5c` resolved the lane's OWN checkout for this exact +# reason and left the trees under it unresolved. The lane below reaches the same +# checkout through a link, so the defect is reproducible on every platform. +RC_LINK="$HOME/projects/repoRCL" +ln -s "$RC_DIR" "$RC_LINK" +rc_seed_handoff repoRC-8 +git -C "$WIP" add -- handoffs/repoRC >/dev/null 2>&1 +git -C "$WIP" commit -q -m "seed repoRC-8's handoff" >/dev/null 2>&1 || : +rc_row repoRC-8 "harness \`$RC_ID\`" +{ printf '# lane repoRC-8 — object log (lane-collision-protocol Amendment 7)\n' + printf 'STARTED — lane repoRC-8, session %s@Eagle, 2026-09-15T00:00:00Z, lane:repoRC-8 → home opensoft/repoRC; dir %s; profile team-01a\n' \ + "$RC_ID" "$RC_LINK" +} > "$LOGD/repoRC-8.md" +git -C "$WIP" add -- "lanes/log/repoRC-8.md" >/dev/null 2>&1 +git -C "$WIP" commit -q -m "LOG(repoRC-8@Eagle): seed" +git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : +git -C "$WIP" push -q origin main +run "$E" set-lane-state repoRC-8 RUNNING --owner "$RC_ID" --agent claude --profile team-01a +is "a lane whose recorded directory reaches its checkout through a link is RUNNING" "$rc" 0 +run "$E" set-lane-tree repoRC-8 "$RC_LINK/.claude/worktrees/w1" --checkout "$RC_LINK" \ + --branch feat/rc1 --head "$rc5_w1head" --upstream none --dirty 1 --unpushed 0 +is "…and its writer is recorded under the spelling the poll was given" "$rc" 0 +run "$E" lane-reconcile repoRC-8 +is "a tree recorded through a linked path is named ONCE, not once as itself and once as unmanaged" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "TREE" && $4 ~ /worktrees\/w1$/' | grep -c .)" 1 +is "…as the tree it is, and never as one nobody manages" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "TREE" && $4 ~ /worktrees\/w1$/ && $3 == "unmanaged"' | grep -c .)" 0 +is "…and the lane's own checkout is still not a tree either, whichever spelling names it" \ + "$(printf '%s\n' "$out" | awk -F'\037' -v d="$RC_DIR" '$1 == "TREE" && $4 == d' | grep -c .)" 0 + +# ---- 12. the two safety holes of the sixth round (Copilot round 6 on #97) +# +# BOTH OF THESE ARE THE SAME RULE READ IN TWO PLACES. A snapshot that IS THERE +# and cannot be read was answered as *this lane has no snapshot*, which is the +# verdict a launcher goes straight past; and an inventory write that named no +# fence took no mutex at all, so the file every other writer of it is serialized +# on was the one file two writers could race. The rest of that round's findings +# are filed as issues and named in the change's `tasks.md` section 7: rounds are +# capped at two per pull request, and these two are what could not wait. + +rc_seed_handoff repoRC-9 +git -C "$WIP" add -- handoffs/repoRC >/dev/null 2>&1 +git -C "$WIP" commit -q -m "seed repoRC-9's handoff" >/dev/null 2>&1 || : +rc_row repoRC-9 "harness \`$RC_ID\`" +rc_seed_log repoRC-9 + +# (a) A SNAPSHOT THAT IS THERE AND CANNOT BE READ IS NOT A LANE THAT HAS NONE. +# THE FIXTURE IS A DANGLING SYMLINK and not a `chmod 000`, deliberately: 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. A broken link is *the name is there +# and the bytes are not* on every host and every uid — and it is `[ -e ]` FALSE, +# which is the exact corner the reader now asks `[ -L ]` about. +mkdir -p "$RC_STATE_ROOT/repoRC-9" +ln -s "$SANDBOX/no-such-snapshot-91" "$RC_STATE_ROOT/repoRC-9/lane-state.yaml" +run "$E" lane-state repoRC-9 +is "a lifecycle snapshot that exists and cannot be read is 10, never the 8 that means there is none" "$rc" 10 +has "…saying which of the two it is" "$err" "it could not be read" +has "…and citing the rule a failed read answers to" "$err" "R22" +run "$E" lane-reconcile repoRC-9 +is "…the reconciliation still answers for it" "$rc" 0 +is "…with the state word that says nobody read it" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "STATE" { print $2 }')" "UNREADABLE" +is "…and the verdict is INDETERMINATE: no crash is pronounced on a read nobody got" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "indeterminate" +hasnt "…and never 'no-state', which is the answer a launcher goes straight past" "$out" "no-state" +is "…and the file nobody could read was not replaced, moved or deleted by a read" \ + "$( [ -L "$RC_STATE_ROOT/repoRC-9/lane-state.yaml" ] && echo kept || echo gone )" kept +# AND NOT BY A WRITE EITHER (Copilot on 64dfd97): the writers' schema guard took +# a dangling link for an absent file and renamed a fresh snapshot over it. +run "$E" set-lane-state repoRC-9 RUNNING --owner "$RC_ID" +is "a WRITER meeting that dangling snapshot refuses rather than replacing it" "$rc" 1 +is "…and the link is exactly where it was" \ + "$( [ -L "$RC_STATE_ROOT/repoRC-9/lane-state.yaml" ] && echo kept || echo gone )" kept +# THE HELPER'S TREE-ID RULE, RESTATED for the cases that must name a sidecar +# before any write has made one: a `cksum` of the whole path, then the path +# folded to the manifest-key set (Codex on ec9847e: the fold alone collides). +rc_tree_id() { # + rti_f="$(printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '-' | tr -s '-' | sed -e 's/^-*//' -e 's/-*$//')" + printf 'c%s-%s\n' "$(printf '%s' "$1" | cksum | awk '{print $1}')" "$rti_f" +} +rc9_tp="$RC_DIR/.claude/worktrees/dangling-91" +rc9_tid="$(rc_tree_id "$rc9_tp")" +mkdir -p "$RC_STATE_ROOT/repoRC-9/trees" +ln -s "$SANDBOX/no-such-sidecar-91" "$RC_STATE_ROOT/repoRC-9/trees/$rc9_tid.yaml" +run "$E" set-lane-tree repoRC-9 "$rc9_tp" --checkout "$RC_DIR" --branch b --head h --upstream none --dirty 0 --unpushed 0 +is "…and a tree sidecar that is a dangling link is refused the same way" "$rc" 1 +is "…and left where it was" \ + "$( [ -L "$RC_STATE_ROOT/repoRC-9/trees/$rc9_tid.yaml" ] && echo kept || echo gone )" kept +rm -f "$RC_STATE_ROOT/repoRC-9/trees/$rc9_tid.yaml" + +# AND THE OTHER HALF OF THE PAIR, on the same lane and the same control root, so +# that the two answers differ in nothing but whether the file is there: a lane +# that really has NO snapshot is still 8 and still `no-state` (Amendment 7(i)'s +# cutover rule — nothing is backfilled). +rm -f "$RC_STATE_ROOT/repoRC-9/lane-state.yaml" +run "$E" lane-state repoRC-9 +is "a lane that has no snapshot at all is 8, exactly as before" "$rc" 8 +run "$E" lane-reconcile repoRC-9 +is "…and its verdict is the cutover one" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "no-state" + +# (b) EVERY INVENTORY WRITE IS SERIALIZED, FENCE OR NO FENCE. `set-lane-tree` +# took the mutex only when a caller named `--generation`/`--operation`; the +# atomic rename underneath it stops a reader seeing half a file and stops +# nothing else, so an UNFENCED observation could land after a newer fenced one +# and replace it. The lock is held here by a pid that is alive — `$LIVE_PID`, +# the fixture the last case of this file asserts is still running — so it cannot +# be stolen as a dead holder's, and `timeout`'s own 124 is the proof the writer +# was still WAITING for it rather than exiting early on something else. +if [ "$HAVE_TIMEOUT" = 1 ]; then + mkdir -p "$H_LOCK"; printf '%s\n' "$LIVE_PID" > "$H_LOCK/pid" + LANES_NO_FETCH=1 timeout 3 "$E" set-lane-tree repoRC-9 "$RC_DIR/.claude/worktrees/w1" \ + --checkout "$RC_DIR" --branch feat/rc1 --head "$rc5_w1head" --upstream none \ + --dirty 1 --unpushed 0 >/dev/null 2>&1 + rc9_lk=$? + is "an UNFENCED inventory write WAITS on the lane mutex another run holds" "$rc9_lk" 124 + run "$E" lane-trees repoRC-9 + is "…so nothing of it was written while that run held the lock" "$rc" 8 + rm -rf "$H_LOCK" + run env LANES_NO_FETCH=1 "$E" set-lane-tree repoRC-9 "$RC_DIR/.claude/worktrees/w1" \ + --checkout "$RC_DIR" --branch feat/rc1 --head "$rc5_w1head" --upstream none \ + --dirty 1 --unpushed 0 + is "…and the same write lands the moment the mutex is free" "$rc" 0 + run "$E" lane-trees repoRC-9 + is "…which is the inventory this lane then holds" \ + "$(printf '%s\n' "$out" | awk -F'\037' '{print $2}' | head -n 1)" "$RC_DIR/.claude/worktrees/w1" +else + skip "an UNFENCED inventory write WAITS on the lane mutex another run holds" "$NO_TIMEOUT_WHY" + skip "…so nothing of it was written while that run held the lock" "$NO_TIMEOUT_WHY" + skip "…and the same write lands the moment the mutex is free" "$NO_TIMEOUT_WHY" + skip "…which is the inventory this lane then holds" "$NO_TIMEOUT_WHY" +fi + +# ---------------- A LANE BOUND ELSEWHERE IS NEVER PRONOUNCED ON FROM HERE +# +# Amendment 18(b): liveness is pronounced only from inside the binding's own +# host and container, and from anywhere else a binding is UNKNOWN, never dead. +# This workstation's holder read finds nobody for a lane whose last STARTED was +# written on another host, and its snapshot is this workstation's alone, so a +# RUNNING snapshot here plus an empty holder read is not an ungraceful stop. +rc_row repoRC-10 "harness \`$RC_ID\`" +{ printf '# lane repoRC-10 — object log (lane-collision-protocol Amendment 7)\n' + printf 'STARTED — lane repoRC-10, session %s@Eagle, 2026-09-15T00:00:00Z, lane:repoRC-10 → home opensoft/repoRC; dir %s; profile team-01a; host elsewhere-host; container none; os linux\n' \ + "$RC_ID" "$RC_DIR" +} > "$LOGD/repoRC-10.md" +git -C "$WIP" add -- lanes/log/repoRC-10.md +git -C "$WIP" commit -q -m "LOG(repoRC-10@Eagle): seed a binding on another host" +git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : +git -C "$WIP" push -q origin main +run env LANES_NO_FETCH=1 "$E" set-lane-state repoRC-10 RUNNING --expect none --owner "$RC_ID" +is "a lane bound on another host can still be recorded RUNNING here" "$rc" 0 +run "$E" lane-reconcile repoRC-10 +is "lane-reconcile answers for it" "$rc" 0 +is "…naming the binding as ELSEWHERE" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "BINDING" { print $2 }')" "elsewhere" +is "…and where it is" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "BINDING" { print $3 }')" "elsewhere-host/none" +is "…and the verdict is INDETERMINATE, never a crash pronounced from outside the binding" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "indeterminate" +has "…citing the rule" "$out" "Amendment 18(b)" +hasnt "…and never ungraceful-stop" "$out" "ungraceful-stop" +rc_row repoRC-11 "harness \`$RC_ID\`" +rc_seed_log repoRC-11 +run env LANES_NO_FETCH=1 "$E" set-lane-state repoRC-11 RUNNING --expect none --owner "$RC_ID" +run "$E" lane-reconcile repoRC-11 +is "a lane whose STARTED predates Amendment 18(a) is bound HERE, on the line's own workstation" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "BINDING" { print $2 }')" "here" +is "…so the same RUNNING snapshot with no holder IS an ungraceful stop" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "ungraceful-stop" + +# AND THE SNAPSHOT NEVER LEAKS INTO THE LISTING'S BINDING COLUMN (Amendment +# 18's column 13): a lane with a lifecycle snapshot and no binding line is +# still `none` there, because the binding is the published log's and the +# snapshot is this workstation's alone. +rc_row repoRC-12 "harness \`$RC_ID\`" +{ printf '# lane repoRC-12 — object log (lane-collision-protocol Amendment 7)\n' + printf 'NOTED — lane repoRC-12, session %s@Eagle, 2026-09-15T00:00:00Z, lane:repoRC-12 — a log with no binding line\n' "$RC_ID" +} > "$LOGD/repoRC-12.md" +git -C "$WIP" add -- lanes/log/repoRC-12.md +git -C "$WIP" commit -q -m "LOG(repoRC-12@Eagle): a log with no binding line" +git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : +git -C "$WIP" push -q origin main +run "$E" set-lane-state repoRC-12 RUNNING --expect none --owner "$RC_ID" +is "a lane with no binding line can still carry a lifecycle snapshot" "$rc" 0 +run env LANES_NO_FETCH=1 "$E" lanes --lane repoRC-12 +is "…and the listing's binding column says none for it, whatever the snapshot says" \ + "$(printf '%s' "$out" | cut -f13)" "none" + +# ---------- AN UNREADABLE LOG IS NEVER A CLEARANCE (Copilot on 64dfd97) +# +# A log published twice under names that differ only by case cannot be read as +# ONE log (Amendment 15), so the lane's coordinator directory is not established +# and neither of the reconciliation's sweeps can run. A SWAPPED lane with no +# holder must not read as resumable over paths nobody inspected. Published +# through Amendment 15's own plumbing, which never touches a working tree, so +# the case runs on a case-insensitive file system too. +rc_row repoRC-13 "harness \`$RC_ID\`" +rc_seed_log repoRC-13 +run "$E" set-lane-state repoRC-13 SWAPPED --owner "$RC_ID" +is "a lane can be recorded SWAPPED" "$rc" 0 +run "$E" lane-reconcile repoRC-13 +is "…and with its log readable and no holder it is resumable" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "resumable" +a15_publish lanes/log/reporc-13.md "# lane reporc-13 — a second spelling of the same log" +run "$E" lane-reconcile repoRC-13 +is "…but with its log published twice it is indeterminate" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "indeterminate" +hasnt "…and never resumable over paths nobody read" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT"')" "resumable" +a15_unpublish lanes/log/reporc-13.md + +# ------- A REFUSED INVENTORY WRITE KEEPS THE LANE SWAPPING (Copilot on 64dfd97) +# +# `SWAPPED` is the operation's commit point, and an inventory missing a polled +# writer is not what a recovery may read as a finished swap. The refusal is the +# writer's own schema guard, met through a sidecar a newer tooling wrote. +rc_row repoRC-14 "harness \`$RC_ID\`" +rc_seed_log repoRC-14 +rc_seed_handoff repoRC-14 +git -C "$WIP" add -- handoffs/repoRC >/dev/null 2>&1 +git -C "$WIP" commit -q -m "seed the repoRC-14 handoff" +git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : +git -C "$WIP" push -q origin main +git -C "$RC_DIR" worktree add -q -b feat/rc14 "$RC_DIR/.claude/worktrees/w14" >/dev/null 2>&1 +rc14_tid="$(rc_tree_id "$RC_DIR/.claude/worktrees/w14")" +mkdir -p "$RC_STATE_ROOT/repoRC-14/trees" +printf 'schema: 999\nlane: repoRC-14\n' > "$RC_STATE_ROOT/repoRC-14/trees/$rc14_tid.yaml" +run "$E" set-lane-state repoRC-14 RUNNING --owner "$RC_ID" --agent claude --profile team-01a +run env LANE_HANDOFF_NO_TMUX=1 LANES_EDIT="$E" CLAUDE_CODE_SESSION_ID="$RC_ID" \ + CLAUDE_PROFILE_NAME=team-01a "$HANDOFF_CMD" --lane repoRC-14 clear +is "a handoff whose inventory write is refused still completes the swap's records" "$rc" 0 +has "…naming the tree whose entry was not recorded" "$err" "the worktree inventory was not recorded for $RC_DIR/.claude/worktrees/w14" +run "$E" lane-state repoRC-14 +is "…and the lane stays SWAPPING, never SWAPPED over an incomplete inventory" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "state" { print $2 }')" SWAPPING + +# ------------------------------------- the inventory's own reads (Codex on ec9847e) +rc_row repoRC-15 "harness \`$RC_ID\`" +rc_seed_log repoRC-15 +run "$E" set-lane-state repoRC-15 SWAPPED --owner "$RC_ID" +is "a lane is recorded SWAPPED for the inventory cases" "$rc" 0 + +# (a) A CALLER'S OBSERVATION IS WHOLE OR ABSENT: one flag alone used to file the +# other four as `unknown`, `none`, `dirty 0` and `unpushed 0`, nobody's reading. +run "$E" set-lane-tree repoRC-15 "$RC_DIR/.claude/worktrees/half-15" --checkout "$RC_DIR" --branch feat/half +is "set-lane-tree refuses a partial observation as a usage error" "$rc" 64 +has "…saying it takes the observation whole" "$err" "WHOLE" +run "$E" lane-trees repoRC-15 +is "…and nothing was filed" "$rc" 8 + +# (b) TWO PATHS ARE TWO RECORDS, even where folding to the file-name alphabet +# makes them one: `a+b` and `a-b` fold to the same name. +run "$E" set-lane-tree repoRC-15 "$RC_DIR/.claude/worktrees/a+b" --checkout "$RC_DIR" \ + --branch feat/aplusb --head 1111111111111111111111111111111111111111 --upstream none --dirty 0 --unpushed 0 +is "a tree whose name folds onto a sibling's is recorded" "$rc" 0 +run "$E" set-lane-tree repoRC-15 "$RC_DIR/.claude/worktrees/a-b" --checkout "$RC_DIR" \ + --branch feat/aminusb --head 2222222222222222222222222222222222222222 --upstream none --dirty 3 --unpushed 1 +is "…and so is the sibling" "$rc" 0 +run "$E" lane-trees repoRC-15 +is "…as two records, neither replacing the other" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$2 ~ /worktrees\/a[-+]b$/' | grep -c .)" 2 +is "…each keeping its own observation" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$2 ~ /worktrees\/a\+b$/ { print $3 }')" "feat/aplusb" + +# (c) A SIDECAR THAT IS THERE AND CANNOT BE READ IS SAID, NEVER SKIPPED. +ln -s "$SANDBOX/no-such-sidecar-15" "$RC_STATE_ROOT/repoRC-15/trees/c0-orphan-15.yaml" +run "$E" lane-trees repoRC-15 +is "lane-trees names an unreadable sidecar as a row of its own" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "c0-orphan-15" { print $13 }')" "" +run "$E" lane-reconcile repoRC-15 +is "…which the reconciliation reports as unreadable-sidecar" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "TREE" && $2 == "c0-orphan-15" { print $3 }')" "unreadable-sidecar" +is "…while the lane itself is still resumable" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "resumable" + +# (d) AND A RESUMABLE LANE WHOSE TREES NEED READING IS NOT LAUNCHED IN SILENCE. +rc_seed_handoff repoRC-15 +git -C "$WIP" add -- handoffs/repoRC >/dev/null 2>&1 +git -C "$WIP" commit -q -m "seed the repoRC-15 handoff" +git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : +git -C "$WIP" push -q origin main +run "$START" repoRC 15 --no-launch +is "lane-start still starts a resumable lane" "$rc" 0 +has "…but says so first when its trees need reading" "$err" "LANE RECOVERY: resumable" +has "…saying why" "$err" "need reading before a writer is relaunched" +has "…and naming the trees" "$err" "unreadable-sidecar" + +echo "== the managed-owner seam: managed ledger owns enrolled lanes; this tooling owns legacy ==" + +# Brett Heap's ruling of 2026-10-04, verbatim: "managed ledger owns enrolled +# lanes; #97 owns legacy — rework both". A lane the managed ledger has enrolled +# carries a managed-owner marker in its register row's state cell; every legacy +# act refuses it before a byte is written — 2 for a valid marker, naming the +# owner, and 1 where the vocabulary is there and does not parse. +# +# THE FIXTURES ARE WRITTEN STRAIGHT INTO THE REGISTER AND PUSHED, as the managed +# ledger's own writer would land them: no legacy writer here may write that +# vocabulary into a row at all, which is one of the things this section proves. +# +# repoMG-1 VALID-LONG LIVE · · managed-owner mode=managed daemon=ledger-1 generation=3 bound-lane=repoMG-1 +# repoMG-2 VALID-SHORT MANAGED OWNER · ledger-2 +# repoMG-3 LEGACY PAUSED · · swapped for the night +# repoMG-4 MALFORMED generation=0 +# repoMG-5 MALFORMED an empty daemon +# repoMG-6 MALFORMED bound-lane names another lane +# repoMG-7 MALFORMED the shorthand with an empty token +# repoMG-8 MALFORMED `managed: x` in the OBJECTS cell, a legacy state cell +# repoMG-9 LEGACY, dormant (no object log), for the sweep + +MG_ID="3a9e0001-1111-4000-8000-3a9e00011111" +MG_DIR="$HOME/projects/repoMG" +mkdir -p "$MG_DIR" +git init -q -b main "$MG_DIR" +git -C "$MG_DIR" config user.email "test@example.invalid" +git -C "$MG_DIR" config user.name "lane helper tests" +git -C "$MG_DIR" remote add origin "https://github.com/opensoft/repoMG.git" +printf 'seed\n' > "$MG_DIR/a.txt" +git -C "$MG_DIR" add -A >/dev/null 2>&1 +git -C "$MG_DIR" commit -q -m seed + +MG_UTC="2026-10-04T00:00:00Z" +mg_row() { # + printf '| `%s` | harness `%s` | Eagle / test / brett | 2026-10-04T00:00Z | %s | handoffs/repoMG/%s.md | %s |\n' \ + "$1" "$MG_ID" "$2" "$1" "$3" +} +{ mg_row repoMG-1 none "LIVE · $MG_UTC · managed-owner mode=managed daemon=ledger-1 generation=3 bound-lane=repoMG-1" + mg_row repoMG-2 none "MANAGED OWNER · ledger-2" + mg_row repoMG-3 none "PAUSED · $MG_UTC · swapped for the night" + mg_row repoMG-4 none "LIVE · $MG_UTC · managed-owner mode=managed daemon=ledger-1 generation=0 bound-lane=repoMG-4" + mg_row repoMG-5 none "LIVE · $MG_UTC · managed-owner mode=managed daemon= generation=3 bound-lane=repoMG-5" + mg_row repoMG-6 none "LIVE · $MG_UTC · managed-owner mode=managed daemon=ledger-1 generation=3 bound-lane=repoMG-99" + mg_row repoMG-7 none "MANAGED OWNER · " + mg_row repoMG-8 "managed: x" "PAUSED · $MG_UTC · swapped for the night" + mg_row repoMG-9 none "ENDED · $MG_UTC · a pre-Amendment-7 row with no log" +} > "$SANDBOX/mg-rows" +# AFTER THE REGISTER'S LAST ROW, through `cat >` and never `sed -i`, which would +# replace a symlinked register with a file (THE ONE HAZARD in the manual). +mg_last="$(awk 'substr($0,1,3) == "| `" { n = NR } END { print n + 0 }' "$WIP/lanes/LANES.md")" +awk -v n="$mg_last" -v f="$SANDBOX/mg-rows" ' + { print } + NR == n { while ((getline l < f) > 0) print l }' "$WIP/lanes/LANES.md" > "$SANDBOX/mg-register" +cat "$SANDBOX/mg-register" > "$WIP/lanes/LANES.md" +for mg_l in repoMG-1 repoMG-2 repoMG-3 repoMG-4 repoMG-5 repoMG-6 repoMG-7 repoMG-8; do + { printf '# lane %s — object log (lane-collision-protocol Amendment 7)\n' "$mg_l" + printf 'STARTED — lane %s, session %s@Eagle, %s, lane:%s → home opensoft/repoMG; dir %s; profile team-01a\n' \ + "$mg_l" "$MG_ID" "$MG_UTC" "$mg_l" "$MG_DIR" + } > "$LOGD/$mg_l.md" +done +git -C "$WIP" add -- lanes/LANES.md lanes/log >/dev/null 2>&1 +git -C "$WIP" commit -q -m "seed the managed-owner seam's fixtures, as the ledger's own writer would land them" +git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : +git -C "$WIP" push -q origin main + +# THE WHOLE STATE A REFUSED ACT MAY NOT CHANGE, in one string: the register's +# commit here and on origin, this checkout's status, the register's bytes, every +# fixture log's bytes, the lifecycle control roots (names and contents), and the +# tmux, claude and pclaude logs the fakes keep. +mg_fp() { + { git -C "$WIP" rev-parse HEAD + git --git-dir="$ORIGIN" rev-parse main + git -C "$WIP" status --porcelain + cksum < "$WIP/lanes/LANES.md" + cat "$LOGD"/repoMG-*.md 2>/dev/null | cksum + ls "$LOGD" | cksum + find "$HOME/projects/.lane-state" 2>/dev/null | LC_ALL=C sort + find "$HOME/projects/.lane-state" -type f -exec cksum {} + 2>/dev/null | LC_ALL=C sort + cksum < "$FAKE_TMUX_LOG" + cksum < "$FAKE_TMUX_A17_LOG" + cksum < "$FAKE_CLAUDE_LOG" + cksum < "$FAKE_PCLAUDE_LOG" + } | cksum +} +mg_refused() { # + is "$1 is refused with $2" "$rc" "$2" + if [ "$2" = 2 ]; then + has "…naming the owner" "$err" "owner $3" + else + has "…as ownership UNKNOWN" "$err" "UNKNOWN" + fi + is "…and changed nothing: register, origin, logs, control roots, tmux and launch logs" "$(mg_fp)" "$4" +} + +# ---------------------------------------------------------- 1. the read itself +run "$E" managed-projection repoMG-1 +is "managed-projection: a valid long marker is 0" "$rc" 0 +is "…printing the owner, its daemon" "$out" "ledger-1" +run "$E" managed-projection repoMG-2 +is "managed-projection: the valid shorthand is 0" "$rc" 0 +is "…printing its token" "$out" "ledger-2" +run "$E" managed-projection repoMG-3 +is "managed-projection: a legacy row is 8" "$rc" 8 +run "$E" managed-projection repoMG-9 +is "managed-projection: a legacy row with no log is 8 too" "$rc" 8 +for mg_l in repoMG-4 repoMG-5 repoMG-6 repoMG-7 repoMG-8; do + run "$E" managed-projection "$mg_l" + is "managed-projection: the malformed $mg_l is 1, never 8" "$rc" 1 + has "…saying ownership is UNKNOWN" "$err" "UNKNOWN" +done +run "$E" managed-projection +is "managed-projection with no lane is a usage error" "$rc" 64 +run "$E" managed-projection repoMG-1 repoMG-2 +is "…and so is two lanes" "$rc" 64 +run "$E" managed-projection repomg-1 +is "…and the lane is resolved under any case (Amendment 15)" "$out" "ledger-1" + +# ------------------------------- 2. every act on every managed or unknown row +for mg_case in "repoMG-1 2 ledger-1" "repoMG-2 2 ledger-2" "repoMG-4 1 -" "repoMG-5 1 -" \ + "repoMG-6 1 -" "repoMG-7 1 -" "repoMG-8 1 -"; do + read -r mg_l mg_x mg_o <" "$mg_x" "$mg_o" "$mg_b" + mg_b="$(mg_fp)" + run "$E" lane-reconcile "$mg_l" + is "$mg_l: lane-reconcile still answers" "$rc" 0 + if [ "$mg_x" = 2 ]; then + is "…with the verdict managed-owned" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "managed-owned" + is "…naming the owner" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "MANAGED" { print $2 }')" "$mg_o" + else + is "…with the verdict indeterminate" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "VERDICT" { print $2 }')" "indeterminate" + is "…and ownership unknown" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "MANAGED" { print $2 }')" "unknown" + fi + hasnt "…and pronouncing no crash" "$out" "ungraceful-stop" + is "…having read and written nothing" "$(mg_fp)" "$mg_b" +done + +# ------------------- 3. ONE managed lane in a sweep refuses the whole sweep +mg_b="$(mg_fp)" +run env LANES_LANE=repoMG-3 "$E" retire-rows repoMG-9 repoMG-1 +mg_refused "a sweep of a dormant legacy row and a managed one" 2 ledger-1 "$mg_b" +is "…so the dormant legacy row was not retired either" \ + "$(grep -c '^| `repoMG-9`.*| ENDED · ' "$WIP/lanes/LANES.md" || :)" 1 + +# ---------------------------------- 4. no legacy writer forges a marker +mg_b="$(mg_fp)" +run "$E" set-row-state repoMG-3 "LIVE · managed-owner mode=managed daemon=forged generation=1 bound-lane=repoMG-3" +is "set-row-state refuses a phrase in the marker's vocabulary" "$rc" 2 +has "…saying why" "$err" "managed-owner vocabulary" +is "…and changed nothing" "$(mg_fp)" "$mg_b" +run "$E" set-row-state repoMG-3 "PAUSED · notes are in the managed: file" +is "…and any of its three patterns, not only the whole marker" "$rc" 2 +is "…changing nothing" "$(mg_fp)" "$mg_b" +run "$E" add-row "| \`repoMG-10\` | harness \`$MG_ID\` | Eagle / test / brett | 2026-10-04T00:00Z | none | handoffs/repoMG/repoMG-10.md | MANAGED OWNER · forged |" +is "add-row refuses a row in the marker's vocabulary" "$rc" 2 +has "…saying why" "$err" "managed-owner vocabulary" +is "…and changed nothing" "$(mg_fp)" "$mg_b" +run "$E" replace-in-row repoMG-3 "swapped for the night" "managed owner ledger-9" +is "replace-in-row refuses replacement text in the marker's vocabulary" "$rc" 2 +is "…and changed nothing" "$(mg_fp)" "$mg_b" +# AND THE BACK DOORS (Copilot round 3 on #97): any line appended to the register, +# a session cell's new text, and a lane's new name. +run "$E" append-line "| \`repoMG-10\` | harness \`$MG_ID\` | Eagle / test / brett | 2026-10-04T00:00Z | none | handoffs/repoMG/repoMG-10.md | MANAGED OWNER · forged |" +is "append-line refuses a line in the marker's vocabulary, the row-shaped one included" "$rc" 2 +has "…saying why" "$err" "managed-owner vocabulary" +is "…and changed nothing" "$(mg_fp)" "$mg_b" +run env LANES_SESSION="$MG_ID" "$E" append-session-id repoMG-3 "$MG_ID" "managed owner ledger-9" +is "append-session-id refuses appended text in the marker's vocabulary" "$rc" 2 +has "…saying why" "$err" "managed-owner vocabulary" +is "…and changed nothing" "$(mg_fp)" "$mg_b" +run env LANES_SESSION="$MG_ID" "$E" rename-lane repoMG-3 managed-owner-3 --no-github +is "rename-lane refuses a new name in the marker's vocabulary" "$rc" 2 +has "…saying why" "$err" "managed-owner vocabulary" +is "…and changed nothing" "$(mg_fp)" "$mg_b" + +# ------------------------------- 5. a LEGACY lane is exactly what it was +run "$E" set-lane-state repoMG-3 RUNNING --expect none --owner "$MG_ID" +is "a legacy lane still takes a lifecycle transition" "$rc" 0 +mg_gen="$(printf '%s\n' "$out" | awk -F'\t' '$1 == "generation" { print $2 }')" +run env LANES_LANE=repoMG-3 LANES_SESSION="$MG_ID" "$E" log RESUMED "lane:repoMG-3" '→' "home opensoft/repoMG; dir $MG_DIR; profile team-01a" +is "…and a RESUMED line" "$rc" 0 +run "$E" lane-state repoMG-3 +is "…which moves its snapshot on the legacy verdict, under a new generation" \ + "$( [ "$(printf '%s\n' "$out" | awk -F'\t' '$1 == "generation" { print $2 }')" -gt "$mg_gen" ] && echo advanced || echo stuck )" advanced +run "$E" lane-reconcile repoMG-3 +hasnt "…and its reconciliation is not managed-owned" "$out" "managed-owned" +is "…and carries no MANAGED line at all" \ + "$(printf '%s\n' "$out" | awk -F'\037' '$1 == "MANAGED"' | grep -c . || :)" 0 +run env LANES_LANE=repoMG-3 "$E" retire-rows repoMG-9 +is "…and the sweep of a dormant legacy row alone still lands" "$rc" 0 +run env LANES_LANE=repoMG-3 LANES_SESSION="$MG_ID" "$E" log PAUSED "lane:repoMG-3" +is "…and so does a legacy lane's PAUSED line" "$rc" 0 +run env LANES_SESSION="$MG_ID" "$E" append-session-id repoMG-3 "$MG_ID" "→ 3a9e0002-2222-4000-8000-3a9e00022222" +is "…and a legacy row's session cell still takes an id" "$rc" 0 + +# ------ 6. ONE managed row refuses Amendment 13(e)'s WHOLE migration +# +# Copilot round 2 on #97: `migrate-state-cells` rewrites state cells and appends +# to logs, and the shorthand `MANAGED OWNER · ` holds ONE ` · `, so it is +# not the phrase the migration leaves alone, and `--yes` rewrote it to +# `MIGRATED · …`. ITS OWN WORKSPACE, for the reason Amendment 13's own section +# gives: this act takes a whole register, and pointed at the suite's it would +# rewrite every fixture above and below this line the day the refusal stopped +# refusing. A LEGACY diary row comes FIRST, so a refusal that skipped only the +# managed row would still have migrated it: that row is the proof the abort is +# whole. +MGM_ORIGIN="$SANDBOX/mg-mig-origin.git"; MGM_WIP="$SANDBOX/mg-migwip" +git init -q --bare -b main "$MGM_ORIGIN" +git clone -q "$MGM_ORIGIN" "$MGM_WIP" 2>/dev/null +git -C "$MGM_WIP" config user.email "test@example.invalid" +git -C "$MGM_WIP" config user.name "lane helper tests" +mkdir -p "$MGM_WIP/lanes/log" +MGM_DIARY="ACTIVE · 2026-10-04T01:00:00Z opened the PR and it went green" +mgm_register() { # — the whole register, rewritten as the ledger's writer would land it + { printf '# LANES.md — the managed seam, migration sandbox\n\n' + printf '| lane | session id | workstation / env / user | started (UTC) | objects owned | handoff path | state |\n' + printf '|---|---|---|---|---|---|---|\n' + mg_row repoMG-3 none "$MGM_DIARY" + mg_row repoMG-2 none "$1" + } > "$MGM_WIP/lanes/LANES.md" +} +mgm_fp() { # what a refused migration may not change: commits, status, register, logs, archive + { git -C "$MGM_WIP" rev-parse HEAD + git --git-dir="$MGM_ORIGIN" rev-parse main + git -C "$MGM_WIP" status --porcelain + cksum < "$MGM_WIP/lanes/LANES.md" + cat "$MGM_WIP"/lanes/log/*.md | cksum + ls "$MGM_WIP/lanes/log" | cksum + ls "$MGM_WIP/lanes" | cksum + } | cksum +} +mgm_refused() { # + is "$1 is refused with $2" "$rc" "$2" + if [ "$2" = 2 ]; then + has "…naming the owner" "$err" "owner $3" + else + has "…as ownership UNKNOWN" "$err" "UNKNOWN" + fi + has "…refusing the WHOLE migration" "$err" "WHOLE migration" + is "…and changed nothing: register HEAD here and on origin, status, the register's bytes, every log's bytes, no archive" "$(mgm_fp)" "$4" + is "…so the legacy row read BEFORE the refused one still holds its diary" \ + "$(grep -c "^| \`repoMG-3\` .*| $MGM_DIARY |\$" "$MGM_WIP/lanes/LANES.md" || :)" 1 +} +mgm_register "MANAGED OWNER · ledger-2" +for mg_l in repoMG-3 repoMG-2; do + printf '# lane %s — object log (lane-collision-protocol Amendment 7)\n' "$mg_l" > "$MGM_WIP/lanes/log/$mg_l.md" +done +git -C "$MGM_WIP" add -A >/dev/null 2>&1 +git -C "$MGM_WIP" commit -q -m "seed the seam's migration sandbox: a legacy diary row, then the valid shorthand" +git -C "$MGM_WIP" push -q -u origin main + +# (a) THE VALID SHORTHAND: 2, naming the owner, in both modes. +mg_b="$(mgm_fp)" +run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" migrate-state-cells +mgm_refused "the migration's dry run over the valid shorthand" 2 ledger-2 "$mg_b" +run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" migrate-state-cells --yes +mgm_refused "migrate-state-cells --yes over the valid shorthand" 2 ledger-2 "$mg_b" +hasnt "…and never rewrites the marker to the migration's own word" "$(cat "$MGM_WIP/lanes/LANES.md")" "MIGRATED" + +# (b) A MALFORMED SHORTHAND — a token with a space in it — is UNKNOWN: 1. +mgm_register "MANAGED OWNER · ledger 2" +git -C "$MGM_WIP" commit -q -am "the ledger lands a shorthand whose token does not parse" +git -C "$MGM_WIP" push -q origin main +mg_b="$(mgm_fp)" +run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" managed-projection repoMG-2 +is "the malformed shorthand reads as unknown (1) before the migration asks" "$rc" 1 +run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" migrate-state-cells --yes +mgm_refused "migrate-state-cells --yes over a malformed shorthand" 1 - "$mg_b" + +# (c) THE ROW REWRITTEN IS THIS CHECKOUT'S. Published as a legacy one-word row, +# and this checkout one unpushed commit ahead with the shorthand in it: the +# published read alone says legacy, and the row the act would rewrite is not. +mgm_register "PAUSED" +git -C "$MGM_WIP" commit -q -am "the row goes back to a legacy one-word state" +git -C "$MGM_WIP" push -q origin main +mgm_register "MANAGED OWNER · ledger-3" +git -C "$MGM_WIP" commit -q -am "an unpushed marker, in this checkout only" +mg_b="$(mgm_fp)" +run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" managed-projection repoMG-2 +is "the published register reads the row as legacy (8)" "$rc" 8 +run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" migrate-state-cells --yes +mgm_refused "migrate-state-cells --yes over a marker only this checkout carries" 1 - "$mg_b" +has "…saying the two copies disagree" "$err" "the two copies disagree on" + +# (d) AND THE SAME REGISTER WITH NO MARKER IN EITHER COPY MIGRATES, so each +# refusal above was the seam's and not the fixture's. +git -C "$MGM_WIP" reset -q --hard origin/main +run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" migrate-state-cells --yes +is "the legacy register itself migrates" "$rc" 0 +has "…taking the diary row" "$out" "1 to migrate" +has "…into its log" "$(cat "$MGM_WIP/lanes/log/repoMG-3.md")" "NOTED — lane repoMG-3" + +# ------ 7. ONE managed RETIRED row refuses Amendment 19(d)'s WHOLE move +# +# Copilot round 3 on #97: `archive-rows --yes` takes every RETIRED row of +# a repository out of the register, and a RETIRED row may carry the managed +# ledger's marker. In the same workspace as case 6, for the same reason: this +# act takes a whole repository's rows. A LEGACY retired row comes FIRST, so a +# refusal that skipped only the managed row would still have moved it. +mga_row() { # — appended through `cat >>`, as the ledger's writer would land it + mg_row "$1" none "$2" >> "$MGM_WIP/lanes/LANES.md" +} +mga_fp() { + { mgm_fp + cat "$MGM_WIP"/lanes/archive/LANES-retired.md 2>/dev/null | cksum + } | cksum +} +mga_refused() { # + is "$1 is refused with $2" "$rc" "$2" + if [ "$2" = 2 ]; then + has "…naming the owner" "$err" "owner $3" + else + has "…as ownership UNKNOWN" "$err" "UNKNOWN" + fi + has "…refusing the WHOLE move" "$err" "WHOLE move" + is "…and changed nothing: register HEAD here and on origin, status, the register's and the archive's bytes, every log" "$(mga_fp)" "$4" + is "…so the legacy RETIRED row read BEFORE the refused one is still in the register" \ + "$(grep -c '^| `repoMG-12` ' "$MGM_WIP/lanes/LANES.md" || :)" 1 +} +mga_row repoMG-12 "RETIRED · $MG_UTC · retired by hand" +mga_row repoMG-11 "RETIRED · $MG_UTC · managed-owner mode=managed daemon=ledger-1 generation=4 bound-lane=repoMG-11" +git -C "$MGM_WIP" commit -q -am "a legacy retired row, then a retired row the managed ledger owns" +git -C "$MGM_WIP" push -q origin main + +# (a) THE VALID MARKER: 2, naming the owner, in both modes. +mg_b="$(mga_fp)" +run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" archive-rows repoMG +mga_refused "archive-rows' dry run over a retired managed row" 2 ledger-1 "$mg_b" +run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" archive-rows repoMG --yes +mga_refused "archive-rows --yes over a retired managed row" 2 ledger-1 "$mg_b" + +# (b) A MALFORMED ONE — generation=0 — is UNKNOWN: 1. +awk '{ if (index($0, "| `repoMG-11` ") == 1) sub(/generation=4/, "generation=0"); print }' \ + "$MGM_WIP/lanes/LANES.md" > "$SANDBOX/mga-register" +cat "$SANDBOX/mga-register" > "$MGM_WIP/lanes/LANES.md" +git -C "$MGM_WIP" commit -q -am "the ledger lands a retired marker whose generation does not parse" +git -C "$MGM_WIP" push -q origin main +mg_b="$(mga_fp)" +run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" archive-rows repoMG --yes +mga_refused "archive-rows --yes over a malformed retired marker" 1 - "$mg_b" + +# (c) AND WITH THAT ROW GONE THE SAME MOVE LANDS, so each refusal above was the +# seam's and not the fixture's. +grep -v '^| `repoMG-11` ' "$MGM_WIP/lanes/LANES.md" > "$SANDBOX/mga-register" +cat "$SANDBOX/mga-register" > "$MGM_WIP/lanes/LANES.md" +git -C "$MGM_WIP" commit -q -am "the managed ledger takes its row back" +git -C "$MGM_WIP" push -q origin main +run env LANES_WORKSPACE_ROOT="$MGM_WIP" "$E" archive-rows repoMG --yes +is "the legacy retired row alone is archived" "$rc" 0 +is "…out of the register" "$(grep -c '^| `repoMG-12` ' "$MGM_WIP/lanes/LANES.md" || :)" 0 +is "…and into the archive" "$(grep -c '^| `repoMG-12` ' "$MGM_WIP/lanes/archive/LANES-retired.md" || :)" 1 + echo "== the workstation seam: unset, every writer reads the host ==" # THE OTHER HALF OF R-A9-13. Every case above this line runs with