From 9f9bed5dd876b1cee1fa3daba2bbba7d53bdf1ed Mon Sep 17 00:00:00 2001 From: nstranquist Date: Mon, 17 Aug 2026 08:34:51 -0500 Subject: [PATCH 1/5] test(wip): expose concurrent capture failures --- cmd/wip/main_test.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/cmd/wip/main_test.go b/cmd/wip/main_test.go index 32167ae..850faa5 100644 --- a/cmd/wip/main_test.go +++ b/cmd/wip/main_test.go @@ -126,7 +126,22 @@ func TestConcurrentSharedLanesCaptureDisjointPaths(t *testing.T) { t.Fatalf("decode %s output %q: %v; stderr=%s", captured.lane, captured.stdout, err, captured.stderr) } if captured.code != 0 || !output.OK { - t.Fatalf("%s capture: code=%d output=%#v stderr=%s", captured.lane, captured.code, output, captured.stderr) + errorCode, errorMessage := "", "" + if output.Error != nil { + errorCode, errorMessage = output.Error.Code, output.Error.Message + } + data, marshalErr := json.Marshal(output.Data) + if marshalErr != nil { + data = []byte("") + } + var recovery engine.Result + if output.Data != nil { + _ = json.Unmarshal(data, &recovery) + } + t.Fatalf("%s capture: code=%d error_code=%q error_message=%q recovery={ref_updated:%t plan_id:%q plan_digest:%q intent_path:%q intent_state:%q final_commit:%q} data=%s stdout=%q stderr=%q", + captured.lane, captured.code, errorCode, errorMessage, + recovery.RefUpdated, recovery.PlanID, recovery.PlanDigest, recovery.IntentPath, recovery.IntentState, recovery.FinalCommit, + data, captured.stdout, captured.stderr) } var result engine.Result decodeData(t, output.Data, &result) From 1452e17f82c777d11872bb735bb3add802dc09d3 Mon Sep 17 00:00:00 2001 From: nstranquist Date: Mon, 17 Aug 2026 08:34:51 -0500 Subject: [PATCH 2/5] ci(windows): stress concurrent lane capture --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b768392..46d947a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ jobs: cache: true - run: go mod verify - run: go test -count=1 ./... + - name: Stress concurrent shared-lane capture on Windows + if: runner.os == 'Windows' + run: go test -run '^TestConcurrentSharedLanesCaptureDisjointPaths$' -count=25 ./cmd/wip - run: go vet ./... race: From 11e19159d32d89580c9acc954978dea352e3acdd Mon Sep 17 00:00:00 2001 From: nstranquist Date: Mon, 17 Aug 2026 09:41:00 -0500 Subject: [PATCH 3/5] fix(store): fence concurrent record reads --- internal/store/archive.go | 13 ++++-- internal/store/store.go | 77 +++++++++++++++++++++++++++----- internal/store/store_test.go | 86 ++++++++++++++++++++++++++++++++++-- 3 files changed, 157 insertions(+), 19 deletions(-) diff --git a/internal/store/archive.go b/internal/store/archive.go index 3ff7d4e..9107855 100644 --- a/internal/store/archive.go +++ b/internal/store/archive.go @@ -51,6 +51,11 @@ func (store Store) ArchiveCandidates(before time.Time) ([]ArchiveCandidate, erro if before.IsZero() { return nil, fail.New("INVALID_ARGS", "archive cutoff cannot be zero") } + registry, err := store.registryLock(0) + if err != nil { + return nil, err + } + defer func() { _ = registry.Release() }() entries, err := readRecordEntries(filepath.Join(store.Root, "lanes")) if err != nil { return nil, err @@ -61,7 +66,7 @@ func (store Store) ArchiveCandidates(before time.Time) ([]ArchiveCandidate, erro return nil, fail.New("ARCHIVE_REFUSED", "lane record directory contains an unexpected entry: "+entry.Name()) } id := strings.TrimSuffix(entry.Name(), ".json") - candidate, eligible, err := store.archiveCandidate(id, before) + candidate, eligible, err := store.archiveCandidateLocked(id, before) if err != nil { return nil, err } @@ -77,8 +82,8 @@ func (store Store) ArchiveCandidates(before time.Time) ([]ArchiveCandidate, erro return candidates, nil } -func (store Store) archiveCandidate(id string, before time.Time) (ArchiveCandidate, bool, error) { - lane, err := store.Load(id) +func (store Store) archiveCandidateLocked(id string, before time.Time) (ArchiveCandidate, bool, error) { + lane, err := store.loadLane(id) if err != nil { return ArchiveCandidate{}, false, err } @@ -169,7 +174,7 @@ func (store Store) archiveLocked(ctx context.Context, receipt ArchiveReceipt, re if !resume { // Rebuild only the exact reviewed records under every relevant lock. for _, candidate := range receipt.Candidates { - fresh, eligible, err := store.archiveCandidate(candidate.LaneID, receipt.Before) + fresh, eligible, err := store.archiveCandidateLocked(candidate.LaneID, receipt.Before) if err != nil { return ArchiveReceipt{}, err } diff --git a/internal/store/store.go b/internal/store/store.go index ccbf52d..5cca49a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -331,6 +331,9 @@ func (store Store) LaneLock(id string, wait time.Duration) (*filelock.Lock, erro return lock, nil } +// The registry fence serializes operational lane and lease records. Windows +// can reject an open while another process atomically replaces a record. A +// caller that also needs a lane lock must acquire the lane lock first. func (store Store) registryLock(wait time.Duration) (*filelock.Lock, error) { lock, err := filelock.Acquire(filepath.Join(store.Root, "locks", "leases.lock"), wait) if err != nil { @@ -394,7 +397,7 @@ func (store Store) Create(ctx context.Context, options CreateOptions) (Lane, err } candidate := Lane{SchemaVersion: SchemaVersion, ID: options.ID, Agent: options.Agent, Session: options.Session, Mode: options.Mode, Ref: ref, BaseRef: options.BaseRef, BaseSHA: base, CurrentSHA: base, Worktree: worktree, State: "creating"} if _, statErr := os.Stat(store.lanePath(options.ID)); statErr == nil { - existing, loadErr := store.Load(options.ID) + existing, loadErr := store.loadLane(options.ID) if loadErr != nil { return Lane{}, loadErr } @@ -444,7 +447,7 @@ func (store Store) Claim(id, agent, session string, paths []string) (Lease, erro return Lease{}, err } defer func() { _ = lock.Release() }() - lane, err := store.Load(id) + lane, err := store.loadLane(id) if err != nil { return Lease{}, err } @@ -538,7 +541,7 @@ func (store Store) Renew(id, agent, session string) ([]Lease, error) { return nil, err } defer func() { _ = lock.Release() }() - lane, err := store.Load(id) + lane, err := store.loadLane(id) if err != nil { return nil, err } @@ -580,6 +583,15 @@ func (store Store) Renew(id, agent, session string) ([]Lease, error) { } func (store Store) Current(agent, session, id string) (Status, error) { + registry, err := store.registryLock(0) + if err != nil { + return Status{}, err + } + defer func() { _ = registry.Release() }() + return store.currentLocked(agent, session, id) +} + +func (store Store) currentLocked(agent, session, id string) (Status, error) { entries, err := readRecordEntries(filepath.Join(store.Root, "lanes")) if err != nil { return Status{}, fail.Wrap("STORE_FAILED", err) @@ -590,7 +602,7 @@ func (store Store) Current(agent, session, id string) (Status, error) { if entryErr != nil { return Status{}, entryErr } - lane, loadErr := store.Load(fileID) + lane, loadErr := store.loadLane(fileID) if loadErr != nil { return Status{}, loadErr } @@ -613,11 +625,20 @@ func (store Store) Current(agent, session, id string) (Status, error) { sort.Strings(ids) return Status{}, fail.New("LANE_AMBIGUOUS", "multiple lanes match; select one: "+strings.Join(ids, ", ")) } - return store.Status(matches[0].ID) + return store.statusLocked(matches[0].ID) } func (store Store) Status(id string) (Status, error) { - lane, err := store.Load(id) + registry, err := store.registryLock(0) + if err != nil { + return Status{}, err + } + defer func() { _ = registry.Release() }() + return store.statusLocked(id) +} + +func (store Store) statusLocked(id string) (Status, error) { + lane, err := store.loadLane(id) if err != nil { return Status{}, err } @@ -626,6 +647,15 @@ func (store Store) Status(id string) (Status, error) { } func (store Store) Load(id string) (Lane, error) { + registry, err := store.registryLock(0) + if err != nil { + return Lane{}, err + } + defer func() { _ = registry.Release() }() + return store.loadLane(id) +} + +func (store Store) loadLane(id string) (Lane, error) { if err := validateID(id, "lane"); err != nil { return Lane{}, err } @@ -647,6 +677,15 @@ func (store Store) Load(id string) (Lane, error) { } func (store Store) LoadLease(id string) (Lease, error) { + registry, err := store.registryLock(0) + if err != nil { + return Lease{}, err + } + defer func() { _ = registry.Release() }() + return store.loadLease(id) +} + +func (store Store) loadLease(id string) (Lease, error) { if err := validateID(id, "lease"); err != nil { return Lease{}, err } @@ -668,6 +707,15 @@ func (store Store) LoadLease(id string) (Lease, error) { } func (store Store) ActivePaths(id string) ([]string, error) { + registry, err := store.registryLock(0) + if err != nil { + return nil, err + } + defer func() { _ = registry.Release() }() + return store.activePathsLocked(id) +} + +func (store Store) activePathsLocked(id string) ([]string, error) { leases, err := store.leases(id, true) if err != nil { return nil, err @@ -686,7 +734,7 @@ func (store Store) ValidateCapture(ctx context.Context, expected Lane, expectedP return err } defer func() { _ = registry.Release() }() - current, err := store.Load(expected.ID) + current, err := store.loadLane(expected.ID) if err != nil { return err } @@ -722,7 +770,7 @@ func (store Store) RefreshCaptureLease(ctx context.Context, expected Lane, expec return err } defer func() { _ = registry.Release() }() - current, err := store.Load(expected.ID) + current, err := store.loadLane(expected.ID) if err != nil { return err } @@ -940,7 +988,12 @@ func (store Store) validateCaptureIdentity(ctx context.Context, current, expecte } func (store Store) RecordCommit(ctx context.Context, id, commit string) error { - lane, err := store.Load(id) + registry, err := store.registryLock(0) + if err != nil { + return err + } + defer func() { _ = registry.Release() }() + lane, err := store.loadLane(id) if err != nil { return err } @@ -980,7 +1033,7 @@ func (store Store) Release(id, agent, session string, abort bool) error { return err } defer func() { _ = registry.Release() }() - lane, err := store.Load(id) + lane, err := store.loadLane(id) if err != nil { return err } @@ -1041,7 +1094,7 @@ func (store Store) worktreeConflict(id, worktree string, mode Mode) (string, err if entryErr != nil { return "", entryErr } - lane, loadErr := store.Load(fileID) + lane, loadErr := store.loadLane(fileID) if loadErr != nil { return "", loadErr } @@ -1064,7 +1117,7 @@ func (store Store) leases(laneID string, activeOnly bool) ([]Lease, error) { if entryErr != nil { return nil, entryErr } - lease, loadErr := store.LoadLease(fileID) + lease, loadErr := store.loadLease(fileID) if loadErr != nil { return nil, loadErr } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index fe706e6..d0d477a 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -59,6 +59,76 @@ func TestConcurrentSharedClaimsAreExclusive(t *testing.T) { } } +func TestOperationalRecordsUseTheRegistryFence(t *testing.T) { + repo := testRepo(t) + laneStore, err := Open(repo) + if err != nil { + t.Fatal(err) + } + lane, err := laneStore.Create(context.Background(), CreateOptions{ID: "registry-reads", Agent: "agent", Session: "session", Mode: ModeShared}) + if err != nil { + t.Fatal(err) + } + lease, err := laneStore.Claim(lane.ID, lane.Agent, lane.Session, []string{"base.txt"}) + if err != nil { + t.Fatal(err) + } + reads := []struct { + name string + run func() error + }{ + {name: "load lane", run: func() error { _, err := laneStore.Load(lane.ID); return err }}, + {name: "load lease", run: func() error { _, err := laneStore.LoadLease(lease.ID); return err }}, + {name: "current", run: func() error { _, err := laneStore.Current(lane.Agent, lane.Session, lane.ID); return err }}, + {name: "status", run: func() error { _, err := laneStore.Status(lane.ID); return err }}, + {name: "active paths", run: func() error { _, err := laneStore.ActivePaths(lane.ID); return err }}, + {name: "archive candidates", run: func() error { _, err := laneStore.ArchiveCandidates(time.Now().UTC().Add(time.Hour)); return err }}, + } + for _, read := range reads { + t.Run(read.name, func(t *testing.T) { + assertWaitsForRegistry(t, laneStore, read.run) + }) + } + tree := git(t, repo.Root, "rev-parse", lane.CurrentSHA+"^{tree}") + commit := git(t, repo.Root, "commit-tree", tree, "-p", lane.CurrentSHA, "-m", "test: advance lane") + git(t, repo.Root, "update-ref", lane.Ref, commit, lane.CurrentSHA) + assertWaitsForRegistry(t, laneStore, func() error { + return laneStore.RecordCommit(context.Background(), lane.ID, commit) + }) +} + +func assertWaitsForRegistry(t *testing.T, laneStore Store, run func() error) { + t.Helper() + lock, err := laneStore.registryLock(time.Second) + if err != nil { + t.Fatal(err) + } + started := make(chan struct{}) + done := make(chan error, 1) + go func() { + close(started) + done <- run() + }() + <-started + select { + case err := <-done: + _ = lock.Release() + t.Fatalf("operation passed the registry fence: %v", err) + case <-time.After(75 * time.Millisecond): + } + if err := lock.Release(); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("operation did not continue after the registry fence opened") + } +} + func TestClaimIsIdempotentAndExpiredLeaseCannotRenew(t *testing.T) { repo := testRepo(t) laneStore, _ := Open(repo) @@ -983,9 +1053,19 @@ func TestArchiveReceiptRejectsCrossLaneAndExtraRecords(t *testing.T) { leases = append(leases, lease) } cutoff := time.Now().UTC().Add(-24 * time.Hour) - candidate, eligible, err := laneStore.archiveCandidate(lanes[0].ID, cutoff) - if err != nil || !eligible { - t.Fatalf("candidate = %#v eligible=%v err=%v", candidate, eligible, err) + candidates, err := laneStore.ArchiveCandidates(cutoff) + if err != nil { + t.Fatal(err) + } + var candidate ArchiveCandidate + for _, item := range candidates { + if item.LaneID == lanes[0].ID { + candidate = item + break + } + } + if candidate.LaneID == "" { + t.Fatalf("candidate for %s not found in %#v", lanes[0].ID, candidates) } receipt, err := laneStore.prepareArchiveReceipt(cutoff, []ArchiveCandidate{candidate}) if err != nil { From 9f6e4987378c3d9eca058250d73c8559ae1dd83d Mon Sep 17 00:00:00 2001 From: nstranquist Date: Mon, 17 Aug 2026 09:53:56 -0500 Subject: [PATCH 4/5] docs(store): explain record registry fence --- docs/ARCHITECTURE.md | 6 +++++- docs/OSS-PUBLIC-BETA.md | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6a27ded..31792a5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -95,12 +95,16 @@ Commands use this lock order: 1. coordination-domain lock during store creation; 2. archive lock during archive or restore; 3. lane locks in sorted lane order; -4. lease-registry lock. +4. state-registry lock at `locks/leases.lock`. No command acquires those locks in the opposite order. Different lanes can run capture work in parallel. One lane is serialized. An initialization-intent lock is acquired by itself when a completed step is recorded. +The state-registry lock fences operational lane and lease record reads. It also +fences lease replacement and the final lane commit receipt. Windows can reject +an open during atomic replacement without this shared fence. + ## Lane state ```text diff --git a/docs/OSS-PUBLIC-BETA.md b/docs/OSS-PUBLIC-BETA.md index 19f5b1d..3dd921e 100644 --- a/docs/OSS-PUBLIC-BETA.md +++ b/docs/OSS-PUBLIC-BETA.md @@ -148,6 +148,8 @@ The public beta must retain these properties: symmetric across Unicode case pairs and component boundaries. 24. Inherited Git variables cannot redirect repository discovery, refs, object storage, or prepared hooks away from the selected canonical checkout. +25. Operational lane and lease reads use the record-replacement registry + fence, including the final durable commit receipt. The threat model remains part of the release contract. Cooperating processes must honor leases. Hooks and `verify` commands remain trusted repository code. From bfb720d9870f9f63d4c4110f9ee84aafc6d6a361 Mon Sep 17 00:00:00 2001 From: nstranquist Date: Mon, 17 Aug 2026 09:53:56 -0500 Subject: [PATCH 5/5] docs(changelog): record Windows registry fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a4d5dd..1a327b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ Planned first prerelease: `v0.1.0-beta.1`. ### Changed +- Operational lane and lease reads now use the same registry fence as atomic + record replacement. This prevents Windows sharing violations during capture. - Unsupported state-directory, lane, lease, intent, and profile schemas now fail with `MIGRATION_REQUIRED` before the command changes state. - The minimum build toolchain is Go 1.25.12. This patched floor excludes