Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion cmd/wip/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("<could not encode data: " + marshalErr.Error() + ">")
}
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)
Expand Down
6 changes: 5 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/OSS-PUBLIC-BETA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 9 additions & 4 deletions internal/store/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
77 changes: 65 additions & 12 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
Loading