Skip to content

Python: fix(core): quarantine an unreadable memory state file instead of failing every turn - #9547

Open
Yufeng He (he-yufeng) wants to merge 3 commits into
microsoft:mainfrom
he-yufeng:fix/memory-store-corrupt-state
Open

Yufeng He (he-yufeng) wants to merge 3 commits into
microsoft:mainfrom
he-yufeng:fix/memory-store-corrupt-state

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

Summary

MemoryFileStore.read_state parsed state.json with no guard, so a corrupt state file (interrupted editor, partial external write, hand edit) raised JSONDecodeError out 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_snapshot in _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 next write_state recreates a clean file. The deliberate ValueError for a well-formed non-object document is unchanged.

Tests

  • New 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.
  • Mutation check: dropping the guard fails the test (JSONDecodeError propagates); restored green.
  • pytest tests/core/test_harness_memory.py: 34 passed.
  • pyright agent_framework/_harness/_memory.py and the test file: 0 errors.
  • Verified against the worktree checkout (PYTHONPATH pinning confirmed by agent_framework.__file__), not the shared editable install.

Copilot AI 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.

🟡 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.

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.

Reviewed at 68bc218 (merge base 08851e6).

What I ran

  • New test on the PR head: passes. With _harness/_memory.py reverted to the merge base: fails with JSONDecodeError. So the test catches the bug.
  • tests/core/test_harness_memory.py on head: 34 passed. tests/core on head: 6788 passed, 133 skipped.
  • End to end: two agent.run turns with a MemoryContextProvider and a truncated state.json, using the test file's _MemoryHarnessClient. Merge base: both turns raise JSONDecodeError and the bad file stays. PR head: both turns succeed, one .corrupt file is created, and state.json is rewritten clean. The bug and the fix both hold up beyond the unit test.
  • Non-UTF-8 bytes in state.json: the merge base raises UnicodeDecodeError. The PR quarantines the file, since UnicodeDecodeError is a ValueError. Good, but untested.
  • ruff check 0.17.0: clean. ruff format --check 0.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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@he-yufeng

Copy link
Copy Markdown
Contributor Author

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 from builtins import type as builtin_type alias used as builtin_type[EdgeGroup], which griffe does not resolve. This PR only touches _harness/_memory.py and its tests, so the failure comes from the tooling meeting that alias on main, and it will hit any PR that triggers the comparison until griffe handles the pattern or _edge.py spells the annotation differently.

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 type[EdgeGroup] in a separate change if the maintainers want the gate healthy again.

This branch was successfully deployed

1 active deployment
github-app-auth — f0e42a7e Deployed Oct 23, 2026 by he-yufeng via add_label #26028
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants