fix(memory/hindsight): flush buffered turns on session exit - #55936
fix(memory/hindsight): flush buffered turns on session exit#55936bernardokyotoku wants to merge 1 commit into
Conversation
When retain_every_n_turns > 1, sync_turn only enqueues a retain every Nth turn. Turns 1..N-1 accumulate in _session_turns and are silently discarded on exit (Ctrl-D, /quit) -- on_session_switch already flushes them on /reset, /new, /branch, but the exit path goes through on_session_end + shutdown, and the base on_session_end is a no-op while shutdown only drains already-queued work. This adds on_session_end to flush the unretained delta through the same writer queue so shutdown drain picks it up before teardown. The flush logic is extracted into a shared _enqueue_buffered_flush helper, which on_session_switch is refactored to use as well -- removing 36 lines of duplication. on_session_switch shrinks from 127 to 82 lines. Key design choices: - on_session_end sends only the unretained delta (turns since _last_retained_turn_count) to avoid duplicates with append mode. on_session_switch sends all turns since it clears the buffer. - Document/session resolution stays in the caller (switch must resolve before session rotation; exit uses current state). - No-op when retain_every_n_turns=1 (default): _session_turns is empty at exit because every turn was already retained. - Best-effort: failures are caught and logged as warnings, same as on_session_switch. No crash or data corruption on API failure. All 116 existing Hindsight plugin tests pass.
Duplicate of #36219 — same |
|
Thanks for targeting a real buffered-turn loss on shutdown. Current main leaves partial batches in Problems
Suggested changes
Automated hermes-sweeper review. |
|
Heads up on an interaction with #41911 — this PR as written would reintroduce a duplicate-turn bug that PR fixes. The flush added here snapshots the whole buffer: old_turns = list(self._session_turns)In append mode ( I measured it on a local build carrying this patch — driving turns through Turns 1–20 stored twice. With the default The fix mirrors #41911 — resolve the retain target first, then slice: old_document_id, old_update_mode = self._resolve_retain_target(self._document_id)
if old_update_mode == "append":
old_turns = self._session_turns[self._last_retained_turn_count:]
else:
old_turns = list(self._session_turns)Two things that matter alongside it:
I verified the legacy/overwrite path must not be sliced — there each retain replaces the document, so the flush has to carry the whole session (23 turns → 73 shipped, 23 unique, all present). The Since both hooks want byte-identical logic, it may be worth factoring the slice into a small shared helper rather than duplicating it, depending on which of these lands first. Not a criticism of the hook itself — the data-loss problem it solves is real, unflushed turns between the last boundary and session end genuinely do vanish today. Just worth landing it with the slice so it doesn't trade a loss bug for a duplication bug. |
Problem
When
retain_every_n_turns > 1,sync_turn()only enqueues a retain every Nth turn. Turns 1..N-1 accumulate in_session_turnsand are silently discarded on exit (Ctrl-D,/quit,/exit).The plugin already handles this for session switches —
on_session_switch()flushes buffered turns before clearing (it explicitly calls out this data-loss class in its docstring). But the exit path goes throughon_session_end()+shutdown():on_session_end()— base class implementation is a no-op (Hindsight plugin does not override it)shutdown()— only drains already-queued retains from the writer thread; does not flush buffered-but-unqueued turns from_session_turnsFix
Adds
on_session_end()toHindsightMemoryProviderthat flushes the unretained delta through the same writer queue. Sinceon_session_endis called beforeshutdown_allin the exit sequence (cli.py:1049→run_agent.py:3141), the flush is enqueued first, thenshutdown()'s sentinel follows — the writer processes the flush before exiting (FIFO).Refactor
The flush logic is extracted into a shared
_enqueue_buffered_flush()helper. Bothon_session_endandon_session_switchnow call it, removing 36 lines of duplication.on_session_switchshrinks from 127 to 82 lines.The helper handles: metadata building, lineage tags, content serialization, the
_flushclosure, and writer-queue routing. The caller handles:on_session_endsends only the unretained delta (avoids append-mode duplicates);on_session_switchsends all turns (it clears the buffer anyway)on_session_switchmust resolve before session rotation;on_session_enduses current stateDesign choices
retain_every_n_turns=1(default):_session_turnsis empty at exit because every turn was already retained viasync_turn. The first guard (if not self._session_turns: return) makes the patch zero-risk for the default config.on_session_endslices from_last_retained_turn_countto avoid duplicating turns the server already has. In overwrite mode (legacy servers), it resends everything.on_session_switch. No crash or data corruption if the API is down._session_turns: the provider is being torn down, not rotated. No need to reset state that's about to be destroyed.Verification
All 116 existing Hindsight plugin tests pass, including all 5
TestSessionSwitchBufferFlushtests that verify the refactoredon_session_switchstill flushes correctly.Not covered by this patch
The
atexithook (_atexit_shutdown, line 1143) callsshutdown()directly, bypassingon_session_end. This is a pre-existing safety-net limitation for unclean interpreter exits — fixing it would require registering the flush in the atexit handler, which is a separate change.