From dcee0286ae6834cba8cb0ff40982edaecc6e086a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:29:03 +0000 Subject: [PATCH 01/26] =?UTF-8?q?WIP=20(openRepoTools#91):=20a=20lane=20th?= =?UTF-8?q?at=20dies=20mid-handoff=20can=20say=20so=20=E2=80=94=20the=20RU?= =?UTF-8?q?NNING=20=E2=86=92=20SWAPPING=20=E2=86=92=20SWAPPED=20snapshot,?= =?UTF-8?q?=20its=20generation/operation=20fence,=20and=20the=20machine-re?= =?UTF-8?q?adable=20worktree=20inventory=20taken=20at=20the=20poll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session can run out of tokens BEFORE the handoff, AFTER it began and before it finished, or after it finished, and until now all three left the same evidence: a last lane-kind line that is a `STARTED`/`RESUMED` (which is also what a running lane looks like) or a `PAUSED` (which is also what a clean swap looks like). The two crash kinds had no word, and the WRITERS section a handoff leaves is prose — a person can read it, a recovery cannot. This is the work so far, committed at a handoff and not finished: * `lanes-edit.sh` gains a LOCAL lifecycle snapshot beside each lane — not in the register (every write of it is a commit, a pull and a push) and not inside a git worktree (metadata there dirties a checkout and disappears with the directory whose loss it explains), but under a control root derived from the lane's own recorded `dir`, else `$PROJECTS_ROOT`. Five subcommands: `lane-state`, `set-lane-state`, `lane-trees`, `set-lane-tree` and `lane-reconcile`. NO SIXTH LANE VERB IS ADDED to the append-only log — Amendment 7's five stand and every reader of them is untouched. * The fence: every transition carries a monotonic `generation` and a unique `operation`, `--expect*` is the compare-and-swap, and exit 7 is the refusal of a stale finalizer (one meaning on the number this file already spends on `claim`'s CLAIM-LOST: you lost the race). * `write_event` follows the line it wrote — a `STARTED`/`RESUMED` is the confirming act of a new owner and takes the lane to `RUNNING`, an `ENDED` or `RETIRED` to `CLOSED` — so the SessionStart hook keeps all three properties that make it safe in front of every session (it never writes, never touches the network, always exits 0; Amendment 8, R-A8-1). * `lane-handoff` takes `SWAPPING` before it polls anything, records every polled worktree's path, checkout, branch, HEAD, upstream, dirty and unpushed counts and writer against that operation, and lands `SWAPPED` only after the record, the row and the handoff file have all been written. The lifecycle never refuses the swap (`R-A11-11`): a root it cannot derive or a fence another act moved costs the lifecycle and never the record. * Brett Heap's ideation packet and the OpenSpec change are committed with the implementation, as he asked. Still owed on this branch: `lane-reconcile`'s reporting surfaced on resume, `docs/README-lanes.md`, the suite cases for each transition, each fence and each crash kind, the review's deviations written into the design, and a rerun of `openspec validate --strict`. Part of opensoft/openRepoTools#91 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m --- ideation/README.md | 9 + ...orktree-recovery-state-canonical-layout.md | 60 ++ ...covery-state-crash-consistent-lifecycle.md | 68 ++ .../lane-worktree-recovery-state-overview.md | 98 +++ ...ee-recovery-state-resume-reconciliation.md | 66 ++ ...ecovery-state-synthesis-resumable-lanes.md | 64 ++ lane-handoff | 127 +++ lanes-edit.sh | 774 +++++++++++++++++- .../.openspec.yaml | 2 + .../design.md | 139 ++++ .../proposal.md | 31 + .../specs/lane-worktree-recovery/spec.md | 123 +++ .../tasks.md | 45 + 13 files changed, 1604 insertions(+), 2 deletions(-) create mode 100644 ideation/README.md create mode 100644 ideation/brainstorm/lane-worktree-recovery-state-canonical-layout.md create mode 100644 ideation/brainstorm/lane-worktree-recovery-state-crash-consistent-lifecycle.md create mode 100644 ideation/brainstorm/lane-worktree-recovery-state-overview.md create mode 100644 ideation/brainstorm/lane-worktree-recovery-state-resume-reconciliation.md create mode 100644 ideation/brainstorm/lane-worktree-recovery-state-synthesis-resumable-lanes.md create mode 100644 openspec/changes/add-crash-consistent-lane-worktree-recovery/.openspec.yaml create mode 100644 openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md create mode 100644 openspec/changes/add-crash-consistent-lane-worktree-recovery/proposal.md create mode 100644 openspec/changes/add-crash-consistent-lane-worktree-recovery/specs/lane-worktree-recovery/spec.md create mode 100644 openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md 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-handoff b/lane-handoff index acc6688..26c122c 100755 --- a/lane-handoff +++ b/lane-handoff @@ -392,6 +392,97 @@ $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="" + +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_on=1 + 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 }')" + lc_state=SWAPPING + step "lifecycle: $lcb_now -> SWAPPING (generation $lc_gen, operation $lc_op)" ;; + 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, @@ -663,6 +754,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 @@ -707,6 +811,14 @@ poll_writer() { # pw_dirty="$(printf '%s' "$pw_status" | grep -c . || :)" pw_ahead="$(printf '%s' "$pw_unpushed" | grep -c . || :)" pw_brief="$(brief_for "$pw_d" || printf '')" + # THE TWO FIELDS THE PROSE NEVER CARRIED (openRepoTools#91). `%h` is an + # ABBREVIATION — it lengthens as a repository grows and it is ambiguous + # across repositories — so the inventory records the full HEAD, which is + # what a later reconciliation compares; and the UPSTREAM, without which + # `0 unpushed` cannot be told from *this branch tracks nothing at all*. + pw_head="$(git -C "$pw_d" rev-parse HEAD 2>/dev/null || printf 'unknown')" + pw_up="$(git -C "$pw_d" rev-parse --abbrev-ref '@{u}' 2>/dev/null || printf 'none')" + [ "$pw_branch" = HEAD ] && pw_branch=detached writer_count=$((writer_count + 1)) say "" say "WRITER $pw_d — branch $pw_branch, last commit ${pw_last:-}" @@ -717,6 +829,21 @@ 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" ]; 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:-0}" --unpushed "${pw_ahead:-0}" \ + --writer "${uuid:-none}" --generation "$lc_gen" --operation "$lc_op" \ + >/dev/null 2>&1 || + note "the worktree inventory entry for $pw_d could not be written; this handoff's WRITERS section still names it, and \`lane-reconcile\` will report it as unmanaged rather than as lost." + fi return 0 } diff --git a/lanes-edit.sh b/lanes-edit.sh index d1c6cfc..070223c 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -241,6 +241,42 @@ # `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-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 SIXTH LANE VERB IS ADDED TO THE APPEND-ONLY LOG. Amendment 7's five +# stand and every reader of them is untouched; 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. +# # EXIT CODES — every subcommand, one table, no two meanings on one number # 0 done # 1 environment (no register, no writer) @@ -291,7 +327,12 @@ # 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) +# 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). ONE +# MEANING ON THE NUMBER, in two 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; @@ -3605,6 +3646,19 @@ 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. 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" return "$we_rc" } @@ -7502,6 +7556,515 @@ EOF return "$msc_rc" } +# ============================================================================ +# 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 lane verbs are Amendment 7's and no sixth is added here, 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" +} + +# 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), 1 where the +# control root could not be derived. +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" + [ -r "$lsr_f" ] || return 8 + lsr_schema="$(lane_sidecar_field "$lsr_f" schema)" + # AN UNKNOWN SCHEMA FAILS CLOSED (design decision: "conservative fail-closed + # behaviour for unknown versions"). A newer tooling's snapshot read by an + # older reader must not be reported as a lane in a state this reader knows. + case "$lsr_schema" in + "$LANE_STATE_SCHEMA") : ;; + *) printf 'state\tUNKNOWN-SCHEMA\n'; printf 'schema\t%s\n' "${lsr_schema:-}" + printf 'file\t%s\n' "$lsr_f"; return 0 ;; + esac + 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 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 is UNFENCED, deliberately — a +# confirmed `STARTED`/`RESUMED` is the new owner arriving, and advancing the +# generation there is exactly what refuses the stale finalizer of the swap it +# replaced. 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. +lane_state_follow() { # + lsf_lane="${1-}"; lsf_verb="${2-}"; lsf_pay="${3-}"; lsf_uuid="${4-}" + lsf_new="" + case "$lsf_verb" in + STARTED|RESUMED) lsf_new=RUNNING ;; + ENDED|RETIRED) lsf_new=CLOSED ;; + *) return 0 ;; + esac + lsf_root=""; lsf_rc=0 + lsf_root="$(lane_control_root "$lsf_lane" "$lsf_pay")" || lsf_rc=$? + [ "$lsf_rc" = 0 ] || return 0 + lsf_gen="$(lane_sidecar_field "$lsf_root/lane-state.yaml" 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 + return 0 + fi + note "the lane lifecycle snapshot at $lsf_root/lane-state.yaml 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 +} + +# ------------------------------------------------- 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. It is +# the absolute path with every character outside the manifest-key set folded to +# `-`, which is one file name per path and the same file name on every run. +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 + printf '%s\n' "$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. +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 + [ -r "$ltl_f" ] || continue + ltl_n=$((ltl_n + 1)) + printf '%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s\n' \ + "$(lane_sidecar_field "$ltl_f" tree)" "$US" \ + "$(lane_sidecar_field "$ltl_f" path)" "$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)" + 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. + +# 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 nothing at all where the path is not a checkout. +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_b="$(git -C "$ltn_p" rev-parse --abbrev-ref HEAD 2>/dev/null || printf 'unknown')" + [ "$ltn_b" = HEAD ] && ltn_b=detached + ltn_h="$(git -C "$ltn_p" rev-parse HEAD 2>/dev/null || printf 'unknown')" + ltn_u="$(git -C "$ltn_p" rev-parse --abbrev-ref '@{u}' 2>/dev/null || printf 'none')" + ltn_d="$(git -C "$ltn_p" status --short 2>/dev/null | grep -c . || :)" + ltn_n="$(git -C "$ltn_p" log '@{u}..' --oneline 2>/dev/null | grep -c . || :)" + 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:-0}" + return 0 +} + +lane_reconcile() { # + lrc_lane="${1-}" + 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 }')" + else + lrc_state=NONE + 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 + + # 3. THE TREES — the inventory, recomputed. A stored value is a COMPARISON + # POINT and never current truth. + lrc_dir="$(lane_payload_field "$lrc_lane" dir 2>/dev/null || :)" + lrc_seen=""; lrc_n=0; lrc_recover=0; lrc_dirty=0 + 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; do + [ -n "${lrc_id:-}" ] || continue + lrc_n=$((lrc_n + 1)) + lrc_seen="$lrc_seen $lrc_p " + lrc_now=""; lrc_nrc=0 + lrc_now="$(lane_tree_now "$lrc_p")" || lrc_nrc=$? + 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 + if [ "${lrc_nn:-0}" -gt 0 ]; then + if [ "$lrc_class" = dirty ]; then lrc_class=dirty+unpushed; else lrc_class=unpushed; fi + fi + [ "$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}" + 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 + while IFS= read -r lrc_wl; do + case "$lrc_wl" in worktree\ *) : ;; *) continue ;; esac + lrc_wp="${lrc_wl#worktree }" + [ "$lrc_wp" = "$lrc_dir" ] && continue + case "$lrc_seen" in *" $lrc_wp "*) 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 + 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 + case "$lrc_seen" in *" $lrc_c "*) 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_unmanaged=$((lrc_unmanaged + 1)) + done + done <" 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 ;; + *) 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=}"; 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=}"; 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=}"; shift ;; + --operation) sls_op="${2-}"; [ -n "$sls_op" ] || die "--operation needs an operation id" 64; shift 2 ;; + --operation=*) sls_op="${1#--operation=}"; shift ;; + --owner) sls_owner="${2-}"; [ "$#" -ge 2 ] || die "--owner needs a value" 64; shift 2 ;; + --owner=*) sls_owner="${1#--owner=}"; shift ;; + --agent) sls_agent="${2-}"; [ "$#" -ge 2 ] || die "--agent needs a value" 64; shift 2 ;; + --agent=*) sls_agent="${1#--agent=}"; shift ;; + --profile) sls_prof="${2-}"; [ "$#" -ge 2 ] || die "--profile needs a value" 64; shift 2 ;; + --profile=*) sls_prof="${1#--profile=}"; shift ;; + --kind) sls_kind="${2-}"; [ "$#" -ge 2 ] || die "--kind needs a value" 64; shift 2 ;; + --kind=*) sls_kind="${1#--kind=}"; 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 + 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 + 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 + # 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 ] || die "--checkout needs a value" 64; shift 2 ;; + --checkout=*) slt_co="${1#--checkout=}"; shift ;; + --branch) slt_b="${2-}"; [ "$#" -ge 2 ] || die "--branch needs a value" 64; shift 2 ;; + --branch=*) slt_b="${1#--branch=}"; shift ;; + --head) slt_h="${2-}"; [ "$#" -ge 2 ] || die "--head needs a value" 64; shift 2 ;; + --head=*) slt_h="${1#--head=}"; shift ;; + --upstream) slt_u="${2-}"; [ "$#" -ge 2 ] || die "--upstream needs a value" 64; shift 2 ;; + --upstream=*) slt_u="${1#--upstream=}"; shift ;; + --dirty) slt_d="${2-}"; [ "$#" -ge 2 ] || die "--dirty needs a value" 64; shift 2 ;; + --dirty=*) slt_d="${1#--dirty=}"; shift ;; + --unpushed) slt_n="${2-}"; [ "$#" -ge 2 ] || die "--unpushed needs a value" 64; shift 2 ;; + --unpushed=*) slt_n="${1#--unpushed=}"; shift ;; + --writer) slt_w="${2-}"; [ "$#" -ge 2 ] || die "--writer needs a value" 64; shift 2 ;; + --writer=*) slt_w="${1#--writer=}"; shift ;; + --generation) slt_g="${2-}"; [ "$#" -ge 2 ] || die "--generation needs a value" 64; shift 2 ;; + --generation=*) slt_g="${1#--generation=}"; shift ;; + --operation) slt_op="${2-}"; [ "$#" -ge 2 ] || die "--operation needs a value" 64; shift 2 ;; + --operation=*) slt_op="${1#--operation=}"; 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 + 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. + 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=$? + if [ "$slt_nrc" = 0 ]; then + 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}')" + fi + 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 + printf '%s\n' "$(tree_id_for "$slt_path")" + else + 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 + ;; + + 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|migrate-state-cells|commit|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|canon-lane|resolve-home)" 2 + die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|migrate-state-cells|commit|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|canon-lane|resolve-home|lane-state|set-lane-state|lane-trees|set-lane-tree|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..ff91112 --- /dev/null +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -0,0 +1,139 @@ +## 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. + +## 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. + +## 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, and how is it shared across host/container boundaries? +- Does recovery complete an interrupted operation ID or always supersede it with a new generation? +- 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..4f9d8f8 --- /dev/null +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/proposal.md @@ -0,0 +1,31 @@ +## 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. + +## 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. + +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..d3c7b3b --- /dev/null +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/specs/lane-worktree-recovery/spec.md @@ -0,0 +1,123 @@ +## ADDED Requirements + +### 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 + +### 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 after SessionStart confirms the canonical lane, transcript or session, agent, coordinator directory, and exclusive binding. + +#### Scenario: Launcher dies before SessionStart +- **WHEN** a resume command begins but no replacement SessionStart is confirmed +- **THEN** the system does not record the lane as `RUNNING` + +#### Scenario: Confirmed replacement starts +- **WHEN** SessionStart proves the replacement owns the expected lane and binding +- **THEN** the system atomically records `RUNNING` with that owner and 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. + +#### 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` attempts to start while the matching generation is already `SWAPPING` +- **THEN** the system refuses the competing operation or reports the existing operation without minting another owner + +### 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: 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 + +### 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 the estate parked record authorizes reconstruction at durable commits +- **THEN** the system names or invokes the estate `resume` path according to its existing contract + +#### 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 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 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..a939110 --- /dev/null +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md @@ -0,0 +1,45 @@ +## 1. Structured State Model + +- [ ] 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. +- [ ] 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 + +- [ ] 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. +- [ ] 2.3 Reconcile expected sidecars, latest handoff writers, `git worktree list --porcelain`, and directories present beneath the resolved worktree root. +- [ ] 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 + +- [ ] 3.1 Change `/swap` and `lane-handoff` to compare-and-swap `RUNNING` to `SWAPPING` before inventory or preservation work begins. +- [ ] 3.2 Carry one generation and operation ID through writer polling, handoff refresh, lane pause records, and finalization. +- [ ] 3.3 Finalize `SWAPPING` to `SWAPPED` only after every mandatory step succeeds, and print readiness only after that transition lands. +- [ ] 3.4 Refuse stale or competing finalizers whose expected state, generation, operation ID, or owner no longer matches. +- [ ] 3.5 Preserve `SWAPPING` on every refusal or interrupted path and report the unfinished operation and completed steps. + +## 4. Guarded Resume + +- [ ] 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. +- [ ] 4.3 Produce a resume reconciliation report that distinguishes safe, dirty, unpushed, unmanaged, missing, stale-registration, and possible-loss trees. +- [ ] 4.4 Keep state `SWAPPED` during launch and transition to `RUNNING` only after SessionStart confirms lane, transcript/session, agent, directory, and exclusive binding. +- [ ] 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 + +- [ ] 5.1 Add a read-only compatibility path for lanes with no sidecar, preserving the existing explicit `--dir` refusal and launch behavior. +- [ ] 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. +- [ ] 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 + +- [ ] 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. +- [ ] 6.5 Update the lane manual and installed `/swap`/handoff guidance with the state meanings, recovery output, profile-switch sequence, and non-destructive boundaries. +- [ ] 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. From d2cff42e960c69f446225cbfb96daab33fc3c800 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:02:35 +0000 Subject: [PATCH 02/26] =?UTF-8?q?openRepoTools#91:=20a=20resume=20can=20no?= =?UTF-8?q?w=20say=20WHERE=20the=20last=20session=20stopped=20=E2=80=94=20?= =?UTF-8?q?the=20reconciliation=20report,=20the=20manual,=20the=20suite=20?= =?UTF-8?q?cases,=20and=20every=20deviation=20written=20into=20the=20desig?= =?UTF-8?q?n=20rather=20than=20taken=20silently?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot, the fence and the inventory landed in the commit before this one. This is the half that makes them usable and honest. * `lane-reconcile` is printed by `lane-start` BEFORE it writes anything, for every verdict that is not `running`, `resumable` or `closed` — above section 5, because the `STARTED`/`RESUMED` line that run is about to write takes the lane to `RUNNING`, so a report printed after it would describe this launch rather than the crash it is recovering from. It is a REPORT and not a gate: `resume` RESETS NOTHING and `park` CREATES NOTHING (AGENTS.md rule 1), and every refusal this command makes is the one it already made. * `docs/README-lanes.md` gains the four words, the two crash kinds as a table (with `indeterminate` for a holder read nobody got), the fence, the three rungs of the control root, the inventory and what a resumed session does. * `tests/test_lane_helpers.sh` gains a section of its own: the cutover answer of 8 with nothing backfilled; the confirming act writing RUNNING while the SessionStart hook writes nothing (proved by the snapshot's bytes before and after); both crash kinds with and without a live holder; the fence refusing `--expect` that no longer matches and refusing a stale finalizer; a competing resume advancing the generation so the interrupted swap's own finalizer is refused when it returns; the handoff taking SWAPPING BEFORE the poll and SWAPPED after; the inventory's full head and upstream; a lane left SWAPPING when one of the three mandatory writes did not land; possible-loss against missing, unmanaged, stale-registration and detached HEAD; that the read reset, deleted and recreated nothing; an unknown schema failing closed; and the report printed by a real `lane-start`. THE DEVIATIONS ARE WRITTEN INTO THE CHANGE, as decisions 9 to 15 of design.md, never taken silently: 9 `RUNNING` is written by the act that CONFIRMS the binding and never by the SessionStart hook, which never writes, never touches the network and always exits 0 (Amendment 8, `R-A8-1`). The spec is reworded to match. 10 The snapshot is local and is written where `R-A11-14` stops the register: it is filed under NOTHING, so a container with no workstation still keeps a lifecycle it can recover itself from. 11 The control root comes from the lane's own recorded `dir`, not from a shape-governed Speckit worktree root — a lane's writers are not under one. 12 No sixth lane verb: Amendment 7's five stand and every reader is untouched. 13 `SWAPPED` needs three landed writes, and the swap still completes without them (`R-A11-11`); the state stays SWAPPING and names the missing step. 14 The estate's `resume` is NAMED, never invoked, by a read. 15 An unreadable holder is `indeterminate` and never a crash (Amendment 7(d)). `openspec validate add-crash-consistent-lane-worktree-recovery --strict` passes; `openspec list` reads 20/28 tasks, and tasks.md's new section 7 names each of the eight that are left and what stands in its place today. Part of opensoft/openRepoTools#91 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m --- docs/README-lanes.md | 143 ++++++++ lane-handoff | 34 ++ lane-start | 41 +++ lanes-edit.sh | 23 ++ .../design.md | 125 ++++++- .../proposal.md | 4 + .../specs/lane-worktree-recovery/spec.md | 26 +- .../tasks.md | 70 ++-- tests/test_lane_helpers.sh | 324 ++++++++++++++++++ 9 files changed, 761 insertions(+), 29 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 5096873..d2b6c3f 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2127,6 +2127,149 @@ the pid, where that process is (its window, or `bg`), the profile it is under, and the retire act — `lane-end --retire ` — and nothing is moved and nothing is launched. +## 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: **no sixth lane verb is added to the append-only log**. +Amendment 7's `STARTED`, `PAUSED`, `RESUMED`, `ENDED` and `RETIRED` stand, and +every reader of them — `swapped`, `lane-last`, `lane-dir`, `who`, `lane-end` — +is untouched. 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. | + +### 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 +# +``` + +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. + +### 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`, `dirty+unpushed`, `missing`, +`possible-loss`, `not-a-checkout`, `unmanaged`, `stale-registration`. The last +line is the `VERDICT`. `lane-start` prints the report before it writes anything, +for any verdict that is not `running`, `resumable` or `closed`. + +**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; +* a **stale-registration** names the `git worktree prune` that clears it, and + prunes nothing itself. + +### 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. + ## Hand edits After **any** hand edit made with an allowed tool (python read/write, `sed -i diff --git a/lane-handoff b/lane-handoff index 26c122c..d5447b7 100755 --- a/lane-handoff +++ b/lane-handoff @@ -1050,12 +1050,22 @@ 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" + 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 " pclaude ${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 @@ -1148,6 +1158,26 @@ 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" +lifecycle_finish "$lc_unfinished" + # ------------------------------------------- 5. the restart line — one command if [ -n "$row_write_refused" ]; then restart_cmd="pclaude --lane $lane ${profile_name:-}" @@ -1160,6 +1190,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 a92ea12..01c949f 100755 --- a/lane-start +++ b/lane-start @@ -2462,6 +2462,47 @@ 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="" + case "$lrec_verdict" in + running*|resumable*|closed*|'') : ;; + *) + 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 == "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" ;; + esac ;; + 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 070223c..b54f4fd 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -7809,6 +7809,16 @@ 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 + # 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, and a `cksum` of the WHOLE path goes in + # front of it so that two trees sharing a tail keep two ids. + if [ "${#tif_p}" -gt 180 ]; then + tif_c="$(printf '%s' "${1-}" | cksum | awk '{print $1}')" + tif_p="c$tif_c-$(printf '%s' "$tif_p" | tail -c 180)" + fi printf '%s\n' "$tif_p" } @@ -8015,6 +8025,10 @@ EOF 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 or a + # reader is told about one tree twice under two different reasons. + lrc_seen="$lrc_seen $lrc_wp " lrc_unmanaged=$((lrc_unmanaged + 1)) done </dev/null || :) @@ -9663,6 +9677,15 @@ EOF 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 diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index ff91112..76ce978 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -108,6 +108,118 @@ A missing tree may be reconstructed only when the estate parked record and its n 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. + +### 12. No sixth lane verb is added to the append-only log + +Decision 3's alternative (derive everything from events) was rejected there for +the reasons it gives. The converse also holds and is stronger here: Amendment +7's lane-kind verbs are `STARTED`, `PAUSED`, `RESUMED`, `ENDED` and `RETIRED`, +and a sixth 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* — 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. + +### 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. + ## 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. @@ -132,8 +244,17 @@ Rollback disables new writes but preserves sidecars and append-only events for d ## Open Questions -- Which existing lane lock becomes the single transition lock, and how is it shared across host/container boundaries? -- Does recovery complete an interrupted operation ID or always supersede it with a new generation? +- ~~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. - 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 index 4f9d8f8..6708eef 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/proposal.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/proposal.md @@ -28,4 +28,8 @@ None. This repository has no existing OpenSpec capability specifications; curren 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. + +**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 index d3c7b3b..bc47f1e 100644 --- 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 @@ -30,15 +30,19 @@ The system SHALL store lane and tree lifecycle metadata outside Git worktree dir - **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 after SessionStart confirms the canonical lane, transcript or session, agent, coordinator directory, and exclusive binding. +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 SessionStart -- **WHEN** a resume command begins but no replacement SessionStart is confirmed +#### 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** SessionStart proves the replacement owns the expected lane and binding -- **THEN** the system atomically records `RUNNING` with that owner and generation +- **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. @@ -77,6 +81,10 @@ Before launching replacement writers, the system SHALL compare lane and tree sid - **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: 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 @@ -93,8 +101,12 @@ Before launching replacement writers, the system SHALL compare lane and tree sid 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 the estate parked record authorizes reconstruction at durable commits -- **THEN** the system names or invokes the estate `resume` path according to its existing contract +- **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 diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md index a939110..50306b3 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md @@ -1,45 +1,75 @@ ## 1. Structured State Model -- [ ] 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. +- [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. -- [ ] 1.3 Implement atomic sidecar reads and writes under one lane transition lock, with conservative refusal for unknown schema versions and malformed state. +- [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 -- [ ] 2.1 Implement registration and update of lane-owned tree sidecars without placing metadata inside Git worktrees. +- [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. -- [ ] 2.3 Reconcile expected sidecars, latest handoff writers, `git worktree list --porcelain`, and directories present beneath the resolved worktree root. -- [ ] 2.4 Recalculate repository identity, branch or detached HEAD, commit, upstream, dirty files, untracked files, and unpushed commits for every discovered tree. +- [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 -- [ ] 3.1 Change `/swap` and `lane-handoff` to compare-and-swap `RUNNING` to `SWAPPING` before inventory or preservation work begins. -- [ ] 3.2 Carry one generation and operation ID through writer polling, handoff refresh, lane pause records, and finalization. -- [ ] 3.3 Finalize `SWAPPING` to `SWAPPED` only after every mandatory step succeeds, and print readiness only after that transition lands. -- [ ] 3.4 Refuse stale or competing finalizers whose expected state, generation, operation ID, or owner no longer matches. -- [ ] 3.5 Preserve `SWAPPING` on every refusal or interrupted path and report the unfinished operation and completed steps. +- [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 -- [ ] 4.1 Classify `RUNNING` without a verified owner as an ungraceful stop and `SWAPPING` without one as an interrupted swap. +- [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. -- [ ] 4.3 Produce a resume reconciliation report that distinguishes safe, dirty, unpushed, unmanaged, missing, stale-registration, and possible-loss trees. -- [ ] 4.4 Keep state `SWAPPED` during launch and transition to `RUNNING` only after SessionStart confirms lane, transcript/session, agent, directory, and exclusive binding. -- [ ] 4.5 Delegate eligible missing-tree reconstruction to estate `resume` and refuse hand-rolled recreation or recovery claims for missing unpublished work. +- [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 -- [ ] 5.1 Add a read-only compatibility path for lanes with no sidecar, preserving the existing explicit `--dir` refusal and launch behavior. -- [ ] 5.2 Create structured state for a legacy lane only during an explicitly named, repository-verified start, swap, or 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. -- [ ] 5.4 Keep profile selection independent of lane identity and verify that profile rotation resumes the same coordinator and worktree inventory. +- [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 -- [ ] 6.1 Add tests for token exhaustion before `/swap`, during every mandatory swap step, after `SWAPPED`, and before replacement SessionStart. +- [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. -- [ ] 6.5 Update the lane manual and installed `/swap`/handoff guidance with the state meanings, recovery output, profile-switch sequence, and non-destructive boundaries. -- [ ] 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. +- [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 and stale finalizers 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 and detached HEAD 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. diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 37153ef..a41bf2a 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -9392,6 +9392,330 @@ is "…and changed nothing" "$(git -C "$MIG_WIP" rev-list --count "$MIG_HEAD2 run env LANES_WORKSPACE_ROOT="$MIG_WIP" "$E" migrate-state-cells --no-such-flag is "an unknown flag is a refusal, not a silent dry run" "$rc" 2 +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 +has "…with the verdict that names the cutover rule rather than guessing a crash" "$out" "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 +has "RUNNING with no live holder is an UNGRACEFUL STOP — the session died before any handoff began" \ + "$out" "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 +has "SWAPPING with no live holder is an INTERRUPTED SWAP — the handoff began and did not finish" \ + "$out" "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 +has "RUNNING with a live holder is a lane that is RUNNING, and not a crash" "$out" "VERDICT" +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 +has "SWAPPING with a live holder is a swap in flight, not an interrupted one" "$out" "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 +has "a swapped lane with no holder is resumable" "$out" "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 +has "between the handoff and the next session the lane is resumable, not running" "$out" "resumable" +has "…and the dirty writer worktree is reported as dirty" "$out" "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 +has "…which a later session reads as an interrupted swap and not as a clean handoff" "$out" "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" +has "a missing tree that last held dirty or unpushed work is POSSIBLE LOSS" "$rc5_rep" "possible-loss" +has "…and says plainly that nothing here reconstructs uncommitted files" "$rc5_rep" "NOTHING here can reconstruct uncommitted files" +has "a missing tree that was clean and published is MISSING, and names the estate's own resume as the only rebuild" \ + "$rc5_rep" "the estate parked record and \`resume \` are the only rebuild" +has "a tree git registers that no sidecar names is UNMANAGED" "$rc5_rep" "unmanaged" +has "…and is left exactly as it is" "$rc5_rep" "it is left exactly as it is" +has "a registration whose directory is gone is a STALE REGISTRATION" "$rc5_rep" "stale-registration" +has "…naming the prune that clears it, which is a person's act" "$rc5_rep" "worktree prune" +has "a detached HEAD is recorded and reported as detached, not as a branch" "$rc5_rep" "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" && $5 ~ /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 +has "…and the verdict says nothing is assumed about it" "$out" "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 + + echo "== the workstation seam: unset, every writer reads the host ==" # THE OTHER HALF OF R-A9-13. Every case above this line runs with From ac58d3cfe1576cc3663a46c7c1f36397d3f9d106 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:17:09 +0000 Subject: [PATCH 03/26] =?UTF-8?q?openRepoTools#91:=20the=20two=20deviation?= =?UTF-8?q?s=20a=20reader=20would=20otherwise=20have=20to=20find=20for=20t?= =?UTF-8?q?hemselves=20=E2=80=94=20the=20control=20root=20is=20still=20a?= =?UTF-8?q?=20recorded=20path,=20and=20the=20report=20takes=20no=20lock=20?= =?UTF-8?q?=E2=80=94=20and=20the=20spec=20cites=20the=20issue=20at=20its?= =?UTF-8?q?=20head?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision 11 said where the control root comes from and not what that still leaves open. The brainstorm's "derivable lane root" asks for a root resolved *without relying on a historical absolute path*, and rung 2 is exactly such a path: Amendment 11(c)'s recorded `dir`. It is a RECORDED fact rather than a guess or the caller's current directory, which is what makes it safe to act on, but resolving the root from `home owner/repo` and the estate with no recorded path at all is the coordinator-base work of tasks 1.2 and 5.3 and is not here. Decision 14 said the estate's `resume` is named and not invoked, and left out the other half: the brainstorm has resume ACQUIRE THE LANE LOCK before it reconciles, and `lane-reconcile` does not. The lock here is the register's own mutex, and taking it for a READ would serialize every launch on the workstation behind every register write for the length of a `git worktree list` in each of a lane's checkouts. It is taken where it decides something — around the read of the fence and the replacement of the snapshot together, in `set-lane-state`. And the capability specification cites `opensoft/openRepoTools#91` at its head, beside the sentence that sends a reader to the numbered decision wherever a requirement is narrower than the brainstorm that proposed it. Part of opensoft/openRepoTools#91 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m --- .../design.md | 18 ++++++++++++++++++ .../specs/lane-worktree-recovery/spec.md | 2 ++ 2 files changed, 20 insertions(+) diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index 76ce978..dac01d4 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -172,6 +172,14 @@ backfilled. This keeps decision 2's actual rule — *the sidecar is an index ove 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. No sixth lane verb is added to the append-only log Decision 3's alternative (derive everything from events) was rejected there for @@ -210,6 +218,16 @@ of a report. `lane-reconcile` runs `git status`, `git log @{u}..`, 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 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 index bc47f1e..954cbdd 100644 --- 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 @@ -1,5 +1,7 @@ ## 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. From 612ba5c121430f14524d23479a81a714486169b8 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:37:06 +0000 Subject: [PATCH 04/26] openRepoTools#91: a report that called the lane's own checkout an unmanaged tree, and fifteen assertions that would have passed on the wrong field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects the first suite run and a reading of its output found. `lane-reconcile` skipped the lane's own checkout from `git worktree list --porcelain` by comparing the recorded `dir` to what git printed — and git prints the PHYSICAL path, while a recorded `dir` is reached through the estate's `projects` symlink on every workstation that has one. The lane's own checkout was then a tree no sidecar names, reported as UNMANAGED at the head of every report. `cd -P` is the portable resolver here for the reason `lane-start`'s `real_of` gives: `readlink -f` is not in the stock macOS userland. And the suite's own reads: a TREE row is `TREE `, so the path is the FOURTH field and the case that counts a discovered tree was matching the fifth. It went red, which is how it was found; fifteen of its neighbours were `has …` against the whole report, where `unmanaged`, `dirty` and `resumable` each appear in prose as well as in the field that means them — green whatever the classification actually was. Each is now an exact read of the field it is about: the `VERDICT` token, or the tree's own class. Part of opensoft/openRepoTools#91 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019UFwD7bCS73jaUMJ2vuo5m --- lanes-edit.sh | 9 ++++++++ tests/test_lane_helpers.sh | 43 ++++++++++++++++++++++---------------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index b54f4fd..2f067ea 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -8013,10 +8013,19 @@ EOF # 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="$( CDPATH=''; cd -P -- "$lrc_dir" 2>/dev/null && pwd -P )" || lrc_dirp="" 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 case "$lrc_seen" in *" $lrc_wp "*) 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' \ diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index a41bf2a..5e0a359 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -9478,7 +9478,7 @@ 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 -has "…with the verdict that names the cutover rule rather than guessing a crash" "$out" "no-state" +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 @@ -9511,16 +9511,16 @@ is "an ENDED closes the lifecycle too" \ 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 -has "RUNNING with no live holder is an UNGRACEFUL STOP — the session died before any handoff began" \ - "$out" "ungraceful-stop" +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 -has "SWAPPING with no live holder is an INTERRUPTED SWAP — the handoff began and did not finish" \ - "$out" "interrupted-swap" +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" @@ -9529,11 +9529,11 @@ has "…and saying the three writes may each be half done" "$out" "may each be 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 -has "RUNNING with a live holder is a lane that is RUNNING, and not a crash" "$out" "VERDICT" +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 -has "SWAPPING with a live holder is a swap in flight, not an interrupted one" "$out" "swap-in-progress" +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 @@ -9556,7 +9556,7 @@ 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 -has "a swapped lane with no holder is resumable" "$out" "resumable" +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. @@ -9613,8 +9613,9 @@ is "…so the worktree it describes is no dirtier for having been recorded" \ # 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 -has "between the handoff and the next session the lane is resumable, not running" "$out" "resumable" -has "…and the dirty writer worktree is reported as dirty" "$out" "dirty" +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" \ @@ -9636,7 +9637,7 @@ 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 -has "…which a later session reads as an interrupted swap and not as a clean handoff" "$out" "interrupted-swap" +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 @@ -9659,18 +9660,24 @@ run "$E" set-lane-tree repoRC-5 "$RC_DIR/.claude/worktrees/detached" --checkout run "$E" lane-reconcile repoRC-5 rc5_rep="$out" -has "a missing tree that last held dirty or unpushed work is POSSIBLE LOSS" "$rc5_rep" "possible-loss" +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" -has "a missing tree that was clean and published is MISSING, and names the estate's own resume as the only rebuild" \ +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" -has "a tree git registers that no sidecar names is UNMANAGED" "$rc5_rep" "unmanaged" +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" -has "a registration whose directory is gone is a STALE REGISTRATION" "$rc5_rep" "stale-registration" +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" -has "a detached HEAD is recorded and reported as detached, not as a branch" "$rc5_rep" "detached" +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" && $5 ~ /worktrees\/unmanaged$/' | grep -c .)" 1 + "$(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). @@ -9689,7 +9696,7 @@ printf 'schema: 999\nstate: WONDERLAND\n' > "$RC_STATE_ROOT/repoRC-3/lane-state. 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 -has "…and the verdict says nothing is assumed about it" "$out" "unknown-state" +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 From 6391819baca909e54f4b4f8174cebf1af273990b Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:22:12 +0000 Subject: [PATCH 05/26] =?UTF-8?q?openRepoTools#91:=20the=20six=20fences=20?= =?UTF-8?q?this=20capability=20exists=20for,=20each=20of=20them=20a=20hole?= =?UTF-8?q?=20in=20the=20first=20implementation=20=E2=80=94=20and=20the=20?= =?UTF-8?q?tree=20every=20symlinked=20workstation=20was=20reporting=20twic?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review round on #97 found six, and not one of them is cosmetic: they are the compare-and-swap, the fail-closed schema and the single observation this change is FOR, in the places the first implementation did not apply them. * **The lifecycle follow-up wrote outside the mutex and outside any fence.** `write_event` appends its line, commits it and pushes it BEFORE the snapshot is moved, and a lane can be recovered by somebody else inside that window: a `RUNNING` written out of an event that landed minutes ago then overwrote a `SWAPPING` that began since — precisely the overwrite the generation exists to refuse. The follow-up now reads the snapshot under the same mutex `set-lane-state` takes and compares it with the PRE-IMAGE `write_event` took before its own line existed. Equal is this write being the newest act; unequal is another act having got there first, and NOTHING is written. The mutex is taken without dying for it (`lock_try`, the non-fatal half of `acquire_lock`, so the two are one implementation): the event is already on disk, so a lock nobody could take costs the snapshot and says so, never the event. * **Two writers walked past the fail-closed schema check the reader keeps.** `lane_state_read` refuses a schema it does not know, and `set-lane-state`, `set-lane-tree` and the follow-up each read the raw fields and renamed their own file over the top — so an older helper meeting a newer tooling's record destroyed what it could not read, which no later reader can undo. All three ask `lane_sidecar_schema_ok` first and refuse with 1. It is 1 and not 7: 7 says a race was lost and invites a retry, and no retry makes an unknown schema readable. * **The inventory's own fence was written and compared with nothing.** Every tree sidecar carried the generation and operation it was recorded under from the first commit here, and no code read them, so a handoff that stalled while a recovery advanced the lane filed its superseded poll straight over the current one. `set-lane-tree` compares both with the lane's snapshot under the mutex and refuses with 7, writing inside that mutex so nothing lands between the compare and the record; and `lane-reconcile` now SAYS, on the tree's own line, when an observation was taken under an earlier generation. * **The handoff made a second observation of its own.** It computed branch, head, upstream, dirty and unpushed for every writer it polled — a second implementation of `lane_tree_now` with its own error handling, so the WRITERS section a person reads and the sidecar a recovery reads could disagree about what git said. `lanes-edit.sh lane-tree-now ` is that observation as one read verb, and the handoff records what it answers; where it refuses, every field is `?`, no sidecar is filed, and the section says so. * **The inventory reader took every `*.yaml` at face value.** A sidecar written by newer tooling was read field by field and reported as an ordinary observation. `lane-trees` now prints such a record's id, path and schema and not one other field of it, and `lane-reconcile` classes it `unknown-schema` rather than recomputing git against fields it is guessing at. The row gained its `generation`, `operation` and `schema` at the END, so a reader written against the ten fields that were there first still reads those ten. * **Every git read after the first became a clean-looking value.** `|| printf 'unknown'`, `|| printf 'none'` and a count that fell back to `0` meant a partially unreadable repository produced a record that said CLEAN AND PUBLISHED — and a later reconciliation comparing against it would call a tree holding work `missing` rather than `possible-loss`. A failed read is now an incomplete observation that prints nothing (exit 3 in the function, 1 in the verb), which `set-lane-tree` refuses to file and `lane-reconcile` reports as `unreadable`. Two states are answers rather than failures and are spelled: `unborn` for a branch with no commit yet — `rev-parse --abbrev-ref` refuses that exactly as it refuses a corrupt HEAD, so `symbolic-ref` is what tells them apart — and, for a branch whose upstream is configured while its remote-tracking ref is not in this checkout (the ordinary state after a merged branch is deleted), that configured upstream with `unknown` unpushed, never the `0` that reads as *everything here is published*. AND THE FOUR RED CASES ON `tests-macos` AT `612ba5c`, WHICH WERE ONE DEFECT. `expected [dirty], got [dirty unmanaged]`: the report reads three sources that do not agree about spelling — a sidecar holds the path its poll was given, `git worktree list --porcelain` answers with the PHYSICAL path, and the on-disk sweep walks the recorded `dir`. On that runner `$TMPDIR` and `$HOME` are under `/var`, which IS a symlink to `/private/var`, so EVERY tree was reported twice, once as the tree it is and once as a tree nobody manages — and so it would be on every estate that reaches its checkouts through a `projects` link. `612ba5c` resolved the lane's own checkout for this very reason and left the trees under it unresolved; `lane_real_path` is now applied on both sides of every comparison, and a lane whose recorded directory goes through a link has a case of its own so the defect is reproducible on every platform. The suite gains a section for the round — the delayed follow-up (scheduled with a `post-commit` hook, so it is a schedule and not a race), the schema refusals against a file whose bytes are asserted unchanged, the inventory fence in both directions, the handoff and the sidecar agreeing on a value only the shared helper produces, the unknown-schema sidecar, the unreadable tree, the unborn branch, and the linked path named once. `--expect`, `--expect-generation` and `--expect-operation` also refuse an empty value in BOTH spellings, which is the hygiene row that was red on all four jobs, and every other value-taking arm of the two new writers with it. Part of opensoft/openRepoTools#91 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r --- docs/README-lanes.md | 51 +- lane-handoff | 66 +- lanes-edit.sh | 575 +++++++++++++++--- .../design.md | 69 +++ .../specs/lane-worktree-recovery/spec.md | 18 +- .../tasks.md | 11 +- tests/test_lane_helpers.sh | 228 +++++++ 7 files changed, 892 insertions(+), 126 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 96dd927..b794824 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2280,7 +2280,9 @@ 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 @@ -2288,6 +2290,27 @@ 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. + ### The reconciliation, which resets nothing ```sh @@ -2297,10 +2320,12 @@ 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`, `dirty+unpushed`, `missing`, -`possible-loss`, `not-a-checkout`, `unmanaged`, `stale-registration`. The last -line is the `VERDICT`. `lane-start` prints the report before it writes anything, -for any verdict that is not `running`, `resumable` or `closed`. +classification: `ok`, `dirty`, `unpushed`, `unpushed-unknown`, +`dirty+unpushed`, `dirty+unpushed-unknown`, `missing`, `possible-loss`, +`not-a-checkout`, `unreadable`, `unknown-schema`, `unmanaged`, +`stale-registration`. The last line is the `VERDICT`. `lane-start` prints the +report before it writes anything, for any verdict that is not `running`, +`resumable` or `closed`. **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}..`, @@ -2315,9 +2340,21 @@ NOTHING (`AGENTS.md` rule 1), so this read runs `git status`, `git log @{u}..`, 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; + 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. + 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 **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 diff --git a/lane-handoff b/lane-handoff index d5447b7..7c0f1e7 100755 --- a/lane-handoff +++ b/lane-handoff @@ -439,11 +439,21 @@ lifecycle_begin() { --profile "${profile_name:-none}" --kind "$handoff_kind" 2>/dev/null)" || lcb_src=$? case "$lcb_src" in 0) - lc_on=1 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 }')" - lc_state=SWAPPING - step "lifecycle: $lcb_now -> SWAPPING (generation $lc_gen, operation $lc_op)" ;; + # 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) @@ -804,21 +814,43 @@ 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 '')" - # THE TWO FIELDS THE PROSE NEVER CARRIED (openRepoTools#91). `%h` is an - # ABBREVIATION — it lengthens as a repository grows and it is ambiguous - # across repositories — so the inventory records the full HEAD, which is - # what a later reconciliation compares; and the UPSTREAM, without which - # `0 unpushed` cannot be told from *this branch tracks nothing at all*. - pw_head="$(git -C "$pw_d" rev-parse HEAD 2>/dev/null || printf 'unknown')" - pw_up="$(git -C "$pw_d" rev-parse --abbrev-ref '@{u}' 2>/dev/null || printf 'none')" - [ "$pw_branch" = HEAD ] && pw_branch=detached writer_count=$((writer_count + 1)) say "" say "WRITER $pw_d — branch $pw_branch, last commit ${pw_last:-}" @@ -836,13 +868,13 @@ poll_writer() { # # 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" ]; then + 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:-0}" --unpushed "${pw_ahead:-0}" \ + --upstream "$pw_up" --dirty "$pw_dirty" --unpushed "$pw_ahead" \ --writer "${uuid:-none}" --generation "$lc_gen" --operation "$lc_op" \ >/dev/null 2>&1 || - note "the worktree inventory entry for $pw_d could not be written; this handoff's WRITERS section still names it, and \`lane-reconcile\` will report it as unmanaged rather than as lost." + 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, and \`$LANES_EDIT lane-reconcile $lane\` says what the inventory holds." fi return 0 } diff --git a/lanes-edit.sh b/lanes-edit.sh index d0d2232..52da103 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -259,6 +259,7 @@ # [--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 @@ -283,6 +284,21 @@ # 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. +# # EXIT CODES — every subcommand, one table, no two meanings on one number # 0 done # 1 environment (no register, no writer) @@ -337,8 +353,11 @@ # 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). ONE -# MEANING ON THE NUMBER, in two places: you lost the race. +# 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; @@ -780,7 +799,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. @@ -791,17 +821,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 } @@ -3620,6 +3655,17 @@ 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="" + case "$we_verb" in + STARTED|RESUMED|ENDED|RETIRED) we_pre="$(lane_state_preimage "$we_lane" "$we_pay")" ;; + esac acquire_lock capture_register_edit "${we_paths[@]}" handle_preexisting "${we_paths[@]}" @@ -3661,10 +3707,12 @@ write_event() { # `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. 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" + # 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" return "$we_rc" } @@ -7797,6 +7845,36 @@ lane_sidecar_field() { # 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)). +lane_sidecar_schema_ok() { # + [ -e "${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 @@ -7840,15 +7918,16 @@ lane_state_read() { # [ "$lsr_rc" = 0 ] || return 1 lsr_f="$lsr_root/lane-state.yaml" [ -r "$lsr_f" ] || return 8 - lsr_schema="$(lane_sidecar_field "$lsr_f" schema)" # AN UNKNOWN SCHEMA FAILS CLOSED (design decision: "conservative fail-closed - # behaviour for unknown versions"). A newer tooling's snapshot read by an - # older reader must not be reported as a lane in a state this reader knows. - case "$lsr_schema" in - "$LANE_STATE_SCHEMA") : ;; - *) printf 'state\tUNKNOWN-SCHEMA\n'; printf 'schema\t%s\n' "${lsr_schema:-}" - printf 'file\t%s\n' "$lsr_f"; return 0 ;; - esac + # 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 @@ -7885,16 +7964,45 @@ lane_state_put() { # + 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 is UNFENCED, deliberately — a -# confirmed `STARTED`/`RESUMED` is the new owner arriving, and advancing the -# generation there is exactly what refuses the stale finalizer of the swap it -# replaced. 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. -lane_state_follow() { # - lsf_lane="${1-}"; lsf_verb="${2-}"; lsf_pay="${3-}"; lsf_uuid="${4-}" +# 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. +lane_state_follow() { # [] + lsf_lane="${1-}"; lsf_verb="${2-}"; lsf_pay="${3-}"; lsf_uuid="${4-}"; lsf_pre="${5-}" lsf_new="" case "$lsf_verb" in STARTED|RESUMED) lsf_new=RUNNING ;; @@ -7904,16 +8012,40 @@ lane_state_follow() { # lsf_root=""; lsf_rc=0 lsf_root="$(lane_control_root "$lsf_lane" "$lsf_pay")" || lsf_rc=$? [ "$lsf_rc" = 0 ] || return 0 - lsf_gen="$(lane_sidecar_field "$lsf_root/lane-state.yaml" generation 2>/dev/null || :)" + 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 [ -n "$lsf_pre" ] && [ "$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 - note "the lane lifecycle snapshot at $lsf_root/lane-state.yaml 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\`" + 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 } @@ -7967,8 +8099,25 @@ lane_tree_put() { # } # 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=$? @@ -7977,17 +8126,29 @@ lane_trees_list() { # for ltl_f in "$ltl_root"/trees/*.yaml; do [ -r "$ltl_f" ] || continue ltl_n=$((ltl_n + 1)) - printf '%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s\n' \ - "$(lane_sidecar_field "$ltl_f" tree)" "$US" \ - "$(lane_sidecar_field "$ltl_f" path)" "$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)" + 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 @@ -8003,6 +8164,28 @@ lane_trees_list() { # # 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. @@ -8014,20 +8197,82 @@ lane_worktree_roots() { # return 0 } -# What git says about one tree, NOW. ``, -# and nothing at all where the path is not a checkout. +# 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_b="$(git -C "$ltn_p" rev-parse --abbrev-ref HEAD 2>/dev/null || printf 'unknown')" - [ "$ltn_b" = HEAD ] && ltn_b=detached - ltn_h="$(git -C "$ltn_p" rev-parse HEAD 2>/dev/null || printf 'unknown')" - ltn_u="$(git -C "$ltn_p" rev-parse --abbrev-ref '@{u}' 2>/dev/null || printf 'none')" - ltn_d="$(git -C "$ltn_p" status --short 2>/dev/null | grep -c . || :)" - ltn_n="$(git -C "$ltn_p" log '@{u}..' --oneline 2>/dev/null | grep -c . || :)" + 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:-0}" + "${ltn_d:-0}" "$US" "$ltn_n" return 0 } @@ -8077,12 +8322,37 @@ lane_reconcile() { # # POINT and never current truth. lrc_dir="$(lane_payload_field "$lrc_lane" dir 2>/dev/null || :)" lrc_seen=""; lrc_n=0; lrc_recover=0; lrc_dirty=0 - 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; do + 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)) - lrc_seen="$lrc_seen $lrc_p " + # 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:-}" != "$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 @@ -8110,14 +8380,29 @@ lane_reconcile() { # lrc_nn="$(printf '%s' "$lrc_now" | awk -F"$US" '{print $5}')" lrc_class=ok [ "${lrc_nd:-0}" -gt 0 ] && lrc_class=dirty - if [ "${lrc_nn:-0}" -gt 0 ]; then - if [ "$lrc_class" = dirty ]; then lrc_class=dirty+unpushed; else lrc_class=unpushed; fi - fi + # `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}" \ @@ -8138,13 +8423,15 @@ EOF # 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="$( CDPATH=''; cd -P -- "$lrc_dir" 2>/dev/null && pwd -P )" || lrc_dirp="" + 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 - case "$lrc_seen" in *" $lrc_wp "*) continue ;; esac + # 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" @@ -8153,9 +8440,11 @@ EOF "$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 or a - # reader is told about one tree twice under two different reasons. - lrc_seen="$lrc_seen $lrc_wp " + # 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 || :) @@ -8166,10 +8455,12 @@ EOF [ -d "$lrc_wr" ] || continue for lrc_c in "$lrc_wr"/*; do [ -d "$lrc_c" ] || continue - case "$lrc_seen" in *" $lrc_c "*) continue ;; esac + 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 </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)" @@ -9857,24 +10161,24 @@ EOF 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 ] || die "--checkout needs a value" 64; shift 2 ;; - --checkout=*) slt_co="${1#--checkout=}"; shift ;; - --branch) slt_b="${2-}"; [ "$#" -ge 2 ] || die "--branch needs a value" 64; shift 2 ;; - --branch=*) slt_b="${1#--branch=}"; shift ;; - --head) slt_h="${2-}"; [ "$#" -ge 2 ] || die "--head needs a value" 64; shift 2 ;; - --head=*) slt_h="${1#--head=}"; shift ;; - --upstream) slt_u="${2-}"; [ "$#" -ge 2 ] || die "--upstream needs a value" 64; shift 2 ;; - --upstream=*) slt_u="${1#--upstream=}"; shift ;; - --dirty) slt_d="${2-}"; [ "$#" -ge 2 ] || die "--dirty needs a value" 64; shift 2 ;; - --dirty=*) slt_d="${1#--dirty=}"; shift ;; - --unpushed) slt_n="${2-}"; [ "$#" -ge 2 ] || die "--unpushed needs a value" 64; shift 2 ;; - --unpushed=*) slt_n="${1#--unpushed=}"; shift ;; - --writer) slt_w="${2-}"; [ "$#" -ge 2 ] || die "--writer needs a value" 64; shift 2 ;; - --writer=*) slt_w="${1#--writer=}"; shift ;; - --generation) slt_g="${2-}"; [ "$#" -ge 2 ] || die "--generation needs a value" 64; shift 2 ;; - --generation=*) slt_g="${1#--generation=}"; shift ;; - --operation) slt_op="${2-}"; [ "$#" -ge 2 ] || die "--operation needs a value" 64; shift 2 ;; - --operation=*) slt_op="${1#--operation=}"; shift ;; + --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 @@ -9891,25 +10195,102 @@ EOF # 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=$? - if [ "$slt_nrc" = 0 ]; then - 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}')" + 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 + 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. + slt_lk=0 + if [ -n "$slt_g" ] || [ -n "$slt_op" ]; then + acquire_lock; slt_lk=1 + 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 + if [ "$slt_lk" = 1 ]; then release_lock; fi + 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 + if [ "$slt_lk" = 1 ]; then release_lock; fi printf '%s\n' "$(tree_id_for "$slt_path")" else + if [ "$slt_lk" = 1 ]; then release_lock; fi 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 @@ -9925,6 +10306,6 @@ EOF ;; *) - die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|migrate-state-cells|commit|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|canon-lane|resolve-home|lane-state|set-lane-state|lane-trees|set-lane-tree|lane-reconcile)" 2 + die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|migrate-state-cells|commit|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|canon-lane|resolve-home|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/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index dac01d4..8f0430b 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -238,6 +238,75 @@ 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*. + ## 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. 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 index 954cbdd..4dd1f26 100644 --- 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 @@ -62,7 +62,15 @@ The system SHALL transition a running lane to `SWAPPING` before `/swap` performs - **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 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. + +#### 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: Delayed swap finalizer - **WHEN** an old `/swap` process attempts to record `SWAPPED` after another recovery or resume has advanced the generation @@ -99,6 +107,10 @@ Before launching replacement writers, the system SHALL compare lane and tree sid - **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 + ### 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. @@ -135,3 +147,7 @@ The system SHALL create structured state for a legacy lane only from an explicit #### 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 index 50306b3..b4d264c 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md @@ -63,11 +63,14 @@ stands in its place today. - **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 and stale finalizers each - have a case; a live duplicate WRITER on one worktree does not. +- **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 and detached HEAD each have a case; - shape-governed paths do not, because 2.2 does not. + 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. diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 52738d0..1729d86 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -10017,6 +10017,234 @@ 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 + + echo "== the workstation seam: unset, every writer reads the host ==" # THE OTHER HALF OF R-A9-13. Every case above this line runs with From 3840c8755e5744ddea3d42a2f17bf42672b38124 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:09:30 +0000 Subject: [PATCH 06/26] =?UTF-8?q?openRepoTools#91:=20the=20two=20safety=20?= =?UTF-8?q?holes=20of=20the=20sixth=20round=20=E2=80=94=20a=20snapshot=20n?= =?UTF-8?q?obody=20could=20read=20was=20answered=20as=20a=20lane=20that=20?= =?UTF-8?q?has=20none,=20and=20an=20unfenced=20inventory=20write=20took=20?= =?UTF-8?q?no=20mutex=20at=20all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated review rounds are capped at TWO per pull request (Brett Heap's ruling of 2026-09-16) and #97 is at round six, so that round is TRIAGED rather than taken: these two are the findings this capability could not land with, and the other twelve are filed as opensoft/openRepoTools#113, #114, #115, #116, #117 and #118, claimed by this lane, and named one by one — with what each costs and what decides it — in the change's `tasks.md` section 7. * **A snapshot that IS THERE and cannot be read was answered as a lane that has none.** `lane_state_read`'s `[ -r ] || return 8` put a permission error, an I/O error and a lane that never started under this capability behind one number, and `lane_reconcile` mapped every non-zero read to `NONE` — whose verdict is `no-state`, the cutover answer that tells a launcher there is nothing here to recover. So the one report built to say WHERE a session stopped answered *it never ran* about the lane nobody could look at, which is fail-OPEN on a crash pronouncement — the class this change exists to close. The read answers **9** now for a record that is there and could not be opened (`[ -L ]` beside `[ -e ]`, because a dangling symlink is `-e` false and is exactly the name-without-bytes case), `lane-state` exits 9 rather than the 8 a launcher goes past, and `lane-reconcile` reports the state word `UNREADABLE` with the verdict `indeterminate` — the same answer decision 15 already gives an unreadable HOLDER, under the same rule (R22, Amendment 7(d)). * **`set-lane-tree` took the lane mutex only when the caller named a fence.** The atomic temp-file rename underneath it stops a reader seeing half a sidecar and stops nothing else, so an UNFENCED observation — a person's, or any caller that names no transition — could land after a newer fenced one and replace it, leaving the inventory holding a reading older than the transition recorded beside it. Every write of these files is serialized now, and the generation/operation comparison is unchanged for the callers that name one. The lock is taken AFTER the observation and not before it, for the reason decision 14 keeps `lane-reconcile` out of it altogether: `lane_tree_now` runs `git status` and `git rev-list` in somebody's checkout, and what must be serialized is the compare and the write, not the reading of a repository. Ten new assertions in the suite's own `#91` section, as its section 12 (four more that need `timeout(1)` are skipped and named where there is none): the unreadable snapshot is 9, `UNREADABLE` and `indeterminate` and never `no-state`; the SAME lane with no snapshot at all is still 8 and still `no-state`, so the two answers differ in nothing but whether the file is there; and an unfenced `set-lane-tree` run while another process holds the mutex is still waiting when `timeout` kills it (124) and has written nothing, then lands the moment the lock is free. The fixture for an unreadable file is a DANGLING SYMLINK and not a `chmod 000`: a suite run as root reads a mode-000 file, and the case would go red on the one host shape it was written to be harmless on. Exit **9** joins this file's own exit-code table and the five arms' table; the manual's crash-kind table gains the row; the spec gains the two scenarios; and design decisions 19 and 20 record both fixes, beside the twelve deferrals. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EaHzfTLW4u9ukpWuw8Dt6r --- docs/README-lanes.md | 1 + lanes-edit.sh | 73 +++++++++++++-- .../design.md | 37 ++++++++ .../specs/lane-worktree-recovery/spec.md | 8 ++ .../tasks.md | 90 +++++++++++++++++++ tests/test_lane_helpers.sh | 81 +++++++++++++++++ 6 files changed, 281 insertions(+), 9 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index b794824..4242f02 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2220,6 +2220,7 @@ RUNNING --/handoff begins--> SWAPPING --record + row + handoff all landed--> SWA | `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 **9** for it, never the **8** that means *this lane has no snapshot, go on*. | ### The fence diff --git a/lanes-edit.sh b/lanes-edit.sh index 52da103..c1c5057 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -366,6 +366,14 @@ # 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 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; 9 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)). # # --no-sweep (DEFAULT, added 2026-09-09 after 0d84d34/a1f2438 swept another # lane's uncommitted hand edit into an unrelated commit): every mutating @@ -7910,14 +7918,31 @@ lane_op_id() { # 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), 1 where the -# control root could not be derived. +# snapshot at all (the pre-cutover lane, and not a failure), 9 where a snapshot +# IS there and could not be read, 1 where the control root could not be derived. +# +# THE 9 IS THE POINT OF THIS ROUND (Copilot round 6 on openRepoTools#97). 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" - [ -r "$lsr_f" ] || return 8 + if [ ! -r "$lsr_f" ]; then + if [ -e "$lsr_f" ] || [ -L "$lsr_f" ]; then return 9; 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 @@ -8295,8 +8320,17 @@ lane_reconcile() { # 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 }')" - else + 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' \ @@ -8483,6 +8517,7 @@ EOF SWAPPED0) lrc_v=inconsistent; lrc_why="the lane is recorded SWAPPED and a holder is LIVE: a swapped lane has no holder, so one of the two is wrong and neither is overwritten here" ;; CLOSED8) lrc_v=closed; lrc_why="the lane is closed" ;; CLOSED0) lrc_v=inconsistent; lrc_why="the lane is recorded CLOSED and a holder is LIVE" ;; + UNREADABLE*) lrc_v=indeterminate; lrc_why="this lane's lifecycle snapshot at $lrc_root/lane-state.yaml IS THERE and could not be read, so where its session stopped is NOT established — which is not the same as a lane that has none, and is no clearance to relaunch anything (R22, Amendment 7(d): a read that failed is never an answer). Read that file by hand; if you know what wrote it, move it aside and this lane's next transition writes a fresh one" ;; NONE*) lrc_v=no-state; lrc_why="this lane has no lifecycle snapshot: it has not started or handed off under this capability, so its crash kind cannot be told from its record (Amendment 7(i)'s cutover rule — nothing is backfilled)" ;; *) lrc_v=unknown-state; lrc_why="the snapshot holds the state word '$lrc_state', which this reader does not know; nothing is assumed about it" ;; esac @@ -10016,6 +10051,9 @@ EOF # 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) + # 9 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*; 9 tells it *this lane may be mid-crash and nobody could look*. # 64 a usage error of this subcommand's own lane-state) @@ -10029,6 +10067,7 @@ EOF case "$lst_rc" in 0) : ;; 8) exit 8 ;; + 9) 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" 9 ;; *) 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" @@ -10228,9 +10267,25 @@ EOF # 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. - slt_lk=0 + # + # 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 - acquire_lock; slt_lk=1 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 @@ -10252,15 +10307,15 @@ EOF # 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 - if [ "$slt_lk" = 1 ]; then release_lock; fi + 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 - if [ "$slt_lk" = 1 ]; then release_lock; fi + release_lock printf '%s\n' "$(tree_id_for "$slt_path")" else - if [ "$slt_lk" = 1 ]; then release_lock; fi + 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 ;; diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index 8f0430b..de26fc9 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -307,6 +307,43 @@ 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: **9** where the record is there and +could not be opened, 8 where there is none. `lane-state` spends the same 9 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. + ## 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. 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 index 4dd1f26..2acea8d 100644 --- 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 @@ -72,6 +72,10 @@ Every ownership transition SHALL carry a monotonically advancing generation and - **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 @@ -95,6 +99,10 @@ Before launching replacement writers, the system SHALL compare lane and tree sid - **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: 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 diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md index b4d264c..76386ef 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md @@ -76,3 +76,93 @@ stands in its place today. 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 **9** for a record that IS there and cannot be + opened, `lane-state` exits 9 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. +- **`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`. +- **`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. +- **`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. diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 1729d86..4c08b62 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -10244,6 +10244,87 @@ is "…as the tree it is, and never as one nobody manages" \ 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 9, never the 8 that means there is none" "$rc" 9 +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 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 + echo "== the workstation seam: unset, every writer reads the host ==" From b5aedaecda6d38a18875b80024115d16ac184faa Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:37:53 +0000 Subject: [PATCH 07/26] Move lane-state's unreadable-snapshot exit from 9 to 10 Main's #61 landed with exit 9 as `claim --force`'s abandoned takeover, and #97 had independently taken 9 for a lifecycle snapshot that exists and cannot be read. Each PR took 9 as unused. The one that has not landed moves: 10 is spent nowhere in the shipped scripts. The code table and the manual get #61's 9 row back verbatim and a row of their own for 10, which says it used to be 9. `lane_state_read`, the `lane-state` arm, the #91 section of the manual, design.md, tasks.md and the one suite assertion that names the number all move with it. No caller branches on the number: `lane_reconcile` maps any non-0/8 read to UNREADABLE, and `lane-handoff`'s lifecycle_begin treats any non-0/8 as unreadable. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 5 ++-- lanes-edit.sh | 30 +++++++++---------- .../design.md | 4 +-- .../tasks.md | 4 +-- tests/test_lane_helpers.sh | 2 +- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 4893e42..8d90851 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1131,7 +1131,8 @@ else. | 5 | an edit moved more than one line and was refused | | 6 | `git add` / `commit` / `push` failed | | 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 | two meanings, one per verb. `claim`: `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. Never 7 — that code is a RIVAL's claim, and this is the same lane the takeover was granted over. `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 | +| 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 | @@ -2886,7 +2887,7 @@ RUNNING --/handoff begins--> SWAPPING --record + row + handoff all landed--> SWA | `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 **9** for it, never the **8** that means *this lane has no snapshot, go on*. | +| **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*. | ### The fence diff --git a/lanes-edit.sh b/lanes-edit.sh index 18410e6..0038711 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -452,25 +452,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 TWO MEANINGS, ONE PER VERB, and the verb that exited tells them apart: -# issue #61 and openRepoTools#97 each took 9 as unused, and neither verb -# can return the other's. -# `claim`: CLAIM-LOST — issue #30's own dead-lane verdict could not be +# 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. -# `lane-state`: 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 +# 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; 9 says *this lane may be mid-crash and nobody could look*, +# 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 @@ -11251,10 +11249,11 @@ lane_op_id() { # 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), 9 where a snapshot +# 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 9 IS THE POINT OF THIS ROUND (Copilot round 6 on openRepoTools#97). A bare +# 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`, @@ -11273,7 +11272,7 @@ lane_state_read() { # [ "$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 9; fi + if [ -e "$lsr_f" ] || [ -L "$lsr_f" ]; then return 10; fi return 8 fi # AN UNKNOWN SCHEMA FAILS CLOSED (design decision: "conservative fail-closed @@ -14637,9 +14636,10 @@ EOF # 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) - # 9 THE RECORD IS THERE AND COULD NOT BE READ, which is never 8 (Copilot + # 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*; 9 tells it *this lane may be mid-crash and nobody could look*. + # 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) @@ -14653,7 +14653,7 @@ EOF case "$lst_rc" in 0) : ;; 8) exit 8 ;; - 9) 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" 9 ;; + 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" diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index de26fc9..7ccfc7a 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -320,8 +320,8 @@ 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: **9** where the record is there and -could not be opened, 8 where there is none. `lane-state` spends the same 9 at +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 diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md index 76386ef..902be41 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md @@ -89,8 +89,8 @@ 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 **9** for a record that IS there and cannot be - opened, `lane-state` exits 9 rather than the 8 a launcher goes past, and + `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)). diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 2615519..8578253 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -13780,7 +13780,7 @@ rc_seed_log repoRC-9 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 9, never the 8 that means there is none" "$rc" 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 From 64bcf5bbf6959cc9313ff7c5b3eec1404ae01657 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:39:34 +0000 Subject: [PATCH 08/26] Close a swept lane's lifecycle snapshot in the Amendment 19 sweep `retire_rows` appends each RETIRED line itself, through `event_line` and `append_text_block`, and never through `write_event`. So the follow-up that takes a lane to CLOSED on every other RETIRED never ran for a lane the sweep retired, and its snapshot kept whatever it said last. #97 claims the snapshot follows the line "from any caller", and main's sweep is a caller that line did not reach. The sweep now takes each lane's pre-image inside its scan, under the lock it already holds, and after `release_lock` calls `lane_state_follow` for each lane with that pre-image. This is the same order `write_event` uses, so the follow-up still takes the mutex for itself and never fails a sweep whose lines have landed. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 18 ++++++++++++++++++ tests/test_lane_helpers.sh | 10 ++++++++++ 2 files changed, 28 insertions(+) diff --git a/lanes-edit.sh b/lanes-edit.sh index 0038711..e827fb1 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -10694,6 +10694,7 @@ 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 @@ -10859,6 +10860,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" "") +" rr_n=$((rr_n + 1)) done @@ -10896,6 +10905,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; do + [ -n "${rr_fl:-}" ] || continue + lane_state_follow "$rr_fl" RETIRED "" "$rr_uuid" "$rr_fpre" + done < Date: Sun, 4 Oct 2026 17:40:14 +0000 Subject: [PATCH 09/26] Stop claiming #97 keeps the log at five lane-kind verbs #97 said in four places that no sixth lane-kind verb is added to the append-only log, and design decision 12 rested on the claim that a sixth would change what every last-line reader means by a lane's last lane-kind line. Main's Amendment 18(g) has since added one, HANDOFF-REQUESTED. It changes no state, and every state reader enumerates the five state verbs by name and skips it, so the argument no longer holds as written. The four places now say what is still true: this change adds no lane-kind verb. Decision 12 is reworded to rest on the reasons that remain. A transition is taken three times per handoff and must work offline, while every log line is a commit, a pull and a push. And SWAPPING is a state, which HANDOFF-REQUESTED is not. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 10 ++++--- lanes-edit.sh | 14 ++++++---- .../design.md | 27 ++++++++++++++----- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 8d90851..7704267 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2864,10 +2864,12 @@ 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: **no sixth lane verb is added to the append-only log**. -Amendment 7's `STARTED`, `PAUSED`, `RESUMED`, `ENDED` and `RETIRED` stand, and -every reader of them — `swapped`, `lane-last`, `lane-dir`, `who`, `lane-end` — -is untouched. What is new is a SNAPSHOT beside that history. +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 diff --git a/lanes-edit.sh b/lanes-edit.sh index e827fb1..7b6210d 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -344,8 +344,10 @@ # `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 SIXTH LANE VERB IS ADDED TO THE APPEND-ONLY LOG. Amendment 7's five -# stand and every reader of them is untouched; this is a SNAPSHOT beside that +# 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. @@ -11094,9 +11096,11 @@ EOF # 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 lane verbs are Amendment 7's and no sixth is added here, 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 +# 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 diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index 7ccfc7a..2cc15f3 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -180,16 +180,29 @@ 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. No sixth lane verb is added to the append-only log +### 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. The converse also holds and is stronger here: Amendment -7's lane-kind verbs are `STARTED`, `PAUSED`, `RESUMED`, `ENDED` and `RETIRED`, -and a sixth would change what `swapped_candidates`, `lane_row_facts`, +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* — 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. +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 From 28ed7c7388439e594e7d41ad09bba57908c20efd Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:41:48 +0000 Subject: [PATCH 10/26] Never pronounce a crash from outside a lane's binding 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. `lane_reconcile` decided its verdict from this workstation's snapshot and this workstation's `live_holder` alone. So a lane bound on another host, with an old RUNNING snapshot here, read as an ungraceful stop, and a SWAPPED one read as resumable. Neither is a fact this place can establish. The reconcile now reads the binding through the same `lane_binding_scan` and `binding_is_here` rule that `binding` and `holder_is_dead` use, including clause (b)'s one exception: a window gone from this host's tmux is a dead binding, and the local read decides. It prints a BINDING row (here, free, gone, elsewhere, unknown). Bound elsewhere, or a log that could not be read, turns every verdict into indeterminate, and the message names where the lane is bound. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 53 ++++++++++++++++++++++++++++++++++++++ tests/test_lane_helpers.sh | 37 ++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/lanes-edit.sh b/lanes-edit.sh index 7b6210d..d0fb2e9 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -11706,6 +11706,46 @@ lane_reconcile() { # *) 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 || :)" @@ -11880,6 +11920,19 @@ EOF lrc_why="the lane is recorded CLOSED and $lrc_dirty tree(s) below are dirty or unpushed: no cleanup is made here" fi fi + # AND A LANE BOUND ELSEWHERE IS NEVER PRONOUNCED ON FROM HERE (Amendment + # 18(b), step 2b above). Every verdict this function can reach out of a local + # snapshot and a local holder read — a crash, a clearance, a closure, or + # "nothing to recover" — is a pronouncement on liveness, and from outside the + # binding that is UNKNOWN, never dead. So all of them become `indeterminate`. + case "$lrc_bwhere" in + elsewhere) + lrc_v=indeterminate + lrc_why="lane $lrc_lane is bound on $lrc_bat, and liveness is pronounced only from inside that binding (Amendment 18(b)): from here it is UNKNOWN, never dead, so no crash, no clearance and no closure is pronounced — and this workstation's snapshot is this workstation's alone (design decision 10). Read the lane from that place, or ask it to hand off: lane-start --request-handoff" ;; + unknown) + lrc_v=indeterminate + lrc_why="lane $lrc_lane's object log could not be read, so where it is bound is NOT established — and liveness may be pronounced only from inside the binding (Amendment 18(b)). A read that failed is never an answer (Amendment 7(d))" ;; + esac printf 'TREES%s%s inventoried%s%s dirty or unpushed%s%s require recovery%s%s unmanaged or stale\n' \ "$US" "$lrc_n" "$US" "$lrc_dirty" "$US" "$lrc_recover" "$US" "$lrc_unmanaged" printf 'VERDICT%s%s%s%s\n' "$US" "$lrc_v" "$US" "$lrc_why" diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 9aa6270..0d10780 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -13846,6 +13846,43 @@ else 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" + echo "== the workstation seam: unset, every writer reads the host ==" # THE OTHER HALF OF R-A9-13. Every case above this line runs with From e9cd956e2ef3ba06e9ce58b73daa9a0ca3141139 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:42:56 +0000 Subject: [PATCH 11/26] Carry a renamed lane's lifecycle snapshot to its new name #97's control root is keyed by the lane's name on every rung: `/.lane-state/`. Main's Amendment 16 `rename-lane` moves the row, the log, the handoff and the alias table, and knew nothing of that directory. After a rename the snapshot and the inventory sat under a name every reader now resolves away from. The lane's next reconciliation read `no-state`, which is the answer a launcher goes straight past. `rename-lane` now reads the old name's control root while the old name still has its log. Once the commit has returned 0, it moves that directory to the same parent under the new name, inside the rename's own lock. The move never goes over something already at the new name; that case is reported for a person, because two records of one lane are not merged here. A rename by case alone goes through a temporary name so it also lands on a case-insensitive file system. A failed move is named and never fails a rename that has already landed. The lane's `.lane-worktrees/` root is not moved. It holds real git worktrees, and moving them is `git worktree move`, which is a person's act. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 45 ++++++++++++++++++++++++++++++++++++++ tests/test_lane_helpers.sh | 16 ++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/lanes-edit.sh b/lanes-edit.sh index d0fb2e9..aa11705 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -11428,6 +11428,47 @@ lane_state_follow() { # [] 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 @@ -12441,6 +12482,9 @@ 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 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 @@ -13025,6 +13069,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" diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 0d10780..2f44696 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 From 39434bac6dbbcf19d81c837e3f67c3e8b3997d30 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:48:11 +0000 Subject: [PATCH 12/26] Refuse every legacy lane act on a lane the managed ledger owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 lane without one is legacy, and this tooling now answers for legacy lanes only. The reader is ported verbatim from 3c26041:lanes-edit.sh:776-898, the projection reader of branch 001-separate-swap-ctx-handoff (its T019). Not a byte differs, so when that branch's T024 lands the merge is "keep either". The header comment above the block gives the command whose diff is empty. Its four dependencies (row_split_state_cell, rstrip_spaces, lc, row_of_lane) are byte-identical here and there. One wrapper, `lane_is_managed_owned `, answers 0 with the owner, 8 for a legacy lane and 1 for unknown. It closes one gap the ported reader leaves: `row_of_lane` reads an unrendered published register as an empty one, so the reader would call every lane legacy, and the wrapper reads that as unknown. A read verb, `managed-projection `, gives the scripts the same answer. The rule is the reader's own ("no malformed marker may be downgraded to absence"). A valid marker refuses with 2 and names the owner. Unparseable vocabulary or an unreadable register refuses with 1. No vocabulary means legacy, exactly as before. Every refusal comes before the act's first write: - write_event, before its lock, for STARTED, RESUMED, ENDED and RETIRED. The verdict is handed to the lifecycle follow-up. - set-lane-state and set-lane-tree, before the control root and the lock. - retire-rows, in its scan. One managed or unknown lane refuses the whole one-commit sweep. - lane-reconcile, read-only. It prints MANAGED and `VERDICT managed-owned`, or `indeterminate` for unknown, and pronounces nothing else. - row_state_check, add-row and replace-in-row's new text refuse the marker's vocabulary, so no legacy writer can forge a marker. - set-row-state, replace-in-row and rename-lane refuse a managed lane. Each would replace the marker or orphan its bound-lane. These three are the row writers 001 itself guards (b001:9064, 9096, 9613). - lane-handoff, right after its canonical `lane=` step and before the window rename. Every mode (--late, --restart, --exit) is behind it, so SWAPPING and SWAPPED never run for a managed lane. - lane-start, at the head of section 3, before Amendment 18's binding gate and its request-handoff. - lane-end, once canon-lane has named the lane. That covers the ending, --retire and --retire . The scripts treat any status but 0 and 8 from `managed-projection` as unknown, including a helper that predates the verb. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lane-end | 20 ++++ lane-handoff | 21 ++++ lane-start | 20 ++++ lanes-edit.sh | 312 ++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 366 insertions(+), 7 deletions(-) diff --git a/lane-end b/lane-end index a56b2eb..d9c9eb6 100755 --- a/lane-end +++ b/lane-end @@ -835,6 +835,26 @@ 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 +} +# ONCE THE NAME IS CANONICAL and before every act this command has — the +# ending, `--retire`, and `--retire ` — so none of them touches a managed +# lane. (`--retire-dormant` is above and is refused by `retire-rows` itself.) +seam_refuse "$LANE" "a lane end" + # `--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 diff --git a/lane-handoff b/lane-handoff index 0af61e5..68ff010 100755 --- a/lane-handoff +++ b/lane-handoff @@ -609,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 diff --git a/lane-start b/lane-start index 7c6e1b6..2bb8665 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. diff --git a/lanes-edit.sh b/lanes-edit.sh index aa11705..ce3a499 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -375,6 +375,24 @@ # 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`, and the entry of +# `lane-start`, `lane-handoff` and `lane-end`. `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) @@ -1420,6 +1438,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. @@ -4913,9 +4937,17 @@ write_event() { # 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_pre=""; we_seam="" case "$we_verb" in - STARTED|RESUMED|ENDED|RETIRED) we_pre="$(lane_state_preimage "$we_lane" "$we_pay")" ;; + STARTED|RESUMED|ENDED|RETIRED) + # THE SEAM, BEFORE THE LOCK AND BEFORE A BYTE (ruling 2026-10-04): a + # lane-kind line that starts, resumes or ends a lane is a legacy act, and a + # managed-owned lane — or one whose ownership could not be read — refuses + # it here. Past this line the lane is legacy, and that verdict is what the + # lifecycle follow-up at this function's foot is handed. + managed_seam_refuse "$we_lane" "a $we_verb line in its object log" + we_seam=8 + we_pre="$(lane_state_preimage "$we_lane" "$we_pay")" ;; esac acquire_lock capture_register_edit "${we_paths[@]}" @@ -4963,7 +4995,7 @@ write_event() { # 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" + lane_state_follow "$we_lane" "$we_verb" "$we_pay" "$we_uuid" "$we_pre" "$we_seam" return "$we_rc" } @@ -10700,6 +10732,10 @@ retire_rows() { # … [--reason ""] [--writer ] 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. @@ -10868,7 +10904,7 @@ $(session_ids_local_of_lane "$rr_l" 2>/dev/null || :)" # 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" "") + rr_follow="$rr_follow$rr_l$US$(lane_state_preimage "$rr_l" "")${US}8 " rr_n=$((rr_n + 1)) done @@ -10910,9 +10946,9 @@ EOF # 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; do + 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" + lane_state_follow "$rr_fl" RETIRED "" "$rr_uuid" "$rr_fpre" "$rr_fseam" done < + 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 sweep), before its first write — so a refusal changes +# nothing; `die` releases a held lock on the way out. +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) return 0 ;; + *) 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 +} + # ============================================================================ # THE CRASH-CONSISTENT LANE LIFECYCLE AND THE WORKTREE INVENTORY # (openspec/changes/add-crash-consistent-lane-worktree-recovery, @@ -11698,6 +11936,25 @@ lane_tree_now() { # 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 @@ -12113,6 +12370,10 @@ Nothing was written." 2 # AMENDMENT 15 — the row's own spelling, which is what the commit subject # and every message below then carry. lane="$(canon_lane "$lane")" || exit 2 + # THE CELL THIS REPLACES MAY BE THE MANAGED LEDGER'S MARKER (ruling + # 2026-10-04), and replacing it would take the lane out of the ledger's + # hands without the ledger: refused before the lock, nothing written. + managed_seam_refuse "$lane" "set-row-state" acquire_lock; handle_preexisting n="$(row_line "$lane")" || exit 2 row="$(sed -n -e "${n}p" "$LANES_FILE")" @@ -12138,6 +12399,12 @@ Nothing was written." 2 lane="${1-}"; old="${2-}"; new="${3-}"; why="${4-}" [ -n "$lane" ] && [ -n "$old" ] || die "usage: replace-in-row \"\" \"\" [\"\"]" 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")" @@ -12267,6 +12534,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 @@ -12485,6 +12755,10 @@ Nothing was written." 2 # 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 @@ -14739,6 +15013,28 @@ 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 @@ -14817,6 +15113,7 @@ EOF 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 ] || @@ -14931,6 +15228,7 @@ EOF 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 ] || @@ -15067,6 +15365,6 @@ EOF ;; *) - die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|retire-rows|archive-rows|migrate-state-cells|commit|rename-lane|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|binding|request-handoff|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|duplicate-holder|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|register-row-local|retired-identity|canon-lane|resolve-home|lane-state|set-lane-state|lane-trees|set-lane-tree|lane-tree-now|lane-reconcile)" 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 From 673a81a6cac9f3b82d771a58e7bab4f2e4f7d633 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:48:36 +0000 Subject: [PATCH 13/26] Move a lane's lifecycle only on a legacy verdict and an unchanged pre-image The follow-up that moves a snapshot on a lane-kind line now writes only when three things hold. The verb is STARTED or RESUMED (giving RUNNING) or ENDED or RETIRED (giving CLOSED). The caller's seam read said 8, meaning a legacy lane with no managed-owner marker and no vocabulary of one. And a pre-image was taken before the line was written, and the snapshot still matches it under the mutex. Until now an empty pre-image skipped the comparison, so a caller that took none could overwrite a newer state. It now writes nothing, and so does a missing verdict: a caller that did not ask is not a caller that was told "legacy". Both callers, write_event and the Amendment 19 sweep, pass both. So a marked or unknown row is never written over, and RUNNING is never the legacy tooling's to write for a lane the managed ledger owns. Design decision 21 leaves room for a later change to own RUNNING for an operation it holds; no stub ships here. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index ce3a499..e87a10f 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -11618,14 +11618,30 @@ lane_state_preimage() { # # 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. -lane_state_follow() { # [] +# +# 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 @@ -11646,7 +11662,7 @@ lane_state_follow() { # [] return 0 fi lsf_seen="$(lane_state_fingerprint "$lsf_f")" - if [ -n "$lsf_pre" ] && [ "$lsf_seen" != "$lsf_pre" ]; then + 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 From e959c3f5806b287fe1fc058d4e21a9a68731d25b Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:50:25 +0000 Subject: [PATCH 14/26] Document the managed-owner seam and the four merge fixes The manual gains a "Managed-owned lanes" section. It gives Brett Heap's ruling of 2026-10-04 verbatim, the two marker forms, the `managed-projection` read and its four exits with what every legacy act does on each, the acts that ask and where, and the statement that branch 001's governance review is PROPOSED, NOT APPROVED, and that no supersession of Amendment 17 is asserted. The #91 section's verdict table gains the bound-elsewhere row (Amendment 18(b)) and the managed-owned row. Its reconciliation paragraph names the new BINDING line, and says a renamed or swept lane keeps or closes its snapshot. In the OpenSpec change: - the proposal scopes it to legacy lanes by the ruling, verbatim; - design decision 21 records the seam, the ported lines, the codes, the provenance and what is not asserted; - decisions 22 to 24 record exit 10, Amendment 18(b), the sweep and the rename; - two risks are added, the hint's over-match (measured 0 of the 57 published rows at brett-wip 225b9f90a) and a marker missed under LANES_NO_FETCH; - the spec gains the requirement "Legacy lifecycle refuses a managed-owned lane" with its three scenarios, and a bound-elsewhere scenario; - tasks gain section 8, whose test task stays open until the suite proves it. `openspec validate add-crash-consistent-lane-worktree-recovery --strict` reports the change valid. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 61 ++++++++++++++- .../design.md | 76 +++++++++++++++++++ .../proposal.md | 4 + .../specs/lane-worktree-recovery/spec.md | 19 +++++ .../tasks.md | 16 ++++ 5 files changed, 173 insertions(+), 3 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 7704267..7cac2fe 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2890,6 +2890,8 @@ RUNNING --/handoff begins--> SWAPPING --record + row + handoff all landed--> SWA | `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 @@ -2993,9 +2995,19 @@ 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`, `unknown-schema`, `unmanaged`, -`stale-registration`. The last line is the `VERDICT`. `lane-start` prints the -report before it writes anything, for any verdict that is not `running`, -`resumable` or `closed`. +`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`, `resumable` or `closed`. + +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 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}..`, @@ -3035,6 +3047,49 @@ one worktree, one writer). `ungraceful-stop` and `interrupted-swap` both mean `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`, `set-lane-state`, `set-lane-tree`, `retire-rows` (one managed or +unknown lane refuses the whole sweep), `set-row-state`, `replace-in-row` and +`rename-lane`. `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`, the sweep — may +write the marker's vocabulary into a row 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/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index 2cc15f3..329ea8a 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -357,6 +357,80 @@ 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 the four verbs that move a lifecycle, `set-lane-state`, `set-lane-tree`, +`retire-rows` (any hit refuses the whole sweep), `set-row-state`, +`replace-in-row`, `rename-lane`, and the entry of `lane-start` (before Amendment +18's binding gate), `lane-handoff` (before every mode) and `lane-end`. +`lane-reconcile` is read-only and answers `managed-owned`. `row_state_check`, +`add-row` and `replace-in-row`'s new text refuse the marker's vocabulary, so no +legacy writer forges a marker. + +**`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. + ## 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. @@ -367,6 +441,8 @@ serialized is the compare and the write, not the reading of a repository. - **[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 diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/proposal.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/proposal.md index 6708eef..f4556f2 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/proposal.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/proposal.md @@ -2,6 +2,8 @@ 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. @@ -30,6 +32,8 @@ Tracked by [opensoft/openRepoTools#91](https://github.com/opensoft/openRepoTools **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 index 2acea8d..9d43d57 100644 --- 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 @@ -103,6 +103,10 @@ Before launching replacement writers, the system SHALL compare lane and tree sid - **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 @@ -145,6 +149,21 @@ The system SHALL distinguish a lane or tree that completed a temporary swap from - **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, log, register-row, sweep, start, handoff and end act SHALL read the lane's managed-owner marker, refuse a valid one, refuse an unreadable or malformed one as unknown, and run unchanged for a lane with none. + +#### 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, 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. diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md index 902be41..3db4334 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md @@ -166,3 +166,19 @@ Nothing is left as a comment thread and nothing is left unnamed. 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. + +## 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. +- [ ] 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. From a0cf37b87f6f2d8563c5c5aaed0e348081b6e1c6 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:58:34 +0000 Subject: [PATCH 15/26] Prove the managed-owner seam on every fixture class and every act MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new suite section, after #91's. Its fixtures are written straight into the sandbox register and pushed, as the managed ledger's own writer would land them, because no legacy writer may now write that vocabulary at all: - VALID-LONG (`managed-owner mode=managed daemon=ledger-1 generation=3 bound-lane=repoMG-1`) - VALID-SHORT (`MANAGED OWNER · ledger-2`) - LEGACY - five MALFORMED: generation=0, an empty daemon, a bound-lane that is another lane, the shorthand with an empty token, and `managed: x` in the objects cell - a dormant legacy row for the sweep Every act runs against both valid rows and all five malformed ones: `log` with each of STARTED, RESUMED, ENDED and RETIRED, set-lane-state, set-lane-tree, retire-rows, set-row-state, replace-in-row, rename-lane, lane-handoff (plain, --late and --restart), lane-start (--no-launch and a launch) and lane-end. A valid marker exits 2 naming the owner, and a malformed one exits 1 saying ownership is UNKNOWN. Every refusal is proved zero-change by one fingerprint: the register's commit here and on origin, the 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. lane-reconcile answers `managed-owned` (or `indeterminate`) and reads and writes nothing. One managed lane refuses a whole sweep. set-row-state, add-row and replace-in-row refuse the marker's vocabulary. The legacy lane still transitions, moves its snapshot on a RESUMED, reconciles without a MANAGED line and is swept. managed-projection's codes (0, 8, 1, 64) are asserted on every fixture. 410 assertions in the section. Run alone in a harness of the suite's own setup it is 410 passed. Against a copy whose `lane_is_managed_owned` always answers legacy it is 339 failed, including 91 zero-change assertions. Of #91's own section, the one removed line is the assertion of exit 9 that the move to 10 replaced. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- tests/test_lane_helpers.sh | 233 +++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 2f44696..8226e0e 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -13899,6 +13899,239 @@ is "a lane whose STARTED predates Amendment 18(a) is bound HERE, on the line' 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" +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 < Date: Sun, 4 Oct 2026 18:04:36 +0000 Subject: [PATCH 16/26] Assert a lifecycle snapshot never reaches the listing's binding column Amendment 18 gives `lanes` a 13th column, the binding, and it must print `none` for a lane whose log carries no binding line. A lifecycle snapshot is this workstation's alone and is never a binding, so a lane with a RUNNING snapshot and no binding line must still read `none` there. The lane is given a log with only a NOTED line, so Amendment 19 does not hide it as dormant, and its column 13 is asserted directly. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- tests/test_lane_helpers.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 8226e0e..1b6df45 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -13899,6 +13899,24 @@ is "a lane whose STARTED predates Amendment 18(a) is bound HERE, on the line' 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" + 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 From a98b0d85a32f022e3fbcd54c49eaa89946d84914 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:04:56 +0000 Subject: [PATCH 17/26] Tick task 8.11: the seam is proved on every fixture class The suite section "the managed-owner seam" runs every act against both valid markers and all five malformed ones, proves each refusal zero-change, and keeps the legacy lane's behaviour. Run alone with the suite's setup and #91's section it is 573 passed, 0 failed. Of #91's section, the one assertion whose line changed is the exit 9 that the move to 10 replaced. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- .../add-crash-consistent-lane-worktree-recovery/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md index 3db4334..32531d5 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md @@ -181,4 +181,4 @@ Brett Heap's ruling of 2026-10-04, verbatim: "managed ledger owns enrolled lanes - [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. -- [ ] 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.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. From 6e64660c7c68636a229d06bf7c21692e287ccdc5 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:49:18 +0000 Subject: [PATCH 18/26] Let lane-end's own pair refusal speak before the seam Main's Amendment 15 suite proves that lane-end, driven by a helper that predates `canon-lane`, still refuses a register that spells the lane two ways. It exits 2 with 15(d)'s words, out of its OWN read of the register in "the row". Such a helper predates `managed-projection` as well. The seam, placed right after the canon-lane block, rightly called that UNKNOWN and exited 1 first, so the person lost the words that say what to do. The full suite caught it: 3 of 3931 failed. Both are refusals that write nothing, so the fix is ordering, not weakening either one. The seam now runs in two places, each before the first thing its path does: - at the head of the --retire door, before any process is looked for or signalled; - right after "the row"'s own refusals on the ending path (the ending and --retire), before the state cell, the in-flight guard and every write. The seam section now also runs `lane-end --retire` and `lane-end --retire ` against every managed and malformed fixture: 453 passed, 0 failed, run alone with the suite's setup. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lane-end | 20 ++++++++++++++++---- tests/test_lane_helpers.sh | 6 ++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/lane-end b/lane-end index d9c9eb6..0fec0ec 100755 --- a/lane-end +++ b/lane-end @@ -850,10 +850,16 @@ seam_refuse() { # *) 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 } -# ONCE THE NAME IS CANONICAL and before every act this command has — the -# ending, `--retire`, and `--retire ` — so none of them touches a managed -# lane. (`--retire-dormant` is above and is refused by `retire-rows` itself.) -seam_refuse "$LANE" "a lane end" +# 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 — @@ -940,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 @@ -1555,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/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 1b6df45..e25fada 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -14093,6 +14093,12 @@ MG_CASE run "$END" "$mg_l" mg_refused "$mg_l: lane-end" "$mg_x" "$mg_o" "$mg_b" mg_b="$(mg_fp)" + run "$END" "$mg_l" --retire + mg_refused "$mg_l: lane-end --retire" "$mg_x" "$mg_o" "$mg_b" + mg_b="$(mg_fp)" + run "$END" "$mg_l" --retire 999999 + mg_refused "$mg_l: lane-end --retire " "$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 From ec9847e66d2f3e04b1bbb9a0601a85847d5a67fd Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:49:37 +0000 Subject: [PATCH 19/26] Say where lane-end's seam actually runs Decision 21 and the lanes-edit.sh header said the seam ran at lane-end's "entry". Since the previous commit it runs before lane-end's first act on each of its paths. That is the head of the --retire door, and on the ending path it is right after the command's own row refusals, so that Amendment 15(d)'s pair refusal is still the one a person reads through a helper that predates both reads. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 5 +++-- .../add-crash-consistent-lane-worktree-recovery/design.md | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index e87a10f..9a32c57 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -388,8 +388,9 @@ # 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`, and the entry of -# `lane-start`, `lane-handoff` and `lane-end`. `lane-reconcile` pronounces +# `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. # diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index 329ea8a..aee82b6 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -384,8 +384,11 @@ 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 the four verbs that move a lifecycle, `set-lane-state`, `set-lane-tree`, `retire-rows` (any hit refuses the whole sweep), `set-row-state`, -`replace-in-row`, `rename-lane`, and the entry of `lane-start` (before Amendment -18's binding gate), `lane-handoff` (before every mode) and `lane-end`. +`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` and `replace-in-row`'s new text refuse the marker's vocabulary, so no legacy writer forges a marker. From 79157456cd99edf53c849963b0df0523275f010a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:23:53 +0000 Subject: [PATCH 20/26] Close the three findings Copilot left open on 64dfd97 Three review threads from the last Copilot round on this PR (at 64dfd97) were never answered. Each is a fail-open hole in the lifecycle's own contract, and each fix is small. 1. A dangling symlink is not absent to the writers either. `lane_sidecar_schema_ok` tested `-e` alone, which is false for a broken link. So set-lane-state, lane_state_follow and set-lane-tree passed the guard and renamed a fresh file over a record that `lane_state_read` itself calls present-and-unreadable. It now asks `[ -L ]` beside `[ -e ]`, as the reader does. 2. An unreadable lane log is never an empty coordinator directory. lane_reconcile collapsed `lane_payload_field`'s 1 (log unreadable) into an empty `dir`. That skipped both the registration sweep and the on-disk sweep, so a SWAPPED lane with no holder could read as resumable over paths nobody inspected. The 1 is now kept and the verdict is indeterminate. (The binding read of 28ed7c7 already failed on the same log and gave indeterminate. This makes the rule independent of it.) 3. A refused inventory write keeps the lane SWAPPING. lane-handoff only logged a set-lane-tree failure, so a disk, lock or schema refusal could still finalize SWAPPED over an inventory missing a polled writer. The tree is now named in `lc_unfinished`, on both the ordinary and the late path. Tests: a writer meeting a dangling snapshot and a dangling tree sidecar refuses and keeps the link. A SWAPPED lane is resumable with its log readable and indeterminate with the log published twice (through Amendment 15's own plumbing, so it runs on a case-insensitive file system too). A handoff whose inventory write is refused stays SWAPPING. Against ec9847e, which lacks the fixes, 6 of these fail. With them, the suite's setup plus #91's section and the seam section give 626 passed. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lane-handoff | 14 +++++++-- lanes-edit.sh | 21 +++++++++++-- tests/test_lane_helpers.sh | 63 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/lane-handoff b/lane-handoff index 68ff010..2ecb755 100755 --- a/lane-handoff +++ b/lane-handoff @@ -409,7 +409,7 @@ $sr_why" 2 ;; # 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_on=""; lc_gen=""; lc_op=""; lc_state=""; lc_inventory_missed="" lifecycle_begin() { lc_on=""; lc_gen=""; lc_op=""; lc_state="" @@ -894,8 +894,14 @@ poll_writer() { # --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 || - 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, and \`$LANES_EDIT lane-reconcile $lane\` says what the inventory holds." + >/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 } @@ -1113,6 +1119,7 @@ if [ "$do_late" = 1 ]; then # 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" @@ -1234,6 +1241,7 @@ 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 diff --git a/lanes-edit.sh b/lanes-edit.sh index 9a32c57..8c30123 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -11457,8 +11457,12 @@ lane_sidecar_field() { # # 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-}" ] || return 0 + [ -e "${1-}" ] || [ -L "${1-}" ] || return 0 lss_v="$(lane_sidecar_field "$1" schema 2>/dev/null || :)" [ "$lss_v" = "$LANE_STATE_SCHEMA" ] } @@ -12063,7 +12067,16 @@ LRC_BIND # 3. THE TREES — the inventory, recomputed. A stored value is a COMPARISON # POINT and never current truth. - lrc_dir="$(lane_payload_field "$lrc_lane" dir 2>/dev/null || :)" + # THE COORDINATOR DIRECTORY, AND A LOG NOBODY COULD READ IS NOT A LANE WITH + # NONE (Copilot on 64dfd97, PR #97). `lane_payload_field` answers 8 for a log + # that carries no `dir` and 1 for a log that could not be read; collapsed into + # one empty string, the second skipped both sweeps below — the registrations + # `git worktree list` holds and the trees on disk — and a SWAPPED lane with no + # holder then read as resumable over paths nobody inspected. The 1 is kept, + # and the verdict below is `indeterminate` for it. + lrc_dir=""; lrc_drc=0 + lrc_dir="$(lane_payload_field "$lrc_lane" dir 2>/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 @@ -12248,6 +12261,10 @@ EOF lrc_v=indeterminate lrc_why="lane $lrc_lane's object log could not be read, so where it is bound is NOT established — and liveness may be pronounced only from inside the binding (Amendment 18(b)). A read that failed is never an answer (Amendment 7(d))" ;; esac + if [ "$lrc_drc" != 0 ] && [ "$lrc_drc" != 8 ] && [ "$lrc_v" != indeterminate ]; then + lrc_v=indeterminate + lrc_why="lane $lrc_lane's object log could not be read, so its coordinator directory is NOT established and neither the worktrees git registers there nor the trees on disk under its roots were inspected — no clearance is pronounced over paths nobody read (Amendment 7(d))" + fi printf 'TREES%s%s inventoried%s%s dirty or unpushed%s%s require recovery%s%s unmanaged or stale\n' \ "$US" "$lrc_n" "$US" "$lrc_dirty" "$US" "$lrc_recover" "$US" "$lrc_unmanaged" printf 'VERDICT%s%s%s%s\n' "$US" "$lrc_v" "$US" "$lrc_why" diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index e25fada..bbec475 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -13818,6 +13818,21 @@ is "…and the verdict is INDETERMINATE: no crash is pronounced on a read nob 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 +rc9_tp="$RC_DIR/.claude/worktrees/dangling-91" +rc9_tid="$(printf '%s' "$rc9_tp" | tr -c 'A-Za-z0-9._-' '-' | tr -s '-' | sed -e 's/^-*//' -e 's/-*$//')" +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 @@ -13917,6 +13932,54 @@ 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="$(printf '%s' "$RC_DIR/.claude/worktrees/w14" | tr -c 'A-Za-z0-9._-' '-' | tr -s '-' | sed -e 's/^-*//' -e 's/-*$//')" +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 + 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 From 777326771c883aa9a79147f5ab82fb537c01b762 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:33:45 +0000 Subject: [PATCH 21/26] Take Codex's four inventory findings on ec9847e Each finding is a tree a recovery would misread, and each is part of a follow-up this change had filed and deferred, so they are taken here rather than left behind a review thread: - An unreadable tree sidecar was skipped, so a record nobody could read and no record at all were one silence. lane-trees now carries it as a row named by its file with the schema , and lane-reconcile reports it as unreadable-sidecar and counts it toward recovery (#113's second read). - tree_id_for folded a path to the file-name alphabet, so a/a+b and a/a-b shared one sidecar and the second write replaced the first tree's last observation. 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). - set-lane-tree completed a partial observation with unknown, none, dirty 0 and unpushed 0, the clean-and-published shape nobody observed. It is now a usage refusal (64) that writes nothing (#114's second half). - lane-start stayed silent over any resumable lane. It is now quiet only when the TREES line counts nothing dirty or unpushed, requiring recovery, or unmanaged or stale, and its report names a binding that is neither here nor free. The manual, design decision 25, four spec scenarios and task 8.12 say the same; repoRC-15 proves each case. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 25 +++++++- lane-start | 21 +++++-- lanes-edit.sh | 45 +++++++++++--- .../design.md | 27 ++++++++ .../specs/lane-worktree-recovery/spec.md | 16 +++++ .../tasks.md | 10 ++- tests/test_lane_helpers.sh | 62 ++++++++++++++++++- 7 files changed, 186 insertions(+), 20 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 7cac2fe..9c5eab0 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -2983,6 +2983,16 @@ 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 @@ -2994,13 +3004,16 @@ 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`, `unknown-schema`, `unmanaged`, -`stale-registration`. A `BINDING` line says where the lane is bound — `here`, +`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`, `resumable` or `closed`. +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 @@ -3034,6 +3047,12 @@ NOTHING (`AGENTS.md` rule 1), so this read runs `git status`, `git log @{u}..`, * 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. diff --git a/lane-start b/lane-start index 2bb8665..0b7afd0 100755 --- a/lane-start +++ b/lane-start @@ -3083,17 +3083,30 @@ if (( ! dry_run )); then 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*|resumable*|closed*|'') : ;; - *) + 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" ;; - esac ;; + 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 diff --git a/lanes-edit.sh b/lanes-edit.sh index 8c30123..087c590 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -11731,24 +11731,27 @@ lane_state_rename() { # # ------------------------------------------------- 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. It is -# the absolute path with every character outside the manifest-key set folded to -# `-`, which is one file name per path and the same file name on every run. +# 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, and a `cksum` of the WHOLE path goes in - # front of it so that two trees sharing a tail keep two ids. + # so the head is what is dropped. if [ "${#tif_p}" -gt 180 ]; then - tif_c="$(printf '%s' "${1-}" | cksum | awk '{print $1}')" - tif_p="c$tif_c-$(printf '%s' "$tif_p" | tail -c 180)" + tif_p="$(printf '%s' "$tif_p" | tail -c 180)" fi - printf '%s\n' "$tif_p" + printf 'c%s-%s\n' "$tif_c" "$tif_p" } # ONE TREE'S SIDECAR. Every field is an OBSERVATION and none of them is truth @@ -11803,8 +11806,19 @@ lane_trees_list() { # [ "$ltl_rc" = 0 ] || return 1 [ -d "$ltl_root/trees" ] || return 8 for ltl_f in "$ltl_root"/trees/*.yaml; do - [ -r "$ltl_f" ] || continue + [ -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 || :)" @@ -12092,6 +12106,12 @@ LRC_BIND # 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" @@ -15293,6 +15313,13 @@ EOF 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 diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index aee82b6..915ea92 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -434,6 +434,33 @@ 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. 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 index 9d43d57..8475561 100644 --- 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 @@ -13,6 +13,14 @@ The system SHALL maintain a structured inventory for every worktree owned by a l - **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. @@ -123,6 +131,14 @@ Before launching replacement writers, the system SHALL compare lane and tree sid - **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. diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md index 32531d5..414d80e 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md @@ -114,6 +114,8 @@ Nothing is left as a comment thread and nothing is left unnamed. 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 @@ -125,7 +127,8 @@ Nothing is left as a comment thread and nothing is left unnamed. 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`. + 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 @@ -144,7 +147,9 @@ Nothing is left as a comment thread and nothing is left unnamed. 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. + 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 @@ -182,3 +187,4 @@ Brett Heap's ruling of 2026-10-04, verbatim: "managed ledger owns enrolled lanes - [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). diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index bbec475..f5c7fa0 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -13824,8 +13824,15 @@ 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="$(printf '%s' "$rc9_tp" | tr -c 'A-Za-z0-9._-' '-' | tr -s '-' | sed -e 's/^-*//' -e 's/-*$//')" +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 @@ -13968,7 +13975,7 @@ 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="$(printf '%s' "$RC_DIR/.claude/worktrees/w14" | tr -c 'A-Za-z0-9._-' '-' | tr -s '-' | sed -e 's/^-*//' -e 's/-*$//')" +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 @@ -13980,6 +13987,57 @@ 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 From d2d2eec4d9885d76cef4ff94acde2285967d83d5 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:00:43 +0000 Subject: [PATCH 22/26] Say that the reconciliation reads the published register first Copilot read lane-reconcile as a local report and asked for its fetch to go. The fetch is kept: the lane's name resolves through the published register and 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 moved. It is bounded, falls back to the ref as it stands, touches no lane tree, and lane-start already skips it with LANES_NO_FETCH=1 after its own fetch. The manual now says so, and the question of a local default is the one deferred finding, filed as #157. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 10 ++++++++++ .../tasks.md | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 9c5eab0..45b4cd4 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3022,6 +3022,16 @@ holds real git worktrees and is not moved — that is a `git worktree move`, and 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: diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md index 414d80e..5fb2a5c 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md @@ -171,6 +171,14 @@ Nothing is left as a comment thread and nothing is left unnamed. 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. ## 8. The rework of 2026-10-04 — managed ledger owns enrolled lanes; this change owns legacy From 19df77439da8cbc91ea36a829e826539c816680d Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:51:19 +0000 Subject: [PATCH 23/26] Refuse a managed or unknown row in the state-cell migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 2 on #97 (lanes-edit.sh:11313): migrate-state-cells rewrites state cells and appends to logs, yet it never asked the seam. The shorthand marker `MANAGED OWNER · ` holds one ` · `, so mig_cell_is_phrase does not recognise it, and `--yes` rewrote a managed row to `MIGRATED · …`. The scan now calls managed_seam_refuse for every row it would rewrite. The call comes after the 15(d) duplicate check and the phrase skip, and before the plan is built. One managed row (exit 2) or unknown row (exit 1) refuses the whole migration, the same way one refuses Amendment 19's sweep, because the migration is one commit. The migration rewrites this checkout's row while the seam reads the published register. So a local row that carries the vocabulary where the published one does not counts as unknown too. The repoMG section gets case 6, in its own workspace. The valid shorthand (in the dry run and with --yes), a malformed shorthand, and a marker that only this checkout carries all refuse with zero change. The same register with no marker migrates. Without the fix, 22 of the case's assertions fail. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 23 +++++++++ tests/test_lane_helpers.sh | 97 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/lanes-edit.sh b/lanes-edit.sh index 087c590..7711bbf 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -10363,6 +10363,29 @@ 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. + managed_seam_refuse "$msc_lane" "migrate-state-cells (the WHOLE migration, which Amendment 13(e) makes one commit)" + # AND THE ROW IT WOULD REWRITE IS THIS CHECKOUT'S, while the seam reads the + # PUBLISHED register (R19). A checkout ahead of origin, or holding an edit + # `handle_preexisting` has just captured, can carry vocabulary the published + # row does not — an ownership the two copies disagree on, which is UNKNOWN + # and never legacy (Amendment 7(d)). + msc_hrc=0; managed_projection_hint "$msc_row" || msc_hrc=$? + [ "$msc_hrc" = 8 ] || + die "whether the managed ledger owns lane $msc_lane is UNKNOWN: this checkout's row for it carries managed-owner vocabulary (or could not be read for it) where the published register's does not, and migrate-state-cells rewrites THIS checkout's row — an ownership the two copies disagree on is never read as 'legacy' (Amendment 7(d)). The WHOLE migration was refused and it wrote nothing. Publish or undo this checkout's change to that row first (git -C $LANES_REPO log origin/$LANES_BRANCH..HEAD -- $LANES_PATH), and re-run." 1 fi if [ -z "$msc_why" ]; then # The row's own columns, walked from the LEFT out of the head this split diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index f5c7fa0..ced0c2a 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -14277,6 +14277,103 @@ is "…and carries no MANAGED line at all" \ 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 +# ------ 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" + echo "== the workstation seam: unset, every writer reads the host ==" # THE OTHER HALF OF R-A9-13. Every case above this line runs with From eada5fff495452cf5da47e52d9550fa4b40a4c96 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:53:06 +0000 Subject: [PATCH 24/26] Defer the live-swap takeover to #157 and list the migration's seam Copilot round 2 on #97 (lane-handoff:434) asked lane-handoff to refuse a second swap while the first is still live. lifecycle_begin supersedes any lane recorded SWAPPING, which is design.md's settled answer for an interrupted swap. Telling a live operation from an interrupted one needs the handoff process's liveness. The snapshot does not record that, and design.md rules out a PID alone. The fence already bounds the harm: the superseded operation's finalizer and inventory writes are refused with 7. So the spec's "Competing swap begins" scenario now states what the code guarantees, not "without minting another owner". The requirement names the deferral. design.md's settled answer says what it cannot yet tell, and tasks.md files the finding under #157 beside the first one. The manual's and decision 21's lists of seam call sites now name migrate-state-cells, which 19df774 added, and task 8.13 records that change. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 5 +++-- .../design.md | 11 ++++++++++- .../specs/lane-worktree-recovery/spec.md | 7 ++++--- .../tasks.md | 12 ++++++++++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 45b4cd4..9415be5 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3109,8 +3109,9 @@ section 3, before Amendment 18's binding gate and `--request-handoff`), `--exit` never reach `SWAPPING` or `SWAPPED`), `lane-end` (the ending, `--retire` and `--retire `), the object log's `STARTED`, `RESUMED`, `ENDED` and `RETIRED`, `set-lane-state`, `set-lane-tree`, `retire-rows` (one managed or -unknown lane refuses the whole sweep), `set-row-state`, `replace-in-row` and -`rename-lane`. `lane-reconcile` reads nothing of a managed lane and prints +unknown lane refuses the whole sweep), `migrate-state-cells` (one managed or +unknown row it would rewrite refuses the whole migration), `set-row-state`, +`replace-in-row` and `rename-lane`. `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`, the sweep — may write the marker's vocabulary into a row at all, so a marker is never forged. diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index 915ea92..80e1ae7 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -383,7 +383,9 @@ 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 the four verbs that move a lifecycle, `set-lane-state`, `set-lane-tree`, -`retire-rows` (any hit refuses the whole sweep), `set-row-state`, +`retire-rows` (any hit refuses the whole sweep), `migrate-state-cells` (any +row it would rewrite refuses the whole migration, which is one commit), +`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 @@ -498,6 +500,13 @@ Rollback disables new writes but preserves sidecars and append-only events for d 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/specs/lane-worktree-recovery/spec.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/specs/lane-worktree-recovery/spec.md index 8475561..c9ea3e0 100644 --- 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 @@ -70,7 +70,7 @@ The system SHALL transition a running lane to `SWAPPING` before `/swap` performs - **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. +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 @@ -89,8 +89,9 @@ Every ownership transition SHALL carry a monotonically advancing generation and - **THEN** the system refuses the stale finalizer without changing current state #### Scenario: Competing swap begins -- **WHEN** a second `/swap` attempts to start while the matching generation is already `SWAPPING` -- **THEN** the system refuses the competing operation or reports the existing operation without minting another owner +- **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. diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md index 5fb2a5c..77c570a 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md @@ -179,6 +179,17 @@ Nothing is left as a comment thread and nothing is left unnamed. 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 @@ -196,3 +207,4 @@ Brett Heap's ruling of 2026-10-04, verbatim: "managed ledger owns enrolled lanes - [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. From 07fb3adb8b4ac1476c641685096f00f0d20593c6 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:10:40 +0000 Subject: [PATCH 25/26] Close the seam on every lane-kind line, request-handoff and archive-rows Copilot round 3 on #97 (lanes-edit.sh:4947, spec.md:169) found legacy writers that still reach a managed lane. The spec says every log, register-row and sweep act refuses one: - write_event asked the seam for STARTED, RESUMED, ENDED and RETIRED only, so `log PAUSED` and request-handoff's HANDOFF-REQUESTED wrote to a managed lane's log. Every lane-kind verb now asks. The two new ones get no lifecycle follow-up. - request-handoff now refuses at its head, so --dry-run plans nothing. - archive-rows moved a RETIRED managed row out of the register. Its scan now asks after the RETIRED test, and one hit refuses the whole move. The sweep of every writer arm closed four more gaps. append-session-id rewrote a managed row's session cell. add-row now names the owner of an existing managed lane before its duplicate refusal. append-line, the new text of append-session-id and rename-lane's new name could all write the marker's vocabulary into the register. managed_seam_refuse now also reads this checkout's row. Every legacy row writer rewrites that row, while the seam's first read is the published one, so a local row that carries the vocabulary where the published one does not is UNKNOWN (1). The migration's own copy of that check is now this one. The repoMG section proves each newly guarded arm on all seven managed and unknown fixtures with the zero-change fingerprint. It also covers the three back doors, the legacy controls, and case 7: archive-rows over a valid and a malformed retired marker, then the same move landing once the managed row is gone. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 81 +++++++++++++++++++++++------ tests/test_lane_helpers.sh | 101 +++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 16 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 7711bbf..3c9bf5f 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -4939,14 +4939,18 @@ write_event() { # 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) - # THE SEAM, BEFORE THE LOCK AND BEFORE A BYTE (ruling 2026-10-04): a - # lane-kind line that starts, resumes or ends a lane is a legacy act, and a - # managed-owned lane — or one whose ownership could not be read — refuses - # it here. Past this line the lane is legacy, and that verdict is what the - # lifecycle follow-up at this function's foot is handed. - managed_seam_refuse "$we_lane" "a $we_verb line in its object log" + # 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 @@ -10377,15 +10381,9 @@ EOF # 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)" - # AND THE ROW IT WOULD REWRITE IS THIS CHECKOUT'S, while the seam reads the - # PUBLISHED register (R19). A checkout ahead of origin, or holding an edit - # `handle_preexisting` has just captured, can carry vocabulary the published - # row does not — an ownership the two copies disagree on, which is UNKNOWN - # and never legacy (Amendment 7(d)). - msc_hrc=0; managed_projection_hint "$msc_row" || msc_hrc=$? - [ "$msc_hrc" = 8 ] || - die "whether the managed ledger owns lane $msc_lane is UNKNOWN: this checkout's row for it carries managed-owner vocabulary (or could not be read for it) where the published register's does not, and migrate-state-cells rewrites THIS checkout's row — an ownership the two copies disagree on is never read as 'legacy' (Amendment 7(d)). The WHOLE migration was refused and it wrote nothing. Publish or undo this checkout's change to that row first (git -C $LANES_REPO log origin/$LANES_BRANCH..HEAD -- $LANES_PATH), and re-run." 1 fi if [ -z "$msc_why" ]; then # The row's own columns, walked from the LEFT out of the head this split @@ -11072,6 +11070,18 @@ archive_rows() { # <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" @@ -11331,16 +11341,33 @@ lane_is_managed_owned() { # # 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 sweep), before its first write — so a refusal changes +# 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) return 0 ;; + 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 } # ============================================================================ @@ -12542,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")" @@ -12560,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 @@ -12663,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." @@ -12793,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 @@ -14252,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 diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index ced0c2a..90f1718 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -14173,6 +14173,29 @@ MG_CASE run env LANES_LANE="$mg_l" LANES_SESSION="$MG_ID" "$E" log "$mg_v" "lane:$mg_l" mg_refused "$mg_l: log $mg_v" "$mg_x" "$mg_o" "$mg_b" done + # EVERY LANE-KIND LINE, and not only the four that move a lifecycle (Copilot + # round 3 on #97): a swap's PAUSED, and Amendment 18(g)'s HANDOFF-REQUESTED, + # which only `request-handoff` writes — refused before the binding is read, + # so its --dry-run plans nothing either. + mg_b="$(mg_fp)" + run env LANES_LANE="$mg_l" LANES_SESSION="$MG_ID" "$E" log PAUSED "lane:$mg_l" + mg_refused "$mg_l: log PAUSED" "$mg_x" "$mg_o" "$mg_b" + mg_b="$(mg_fp)" + run env LANES_SESSION="$MG_ID" "$E" request-handoff "$mg_l" --no-wait + mg_refused "$mg_l: request-handoff" "$mg_x" "$mg_o" "$mg_b" + mg_b="$(mg_fp)" + run env LANES_SESSION="$MG_ID" "$E" request-handoff "$mg_l" --dry-run + mg_refused "$mg_l: request-handoff --dry-run" "$mg_x" "$mg_o" "$mg_b" + hasnt "…which plans nothing" "$err" "PLAN:" + # AND THE TWO ROW WRITERS THE FIRST PASS LEFT: the session cell, and a + # second row for a lane that has one, refused naming the owner rather than + # only as a duplicate. + mg_b="$(mg_fp)" + run env LANES_SESSION="$MG_ID" "$E" append-session-id "$mg_l" "$MG_ID" "→ 3a9e0002-2222-4000-8000-3a9e00022222" + mg_refused "$mg_l: append-session-id" "$mg_x" "$mg_o" "$mg_b" + mg_b="$(mg_fp)" + run "$E" add-row "| \`$mg_l\` | harness \`$MG_ID\` | Eagle / test / brett | 2026-10-04T00:00Z | none | handoffs/repoMG/$mg_l.md | PAUSED · a second row for one lane |" + mg_refused "$mg_l: add-row for a lane that has a row" "$mg_x" "$mg_o" "$mg_b" mg_b="$(mg_fp)" run "$E" set-lane-state "$mg_l" RUNNING --expect none --owner "$MG_ID" mg_refused "$mg_l: set-lane-state" "$mg_x" "$mg_o" "$mg_b" @@ -14260,6 +14283,20 @@ 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" @@ -14276,6 +14313,10 @@ 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 # @@ -14374,6 +14415,66 @@ 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 From 8c5958fcb366b0f749e878f13f10877afce85637 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:05:15 +0000 Subject: [PATCH 26/26] Name every arm the seam guards in the spec, the design and the manual 07fb3ad closed the seam on every legacy writer arm. The documents now list the same set: - the spec requirement names lane-kind log lines, the archive, the migration and this checkout's row, and says no legacy writer may write the marker's vocabulary; - design decision 21 and the manual list PAUSED, request-handoff, archive-rows, append-session-id, add-row for an existing lane and the three back doors; - task 8.14 records the round. Object-kind lines (a claim, a release) are stated as unchanged, because they concern the object a lane holds and not the lane. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 19 +++++++++++++------ .../design.md | 15 ++++++++++++--- .../specs/lane-worktree-recovery/spec.md | 4 ++-- .../tasks.md | 1 + 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 9415be5..244e1ad 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3108,13 +3108,20 @@ 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`, `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), `set-row-state`, -`replace-in-row` and `rename-lane`. `lane-reconcile` reads nothing of a managed lane and prints +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`, the sweep — may -write the marker's vocabulary into a row at all, so a marker is never forged. +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 diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md index 80e1ae7..369b936 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/design.md @@ -382,9 +382,13 @@ 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 the four verbs that move a lifecycle, `set-lane-state`, `set-lane-tree`, +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` @@ -392,8 +396,13 @@ row it would rewrite refuses the whole migration, which is one commit), 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` and `replace-in-row`'s new text refuse the marker's vocabulary, so no -legacy writer forges a marker. +`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 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 index c9ea3e0..d2bf185 100644 --- 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 @@ -167,14 +167,14 @@ The system SHALL distinguish a lane or tree that completed a temporary swap from - **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, log, register-row, sweep, start, handoff and end act SHALL read the lane's managed-owner marker, refuse a valid one, refuse an unreadable or malformed one as unknown, and run unchanged for a lane with none. +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, or the register 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 diff --git a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md index 77c570a..6def619 100644 --- a/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md +++ b/openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md @@ -208,3 +208,4 @@ Brett Heap's ruling of 2026-10-04, verbatim: "managed ledger owns enrolled lanes - [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.