fix(agent): add stale-state guard to background review writes (#9055) - #39806
fix(agent): add stale-state guard to background review writes (#9055)#39806rodboev wants to merge 1 commit into
Conversation
|
Verified the stale-write guard design. The core concern is real: The snapshot-token approach is the right pattern — comparing a JSON-normalized hash of the snapshot messages against the live Scope check: the One design note: |
|
Read through the guard — the snapshot-token approach is clean and the The guard keys on this parent's conversation, but three of the four sources #9055 lists
Each of those writes to the shared memory/skill store via a different agent whose activity The two stores aren't equally exposed to that residual, which affects how much it matters:
So the net after this PR: the same-session race is closed for both stores (great — that's
Not a blocker on the fix as-is — it's a real improvement over (Minor, separate: |
bf4bede to
d1afd62
Compare
|
Good analysis, thanks for tracing through the cross-writer paths. You're right that the guard is keyed on the parent conversation, so it only covers the same-session race (which is the common one in practice, since the review fork is spawned from a specific parent turn). The three cross-writer sources you identified bypass the conversation token entirely. Agreed that memory's store-layer reload-before-mutate already fails closed for the destructive cases there, so the residual is thin. The exposed surface is skill overwrite from a concurrent session or cron-spawned review, where For now I'll go with option 1 and document the scope in the guard's docstring. A complementary store-level check for the skill overwrite path is a good follow-up but feels like a separate concern from this PR's same-session guard. Also noted the fail-open edge on the Rebased onto current main to resolve the conflict (upstream added memory notification tests in the same file). |
d1afd62 to
3d60c80
Compare
|
Context: #55906 (merged) added a read-before-write invariant for background-review skill writes, closing the corruption path in #55647. This PR addresses a different failure mode — a stale-state race where the review writes after the parent conversation has moved past the snapshot (#9055). Not superseded by #55906; leaving open for separate review. |
8f853ba to
0a0b151
Compare
teknium1
left a comment
There was a problem hiding this comment.
Thanks for isolating the same-session stale-review race. The premise still exists on current main: agent/turn_finalizer.py:500-508 starts a daemon review from list(messages), while the parent later refreshes _session_messages (run_agent.py:1693).
Problems
agent/background_review.py:80-86only treats top-levelactionas a memory mutation.tools/memory_tool.py:959-997also acceptsoperations=[...]with no top-level action and persists it throughapply_batch(), so a stale batched memory write bypasses this guard.agent/background_review.py:689falls back tomessages_snapshotif live state is absent. That makes the comparison succeed and permits the write, rather than failing closed.
Suggested changes
- Recognize mutating entries in
memory.operationsand add a stale-batch regression through the real pre-tool dispatch path. - Block when
_session_messagesis unavailable or not a valid live message list.
Automated hermes-sweeper review.
0a0b151 to
eb363a0
Compare
Summary
Background self-improvement review can write memory or skill updates after the parent agent has moved on to a new conversation turn. This adds a live-state guard to the background review tool dispatch path that classifies both memory payload forms and fails closed when the parent state is unavailable.
Write-capable memory calls now include top-level
add,replace, andremoveplus every nonemptyoperationsbatch — the batch form thatmemory_tool()routes toapply_batch(). All sixskill_managewrite actions (create,edit,patch,delete,write_file,remove_file) require a matching parent snapshot.skill_viewandskills_listpass through unchanged.A write proceeds only when the parent exposes a valid live message list whose canonical token matches the review snapshot. A missing, non-list, or non-dict-containing
_session_messagesblocks the write rather than falling back to the snapshot.Changes
agent/background_review.py: add payload classifier for both memory forms and all sixskill_managewrite actions, add fail-closed freshness predicate, install callback viaset_thread_tool_whitelist, keep cleanup in the existingfinally(~+80 lines net)hermes_cli/plugins.py: extendset_thread_tool_whitelistwith optionalblock_callback, clear it inclear_thread_tool_whitelist, invoke it in_get_pre_tool_call_directive_detailsafter the whitelist admit-check and convert a non-empty string result to a block directive (~+20 lines)tests/run_agent/test_background_review.py: add regression coverage for memory batch writes (all fouroperationsmutation shapes), all four invalid parent-state variants, absent_session_messagesattribute, matching-state single-action and batch writes, and read-only skill calls, all routed through_run_agent_tool_execution_middlewareand the installed callback (~+180 lines)tests/run_agent/test_background_review_toolset_restriction.py: extend_capture_whiteliststub to accept and assertblock_callbackwithout changing whitelist membership assertions (~+3 lines)Validation
operations)apply_batch()_session_messagesabsent_session_messagesisNone, dict, string, or list with non-dict itemskill_vieworskills_listpytest tests/run_agent/test_background_review.py tests/run_agent/test_background_review_toolset_restriction.py -v --timeout=0— 28 passedNot in scope
This PR does not serialize cross-session, cron, or sibling-review writers. PR #55906 separately requires background review to read the exact skill target before writing; it covers a different cross-writer path and does not supersede this same-parent freshness guard.
Closes #9055.
Reported by @yexxx.