Skip to content

feat(checkpoint): durable cross-process checkpoint persistence - #218

Open
ishan-parihar wants to merge 2 commits into
cortexkit:mainfrom
ishan-parihar:durable-checkpoints
Open

feat(checkpoint): durable cross-process checkpoint persistence#218
ishan-parihar wants to merge 2 commits into
cortexkit:mainfrom
ishan-parihar:durable-checkpoints

Conversation

@ishan-parihar

@ishan-parihar ishan-parihar commented Aug 14, 2026

Copy link
Copy Markdown

Checkpoints were in-memory only (a bridge crash dropped all of them). This makes them durable: persist on create, hydrate on startup, mirror delete/cleanup to disk, store Permissions not Metadata, sanitize+hash dir components. 27 checkpoint tests pass incl. cross-store durability; protocol-verified across 3 subprocesses.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Makes checkpoints durable across process restarts. Previously they were in-memory and lost on crash; now we persist on create, hydrate on startup, and mirror delete/cleanup to disk without changing the external API.

  • On-disk layout: <storage_root>/<session>-<hash>/<name>-<hash>/{manifest.json, blob-N.bin}. The manifest stores the raw session id; hydration uses it (not dir names). Writes are atomic and orphaned blobs are cleaned on overwrite.
  • Storage root derives from the parent of the existing lock file; hydration runs at store initialization. Cleanup prunes aged-out checkpoints on disk and removes empty session dirs.
  • Security and correctness: session/name components are sanitized and hash-suffixed to prevent traversal and avoid collisions (e.g. a/b vs a_b). Hydration skips unreadable/corrupt entries with warnings.
  • Restore re-applies file permissions from stored mode bits. CheckpointFile now stores Permissions instead of fs::Metadata so permissions survive hydration.
  • Tests cover cross-store durability, traversal/collision handling, and delete mirroring to disk.

Written for commit e6a022b. Summary will update on new commits.

Review in cubic

Greptile Summary

This PR adds filesystem-backed checkpoint persistence, startup hydration, durable deletion and cleanup, path-safe checkpoint directories, and serialized permission metadata.

  • Persists checkpoint manifests and regular-file blobs beneath a project-scoped storage root.
  • Hydrates persisted checkpoints when a store is initialized.
  • Mirrors deletion and TTL cleanup to disk.
  • Replaces non-serializable filesystem metadata with persisted permission modes.
  • Adds durability, path-sanitization, collision, and deletion tests.

Confidence Score: 2/5

This PR is not safe to merge until checkpoint persistence, overwrite publication, and deletion failures preserve the promised durable state or return errors.

Creation and deletion currently report success after disk failures, while overwrites modify published blobs before the replacement manifest is committed, allowing checkpoints to disappear, reappear, or restore mismatched contents after restart.

Files Needing Attention: crates/aft/src/checkpoint.rs

Important Files Changed

Filename Overview
crates/aft/src/checkpoint.rs Adds durable checkpoint serialization and hydration, but failed or interrupted filesystem mutations can be reported as successful or leave inconsistent checkpoint generations.

Sequence Diagram

sequenceDiagram
  participant Caller
  participant Store as CheckpointStore
  participant Disk as Durable checkpoint directory
  participant Next as Restarted process
  Caller->>Store: create(session, name, files)
  Store->>Store: Insert checkpoint in memory
  Store->>Disk: Remove old blobs
  Store->>Disk: Write replacement blobs
  Store->>Disk: Rename replacement manifest
  Disk-->>Store: Success or I/O error
  Store-->>Caller: Success
  Next->>Disk: Read manifests and blobs
  Disk-->>Next: Hydrated checkpoint or incomplete generation
Loading

Reviews (1): Last reviewed commit: "fix(checkpoint): review fixes for durabl..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

Context used:

Checkpoints were in-memory only (a bridge crash dropped all of them).
Now every mutation mirrors to disk under <storage>/<session>/<name>/
(manifest.json + blob-N.bin) and CheckpointStore hydrates on startup:
- create() persists after the in-memory insert (atomic manifest write)
- delete() removes the on-disk dir
- cleanup() prunes aged-out persisted checkpoints
- CheckpointFile stores Permissions instead of fs::Metadata so
  permissions can be re-applied after hydration (Metadata is not
  reconstructible)
- new durability test: create in store A, list+restore in store B
  over the same storage root

