Skip to content

fix(memory): refuse mutations when on-disk file changed since last read (#26045) - #30993

Open
bradhallett wants to merge 1 commit into
NousResearch:mainfrom
bradhallett:fix/memory-replace-on-disk-guard-26045
Open

fix(memory): refuse mutations when on-disk file changed since last read (#26045)#30993
bradhallett wants to merge 1 commit into
NousResearch:mainfrom
bradhallett:fix/memory-replace-on-disk-guard-26045

Conversation

@bradhallett

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #26045 (commit 6855d17). The drift guard detects structurally un-roundtrippable content, but jrhouston-trilogy recommended an additional defense: refuse mutations when the on-disk file has changed since last read — the same contract the patch tool already enforces.

This alone would have prevented the original ~8KB data-loss incident.

Changes

  • tools/memory_tool.py: Added SHA-256 content-hash snapshot at lock acquisition. Before save_to_disk(), re-reads the file and refuses if the hash differs. Applied to all three mutation paths (add, replace, remove).
  • tests/tools/test_memory_tool.py: 6 new tests in TestOnDiskChangeGuard

Relationship to existing drift guard

Both defenses are active and independent:

  1. Drift guard fires first — detects structurally un-roundtrippable content (missing delimiters, oversized entries)
  2. On-disk guard fires second — detects any content change between read and write, even if structurally valid

Test plan

pytest tests/tools/test_memory_tool.py -v   # 46 passed (40 existing + 6 new)

Refs #26045

…e last read (NousResearch#26045)

Add a content-hash guard to the replace, add, and remove mutation paths.
Before flushing, the tool re-reads the on-disk file and compares its
SHA-256 hash to the snapshot taken at lock-acquire time. If the file was
modified externally (concurrent session, patch tool edit, shell append)
between those two points, the mutation is refused with a clear error
message directing the caller to re-read and retry.

This is the same contract the patch tool already enforces ('file was
modified since you last read it on disk'). It supplements the existing
drift guard (round-trip mismatch / oversized-entry detection) and would
alone have prevented the original ~8KB data-loss incident.

New tests:
  - test_replace_refuses_when_file_changed_between_read_and_write
  - test_replace_succeeds_when_file_unchanged
  - test_remove_refuses_when_file_changed_between_read_and_write
  - test_add_refuses_when_file_changed_between_read_and_write
  - test_on_disk_guard_does_not_trigger_when_file_is_new
  - test_on_disk_guard_and_drift_guard_are_independent
@alt-glitch alt-glitch added type/bug Something isn't working tool/memory Memory tool and memory providers P2 Medium — degraded but workaround exists labels May 23, 2026

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

Reviewed this carefully. The on-disk change guard is well-designed:

What it does: SHA256 snapshot of file content before lock acquisition, then verification before write in add/replace/remove. Refuses mutation if content changed between read and write — prevents data loss from concurrent sessions (#26045).

Strengths:

  • Static methods (_snapshot_on_disk, _check_on_disk_unchanged) — clean separation, testable
  • Guard fires AFTER drift check — correct ordering (drift is structural, on-disk is temporal)
  • 7 tests covering all 3 mutation paths + edge cases (new file, drift vs on-disk independence)
  • Error messages reference issue #26045 and instruct user to re-read + retry

One observation (non-blocking): The pre_hash is taken just before _reload_target() which re-reads from disk. If the file changes between _snapshot_on_disk() and _reload_target(), the drift guard would likely catch it (unparseable content → backup). If it changes between _reload_target() and the check, the on-disk guard catches it. This covers the full window — good.

LGTM.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for pursuing the concurrent-write protection from #26045. The underlying race remains on current main: replace reloads at tools/memory_tool.py:403 and saves at :453 without an on-disk version check.

Problems

  • The proposed pre_hash is captured before _reload_target (tools/memory_tool.py:294 in this diff). A valid write between those reads is incorporated by the reload but still causes a refusal. Snapshot after the authoritative reload instead.
  • The add path appends to live entries before checking (tools/memory_tool.py:328), so a refused add leaves in-memory state changed even though disk was not written.
  • Current main added apply_batch in 38c8a9c10; it also reloads then saves (tools/memory_tool.py:524, :600) and needs the same protection.
  • The new tests mock the check helper, rather than modifying the backing file during the real reload-to-write window.

Suggested changes

  • Centralize a post-reload snapshot plus immediately-pre-write validation for every persistence path, including apply_batch.
  • Add real temp-file mutation tests and assert both disk and live state remain unchanged on refusal.

This is an automated hermes-sweeper review.

Comment thread tools/memory_tool.py
return {"success": False, "error": scan_error}

with self._file_lock(self._path_for(target)):
path = self._path_for(target)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

_reload_target() reads the file again immediately after this snapshot. If a valid external write lands between these reads, the reload already incorporates it, but the later check still rejects the mutation. Capture the snapshot after the authoritative reload so the guard covers only changes after the state used for this mutation.

Comment thread tools/memory_tool.py
@@ -307,6 +328,19 @@ def add(self, target: str, content: str) -> Dict[str, Any]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This refusal happens after entries.append(content), so a failed add leaves memory_entries/user_entries changed in memory although disk was not written. Validate before mutating live entries, or restore the previous list before returning the error.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/memory Memory subsystem: store, providers, sync, background reviews labels Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/memory Memory subsystem: store, providers, sync, background reviews P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state tool/memory Memory tool and memory providers type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants