fix: run CLI new-session memory flush off-thread - #16454
Conversation
|
Context for reviewers: this is one of the small follow-up slices from the closed broad host-support PR #13370. Teknium salvaged the compression-boundary signal into #16306, and the remaining host-side pieces from #13370 are being resubmitted as narrow, independent PRs instead of reviving the original large PR. This slice is intentionally limited to its described scope; it does not vendor or bundle any external context-engine implementation. |
537954d to
ae53e42
Compare
ae53e42 to
b5aaf8a
Compare
21851b6 to
831cbd6
Compare
|
Re-reviewed this against latest Why:
I rebased the PR onto current
Validation:
@Teknium can you take another look when you have a chance? |
2522c57 to
7d87972
Compare
7d87972 to
3e82a86
Compare
|
Small ping with the correct handle: @teknium1 can you take another look when you have a chance? I re-reviewed this against current |
|
Thanks @Tosko4 — this fix is landing via #61139, which cherry-picks your commit with authorship preserved and builds on it. Your diagnosis was exactly right: Closing this in favor of #61139 (auto-merge armed, rebase — your commit lands under your name). Appreciate the contribution! |
Deep review of the cherry-picked #16454 found the ad-hoc flush thread raced new_session()'s inline on_session_switch(reset=True): memory providers key off internal _session_id state (MemoryManager.on_session_end takes no session id), so a late off-thread extraction ran against post-rotation bindings — misattributing the old transcript to the new session id, double-ingesting the old turn buffer (supermemory), or double-committing (openviking already async-finalizes in on_session_switch). Redesign: new MemoryManager.commit_session_boundary_async queues on_session_end + on_session_switch as ONE task on the manager's existing single-worker background executor (the same worker sync_all already uses). This preserves the strict end→switch ordering providers depend on, serializes against per-turn syncs FIFO, keeps /new non-blocking, and degrades to inline (pre-#16454 behavior) when the executor is unavailable. No ad-hoc threads; no per-provider changes needed. The context-engine on_session_end half stays synchronous in _launch_session_boundary_memory_flush (cheap, must land before reset_session_state rebinds the engine). Exit durability: _run_cleanup calls the manager's existing flush_pending(timeout=10) barrier before shutdown, so '/new then quit' doesn't drop the queued extraction (shutdown_all's own drain is ~5s and cancels queued tasks). Bounded well inside the 30s exit watchdog. Tests: ordering invariant with slow (LLM-like) extraction, FIFO serialization vs sync_all, switch-fires-even-if-end-raises, no-provider no-op, CLI snapshot handoff + inline-switch fallback, sync engine boundary, cleanup flush_pending.
Deep review of the cherry-picked NousResearch#16454 found the ad-hoc flush thread raced new_session()'s inline on_session_switch(reset=True): memory providers key off internal _session_id state (MemoryManager.on_session_end takes no session id), so a late off-thread extraction ran against post-rotation bindings — misattributing the old transcript to the new session id, double-ingesting the old turn buffer (supermemory), or double-committing (openviking already async-finalizes in on_session_switch). Redesign: new MemoryManager.commit_session_boundary_async queues on_session_end + on_session_switch as ONE task on the manager's existing single-worker background executor (the same worker sync_all already uses). This preserves the strict end→switch ordering providers depend on, serializes against per-turn syncs FIFO, keeps /new non-blocking, and degrades to inline (pre-NousResearch#16454 behavior) when the executor is unavailable. No ad-hoc threads; no per-provider changes needed. The context-engine on_session_end half stays synchronous in _launch_session_boundary_memory_flush (cheap, must land before reset_session_state rebinds the engine). Exit durability: _run_cleanup calls the manager's existing flush_pending(timeout=10) barrier before shutdown, so '/new then quit' doesn't drop the queued extraction (shutdown_all's own drain is ~5s and cancels queued tasks). Bounded well inside the 30s exit watchdog. Tests: ordering invariant with slow (LLM-like) extraction, FIFO serialization vs sync_all, switch-fires-even-if-end-raises, no-provider no-op, CLI snapshot handoff + inline-switch fallback, sync engine boundary, cleanup flush_pending.
Deep review of the cherry-picked NousResearch#16454 found the ad-hoc flush thread raced new_session()'s inline on_session_switch(reset=True): memory providers key off internal _session_id state (MemoryManager.on_session_end takes no session id), so a late off-thread extraction ran against post-rotation bindings — misattributing the old transcript to the new session id, double-ingesting the old turn buffer (supermemory), or double-committing (openviking already async-finalizes in on_session_switch). Redesign: new MemoryManager.commit_session_boundary_async queues on_session_end + on_session_switch as ONE task on the manager's existing single-worker background executor (the same worker sync_all already uses). This preserves the strict end→switch ordering providers depend on, serializes against per-turn syncs FIFO, keeps /new non-blocking, and degrades to inline (pre-NousResearch#16454 behavior) when the executor is unavailable. No ad-hoc threads; no per-provider changes needed. The context-engine on_session_end half stays synchronous in _launch_session_boundary_memory_flush (cheap, must land before reset_session_state rebinds the engine). Exit durability: _run_cleanup calls the manager's existing flush_pending(timeout=10) barrier before shutdown, so '/new then quit' doesn't drop the queued extraction (shutdown_all's own drain is ~5s and cancels queued tasks). Bounded well inside the 30s exit watchdog. Tests: ordering invariant with slow (LLM-like) extraction, FIFO serialization vs sync_all, switch-fires-even-if-end-raises, no-provider no-op, CLI snapshot handoff + inline-switch fallback, sync engine boundary, cleanup flush_pending.
Deep review of the cherry-picked NousResearch#16454 found the ad-hoc flush thread raced new_session()'s inline on_session_switch(reset=True): memory providers key off internal _session_id state (MemoryManager.on_session_end takes no session id), so a late off-thread extraction ran against post-rotation bindings — misattributing the old transcript to the new session id, double-ingesting the old turn buffer (supermemory), or double-committing (openviking already async-finalizes in on_session_switch). Redesign: new MemoryManager.commit_session_boundary_async queues on_session_end + on_session_switch as ONE task on the manager's existing single-worker background executor (the same worker sync_all already uses). This preserves the strict end→switch ordering providers depend on, serializes against per-turn syncs FIFO, keeps /new non-blocking, and degrades to inline (pre-NousResearch#16454 behavior) when the executor is unavailable. No ad-hoc threads; no per-provider changes needed. The context-engine on_session_end half stays synchronous in _launch_session_boundary_memory_flush (cheap, must land before reset_session_state rebinds the engine). Exit durability: _run_cleanup calls the manager's existing flush_pending(timeout=10) barrier before shutdown, so '/new then quit' doesn't drop the queued extraction (shutdown_all's own drain is ~5s and cancels queued tasks). Bounded well inside the 30s exit watchdog. Tests: ordering invariant with slow (LLM-like) extraction, FIFO serialization vs sync_all, switch-fires-even-if-end-raises, no-provider no-op, CLI snapshot handoff + inline-switch fallback, sync engine boundary, cleanup flush_pending.
…search#31597) Redesign of the original patch against the current boundary lifecycle, per the hermes-sweeper review on PR NousResearch#31609: - compression: snapshot is captured before compress(), but the review is spawned only after the abort / no-op guards — an aborted compression retains the transcript and never pays the review cost - reset now covers all three surfaces: CLI HermesCLI.new_session, TUI _reset_session_agent, gateway _handle_reset_command - session_end covers CLI exit (bounded join), TUI _finalize_session, and gateway session expiry - CLI /new: the provider end→switch ordering contract (NousResearch#16454) is untouched — the review runs on its own fork with its own snapshot copy, gated centrally in maybe_spawn_boundary_review()
…kes) (#438) Three wall-clock flakes hit in a single day. They share one defect: the test proves a CONCURRENCY or NON-BLOCKING property by measuring elapsed real time, which makes the OS scheduler part of the assertion. Under load the inequality flips with nothing wrong in the code under test. The worst one blocked the merge queue: tests/gateway/test_session_hygiene.py failed `assert elapsed < 2.0` with 2.005265276999978 -- five milliseconds -- while sitting at the head of a queue for a PR touching zero gateway files. Profiling that test shows the bound was not even measuring the behavior it protects: ~1.5s of the 2.3s window is models_dev.fetch_models_dev -> _save_disk_cache -> atomic_json_write (~600k json-encoder calls), i.e. one-time provider-metadata cache work. Instrumented runs on an IDLE box measured 1.40s, 1.87s and 2.39s -- the 2.0 threshold sits inside the natural distribution, so the failure was not an outlier. This applies the pattern from #426 (barrier, not stopwatch) to the rest of the family: - tests/gateway/test_session_hygiene.py -- ordering witness: an Event set in a finally on every worker exit path, asserted UNSET when the handler returns. - tests/agent/test_context_refs_concurrent.py -- asyncio.Barrier(3): all three @url: fetches must be in flight at once before any may return. - tests/tools/test_mcp_tool.py -- threading.Barrier(3) for parallel shutdown. - tests/plugins/memory/test_mem0_rerank_guard.py -- alert_returned witness replaces `elapsed < 0.1` across a 16-thread pool. - tests/agent/test_memory_boundary_commit.py -- assert the provider recorded NOTHING yet, proving /new was not gated on the slow extraction. Every conversion keeps a finite barrier/wait timeout (10-30s) so a genuine regression fails fast instead of hanging, but that is orders of magnitude above real rendezvous latency, so it is not itself a timing assertion. RED-PROVEN: each was verified by breaking the behavior it protects and confirming the new assertion fails by name -- removing the wait_for timeout wiring, replacing asyncio.gather with a serial loop (x2), making observe() join the alert thread, and running the boundary commit inline (the pre-NousResearch#16454 blocking bug). Two of the five first drafts could NOT fail and were caught and strengthened by that exercise. All source mutations reverted; this diff is test-only. Side effect: the barrier forms delete fixed sleeps, so test_mcp_tool shutdown drops ~1s -> ~0.1s and the mem0 rerank test 7.0s -> 1.7s. Deliberately NOT changed: hang-guards. `assert ev.wait(timeout=5)` asserts the EVENT, not the duration; lower bounds like `assert elapsed >= 0.04` prove an injected wait happened and get MORE reliable under load; and ceilings an order of magnitude above the hang they guard fail only on real regressions. The rule applied: convert when load can cause failure, leave alone when only a real regression can. Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Deep review of the cherry-picked NousResearch#16454 found the ad-hoc flush thread raced new_session()'s inline on_session_switch(reset=True): memory providers key off internal _session_id state (MemoryManager.on_session_end takes no session id), so a late off-thread extraction ran against post-rotation bindings — misattributing the old transcript to the new session id, double-ingesting the old turn buffer (supermemory), or double-committing (openviking already async-finalizes in on_session_switch). Redesign: new MemoryManager.commit_session_boundary_async queues on_session_end + on_session_switch as ONE task on the manager's existing single-worker background executor (the same worker sync_all already uses). This preserves the strict end→switch ordering providers depend on, serializes against per-turn syncs FIFO, keeps /new non-blocking, and degrades to inline (pre-NousResearch#16454 behavior) when the executor is unavailable. No ad-hoc threads; no per-provider changes needed. The context-engine on_session_end half stays synchronous in _launch_session_boundary_memory_flush (cheap, must land before reset_session_state rebinds the engine). Exit durability: _run_cleanup calls the manager's existing flush_pending(timeout=10) barrier before shutdown, so '/new then quit' doesn't drop the queued extraction (shutdown_all's own drain is ~5s and cancels queued tasks). Bounded well inside the 30s exit watchdog. Tests: ordering invariant with slow (LLM-like) extraction, FIFO serialization vs sync_all, switch-fires-even-if-end-raises, no-provider no-op, CLI snapshot handoff + inline-switch fallback, sync engine boundary, cleanup flush_pending.
Deep review of the cherry-picked NousResearch#16454 found the ad-hoc flush thread raced new_session()'s inline on_session_switch(reset=True): memory providers key off internal _session_id state (MemoryManager.on_session_end takes no session id), so a late off-thread extraction ran against post-rotation bindings — misattributing the old transcript to the new session id, double-ingesting the old turn buffer (supermemory), or double-committing (openviking already async-finalizes in on_session_switch). Redesign: new MemoryManager.commit_session_boundary_async queues on_session_end + on_session_switch as ONE task on the manager's existing single-worker background executor (the same worker sync_all already uses). This preserves the strict end→switch ordering providers depend on, serializes against per-turn syncs FIFO, keeps /new non-blocking, and degrades to inline (pre-NousResearch#16454 behavior) when the executor is unavailable. No ad-hoc threads; no per-provider changes needed. The context-engine on_session_end half stays synchronous in _launch_session_boundary_memory_flush (cheap, must land before reset_session_state rebinds the engine). Exit durability: _run_cleanup calls the manager's existing flush_pending(timeout=10) barrier before shutdown, so '/new then quit' doesn't drop the queued extraction (shutdown_all's own drain is ~5s and cancels queued tasks). Bounded well inside the 30s exit watchdog. Tests: ordering invariant with slow (LLM-like) extraction, FIFO serialization vs sync_all, switch-fires-even-if-end-raises, no-provider no-op, CLI snapshot handoff + inline-switch fallback, sync engine boundary, cleanup flush_pending.
Summary
/newold-session memory extraction onto a daemon background threadsession_idbefore rotating/clearing the sessionAIAgent.commit_memory_session(...)finalize an explicit old session id so async callers do not race with the new session idWhy
Starting a fresh CLI session should not block on potentially slow memory extraction from the previous session. Current
mainstill callscommit_memory_session(...)synchronously at the top ofHermesCLI.new_session(), and #32234 now reports the exact user-facing hang this PR was originally meant to avoid.This keeps
/newresponsive while preserving best-effort old-session memory/context-engine extraction.Fixes #32234.
Validation
python -m pytest tests/cli/test_cli_new_session.py tests/run_agent/test_commit_memory_session_context_engine.py -q --tb=short—15 passed./scripts/run_tests.sh tests/cli/test_cli_new_session.py tests/run_agent/test_commit_memory_session_context_engine.py -q --tb=short—15 passedpython -m compileall -q cli.py run_agent.py tests/cli/test_cli_new_session.py tests/run_agent/test_commit_memory_session_context_engine.pyruff check cli.py run_agent.py tests/cli/test_cli_new_session.py tests/run_agent/test_commit_memory_session_context_engine.py— passed (existing warning only: invalid historical# noqaonrun_agent.py:107)git diff --checkNotes
main(d577408f3)flush_memories(...)is no longer part of the liveAIAgentpath, so this now offloadscommit_memory_session(...)directly and pins the ended session id for the context-engine hook