Protocol-verified: list_checkpoints and restore_checkpoint both work
across fresh bridge subprocesses.
- Store the raw session id in the manifest; hydrate by it instead of the
  sanitized+hashed on-dir dir name (which broke session scoping)
- Sanitize+hash both dir components so names that sanitize identically
  (a/b vs a_b) never collide on disk or silently overwrite on hydration
- cleanup() now compares on-disk dirs against the computed alive set
  (handles hash-suffixed dir names); prunes empty session dirs
- Drop orphaned blobs on overwrite; drop dead readonly manifest field
- New tests: sanitize traversal/collision, delete mirrors to disk
Comment on lines +351 to +357
if let Err(e) = self.persist_checkpoint(session, name) {
crate::slog_warn!(
"checkpoint {}: persisted in memory but failed to write to disk: {}",
name,
e
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Persistence failures report success

When a checkpoint blob or manifest write fails, create_impl keeps the checkpoint only in memory and still returns success, causing the checkpoint to disappear when the bridge restarts.

Knowledge Base Used: AFT state, storage, and diagnostic artifacts

Comment on lines +571 to +578
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let file_name = entry.file_name().to_string_lossy().to_string();
if file_name.starts_with("blob-") && file_name.ends_with(".bin") {
let _ = fs::remove_file(entry.path());
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Overwrite publishes mixed generations

When an existing checkpoint is overwritten and persistence stops before the new manifest is renamed, this removes or replaces the published blobs while the old manifest remains, causing restart hydration to drop the checkpoint or restore new bytes using old file paths.

Knowledge Base Used: AFT state, storage, and diagnostic artifacts

Comment on lines +452 to +459
if removed {
if let Err(e) = fs::remove_dir_all(&dir) {
crate::slog_warn!(
"checkpoint {}: removed from memory but failed to delete on disk: {}",
name,
e
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Failed deletions resurrect checkpoints

When remove_dir_all fails, delete has already removed the in-memory entry and still reports success, so the durable directory remains and the deleted checkpoint is hydrated, listed, and restorable after restart.

Knowledge Base Used: AFT state, storage, and diagnostic artifacts

@cubic-dev-ai cubic-dev-ai 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.

7 issues found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/aft/src/checkpoint.rs">

<violation number="1" location="crates/aft/src/checkpoint.rs:351">
P1: When checkpoint storage is unavailable or out of space, `create` reports success even though the checkpoint will disappear after a bridge restart. Return the persistence error and keep the memory/disk state consistent, or explicitly mark the result non-durable.</violation>

<violation number="2" location="crates/aft/src/checkpoint.rs:453">
P2: When `fs::remove_dir_all(&dir)` fails here, delete() has already removed the in-memory entry and still returns `true` (success) to the caller. The durable directory on disk is left behind, so hydrate_from_disk resurrects the "deleted" checkpoint after the next restart, making it listable and restorable again despite the delete having reportedly succeeded. Consider returning an error (or re-inserting the in-memory entry) when the on-disk removal fails so the caller and durable state stay consistent.</violation>

<violation number="3" location="crates/aft/src/checkpoint.rs:453">
P2: `delete` now performs destructive on-disk I/O (`fs::remove_dir_all`) without acquiring the mutation lock, unlike `create` which calls `persist_checkpoint` under `acquire_mutation_lock` (the persist path even documents "We are under the mutation lock, so this is safe"). Because the whole point of this PR is cross-process durability, two processes sharing a storage root can now race: process A's locked `persist_checkpoint` writes a manifest/dir while process B's unlocked `delete` removes that same dir, or B removes the dir that A just finalized. Acquire the mutation lock (like `create`/`restore`) before the filesystem removal so deletion is serialized with other processes' persists.</violation>

<violation number="4" location="crates/aft/src/checkpoint.rs:521">
P3: In the cleanup disk mirror, `session_empty` is set to false as soon as any checkpoint subdirectory inside the session is visited, even when that subdirectory is removed because it aged out. As a result a session whose only checkpoint(s) were deleted still has its session directory left behind on disk. Hydration tolerates the empty dir (it iterates to nothing), so the impact is only stale on-disk clutter, but it contradicts the mirroring intent that delete/cleanup keep disk consistent with memory. Set `session_empty = false` only for checkpoint dirs that are actually kept (i.e. present in `alive_dirs`) and re-check emptiness after removals.</violation>

<violation number="5" location="crates/aft/src/checkpoint.rs:522">
P1: A long-lived bridge can delete checkpoints created by another process after startup because those directories are absent from its stale `alive_dirs` set. Refresh state under the mutation lock, or remove only on-disk entries independently proven to be expired.</violation>

<violation number="6" location="crates/aft/src/checkpoint.rs:571">
P2: `persist_checkpoint` deletes every existing `blob-*.bin` before writing the new blobs and renaming the manifest, but the manifest itself is only finalized at the very end. If the persist fails partway (disk full, IO error, or a crash during the cross-process window this PR targets) after the old blobs are removed but before `manifest.json` is replaced, the still-on-disk previous manifest now references blob files that no longer exist. `hydrate_from_disk` treats any blob-missing entry as `!ok` and drops the whole checkpoint, so a failed overwrite destroys the previously good durable snapshot instead of leaving it intact. Write the new blobs to fresh names (or delete the old blobs only after the manifest rename succeeds) so a failed persist leaves the prior snapshot restorable.</violation>

<violation number="7" location="crates/aft/src/checkpoint.rs:765">
P2: On Windows, checkpoint names containing invalid filename characters are not durable: directory creation fails, and `create` only logs that failure. Replace all characters outside a platform-safe allowlist, not just separators and controls.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

.or_default()
.insert(name.to_string(), checkpoint);

if let Err(e) = self.persist_checkpoint(session, name) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When checkpoint storage is unavailable or out of space, create reports success even though the checkpoint will disappear after a bridge restart. Return the persistence error and keep the memory/disk state consistent, or explicitly mark the result non-durable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/aft/src/checkpoint.rs, line 351:

<comment>When checkpoint storage is unavailable or out of space, `create` reports success even though the checkpoint will disappear after a bridge restart. Return the persistence error and keep the memory/disk state consistent, or explicitly mark the result non-durable.</comment>

<file context>
@@ -289,6 +348,14 @@ impl CheckpointStore {
             .or_default()
             .insert(name.to_string(), checkpoint);
 
+        if let Err(e) = self.persist_checkpoint(session, name) {
+            crate::slog_warn!(
+                "checkpoint {}: persisted in memory but failed to write to disk: {}",
</file context>

continue;
}
session_empty = false;
if !alive_dirs.contains(&cp_path) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: A long-lived bridge can delete checkpoints created by another process after startup because those directories are absent from its stale alive_dirs set. Refresh state under the mutation lock, or remove only on-disk entries independently proven to be expired.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/aft/src/checkpoint.rs, line 522:

<comment>A long-lived bridge can delete checkpoints created by another process after startup because those directories are absent from its stale `alive_dirs` set. Refresh state under the mutation lock, or remove only on-disk entries independently proven to be expired.</comment>

<file context>
@@ -417,6 +497,238 @@ impl CheckpointStore {
+                            continue;
+                        }
+                        session_empty = false;
+                        if !alive_dirs.contains(&cp_path) {
+                            let _ = fs::remove_dir_all(&cp_path);
+                        }
</file context>

.map(|session_checkpoints| session_checkpoints.remove(name).is_some())
.unwrap_or(false);
if removed {
if let Err(e) = fs::remove_dir_all(&dir) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When fs::remove_dir_all(&dir) fails here, delete() has already removed the in-memory entry and still returns true (success) to the caller. The durable directory on disk is left behind, so hydrate_from_disk resurrects the "deleted" checkpoint after the next restart, making it listable and restorable again despite the delete having reportedly succeeded. Consider returning an error (or re-inserting the in-memory entry) when the on-disk removal fails so the caller and durable state stay consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/aft/src/checkpoint.rs, line 453:

<comment>When `fs::remove_dir_all(&dir)` fails here, delete() has already removed the in-memory entry and still returns `true` (success) to the caller. The durable directory on disk is left behind, so hydrate_from_disk resurrects the "deleted" checkpoint after the next restart, making it listable and restorable again despite the delete having reportedly succeeded. Consider returning an error (or re-inserting the in-memory entry) when the on-disk removal fails so the caller and durable state stay consistent.</comment>

<file context>
@@ -376,12 +443,25 @@ impl CheckpointStore {
+            .map(|session_checkpoints| session_checkpoints.remove(name).is_some())
+            .unwrap_or(false);
+        if removed {
+            if let Err(e) = fs::remove_dir_all(&dir) {
+                crate::slog_warn!(
+                    "checkpoint {}: removed from memory but failed to delete on disk: {}",
</file context>

}
}
c if c.is_ascii_control() => out.push('_'),
c => out.push(c),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: On Windows, checkpoint names containing invalid filename characters are not durable: directory creation fails, and create only logs that failure. Replace all characters outside a platform-safe allowlist, not just separators and controls.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/aft/src/checkpoint.rs, line 765:

<comment>On Windows, checkpoint names containing invalid filename characters are not durable: directory creation fails, and `create` only logs that failure. Replace all characters outside a platform-safe allowlist, not just separators and controls.</comment>

<file context>
@@ -429,6 +741,88 @@ impl CheckpointStore {
+                }
+            }
+            c if c.is_ascii_control() => out.push('_'),
+            c => out.push(c),
+        }
+    }
</file context>
Suggested change
c => out.push(c),
c if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') => out.push(c),
_ => out.push('_'),

.map(|session_checkpoints| session_checkpoints.remove(name).is_some())
.unwrap_or(false);
if removed {
if let Err(e) = fs::remove_dir_all(&dir) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: delete now performs destructive on-disk I/O (fs::remove_dir_all) without acquiring the mutation lock, unlike create which calls persist_checkpoint under acquire_mutation_lock (the persist path even documents "We are under the mutation lock, so this is safe"). Because the whole point of this PR is cross-process durability, two processes sharing a storage root can now race: process A's locked persist_checkpoint writes a manifest/dir while process B's unlocked delete removes that same dir, or B removes the dir that A just finalized. Acquire the mutation lock (like create/restore) before the filesystem removal so deletion is serialized with other processes' persists.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/aft/src/checkpoint.rs, line 453:

<comment>`delete` now performs destructive on-disk I/O (`fs::remove_dir_all`) without acquiring the mutation lock, unlike `create` which calls `persist_checkpoint` under `acquire_mutation_lock` (the persist path even documents "We are under the mutation lock, so this is safe"). Because the whole point of this PR is cross-process durability, two processes sharing a storage root can now race: process A's locked `persist_checkpoint` writes a manifest/dir while process B's unlocked `delete` removes that same dir, or B removes the dir that A just finalized. Acquire the mutation lock (like `create`/`restore`) before the filesystem removal so deletion is serialized with other processes' persists.</comment>

<file context>
@@ -376,12 +443,25 @@ impl CheckpointStore {
+            .map(|session_checkpoints| session_checkpoints.remove(name).is_some())
+            .unwrap_or(false);
+        if removed {
+            if let Err(e) = fs::remove_dir_all(&dir) {
+                crate::slog_warn!(
+                    "checkpoint {}: removed from memory but failed to delete on disk: {}",
</file context>

// Drop orphaned blobs from a previous snapshot with the same name
// (an overwrite with fewer files would otherwise leave stale blob-N
// files behind). We are under the mutation lock, so this is safe.
if let Ok(entries) = fs::read_dir(&dir) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: persist_checkpoint deletes every existing blob-*.bin before writing the new blobs and renaming the manifest, but the manifest itself is only finalized at the very end. If the persist fails partway (disk full, IO error, or a crash during the cross-process window this PR targets) after the old blobs are removed but before manifest.json is replaced, the still-on-disk previous manifest now references blob files that no longer exist. hydrate_from_disk treats any blob-missing entry as !ok and drops the whole checkpoint, so a failed overwrite destroys the previously good durable snapshot instead of leaving it intact. Write the new blobs to fresh names (or delete the old blobs only after the manifest rename succeeds) so a failed persist leaves the prior snapshot restorable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/aft/src/checkpoint.rs, line 571:

<comment>`persist_checkpoint` deletes every existing `blob-*.bin` before writing the new blobs and renaming the manifest, but the manifest itself is only finalized at the very end. If the persist fails partway (disk full, IO error, or a crash during the cross-process window this PR targets) after the old blobs are removed but before `manifest.json` is replaced, the still-on-disk previous manifest now references blob files that no longer exist. `hydrate_from_disk` treats any blob-missing entry as `!ok` and drops the whole checkpoint, so a failed overwrite destroys the previously good durable snapshot instead of leaving it intact. Write the new blobs to fresh names (or delete the old blobs only after the manifest rename succeeds) so a failed persist leaves the prior snapshot restorable.</comment>

<file context>
@@ -417,6 +497,238 @@ impl CheckpointStore {
+        // Drop orphaned blobs from a previous snapshot with the same name
+        // (an overwrite with fewer files would otherwise leave stale blob-N
+        // files behind). We are under the mutation lock, so this is safe.
+        if let Ok(entries) = fs::read_dir(&dir) {
+            for entry in entries.flatten() {
+                let file_name = entry.file_name().to_string_lossy().to_string();
</file context>

if !cp_path.is_dir() {
continue;
}
session_empty = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: In the cleanup disk mirror, session_empty is set to false as soon as any checkpoint subdirectory inside the session is visited, even when that subdirectory is removed because it aged out. As a result a session whose only checkpoint(s) were deleted still has its session directory left behind on disk. Hydration tolerates the empty dir (it iterates to nothing), so the impact is only stale on-disk clutter, but it contradicts the mirroring intent that delete/cleanup keep disk consistent with memory. Set session_empty = false only for checkpoint dirs that are actually kept (i.e. present in alive_dirs) and re-check emptiness after removals.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/aft/src/checkpoint.rs, line 521:

<comment>In the cleanup disk mirror, `session_empty` is set to false as soon as any checkpoint subdirectory inside the session is visited, even when that subdirectory is removed because it aged out. As a result a session whose only checkpoint(s) were deleted still has its session directory left behind on disk. Hydration tolerates the empty dir (it iterates to nothing), so the impact is only stale on-disk clutter, but it contradicts the mirroring intent that delete/cleanup keep disk consistent with memory. Set `session_empty = false` only for checkpoint dirs that are actually kept (i.e. present in `alive_dirs`) and re-check emptiness after removals.</comment>

<file context>
@@ -417,6 +497,238 @@ impl CheckpointStore {
+                        if !cp_path.is_dir() {
+                            continue;
+                        }
+                        session_empty = false;
+                        if !alive_dirs.contains(&cp_path) {
+                            let _ = fs::remove_dir_all(&cp_path);
</file context>

@ualtinok ualtinok 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.

Thanks for this — the core mechanism is right (persist on create, hydrate on startup, mirror mutations), and several details are better than typical first contributions: atomic manifest writes, fail-closed hydration of corrupt manifests, the stable FNV hash with collision disambiguation, and real traversal tests. Two blockers and two design items before this can merge:

Blocker 1: the Windows branch doesn't compile. std::os::windows::fs::PermissionsExt is an unstable library feature — cargo check --target x86_64-pc-windows-gnu fails with E0658 on both permission_mode and permission_from_mode. (Our Rust CI didn't run on this PR — fork PRs need workflow approval, which is why the bot checks are the only green you saw.) Suggested fix: persist the full mode bits under cfg(unix) only, and use the readonly() bit everywhere else (your existing not(any(unix, windows)) branch is the right shape for Windows too).

Blocker 2: the disk mirror in cleanup_expired deletes other processes' live checkpoints. The sweep removes any on-disk checkpoint dir not present in this process's in-memory map. The storage root is shared per project, and multiple bridge processes serve the same project concurrently (multi-session is our production norm): process A — hydrated before B created its checkpoints — would sweep B's live checkpoints on A's next TTL cleanup. Disk deletion needs to be driven by disk state: read each dir's manifest.json, compare its created_at against the TTL under the mutation lock, and remove only expired entries. Memory-diff must never be deletion authority for shared storage.

Design 1: hydration loads every blob into memory at construction. hydrate_from_disk reads all blob bytes for all checkpoints eagerly, and store construction happens on the startup path. A project with a few large checkpoints turns that into real startup cost and resident memory. Suggested: hydrate manifests only, keep the blob path in the map, and read bytes on restore (with the missing-blob case handled as a restore-time error).

Design 2 (note, not a blocker): the storage key inherits the lock path's scoping. CheckpointStore::new keys storage off project_scope_key(current_dir()); under the daemon a single process serves many roots, so all of them share one bucket. That's a pre-existing property of the lock path, but this PR promotes it into the durability layout. Fine to land as-is — we'll thread the real project root through when we touch this next — but worth a comment at the site so the assumption is visible.

Happy to re-review quickly — with blockers 1 and 2 fixed this is a solid contribution we want.

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.

2 participants