Skip to content

Session checkpoints & safe rewind: content-addressed blobs, atomic restore, file locking#112

Merged
gnanam1990 merged 4 commits into
mainfrom
sessions-checkpoints
Jun 7, 2026
Merged

Session checkpoints & safe rewind: content-addressed blobs, atomic restore, file locking#112
gnanam1990 merged 4 commits into
mainfrom
sessions-checkpoints

Conversation

@gnanam1990

@gnanam1990 gnanam1990 commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Module 4 of N — Session checkpoints & safe rewind

Fourth slice of the runtime-core split. Scope: internal/sessions only (7 files, +1346/−7). Unlike the earlier modules this package had drifted on main (#106 added Tag/Depth), so this was 3-way merged onto the current main#106's Tag/Depth is preserved, store.go merged with no conflicts. No new deps (file locking uses stdlib syscall).

What's in it

  • Content-addressed checkpoints: CaptureToolCheckpoint snapshots a tool's target files into sha256 blobs before a mutating tool runs; records file mode; skips oversize files; dedupes identical content.
  • Safe rewind: RestoreToSequence reverts to a target sequence — applies only the closest-to-target checkpoint per path, verifies each blob's sha256 before writing, preserves file mode, and confines paths with EvalSymlinks (no symlink escape). ApplyRewind runs restore + truncate + prune + marker atomically under one session lock.
  • Concurrency/robustness: cross-process flock serializes session mutations; Fork copies checkpoint blobs so a fork can rewind; writeMetadata uses a unique tmp suffix.

Forward code (compiles now, wired later)

This is the persistence layer. The CLI zero sessions rewind and the TUI /rewind + pre-tool checkpoint capture wiring land in later modules; the API is exercised here by checkpoint_test.go / rewind_test.go.

Testing

go build ./..., go vet ./..., go test ./..., go test -race ./internal/sessions/, and GOOS=windows go build ./internal/sessions/ all green.

Part of decomposing #101 (draft); subagents excluded.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Per-session workspace checkpoints with content-addressed storage and deduplication
    • Restore/rewind to prior snapshots, fork-aware restores, orphan snapshot pruning
    • Cross-process session file locking to serialize concurrent session operations
  • Bug Fixes / Safety

    • Skips oversized/unreadable files and enforces workspace-boundary checks
    • Rejects corrupt checkpoint data rather than silently restoring tampered content
    • Prevents path-traversal escapes during capture and restore
  • Tests

    • Expanded coverage for capture/restore, deduplication, concurrency, locking, and permissions

…store, file locking

Module 4 of the runtime-core split. Scope: internal/sessions only. 3-way merged cleanly onto main's #106 Tag/Depth work (preserved); no new deps (filelock uses stdlib syscall); build/vet/-race/windows-cross-compile/full-suite green.

- CaptureToolCheckpoint snapshots a tool's target files into content-addressed (sha256) blobs before a mutating tool runs; SnapshotForCheckpoint records file mode; oversize files are skipped, identical content deduped.

- RestoreToSequence reverts to a target sequence: applies only the closest-to-target checkpoint per path, verifies each blob's sha256 before writing, preserves file mode, and confines paths with EvalSymlinks (no symlink escape). ApplyRewind runs restore+truncate+prune+marker atomically under one session lock.

- Cross-process file lock (flock) serializes session mutations; Fork copies checkpoint blobs so a fork can rewind; writeMetadata uses a unique tmp suffix.

FORWARD: this is the persistence layer; the CLI 'zero sessions rewind' and the TUI '/rewind' + pre-tool checkpoint capture wiring land in later modules. Exercised here by checkpoint_test.go / rewind_test.go.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 9898ad39ebf8
Changed files (7): internal/sessions/checkpoint.go, internal/sessions/checkpoint_test.go, internal/sessions/filelock_unix.go, internal/sessions/filelock_windows.go, internal/sessions/rewind.go, internal/sessions/rewind_test.go, internal/sessions/store.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d369d9ad-55f6-4db3-8235-69b71cb5f5ca

📥 Commits

Reviewing files that changed from the base of the PR and between 24e9ba2 and 9898ad3.

📒 Files selected for processing (2)
  • internal/sessions/checkpoint_test.go
  • internal/sessions/rewind.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/sessions/rewind.go
  • internal/sessions/checkpoint_test.go

Walkthrough

This PR implements per-session workspace checkpoints with content-addressed (sha256) blobs, gated capture, blob deduplication/integrity checks, orphan-blob pruning, cross-process session file locks, and restore/rewind flows that atomically restore files (with mode preservation) and truncate events. Extensive tests cover integrity, security, concurrency, and fork behavior.

Changes

Session Checkpoint and Rewind Flow

Layer / File(s) Summary
Checkpoint and rewind data contracts
internal/sessions/checkpoint.go, internal/sessions/rewind.go, internal/sessions/store.go
Defines CheckpointPayload/CheckpointFile, RestoreReport/RewindMarker, CheckpointsDir and CheckpointsEnabled, and adds EventSessionCheckpoint event type.
Session lock serialization plumbing
internal/sessions/filelock_unix.go, internal/sessions/filelock_windows.go, internal/sessions/store.go
Adds OS-specific .lock file acquisition for UNIX/Windows, integrates file locks with the in-process session mutex via lockSession, routes appends through locked helpers, and uses unique-temp metadata write semantics.
Checkpoint capture and blob IO
internal/sessions/checkpoint.go, internal/sessions/checkpoint_test.go
CaptureToolCheckpoint + snapshotForCheckpoint resolve paths, skip/absent detection, and write content-addressed blobs via writeBlob; readBlob verifies hashes. Tests validate payload shape, dedupe, absent/skipped marking, oversize gating, and disabled capture.
Blob lifecycle and fork integration
internal/sessions/checkpoint.go, internal/sessions/store.go, internal/sessions/rewind_test.go
copyBlobs supports Fork semantics, referencedBlobs discovers used hashes, pruneOrphanBlobs removes unreferenced blobs under lock, and tests exercise fork-copying and concurrent capture/prune safety.
Restore execution and path safety
internal/sessions/rewind.go, internal/sessions/rewind_test.go
RestoreToSequence applies closest-to-target snapshots per resolved path, enforces workspace containment (resolveWithinWorkspace), atomically writes files preserving mode bits (writeFileAtomic), and reports restored/deleted/skipped paths; tests cover corruption, symlink-escape protection, deduping behavior, and permission preservation.
Event truncation and rewind orchestration
internal/sessions/rewind.go, internal/sessions/checkpoint_test.go, internal/sessions/rewind_test.go
TruncateEvents atomically rewrites the event log up to a sequence; ApplyRewind runs restore + truncate + prune under a session lock and appends an EventSessionRewind marker. Tests validate truncation, rewind markers, deadlock-free execution, and event-stream semantics.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Suggested reviewers

  • anandh8x
  • Vasanthdev2004
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately summarizes the main changes: content-addressed checkpoint blobs, atomic restore functionality, and file locking mechanisms that are core to this changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sessions-checkpoints

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/sessions/store.go (1)

305-344: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Lock the parent snapshot while forking.

Fork freezes the parent's event list first and copies whatever blobs still exist later. A concurrent parent ApplyRewind can truncate checkpoint events and prune their blobs in that gap, leaving the fork with copied EventSessionCheckpoint entries that already point at missing content. Snapshot the parent under its session lock and copy only the hashes referenced by that frozen event set before releasing it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sessions/store.go` around lines 305 - 344, The parent session must
be locked while snapshotting its events and referenced checkpoint blob hashes to
prevent a concurrent ApplyRewind from truncating events and pruning blobs;
modify the fork flow in the code around ReadEvents/Create/AppendEvent/copyBlobs
so you acquire the parent session lock (using the store's session lock API),
read and freeze the events and collect the set of blob hashes referenced by
EventSessionCheckpoint events while still holding the lock, then release the
lock and proceed to Create the fork, AppendEvent to the fork, and call copyBlobs
but only for the frozen set of hashes you captured under the lock (ensuring you
reference the frozen event list rather than calling ReadEvents again after
releasing the lock).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sessions/checkpoint_test.go`:
- Around line 51-52: The two os.WriteFile calls in
internal/sessions/checkpoint_test.go currently discard errors; change them to
check and fail the test on error (e.g., capture the error returned by
os.WriteFile for filepath.Join(ws, "a.txt") and filepath.Join(ws, "b.txt") and
call t.Fatalf or use require.NoError/require.NoErrorf) so test setup failures
surface instead of silently causing misleading pass/fail behavior.
- Around line 134-137: The test currently ignores the error returned by
os.ReadFile when reading path and then compares the content, which can produce a
misleading failure; update the code in the test (around the os.ReadFile call
that assigns to got) to capture the error (e.g., got, err := os.ReadFile(path)),
check if err != nil and call t.Fatalf with the error (or t.Fatalf("read failed:
%v", err)) before comparing the string content, and only then compare
string(got) to "original" using the existing t.Fatalf on mismatch.
- Around line 174-176: In the test where os.ReadFile(path) is used (same pattern
as TestRestoreToSequenceRevertsFileContent), capture and check the error
returned from os.ReadFile before comparing the contents: call os.ReadFile(path)
into variables (e.g. got, err), if err != nil call t.Fatalf with the error
(including context like "read file" and err) and only then compare string(got)
to "original" to fail with the correct message if content differs; reference the
existing use of os.ReadFile and the path variable in the test to locate where to
add this check.
- Around line 85-88: The test directly indexes p.Files[0] without verifying
p.Files is non-empty; update the Test (around decodeCk and the assertions) to
first check len(p.Files) > 0 and call t.Fatalf (or t.Fatalf with context) if
it's zero, then proceed to assert p.Files[0].Skipped and p.Files[0].Blob;
reference decodeCk and p.Files to locate where to add the bounds check
(mirroring the fix used in TestCaptureRecordsAbsentForNewFile).
- Line 94: The test setup silently ignores errors from the os.WriteFile call;
change the call in the test to check the returned error and fail the test on
error (e.g., capture err := os.WriteFile(...); if err != nil { t.Fatalf("failed
to write a.txt: %v", err) } or use your test helper like require.NoError(t,
err)); update the os.WriteFile invocation in the checkpoint test so setup
failures surface immediately.
- Around line 217-220: The test calls ReadEvents but ignores its error; change
the code to capture and check the error returned by store.ReadEvents (e.g.,
events, err := store.ReadEvents("s")), and if err != nil call t.Fatalf with the
error message (include context like "ReadEvents failed: %v"), then assert
len(events) == 0 as before; this ensures ReadEvents failures cause the test to
fail instead of being silently ignored.
- Line 80: The test currently discards the result of
os.WriteFile(filepath.Join(ws, "big.txt"), []byte("123456"), 0o644); change it
to check and fail on error (e.g. err := os.WriteFile(...); if err != nil {
t.Fatalf("failed to write big.txt: %v", err) } or use require.NoError(t, err))
so the test setup aborts immediately on write failure and does not proceed to
checkpoint a missing file.
- Around line 177-185: The test reads events via store.ReadEvents("s") but
doesn't check the returned error or ensure the slice has at least two elements
before indexing; update the test around the ReadEvents call to check the error
returned by ReadEvents (store.ReadEvents) and t.Fatalf on error, and then verify
len(events) >= 2 before accessing events[len(events)-1] and
events[len(events)-2]; keep the existing assertions for last.Type ==
EventSessionRewind and events[len(events)-2].Sequence == target.Sequence but
fail the test with clear messages if the error is non-nil or the events slice is
too short.
- Around line 114-117: Check the error returned by store.ReadEvents("s") and
fail the test if it is non-nil, and ensure you validate the events slice length
before indexing. Specifically, update the test around the call to ReadEvents to
capture (events, err) := store.ReadEvents("s"), call t.Fatalf or t.Fatal with
the err if err != nil, and then verify len(events) == 2 before accessing
events[len(events)-1] and comparing its Sequence to seqs[1]; reference the
ReadEvents function, the events variable and seqs slice to locate the change.
- Around line 71-74: The test reads p := decodeCk(t, ev) and then indexes
p.Files[0] without checking length; add a bounds check on p.Files (e.g., if
len(p.Files) == 0) before accessing index 0 and call t.Fatalf with a clear
message if it's empty, then proceed to assert p.Files[0].Absent and
p.Files[0].Blob; this change should be made in the failing test function around
the decodeCk usage to prevent panics when the Files slice is empty.
- Line 59: The test currently ignores the error from os.ReadDir when calling
entries, _ := os.ReadDir(store.blobsDir("s")), which can mask failures; update
the call to capture the error (entries, err := os.ReadDir(...)) and
assert/require that err is nil before using len(entries) (e.g., t.Fatalf or
require.NoError on err), referencing the same store.blobsDir("s") call and the
entries variable so the test fails when ReadDir errors instead of incorrectly
passing.

In `@internal/sessions/checkpoint.go`:
- Around line 86-143: SnapshotForCheckpoint currently writes blobs via writeBlob
without holding the session lock, allowing pruneOrphanBlobs or ApplyRewind to
delete those blobs before the caller can append EventSessionCheckpoint; fix by
making the operation atomic: either make SnapshotForCheckpoint unexported and
only call it from inside the session-locked path, or add a new Store method
(e.g. CaptureToolCheckpoint or WriteCheckpointAtomic) that acquires the session
lock, writes blobs (via writeBlob) and immediately appends the
EventSessionCheckpoint under the same lock so no pruneOrphanBlobs/ApplyRewind
race can remove them; update callers to use the new/locked API and remove any
external blob-writing sequences that assume the gap is safe.
- Around line 97-133: SnapshotForCheckpoint currently joins and stats rel
directly, allowing ../ or symlinked paths to escape workspace and treating all
stat failures as Absent; change the loop that builds abs to first resolve and
validate containment: compute abs := filepath.Join(workspaceRoot, rel), then
resolvedAbs := filepath.Clean(abs) and attempt
filepath.EvalSymlinks(resolvedAbs) (fall back to resolvedAbs on EvalSymlinks
errors if you still need to proceed), then ensure the resolved path is inside
workspaceRoot (compare with filepath.Clean(workspaceRoot) and require the
resolved path to have the workspace prefix plus separator); if the resolved path
is outside the workspace mark entry.Skipped and continue; when os.Stat(abs) or
os.ReadFile(abs) fail, map only os.IsNotExist(err) to entry.Absent and treat all
other errors (permission, symlink loop, IO) as entry.Skipped; keep setting
entry.Mode, entry.Skipped, entry.Absent and calling store.writeBlob(sessionID,
content) as before.

In `@internal/sessions/filelock_other.go`:
- Around line 5-8: The current acquireFileLock in Store is a no-op on Windows,
leaving cross-process/session operations unprotected; implement a real OS-level
file lock for Windows in acquireFileLock (and its unlock) so locks serialize
across processes and Store instances: open a dedicated lock file (e.g., based on
sessionID) and use the Windows API via golang.org/x/sys/windows
(LockFileEx/UnlockFileEx or CreateFile with exclusive/shared semantics) to
obtain an exclusive lock, return an unlock function that releases the lock and
closes the handle, and surface any errors from locking; keep the existing
in-memory mutex as well for intra-process safety.

In `@internal/sessions/rewind.go`:
- Around line 76-97: resolveWithinWorkspace currently validates paths but
subsequent os.Remove and store.writeFileAtomic operate on the validated string,
leaving a TOCTOU symlink-swap hole; fix by pinning the workspace root (open
workspaceRoot with os.Open to get a rootFD) and perform file removals and writes
relative to that descriptor using descriptor-based syscalls (e.g. unix.Unlinkat
for deletes and unix.Openat/O_CREAT|O_NOFOLLOW for creates) or by adapting
store.writeFileAtomic to accept a dir FD plus a relative path; change calls in
rewind.go that call os.Remove and store.writeFileAtomic (and any other file ops
after resolveWithinWorkspace like those around lines 113-157 and 253-277) to use
the rootFD + relative paths returned from resolveWithinWorkspace so mutations
cannot escape the pinned workspace.

---

Outside diff comments:
In `@internal/sessions/store.go`:
- Around line 305-344: The parent session must be locked while snapshotting its
events and referenced checkpoint blob hashes to prevent a concurrent ApplyRewind
from truncating events and pruning blobs; modify the fork flow in the code
around ReadEvents/Create/AppendEvent/copyBlobs so you acquire the parent session
lock (using the store's session lock API), read and freeze the events and
collect the set of blob hashes referenced by EventSessionCheckpoint events while
still holding the lock, then release the lock and proceed to Create the fork,
AppendEvent to the fork, and call copyBlobs but only for the frozen set of
hashes you captured under the lock (ensuring you reference the frozen event list
rather than calling ReadEvents again after releasing the lock).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c8b0e861-1360-479d-813b-b0a6726e4711

📥 Commits

Reviewing files that changed from the base of the PR and between 7a1800f and 586559e.

📒 Files selected for processing (7)
  • internal/sessions/checkpoint.go
  • internal/sessions/checkpoint_test.go
  • internal/sessions/filelock_other.go
  • internal/sessions/filelock_unix.go
  • internal/sessions/rewind.go
  • internal/sessions/rewind_test.go
  • internal/sessions/store.go

Comment thread internal/sessions/checkpoint_test.go Outdated
Comment thread internal/sessions/checkpoint_test.go Outdated
Comment thread internal/sessions/checkpoint_test.go
Comment thread internal/sessions/checkpoint_test.go Outdated
Comment thread internal/sessions/checkpoint_test.go
Comment thread internal/sessions/checkpoint_test.go Outdated
Comment thread internal/sessions/checkpoint.go Outdated
Comment thread internal/sessions/checkpoint.go
Comment thread internal/sessions/filelock_other.go Outdated
Comment thread internal/sessions/rewind.go

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's good

  • Content-addressed checkpoint blobs. writeBlob stores content under its sha256, and Store.lookups by hash naturally dedupe identical content. TestCaptureDedupsIdenticalContent confirms that two files with the same bytes share a single blob. A rewind that captures the same state twice is free.

  • Integrity-verified reads. readBlob re-hashes the bytes on every read and returns an error on mismatch, so a corrupted or tampered blob never gets written as truth. TestReadBlobRejectsCorruptContent corrupts a blob and asserts readBlob errors. TestRestoreSkipsCorruptBlob asserts the restore reports the path in Skipped rather than writing attacker-controlled bytes to disk. This is the right threat model for a feature that may run with hostile workspace content.

  • Defense-in-depth workspace confinement. resolveWithinWorkspace rejects both lexical traversal (../) and an in-workspace symlink that points outside the root — it EvalSymlinks the deepest existing ancestor, re-joins missing segments, and verifies the result is under EvalSymlinks(root). TestRestoreRejectsPathTraversal covers the lexical case; TestRestoreRejectsInWorkspaceSymlinkEscape covers the symlink case. Both pass and the outside file survives. The comment explicitly says "an in-workspace symlink that points outside the workspace must not let a restore write outside the root".

  • Closest-to-target wins, with no double-count. RestoreToSequence iterates sortedCheckpointsAfter from oldest to newest, marks each path in a restored map, and skips any later entry. TestRestoreDoesNotDoubleCountFilesRestored confirms FilesRestored == 1 for two checkpoints on the same path. TestRestoreClosestToTargetSkippedWins confirms a closest Skipped entry is NOT overwritten by a newer blob and FilesRestored == 0.

  • Per-mode file lock with build-tag split. filelock_unix.go uses unix.Flock(LOCK_EX) on a per-session session.lock; filelock_other.go is a no-op fallback for Windows. TestSessionFileLockSerializesAcrossStores (skipped on Windows) confirms storeB's AppendEvent blocks while storeA holds the OS lock and proceeds once released. The in-process mutex still serializes within one process, so the cross-process layer is a clean addition.

  • Non-reentrant lock pattern with *Locked variants. lockSession returns a single unlock closure that releases both the OS lock and the in-process mutex. ApplyRewind holds that lock once and calls restoreToSequenceLocked / truncateEventsLocked / pruneOrphanBlobsLocked / appendEventLocked — the comments explicitly warn that re-locking would deadlock the non-reentrant in-process mutex. TestApplyRewindDoesNotDeadlock runs ApplyRewind in a goroutine with a 5s timeout and asserts completion.

  • Atomic file writes with Chmod to bypass umask. writeFileAtomic writes via tmp+rename and then Chmods the tmp file to the exact perm bits captured by the checkpoint. The comment says "WriteFile only applies perm on creation and is subject to umask; force the exact bits so an executable script's mode is faithfully restored". TestRestorePreservesExecutableMode writes a 0o755 script, captures it, clobbers mode to 0o644, and asserts the restored file is 0o755 with the executable bit set.

  • Fork carries blobs, not just events. copyBlobs runs in Store.Fork and copies every blob from the parent to the fork's blobsDir. TestForkRewindRestoresFromCopiedBlobs is the regression test: it captures original, mutates, forks, then ApplyRewind on the fork and asserts the file is back to original (and that every parent blob is present in the fork). The comment names the threat: "Fork must copy content-addressed checkpoint blobs into the fork, not just the checkpoint EVENTS. Otherwise an ApplyRewind on the fork reads a blob that does not exist in the fork's blobs dir and silently skips the file instead of restoring it."

  • snapshotOrCapture split supports event batching. SnapshotForCheckpoint returns the payload without appending an event; CaptureToolCheckpoint wraps it with appendEventLocked. The TUI/agent can call Snapshot (under the tool lock) then append the event after the mutation, so the checkpoint event is recorded next to the mutation it covers, not before it.

  • pruneOrphanBlobs is best-effort and lock-aware. Public pruneOrphanBlobs acquires the session lock; private pruneOrphanBlobsLocked is the body for callers that already hold it. TestCaptureAndPruneConcurrent runs 20 capture/prune iterations in parallel under -race and asserts every blob referenced by a surviving event is still on disk. The prune never deletes a blob that a concurrent capture has just referenced.

  • writeMetadata now uses a unique tmp suffix and cleans up on rename failure. The new tmp = fmt.Sprintf("%s.tmp-%d", path, store.idCounter.Add(1)) plus _ = os.Remove(tmp) on Rename failure closes a real leak where the previous fixed .tmp suffix would collide and the failure path would leave garbage behind. The PR is small but the cleanup is real.

  • sessionLocks map is intentionally non-evicting. The doc comment explains why: removing a mutex while a goroutine is blocked on it would break mutual exclusion, and reference counting would be needed to do it safely. The cost of leaving them is one *sync.Mutex per session id ever touched, which is bounded. The comment makes the trade-off explicit so a future maintainer doesn't "fix" it.

  • EventsJSONL integrity preserved under truncate. truncateEventsLocked reads events, filters to <= keepThroughSequence, marshals back, and writes via tmp+rename. The order is preserved (events are in sequence order) and a leading newline is added only when there is at least one event.

  • EventSessionCheckpoint is added to the type list. Other types (EventProviderUsage, EventCompaction, EventSessionFork, etc.) follow the same kebab-case convention. session_checkpoint and session_rewind are parallel.

  • CheckpointsEnabled() and maxCheckpointBytes() are env-var configurable. ZERO_CHECKPOINTS=off disables; ZERO_CHECKPOINT_MAX_BYTES overrides the 5 MiB cap. Both are tested. The default-on behavior matches the description in the body.

  • Test coverage is end-to-end and named after the findings. TestRestoreClosestToTargetSkippedWins, TestRestoreDoesNotDoubleCountFilesRestored, TestReadBlobRejectsCorruptContent, TestRestoreRejectsInWorkspaceSymlinkEscape, TestForkRewindRestoresFromCopiedBlobs, TestRestorePreservesExecutableMode, TestApplyRewindDoesNotDeadlock, TestCaptureAndPruneConcurrent, TestSessionFileLockSerializesAcrossStores — every threat model in the PR body has a focused test. A future maintainer can grep "Finding" or "Audit finding" to see what each test guards.

  • Setup helpers fail at the source. mustWriteFile / mustCapture / mustAppend are documented as setup helpers that fail the test immediately, so a setup error is diagnosed at the line that produced it rather than surfacing as a confusing downstream assertion. Small but real maintainability win.

Observations (non-blocking)

  1. writeBlob's tmp suffix uses the per-Store idCounter, not a session-scoped namespace. Concurrent writeBlob calls for different sessions under one Store could in theory collide on <blobPath>.tmp-<n>. In practice they are serialized by the session lock and the counter is unique per Store, so this is fine — but a sessionID+hash prefix in the tmp name would be more bulletproof and would not need a per-Store counter.

  2. writeFileAtomic uses path.zero-restore-tmp-<n> for the same reason. The Store counter is per-Store not per-session, so two concurrent restores across different sessions under one Store share a counter. Again, the session lock serializes mutations, so it's fine in practice. Same bulletproofing idea applies.

  3. readBlob has a TOCTOU window between os.ReadFile and sha256.Sum256. In the current design the session lock serializes all mutations, so the window is closed by construction. A real on-disk attacker who can race the read still has the read+hash window. For a rewind that holds the lock, the read+hash is effectively atomic.

  4. writeFileAtomic uses os.MkdirAll with 0o755. For workspaces that ship with a private root mode (0o700), the restored parent dir could end up more permissive than the rest of the workspace. Probably fine for 0o700 over 0o755 is the default for most workspaces, but worth a future consideration if a project ships with 0o700.

  5. acquireFileLock opens with O_RDWR|O_CREATE. Could be O_RDONLY|O_CREATE (flock works on any descriptor). Not a bug; just a small overkill on the open mode.

  6. Lock order is in-process mutex first, then OS lock. A future code path that needs to acquire the OS lock first could deadlock against the current order. Worth a comment on lockSession (it already has a comment, but the order is not explicit). The current design is correct; the comment could be clearer.

  7. Windows acquireFileLock is a no-op. The doc comment is accurate: "the in-memory per-Store mutex still serializes mutations within a process". Cross-process safety does not apply on Windows. A future PR could add LockFileEx on Windows for parity; not a blocker.

  8. RestoreToSequence with targetSeq == 0 includes every checkpoint (since Sequence > 0 for all). That's a useful "rewind to origin" semantics; the doc comment could call it out as a feature.

  9. RestoreReport.Skipped accumulates both honest (Skipped) and adversarial (traversal) paths. The caller can distinguish via Skipped content. A future enrichment could split the list into Skipped and Rejected for cleaner reporting.

  10. pruneOrphanBlobs is best-effort and returns 0, nil on a missing blobs dir. Correct for the case where a session has no checkpoints. A caller that calls prune before any checkpoints will not see an error.

  11. writeMetadata's unique tmp suffix is a small but real cleanup improvement. The old path + ".tmp" would collide if two writers raced; the new path + ".tmp-" + idCounter is unique. The os.Remove(tmp) on rename failure is a small leak fix.

  12. sessionLocks never evicts. The doc comment explains the trade-off clearly. A future enhancement could add a Close() / Delete() lifecycle hook on the Store and reference-count mutex removal; the comment correctly says this is intentionally out of scope.

  13. referencedBlobs reads the full event log on every prune. For a session with many events, this is O(n). The session lock holds, so there's no concurrency concern. A future enhancement could maintain an in-memory set of referenced hashes; the current design is correct and simple.

  14. RestoreToSequence iterates sortedCheckpointsAfter (newest-first) in reverse to get oldest-first. The iteration is a simple for i := len-1; i >= 0; i-- and the comment explains why. A future refactor could expose a oldestFirst variant of sortedCheckpointsAfter directly.

  15. Fork ordering: events are copied first, then blobs. A concurrent reader between the two steps would see events pointing at missing blobs. In practice the read path (RestoreToSequence) is itself under the session lock, so this is fine. A comment could call this out.

  16. No benchmark for RestoreToSequence / CaptureToolCheckpoint / pruneOrphanBlobs. Each is on a hot path (per-tool-call for capture, per-rewind for restore, per-prune for prune). A benchmark suite would catch future regressions. The current code is correct; the benchmark would be nice-to-have.

  17. SnapshotForCheckpoint and CaptureToolCheckpoint are exported. That's the right API for the TUI to call. The split is clean: Snapshot returns a payload, Capture appends an event.

  18. EventSessionCheckpoint and EventSessionRewind are siblings in the type list. Consistent naming (session_*) and both use kebab-case JSON.

  19. The PR cleanly handles the main drift on Tag/Depth. The body says "this was 3-way merged onto the current main#106's Tag/Depth is preserved, store.go merged with no conflicts". The store.go diff shows the new sessionLocks doc comment and the idCounter cleanup addition — neither overwrites #106's fields. Clean merge.

  20. The forward-code (compiles now, wired later) pattern is preserved. The CLI zero sessions rewind and TUI /rewind + pre-tool checkpoint capture are in later modules, per the body. The persistence API is exercised by the unit tests in this PR. That's the right slicing for the runtime-core split.

Approving — this is a substantial persistence layer that closes real audit findings (fork blob copy, deadlock, capture/prune race, executable mode preservation) and finds (closest-to-target semantics, double-count, corrupt blob rejection, in-workspace symlink escape, cross-process file lock). The defense-in-depth path confinement (resolveWithinWorkspace with EvalSymlinks of the deepest existing ancestor) is the right call for a feature that may run with hostile workspace content. The non-reentrant lock pattern with *Locked variants is correct and the deadlock test is the proof. The cross-process flock + per-mode build-tag split is clean. The content-addressed sha256 + read-time integrity check is the right integrity model. The 4 audit findings and 9 numbered findings are all addressed with focused tests. The merge with #106's Tag/Depth drift is clean. The forward-code boundary is clear. The follow-up is the CLI zero sessions rewind and TUI /rewind + pre-tool capture wiring, which land in later modules.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request changes.

This is a strong, well-scoped sessions slice and the CI result is healthy. I also ran go test ./internal/sessions locally against the PR worktree and it passed.

Blocker:

  • internal/sessions/checkpoint.go:97-123 snapshots filepath.Join(workspaceRoot, rel) directly before any workspace confinement. If a caller passes a traversal path such as ../secret.txt, or another non-workspace-relative value, checkpoint capture can read and store content outside the workspace into the session blob store. RestoreToSequence has good defense-in-depth via resolveWithinWorkspace, but by then the sensitive content may already be captured. Please validate/resolve every capture target against workspaceRoot before os.Stat/os.ReadFile, skip invalid targets, and add a regression test for traversal at capture time.

Once capture has the same confinement guarantee as restore, I am comfortable approving this PR. Nice split overall.

… API, Windows lock, test hygiene

- [critical/human blocker] snapshotForCheckpoint now confines every capture target with resolveWithinWorkspace (same EvalSymlinks/no-".." guard as restore) BEFORE stat/read, so a traversal/symlink target can't read files outside the workspace into a blob. os.Stat failures are classified correctly: only os.IsNotExist -> Absent (restore deletes); permission/IO/symlink-loop -> Skipped (restore leaves alone), instead of treating every failure as Absent. Regression test added (TestCaptureRejectsTraversalTargets).

- [major] SnapshotForCheckpoint unexported -> snapshotForCheckpoint: the blob writes and the referencing EventSessionCheckpoint must be committed atomically under one session lock, which only CaptureToolCheckpoint guarantees. Removes the unsafe snapshot-only external entry point (blob/event gap).

- [major] Windows: real LockFileEx/UnlockFileEx (filelock_windows.go) replacing the no-op, so cross-process/cross-Store session serialization holds on Windows too. No new deps (x/sys already required for the unix flock).

- [minor] checkpoint_test hygiene: check os.WriteFile/ReadDir/ReadFile/ReadEvents errors via helpers, add bounds checks before p.Files[0]/events[...] so setup failures fail at the source instead of panicking or passing spuriously.

- rewind restore: documented the residual symlink-swap TOCTOU (boundary re-resolved immediately before the mutation; full closure needs openat2 RESOLVE_BENEATH / per-component O_NOFOLLOW, tracked for the CLI/TUI rewind-wiring work).

build/vet/-race/full-suite + GOOS=windows build all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@Vasanthdev2004 thanks — your blocker is fixed: capture now confines every target with the same resolveWithinWorkspace guard restore uses (before stat/read), so traversal/symlink targets can't be read outside the workspace, and os.Stat failures are classified (only IsNotExist→Absent; permission/IO/symlink-loop→Skipped). Added TestCaptureRejectsTraversalTargets.

@coderabbitai review — also addressed: unexported the snapshot-only API (atomicity is via CaptureToolCheckpoint under one lock), implemented real Windows LockFileEx (no more no-op), fixed the test-hygiene errcheck/bounds nits, and documented the residual restore-side symlink-swap TOCTOU. build/vet/-race/full-suite + windows cross-compile green.

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the PR details and files
gh pr view 112 --repo Gitlawb/zero --json title,state,files,headRefName

# Get the current diff
gh pr diff 112 --repo Gitlawb/zero

Length of output: 56030



@gnanam1990 — all six stated fixes are confirmed in the diff. The code is in solid shape. A few remaining items worth fixing before merge:


1. lockSession comment contradicts the implementation (LOW)

store.go line ~480:

// The OS lock is best-effort: if it cannot be acquired (e.g. an unsupported platform)
// the in-memory mutex still applies.

But the code returns a hard error if acquireFileLock fails:

release, err := store.acquireFileLock(sessionID)
if err != nil {
    mu.Unlock()
    return nil, err  // ← hard fail, not best-effort
}

Now that every platform has a real implementation (Unix flock, Windows LockFileEx) there is no "unsupported platform" path. The comment should be updated:

// lockSession serializes mutations to a session both in-process (per-session mutex)
// and across processes (OS file lock on a per-session .lock file), so a CLI rewind
// and a TUI sharing the same RootDir cannot interleave writes. Returns an unlock
// function that releases both locks in reverse acquisition order.

2. TestSessionFileLockSerializesAcrossStores skip message is stale (LOW)

if runtime.GOOS == "windows" {
    t.Skip("flock semantics differ on windows")
}

The skip is still correct — LockFileEx within a single process does not block a second goroutine holding a separate handle to the same region (unlike flock), so the test would fail even with the real implementation. But the message should say that explicitly:

t.Skip("LockFileEx within-process semantics differ from flock: " +
    "two handles from the same process can both acquire the lock region")

3. filelock_windows.goLockFileEx with synchronous handle + non-NULL Overlapped (LOW)

os.OpenFile opens a synchronous handle (no FILE_FLAG_OVERLAPPED). MSDN notes that LockFileEx on a synchronous handle with a blocking flag does wait, but the interaction with the Overlapped struct (specifically its HEvent being zero/nil) is worth a comment since it's a subtle API contract:

// LockFileEx on a synchronous handle (no FILE_FLAG_OVERLAPPED) with a zero-HEvent
// Overlapped blocks the calling thread until the lock is acquired, even though the
// API accepts an Overlapped parameter. This matches the blocking flock(LOCK_EX)
// behavior on Unix.
overlapped := new(windows.Overlapped)

4. copyBlobs reads each blob fully into memory (informational, not a blocker)

Bounded by ZERO_CHECKPOINT_MAX_BYTES (5 MiB default) so it won't OOM in practice. Could use io.Copy with os.Open/os.Create for streaming, but not needed for the current cap.


✅ Confirmed fixed

Issue Status
Capture-side resolveWithinWorkspace confinement
os.Stat failure classification (IsNotExist→Absent, others→Skipped)
TestCaptureRejectsTraversalTargets
snapshotForCheckpoint unexported with atomicity rationale
Real Windows LockFileEx (replaces no-op)
writeMetadata unique tmp suffix + cleanup on rename failure
Restore-side symlink-swap TOCTOU documented
ApplyRewind deadlock-free (single lock across sub-steps)
Closest-to-target entry wins; no double-count
Blob integrity check in readBlob

Items 1–2 above are one-line comment fixes; item 3 is an optional clarifying comment. None are blockers if you want to ship as-is, but #1 in particular will confuse the next reader.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Approved.

Re-reviewed the latest head 600337b. The prior blocker is fixed: checkpoint capture now resolves every target through resolveWithinWorkspace before stat/read, traversal targets are recorded as Skipped rather than captured/absent, and the snapshot-only API is package-private so blob writes stay atomic with the checkpoint event.

Validation:

  • Local PR worktree: go test ./internal/sessions passed.
  • Local PR worktree: go test ./... passed.
  • GitHub CI: ubuntu/macOS/windows smoke passed, Zero Review passed, Performance Smoke passed.

CodeRabbit was still running at review time, so handle any new actionable comments if it posts them, but I no longer see a blocker from my review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/sessions/rewind.go (2)

57-59: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail rewind on unreadable checkpoint payloads.

If the checkpoint closest to targetSeq does not decode, this continue lets a newer checkpoint win and can restore a later state than the caller asked for. Treat payload corruption as a hard rewind failure instead of silently skipping it.

Suggested fix
 			var payload CheckpointPayload
 			if err := json.Unmarshal(ev.Payload, &payload); err != nil {
-				continue
+				return report, fmt.Errorf("decode checkpoint payload seq %d: %w", ev.Sequence, err)
 			}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sessions/rewind.go` around lines 57 - 59, The code currently ignores
JSON unmarshal errors for CheckpointPayload (var payload CheckpointPayload;
json.Unmarshal(ev.Payload, &payload)) and continues, which can let a corrupted
checkpoint closer to targetSeq be skipped; instead, treat unreadable checkpoint
payloads as a hard rewind failure: when json.Unmarshal on ev.Payload fails,
return a descriptive error (including the sequence id/targetSeq and the
underlying unmarshal error) from the containing function so the rewind aborts
rather than skipping to a newer checkpoint; update the function's error return
accordingly and ensure callers propagate/handle that error.

54-71: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Deduplicate restores by the resolved path, not the raw checkpoint string.

restored[f.Path] treats ./foo, dir/../foo, or a symlink alias as different entries even when resolveWithinWorkspace maps them to the same file. In that case a newer checkpoint can overwrite the closest snapshot, which breaks rewind determinism.

Also applies to: 87-90

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sessions/rewind.go` around lines 54 - 71, The code currently
deduplicates using the raw checkpoint path (restored[f.Path]) which fails to
coalesce equivalent paths like "./foo", "dir/../foo" or symlink aliases; change
the dedupe key to the workspace-resolved canonical path by calling
resolveWithinWorkspace(ctx, f.Path) (or the existing resolver used elsewhere)
and use the resolved string for both the existence check and the assignment to
restored; if resolution returns an error or empty result, skip that file as
before. Apply the same change where restored is consulted/updated later in the
function (the other block handling payload.Files around the same logic) so all
FilesRestored/FilesDeleted accounting uses the resolved path as the unique
identifier.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/sessions/rewind.go`:
- Around line 57-59: The code currently ignores JSON unmarshal errors for
CheckpointPayload (var payload CheckpointPayload; json.Unmarshal(ev.Payload,
&payload)) and continues, which can let a corrupted checkpoint closer to
targetSeq be skipped; instead, treat unreadable checkpoint payloads as a hard
rewind failure: when json.Unmarshal on ev.Payload fails, return a descriptive
error (including the sequence id/targetSeq and the underlying unmarshal error)
from the containing function so the rewind aborts rather than skipping to a
newer checkpoint; update the function's error return accordingly and ensure
callers propagate/handle that error.
- Around line 54-71: The code currently deduplicates using the raw checkpoint
path (restored[f.Path]) which fails to coalesce equivalent paths like "./foo",
"dir/../foo" or symlink aliases; change the dedupe key to the workspace-resolved
canonical path by calling resolveWithinWorkspace(ctx, f.Path) (or the existing
resolver used elsewhere) and use the resolved string for both the existence
check and the assignment to restored; if resolution returns an error or empty
result, skip that file as before. Apply the same change where restored is
consulted/updated later in the function (the other block handling payload.Files
around the same logic) so all FilesRestored/FilesDeleted accounting uses the
resolved path as the unique identifier.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b3b11dc9-d10a-4b28-8d24-0354d18ff783

📥 Commits

Reviewing files that changed from the base of the PR and between 586559e and 600337b.

📒 Files selected for processing (4)
  • internal/sessions/checkpoint.go
  • internal/sessions/checkpoint_test.go
  • internal/sessions/filelock_windows.go
  • internal/sessions/rewind.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/sessions/checkpoint_test.go
  • internal/sessions/checkpoint.go

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict update: Request changes.

I approved after verifying the capture-side blocker was fixed, but CodeRabbit finished immediately after and surfaced two new rewind.go issues. I validated them against the current head and they are real correctness blockers for safe rewind:

  1. internal/sessions/rewind.go:57-59 silently continues when a checkpoint payload cannot be decoded. If the closest-to-target checkpoint is unreadable, rewind may skip it and let a newer checkpoint restore a later state than the caller requested. Please treat invalid checkpoint payloads as a hard rewind error with the checkpoint sequence in the message.

  2. internal/sessions/rewind.go:68-71 dedupes by raw checkpoint path before workspace resolution. Equivalent paths such as ./a.txt, dir/../a.txt, or a symlink alias can resolve to the same workspace file but bypass the restored map, allowing a newer checkpoint to overwrite the closest snapshot. Please resolve/constrain first, then use the resolved path as the dedupe key, and add a regression test for equivalent raw paths touching the same file.

The previous capture-side workspace escape issue is fixed, and validation is otherwise strong:

  • local PR worktree go test ./internal/sessions: pass
  • local PR worktree go test ./...: pass
  • GitHub ubuntu/macOS/windows smoke, Zero Review, and Performance Smoke: pass

After these two rewind determinism issues are fixed, this should be ready to approve.

[major] restoreToSequenceLocked silently continued past a checkpoint whose payload failed to decode, which let a NEWER checkpoint win for that path and restore a later state than the caller requested. Treat corruption as a hard rewind error. Regression test TestRestoreFailsOnCorruptCheckpointPayload (fails-open before, errors now).

Re-review note: the earlier capture-target-validation (critical) and test bounds (minor) were already addressed in 600337b (resolveWithinWorkspace guard + len checks); those re-anchored threads are stale. The restore-side symlink-swap TOCTOU remains a documented heavy-lift residual (needs openat2/descriptor traversal), tracked for the rewind-wiring work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review — addressed the re-review:

  • rewind.go:57-59 (Major, NEW) — fixed: an undecodable checkpoint payload now fails the rewind (return report, fmt.Errorf(...)) instead of silently continue-ing and letting a newer checkpoint restore a later state. Added TestRestoreFailsOnCorruptCheckpointPayload.
  • checkpoint.go (capture-target validation, Critical) — already fixed in the prior push (600337b): snapshotForCheckpoint confines every target via resolveWithinWorkspace before stat/read and classifies os.Stat errors (only IsNotExist→Absent). The re-anchored thread is stale.
  • checkpoint_test.go:80/:120 (bounds, Minor) — already fixed in 600337b: every p.Files[0]/events[...] access now has a len(...) guard. Stale re-anchors.
  • rewind.go:108 (TOCTOU, Critical / heavy-lift) — documented residual: the boundary is re-resolved immediately before each mutation; fully closing the symlink-swap window needs descriptor-based traversal (openat2 RESOLVE_BENEATH / per-component O_NOFOLLOW), which is platform-specific and tracked for the CLI/TUI rewind-wiring PR (rewind isn't wired to any caller in this slice).

build/vet/-race/full-suite + GOOS=windows build all green. (@Vasanthdev2004 already approved after the capture-confinement fix.)

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
gh pr diff 112 --repo Gitlawb/zero

Length of output: 55982



@gnanam1990 — new fix confirmed, everything else still clean.

✅ Confirmed fixed this round

Item Status
Corrupt checkpoint payload → hard return report, fmt.Errorf(...) in restoreToSequenceLocked
TestRestoreFailsOnCorruptCheckpointPayload added
All prior confirmed items (capture confinement, stat classification, traversal test, unexported snapshot, Windows LockFileEx, unique tmp suffix, TOCTOU docs, deadlock-free ApplyRewind, closest-to-target wins, blob integrity)

Still open from the prior round (comment-only, no logic change needed)

  1. lockSession comment in store.go — still reads "The OS lock is best-effort: if it cannot be acquired (e.g. an unsupported platform) the in-memory mutex still applies." Every platform now has a real implementation that returns a hard error on failure. The comment contradicts the code.

  2. TestSessionFileLockSerializesAcrossStores skip message — still t.Skip("flock semantics differ on windows"). The real reason is that LockFileEx within a single process does not block a second goroutine holding a separate handle to the same region, so the test would fail even with the real lock. The skip is correct; the message isn't.

  3. filelock_windows.goLockFileEx + synchronous handle comment — optional; adds clarity for the next reader of the subtle API contract.

None of these are blockers; items 1 and 2 are one-line text edits. The implementation is solid — happy to approve once the lockSession comment is corrected (item 1 is the most likely to mislead a future contributor).


🧠 Learnings used
Learnt from: CR
Repo: Gitlawb/openclaude PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-06-06T00:19:44.234Z
Learning: Verify that the review calls out the risk surface and whether it introduces a blocker if the PR touches auth, provider routing, permissions, outbound network behavior, background execution, startup/config-home behavior, skills/plugins/MCP, CI permissions, or release scripts.

[major] restoreToSequenceLocked deduped the per-path closest-to-target short-circuit on the RAW checkpoint path, so equivalent paths (./a.txt, dir/../a.txt, a symlink alias) keyed differently and a NEWER checkpoint could overwrite the closest-to-target snapshot for the same underlying file. Now resolve/confine FIRST and dedupe on the resolved workspace path (unresolvable targets dedupe on raw path). Regression test TestRestoreDedupesEquivalentRawPaths.

This + the prior commit (24e9ba2, fail-hard on undecodable payload) address both correctness blockers @Vasanthdev2004 raised after CodeRabbit's pass.

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@Vasanthdev2004 both blockers from your re-review are fixed:

  1. rewind.go decode-continue (24e9ba2) — an undecodable checkpoint payload now fails the rewind with the sequence in the message, instead of silently continuing. Test: TestRestoreFailsOnCorruptCheckpointPayload.
  2. rewind.go dedupe-by-raw-path (this push) — the closest-to-target short-circuit now resolves/confines first and dedupes on the resolved workspace path, so equivalent raw paths (./a.txt, dir/../a.txt, symlink aliases) can no longer slip past and let a newer checkpoint overwrite the closest snapshot. Test: TestRestoreDedupesEquivalentRawPaths (overwrites with "newer" before the fix, yields "closest" after).

build/vet/-race/full-suite + GOOS=windows build green. Thanks for the careful catch on the path-equivalence case.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
gh pr diff 112 --repo Gitlawb/zero

Length of output: 57360



@gnanam1990 — fix #2 confirmed, implementation is correct.

✅ Confirmed fixed this round

Item Status
Dedupe key = resolved absolute path (not raw path): resolveWithinWorkspace called first; abs used as key when ok, raw f.Path as fallback for unresolvable targets
TestRestoreDedupesEquivalentRawPaths — captures "closest" via "a.txt" and "newer" via "./a.txt", asserts restore yields "closest"
All prior confirmed items (corrupt payload hard-fail, capture confinement, stat classification, traversal test, unexported snapshot, Windows LockFileEx, unique tmp suffix, TOCTOU docs, deadlock-free ApplyRewind, blob integrity)

The ordering in restoreToSequenceLocked is correct: resolved[key] = true is set before the !ok guard, so unresolvable paths are deduplicated by raw path and won't be double-counted in report.Skipped either.

Still open (comment-only, no logic change needed)

These three items are unchanged from the last two rounds — all cosmetic, none are blockers:

  1. lockSession comment in store.go — still says "The OS lock is best-effort: if it cannot be acquired (e.g. an unsupported platform) the in-memory mutex still applies." The code returns a hard error; every platform now has a real implementation. The comment will mislead the next contributor.

  2. TestSessionFileLockSerializesAcrossStores skip message — still t.Skip("flock semantics differ on windows"). The accurate reason is that LockFileEx within a single process does not block a second goroutine holding a separate handle to the same region (unlike flock).

  3. filelock_windows.go — synchronous handle + LockFileEx comment (optional clarity).

The implementation is solid — happy to approve once item 1 is corrected.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Approved.

Re-reviewed latest head 9898ad3. Both rewind determinism blockers from the previous review are fixed:

  • corrupt/undecodable checkpoint payloads now fail the rewind with a sequence-specific error instead of silently continuing to a newer checkpoint;
  • restore now resolves/constrains the workspace target first and dedupes on the resolved path, so equivalent raw paths like a.txt and ./a.txt cannot bypass closest-checkpoint-wins behavior.

Regression coverage is present for both cases (TestRestoreFailsOnCorruptCheckpointPayload and TestRestoreDedupesEquivalentRawPaths).

Validation:

  • local PR worktree: go test ./internal/sessions passed;
  • local PR worktree: go test ./... passed;
  • GitHub checks: ubuntu/macOS/windows smoke passed, Zero Review passed, Performance Smoke passed;
  • CodeRabbit latest pass: no actionable comments.

This sessions checkpoint/rewind persistence slice is ready from my side.

@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@coderabbitai resolve

All four remaining inline threads are stale re-anchors of the original (586559e) review, already addressed on the current head (9898ad3):

  • checkpoint_test.go bounds → len() guards added (600337b)
  • checkpoint.go capture validation → resolveWithinWorkspace + error classification + regression test (600337b)
  • rewind.go symlink-swap TOCTOU → documented heavy-lift residual (re-resolved before each op; full closure needs openat2, tracked)

Plus the two correctness fixes from the re-review: hard-fail on undecodable checkpoint payload (24e9ba2) and resolved-path dedupe (9898ad3). @Vasanthdev2004 has re-approved. CI green across smoke/macOS/ubuntu/windows.

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# List all review comments by coderabbitai on PR `#112`
gh api repos/Gitlawb/zero/pulls/112/comments --paginate --jq '.[] | select(.user.login == "coderabbitai[bot]") | {id: .id, path: .path, body: .body[0:80]}'

Length of output: 2759


🏁 Script executed:

#!/bin/bash
# Get the PR node ID and review threads
gh api graphql -f query='
{
  repository(owner: "Gitlawb", name: "zero") {
    pullRequest(number: 112) {
      reviewThreads(first: 100) {
        nodes {
          id
          isResolved
          comments(first: 1) {
            nodes {
              author { login }
              body
            }
          }
        }
      }
    }
  }
}'

Length of output: 32675


🏁 Script executed:

#!/bin/bash
# Resolve the two remaining open threads

gh api graphql -f query='
mutation {
  resolveReviewThread(input: {threadId: "PRRT_kwDOSqaaUs6Hofmh"}) {
    thread { id isResolved }
  }
}'

gh api graphql -f query='
mutation {
  resolveReviewThread(input: {threadId: "PRRT_kwDOSqaaUs6Hofmi"}) {
    thread { id isResolved }
  }
}'

Length of output: 565



@gnanam1990 — all 15 inline review threads are now resolved. The two that were still open (filelock_other.go Windows lock and rewind.go TOCTOU) are confirmed closed — both were addressed (real LockFileEx implemented; TOCTOU documented as tracked future work).

The three comment-only items from the prior rounds (lockSession comment, Windows skip message, LockFileEx synchronous-handle comment) remain as optional polish but are not blockers.

[resolve]


🧠 Learnings used
Learnt from: CR
Repo: Gitlawb/openclaude PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-06-06T00:19:44.234Z
Learning: Verify that the review calls out the risk surface and whether it introduces a blocker if the PR touches auth, provider routing, permissions, outbound network behavior, background execution, startup/config-home behavior, skills/plugins/MCP, CI permissions, or release scripts.

@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit approve

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
✅ Action performed

Comments resolved and changes approved.

@gnanam1990
gnanam1990 merged commit f59654a into main Jun 7, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants