Repository navigation
Python: fix(core): quarantine an unreadable memory state file instead of failing every turn - #9547
Yufeng He (he-yufeng) wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Quarantine can move aside a concurrently repaired file and reset valid maintenance metadata.
1 open issue
What changed in this PR
Improves Python memory-store recovery so corrupt state files do not repeatedly fail per-turn bookkeeping.
Changes:
- Quarantines unreadable state files, logs a warning, and returns defaults.
- Adds regression coverage for quarantine, content preservation, and subsequent writes.
| File | Description |
|---|---|
| python/packages/core/tests/core/test_harness_memory.py | Tests corrupt-state recovery and later write/read behavior. |
| python/packages/core/agent_framework/_harness/_memory.py | Adds quarantine and default-state recovery. |
Review effort: Balanced
| except ValueError as exc: | ||
| quarantine_path = state_path.with_name(f".{state_path.name}.{uuid.uuid4().hex}.corrupt") | ||
| try: | ||
| state_path.rename(quarantine_path) |
… of failing every turn MemoryFileStore.read_state parsed state.json without a guard, so a corrupt state file (interrupted editor, partial external write) raised out of the per-turn memory bookkeeping path forever. Move the unreadable file aside (the session store's corrupt-snapshot convention) and restart from a clean state; the next write recreates the file.
ade245f to
68bc218
Compare
Somay kousis (Somay-kousis)
left a comment
There was a problem hiding this comment.
Reviewed at 68bc218 (merge base 08851e6).
What I ran
- New test on the PR head: passes. With
_harness/_memory.pyreverted to the merge base: fails withJSONDecodeError. So the test catches the bug. tests/core/test_harness_memory.pyon head: 34 passed.tests/coreon head: 6788 passed, 133 skipped.- End to end: two
agent.runturns with aMemoryContextProviderand a truncatedstate.json, using the test file's_MemoryHarnessClient. Merge base: both turns raiseJSONDecodeErrorand the bad file stays. PR head: both turns succeed, one.corruptfile is created, andstate.jsonis rewritten clean. The bug and the fix both hold up beyond the unit test. - Non-UTF-8 bytes in
state.json: the merge base raisesUnicodeDecodeError. The PR quarantines the file, sinceUnicodeDecodeErroris aValueError. Good, but untested. ruff check0.17.0: clean.ruff format --check0.17.0: fails, see 3.
1. A valid file written between the failed parse and the rename gets quarantined (worth considering)
This confirms the bot comment on _memory.py:905 with a repro. I used a custom loads that writes a valid state through write_state before parsing the corrupt text, to simulate another writer winning the race. On the PR head:
read: returned {'last_consolidated_at': None, 'sessions_since_consolidation': []}
state.json exists after read: False
quarantined file content: {"sessions_since_consolidation": ["written-by-other-writer"]}
The valid state ends up in the .corrupt file and later reads return defaults. In one process the per-owner asyncio.Lock (_state_async_lock) serializes read_state/write_state, so this needs a second process or event loop on the same directory. _sessions.py:_quarantine_corrupt_snapshot, which the PR says it mirrors, re-reads the file and skips the move if the bytes changed. Doing the same here (keep the text you failed to parse, compare before rename, re-read if it changed) would close most of the window.
2. A state file that is valid JSON but not an object still fails every turn (worth considering)
_memory.py:923 still raises ValueError("Memory state file must contain a JSON object."). With state.json set to [], both reads raise on the PR head, the same "fails every later turn" problem the PR fixes for unparseable files. The description says this is on purpose. A hand edit to [] or null is a realistic way to hit it though, and recovery would be the same quarantine call. If it stays a hard error, a test pinning that behaviour would make the choice explicit.
3. ruff format --check fails on the new warning (nit)
Run from python/ with the repo config (line-length 120), ruff@0.17.0 format --check wants the two-part string at _memory.py:908-909 joined onto one line. The diff is in the evidence. The Python CI checks have not run on this PR yet, so this would likely show up there.
Not checked
Real multi-process access (finding 1 is a simulation), Windows rename semantics (the target name is a fresh uuid, so I don't expect a clash), custom loads callables that raise something other than ValueError, and pyright/mypy.
Eduard van Valkenburg (eavanvalkenburg)
left a comment
There was a problem hiding this comment.
One quarantine race should be addressed before merge.
| except ValueError as exc: | ||
| quarantine_path = state_path.with_name(f".{state_path.name}.{uuid.uuid4().hex}.corrupt") | ||
| try: | ||
| state_path.rename(quarantine_path) |
There was a problem hiding this comment.
Please retain the exact text that failed to parse and verify the path still contains that payload before renaming it. Another process can atomically replace state.json with a valid file after the read but before this rename; this code then quarantines the valid replacement and returns defaults, losing current maintenance metadata. _sessions.py::_quarantine_corrupt_snapshot already re-reads and skips quarantine when the contents changed. Please mirror that behavior, retry the read when a replacement wins, and add an interleaving regression test.
There was a problem hiding this comment.
Good catch — that race was real. Fixed in 06edcaf: read_state now keeps the exact text that failed to parse, and before quarantining it re-reads the file and compares — the same shape as _sessions.py::_quarantine_corrupt_snapshot. If the content differs (a valid replacement landed), it re-parses the new payload instead of moving it aside; if the file is gone entirely, it returns the default state without touching anything. Added test_memory_file_store_does_not_quarantine_a_valid_replacement, which drives the interleave through a scripted loads (replace the file mid-parse, raise once): it fails against the pre-fix code and passes now. test_harness_memory.py is 35/35, and the memory/sessions regression set (test_harness_memory, test_harness_file_memory, test_in_memory, test_sessions) is green.
…the failing payload
|
Looked at the Public API Compatibility failure: it is not this diff. The job crashes while analyzing the base tree, before it ever reaches the PR side. The traceback ends in griffe's AliasResolutionError — "Could not resolve alias agent_framework._workflows._edge.EdgeGroup.builtin_type pointing at builtins.type (in python/packages/core/agent_framework/_workflows/_edge.py:315)". That line is the class-body Everything else on this head is green. A re-run will clear it the same way it did for the earlier checks, or the alias can be rewritten as |

Summary
MemoryFileStore.read_stateparsedstate.jsonwith no guard, so a corrupt state file (interrupted editor, partial external write, hand edit) raisedJSONDecodeErrorout of the per-turn memory bookkeeping path — and kept raising on every later turn, since nothing ever removed the bad file.This follows the session store's existing corrupt-snapshot convention (
_quarantine_corrupt_snapshotin_sessions.py): an unreadable state file is moved aside to.<name>.<uuid>.corrupt, a warning is logged, and the store restarts from a clean default state. The nextwrite_staterecreates a clean file. The deliberateValueErrorfor a well-formed non-object document is unchanged.Tests
test_memory_file_store_quarantines_corrupt_state_file: writes state through the real path, corrupts the file, asserts a clean default comes back, the corrupt file was moved aside with its content preserved, and a later write/read round-trips normally.pytest tests/core/test_harness_memory.py: 34 passed.pyright agent_framework/_harness/_memory.pyand the test file: 0 errors.PYTHONPATHpinning confirmed byagent_framework.__file__), not the shared editable install.