Session transcript persistence and signal-handling gaps
NousResearch/hermes-agent · cluster 5021 · agent
Data as of
Nothing open on GitHub. Fix merged in #46071. Check it covers your case.
The short answer
- Known?
- Yes. 27 related items grouped here, 11 issues already closed.
- Fix event
- A fix was merged to main in PR #46071. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #50004, #50299, #56279. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes concern conversation messages and session rows not being reliably written to state.db, particularly on process termination signals (SIGTERM, SIGHUP, Ctrl+C), gateway restarts, or exception paths. The work spans the agent, CLI, gateway, and TUI layers.
Open issues0
Open PRs0
Fixes on record
-
#46071 fix(agent): persist repaired-turn responses
files:
run_agent.py,tests/run_agent/test_compression_persistence.py,tests/run_agent/test_identity_flush.pyShow the diff
-
run_agent.py@@ -1548,9 +1548,10 @@ def _repair_message_sequence(self, messages: List[Dict]) -> int: def _flush_messages_to_session_db(self, messages: List[Dict], conversation_history: List[Dict] = None): """Persist any un-flushed messages to the SQLite session store. - Uses _last_flushed_db_idx to track which messages have already been - written, so repeated calls (from multiple exit paths) only write - truly new messages — preventing the duplicate-write bug (#860). + Uses per-session message identity tracking so repeated calls (from + multiple exit paths) only write truly new messages — preventing the + duplicate-write bug (#860) without relying on positional slices that + can drift after message-sequence repair. """ if not self._session_db: return @@ -1559,14 +1560,41 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo # Retry row creation if the earlier attempt failed transiently. if not self._session_db_created: self._ensure_db_session() - start_idx = len(conversation_history) if conversation_history else 0 - # Guard against the flush cursor overshooting the message list. - # This can happen when repair_message_sequence compacts the list - # (merging consecutive users, dropping stray tools) after the - # cursor was set. Fall back to start_idx so we don't skip - # persisting the assistant/tool chain (#44837). - flush_from = max(start_idx, min(self._last_flushed_db_idx, len(messages))) - for msg in messages[flush_from:]: + # Positional flushing used to slice at + # max(len(conversation_history), _last_flushed_db_idx). That + # assumes the live `messages` list is the original history plus a + # new tail. repair_message_sequence can shrink/merge the history + # copy before the final flush, making len(conversation_history) + # larger than len(messages); the slice is then empty and delivered + # assistant responses never reach state.db (#46053). + # + # Track object identities instead. `messages` is a shallow copy of + # `conversation_history`, so history dicts are skipped by identity, + # and new dicts appended during this turn are written once even if + # repair compacts the list around them. + current_session_id = getattr(self, "session_id", None) + flushed_session_id = getattr(self, "_flushed_db_message_session_id", None) + if flushed_session_id != current_session_id or self._last_flushed_db_idx == 0: + self._flushed_db_message_ids = set() + self._flushed_db_message_session_id = current_session_id + flushed_ids = getattr(self, "_flushed_db_message_ids", None) + if not isinstance(flushed_ids, set): + flushed_ids = set() + self._flushed_db_message_ids = flushed_ids + history_ids = { + id(item) for item in (conversation_history or []) + if isinstance(item, dict) + } + + for msg in messages: + if not isinstance(msg, dict): + continue + msg_id = id(msg) + if msg_id in flushed_ids: + continue + if msg_id in history_ids: + flushed_ids.add(msg_id) + continue role = msg.get("role", "unknown") content = msg.get("content") # Persist multimodal tool results as their text summary only — @@ -1605,6 +1633,7 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo codex_reasoning_items=msg.get("codex_reasoning_items") if role == "assistant" else None, codex_message_items=msg.get("codex_message_items") if role == "assistant" else None, ) + flushed_ids.add(msg_id) self._last_flushed_db_idx = len(messages) except Exception as e: logger.warning("Session DB append_message failed: %s", e) -
tests/run_agent/test_compression_persistence.py@@ -101,7 +101,7 @@ def test_flush_after_compression_with_long_history(self): ) def test_flush_with_stale_history_loses_messages(self): - """Demonstrates the bug condition: stale conversation_history causes data loss.""" + """Stale conversation_history no longer causes data loss.""" from hermes_state import SessionDB with tempfile.TemporaryDirectory() as tmpdir: @@ -120,17 +120,14 @@ def test_flush_with_stale_history_loses_messages(self): {"role": "assistant", "content": "continuing..."}, ] - # Bug: passing a conversation_history longer than compressed messages + # Stale history longer than messages: the old positional flush + # sliced past the end and dropped both messages (#46053). stale_history = [{"role": "user", "content": f"msg{i}"} for i in range(100)] agent._flush_messages_to_session_db(compressed, stale_history) rows = db.get_messages("new-session") - # With the stale history, flush_from = max(100, 0) = 100 - # But compressed only has 2 entries → messages[100:] = empty - assert len(rows) == 0, ( - "Expected 0 messages with stale conversation_history " - "(this test verifies the bug condition exists)" - ) + assert len(rows) == 2 + assert [row["content"] for row in rows] == ["summary", "continuing..."] # --------------------------------------------------------------------------- -
tests/run_agent/test_identity_flush.py@@ -0,0 +1,150 @@ +"""Regression tests for identity-based SessionDB flushing (#46053).""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import patch + +SESSION_ID = "test-identity-flush" + + +def _make_agent(session_db, session_id=SESSION_ID): + with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}): + from run_agent import AIAgent + + agent = AIAgent( + api_key="[redacted:secret-assignment]", + base_url="https://openrouter.ai/api/v1", + model="test/model", + quiet_mode=True, + session_db=session_db, + session_id=session_id, + skip_context_files=True, + skip_memory=True, + ) + agent._ensure_db_session() + return agent + + +def _contents(db, session_id=SESSION_ID): + return [row["content"] for row in db.get_messages(session_id)] + + +class TestIdentityFlush: + def test_repair_shrunk_messages_below_history_length_still_persists_assistant(self): + """When repair shortens messages below conversation_history, don't slice empty.""" + from hermes_state import SessionDB + + with tempfile.TemporaryDirectory() as tmpdir: + db = SessionDB(db_path=Path(tmpdir) / "t.db") + try: + agent = _make_agent(db) + + # Simulate history already loaded from state.db. + history = [{"role": "user", "content": f"u{i}"} for i in range(6)] + for msg in history: + db.append_message( + session_id=SESSION_ID, + role=msg["role"], + content=msg["content"], + ) + + # repair_message_sequence merged the six history rows into one + # dict before this turn appended the new user/assistant pair. + messages = [ + {"role": "user", "content": "\n\n".join(f"u{i}" for i in range(6))}, + {"role": "user", "content": "new question"}, + {"role": "assistant", "content": "new answer"}, + ] + assert len(history) > len(messages) + + # The old positional flush computed flush_from >= len(messages) + # and dropped the assistant. Identity flush persists new dicts. + agent._last_flushed_db_idx = len(history) + agent._flush_messages_to_session_db(messages, history) + + contents = _contents(db) + assert "new question" in contents + assert "new answer" in contents + finally: + db.close() + + def test_overlapping_turn_stale_cursor_does_not_drop_assistant(self): + """A stale cached-agent cursor must not suppress this turn's new dicts.""" + from hermes_state import SessionDB + + with tempfile.TemporaryDirectory() as tmpdir: + db = SessionDB(db_path=Path(tmpdir) / "t.db") + try: + agent = _make_agent(db) + history = [ + {"role": "user", "content": "old question"}, + {"role": "assistant", "content": "old answer"}, + ] + for msg in history: + db.append_message( + session_id=SESSION_ID, + role=msg["role"], + content=msg["content"], + ) + + messages = history + [ + {"role": "user", "content": "current question"}, + {"role": "assistant", "content": "current answer"}, + ] + agent._last_flushed_db_idx = len(messages) + 10 + agent._flush_messages_to_session_db(messages, history) + + assert _contents(db) == [ + "old question", + "old answer", + "current question", + "current answer", + ] + finally: + db.close() + + def test_repeated_flush_same_turn_writes_once(self): + """Identity tracking preserves #860 same-turn dedup behavior.""" + from hermes_state import SessionDB + + with tempfile.TemporaryDirectory() as tmpdir: + db = SessionDB(db_path=Path(tmpdir) / "t.db") + try: + agent = _make_agent(db) + messages = [{"role": "user", "content": "q"}] + + agent._flush_messages_to_session_db(messages, []) + messages.append({"role": "assistant", "content": "a"}) + agent._flush_messages_to_session_db(messages, []) + agent._flush_messages_to_session_db(messages, []) + + assert _contents(db) == ["q", "a"] + finally: + db.close() + + def test_cursor_reset_starts_new_turn_identity_window(self): + """Gateway resets _last_flushed_db_idx=0 before a cached-agent turn.""" + from hermes_state import SessionDB + + with tempfile.TemporaryDirectory() as tmpdir: + db = SessionDB(db_path=Path(tmpdir) / "t.db") + try: + agent = _make_agent(db) + first_turn = [ + {"role": "user", "content": "q1"}, + {"role": "assistant", "content": "a1"}, + ] + agent._flush_messages_to_session_db(first_turn, []) + + history = [dict(m) for m in first_turn] + second_turn = history + [ + {"role": "user", "content": "q2"}, + {"role": "assistant", "content": "a2"}, + ] + agent._last_flushed_db_idx = 0 + agent._flush_messages_to_session_db(second_turn, history) + + assert _contents(db) == ["q1", "a1", "q2", "a2"] + finally: + db.close()
-
-
#50004 fix(cli): persist interrupted session transcript on SIGHUP/SIGTERM/window-close
files:
cli.py,tests/cli/test_cli_shutdown_memory_messages.py -
#50299 fix(session): finalize owned SQLite session rows on AIAgent.close() (#12029)
files:
agent/agent_init.py,agent/background_review.py,gateway/run.py,run_agent.py,tests/gateway/test_busy_session_ack.py,tests/run_agent/test_background_review.py,tests/tools/test_zombie_process_cleanup.pyShow the diff
-
agent/agent_init.py@@ -1096,6 +1096,12 @@ def init_agent( agent._parent_session_id = parent_session_id agent._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes agent._session_db_created = False # DB row deferred to run_conversation() + # Most agents own their session row and should finalize it on close(). + # Some temporary helper agents (manual compression / session-hygiene / + # background-review forks) rotate or share the session forward to a + # continuation row that must remain open after the helper is torn down; + # those callers explicitly set this flag to False. + agent._end_session_on_close = True agent._session_init_model_config = { "max_iterations": agent.max_iterations, "reasoning_config": reasoning_config, -
agent/background_review.py@@ -575,6 +575,13 @@ def _bg_review_auto_deny(command, description, **kwargs): # if a future code path bypasses the cache. review_agent.session_start = agent.session_start review_agent.session_id = agent.session_id + # The fork shares the parent's live session_id (pinned above for + # prefix-cache parity). It is single-lifecycle and calls close() + # right after this run_conversation(); without opting out, close() + # would finalize the parent's still-active session row mid + # conversation (the review fires every ~10 turns). Leave session + # finalization to the real owner (CLI close / gateway reset / cron). + review_agent._end_session_on_close = False # Never let the review fork compress. It shares the parent's # session_id, so if it won a compression race it would rotate the # parent into a NEW child that the gateway never adopts (the fork -
gateway/run.py@@ -4686,6 +4686,27 @@ def _finalize_shutdown_agents(self, active_agents: Dict[str, Any]) -> None: pass self._cleanup_agent_resources(agent) + def _should_emit_long_running_notification( + self, + session_key: Optional[str], + agent: Any, + executor_task: Optional[Any], + ) -> bool: + """Only emit the heartbeat while this task still owns the live run. + + Guards against a stale ``running: delegate_task`` heartbeat outliving the + run that started it: stop once the executor finishes, the agent is gone, + or the session key has been rebound to a different live agent (e.g. the + user sent ``/new`` and a fresh agent took the slot mid-run, #12029). + """ + if agent is None: + return False + if executor_task is not None and executor_task.done(): + return False + if session_key and self._running_agents.get(session_key) is not agent: + return False + return True + def _cleanup_agent_resources(self, agent: Any) -> None: """Best-effort cleanup for temporary or cached agent instances.""" if agent is None: @@ -9194,6 +9215,13 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g session_id=session_entry.session_id, ) try: + # The hygiene agent rotates the session + # forward to a continuation id that becomes + # the gateway session's live row. It must + # never finalize on close() (today it has no + # session_db so close() no-ops, but this + # guards a future where one is wired in). + _hyg_agent._end_session_on_close = False _hyg_agent._print_fn = lambda *a, **kw: None loop = asyncio.get_running_loop() @@ -16274,6 +16302,20 @@ async def _notify_long_running(): _heartbeat_msg_id: Optional[str] = None while True: await asyncio.sleep(_NOTIFY_INTERVAL) + # Stop heartbeating once this run no longer owns the session + # slot or the executor has finished — otherwise a stale + # "running: delegate_task" bubble can outlive the run that + # spawned it (#12029). _executor_task is a closure var bound + # just after this task is scheduled; tolerate the brief window + # before then (the first wake is _NOTIFY_INTERVAL away anyway). + try: + _exec_ref = _executor_task + except NameError: + _exec_ref = None + if not self._should_emit_long_running_notification( + session_key, agent_holder[0], _exec_ref + ): + break _elapsed_mins = int((time.time() - _notify_start) // 60) # Include agent activity context if available. Default # heartbeat is terse: elapsed + current tool. Verbose -
run_agent.py@@ -3250,6 +3250,22 @@ def close(self) -> None: except Exception: pass + # 7. Finalize the owned SQLite session row unless this agent is only a + # temporary helper that deliberately handed session ownership forward + # (manual compression helpers that rotate to a continuation session_id, + # or background-review forks that share the live parent's session_id and + # must leave it open). end_session() is first-reason-wins and no-ops on + # an already-ended row, so this never clobbers a 'compression' / + # 'cron_complete' / 'cli_close' reason set by an earlier terminal path. + try: + if getattr(self, "_end_session_on_close", True): + session_db = getattr(self, "_session_db", None) + session_id = getattr(self, "session_id", None) + if session_db and session_id: + session_db.end_session(session_id, "agent_close") + except Exception: + pass + def _hydrate_todo_store(self, history: List[Dict[str, Any]]) -> None: """ Recover todo state from conversation history. -
tests/gateway/test_busy_session_ack.py@@ -715,3 +715,62 @@ async def test_queue_mode_hint_points_to_interrupt(self, tmp_path, monkeypatch): assert "/busy interrupt" in content # Must NOT tell the user to /busy queue when they're already on queue. assert "/busy queue" not in content + + +class TestLongRunningNotificationOwnership: + """The long-running heartbeat must stop once its run no longer owns the + session slot or the executor finished — otherwise a stale + 'running: delegate_task' bubble outlives the run that spawned it (#12029). + """ + + def test_notification_stops_after_session_ownership_moves(self): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner._running_agents = {} + + original_agent = MagicMock() + replacement_agent = MagicMock() + runner._running_agents["sess"] = replacement_agent + + assert runner._should_emit_long_running_notification( + "sess", original_agent, executor_task=None + ) is False + + def test_notification_stops_after_executor_finishes(self): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + agent = MagicMock() + runner._running_agents = {"sess": agent} + + done_task = MagicMock() + done_task.done.return_value = True + + assert runner._should_emit_long_running_notification( + "sess", agent, executor_task=done_task + ) is False + + def test_notification_stops_when_agent_is_gone(self): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner._running_agents = {} + + assert runner._should_emit_long_running_notification( + "sess", None, executor_task=None + ) is False + + def test_notification_continues_for_live_active_run(self): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + agent = MagicMock() + runner._running_agents = {"sess": agent} + + live_task = MagicMock() + live_task.done.return_value = False + + assert runner._should_emit_long_running_notification( + "sess", agent, executor_task=live_task + ) is True -
tests/run_agent/test_background_review.py@@ -76,6 +76,50 @@ def close(self): ] +def test_background_review_fork_opts_out_of_session_finalization(monkeypatch): + """The review fork shares the parent's live session_id, so it must set + ``_end_session_on_close = False``. Otherwise close() (now finalizing owned + session rows) would end the still-active parent session mid-conversation + every time the review fires (~every 10 turns). Regression for #12029. + """ + seen = {} + + class FakeReviewAgent: + def __init__(self, **kwargs): + self._session_messages = [] + # Default matches AIAgent.__init__ (agent_init.py): owns its row. + self._end_session_on_close = True + + def __setattr__(self, name, value): + object.__setattr__(self, name, value) + if name == "_end_session_on_close": + seen["end_session_on_close"] = value + + def run_conversation(self, **kwargs): + # By the time the fork runs, the opt-out must already be applied. + seen["at_run_time"] = self._end_session_on_close + + def shutdown_memory_provider(self): + pass + + def close(self): + pass + + monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent) + monkeypatch.setattr(run_agent_module.threading, "Thread", ImmediateThread) + + agent = _bare_agent() + + AIAgent._spawn_background_review( + agent, + messages_snapshot=[{"role": "user", "content": "hello"}], + review_memory=True, + ) + + assert seen.get("end_session_on_close") is False + assert seen.get("at_run_time") is False + + def test_background_review_summarizer_receives_captured_messages_after_close(monkeypatch): """The action summarizer must see review messages even after close cleanup. -
tests/tools/test_zombie_process_cleanup.py@@ -155,6 +155,59 @@ def test_close_propagates_to_children(self): child_2.close.assert_called_once() assert agent._active_children == [] + def test_close_ends_owned_session_row(self): + """close() finalizes the agent's owned SQLite session row.""" + from unittest.mock import MagicMock, patch + + with patch("run_agent.AIAgent.__init__", return_value=None): + from run_agent import AIAgent + agent = AIAgent.__new__(AIAgent) + agent.session_id = "test-close-session-row" + agent._active_children = [] + agent._active_children_lock = threading.Lock() + agent.client = None + agent._end_session_on_close = True + agent._session_db = MagicMock() + + agent.close() + + agent._session_db.end_session.assert_called_once_with( + "test-close-session-row", "agent_close" + ) + + def test_close_skips_session_end_for_forwarded_continuation_agents(self): + """Helper agents that handed session ownership forward opt out.""" + from unittest.mock import MagicMock, patch + + with patch("run_agent.AIAgent.__init__", return_value=None): + from run_agent import AIAgent + agent = AIAgent.__new__(AIAgent) + agent.session_id = "test-close-forwarded-session" + agent._active_children = [] + agent._active_children_lock = threading.Lock() + agent.client = None + agent._end_session_on_close = False + agent._session_db = MagicMock() + + agent.close() + + agent._session_db.end_session.assert_not_called() + + def test_close_session_end_noops_without_session_db(self): + """close() is a no-op for session finalization when no DB is wired in.""" + from unittest.mock import patch + + with patch("run_agent.AIAgent.__init__", return_value=None): + from run_agent import AIAgent + agent = AIAgent.__new__(AIAgent) + agent.session_id = "test-close-no-db" + agent._active_children = [] + agent._active_children_lock = threading.Lock() + agent.client = None + # No _session_db / _end_session_on_close attributes at all — + # getattr defaults must keep close() from raising. + agent.close() # must not raise + def test_close_survives_partial_failures(self): """close() continues cleanup even if one step fails.""" from unittest.mock import patch
-
-
#56279 fix(agent): persist recovered final responses at the finalize_turn chokepoint
files:
agent/turn_finalizer.py,scripts/release.py,tests/agent/test_turn_finalizer_final_response_persistence.py
12 PRs closed without merging
From the close comments: done elsewhere 4 · merged via another change 2 · PR volume cleanup 1 · duplicate 1 · partial fix 1 · stale or abandoned 1 · superseded by another PR 1 · other 1
- #6482 fix(cli): persist session messages on SIGTERM/SIGHUP/window close · stale or abandoned
-
#12080 Fix leaked session rows and stale long-running delegate status · merged via another change →
b17180d,#50299 -
#26894 fix(cli): persist sessions before shutdown · duplicate →
#22231 - #35375 fix(agent): add SIGTERM/SIGINT handlers for emergency message persistence (#35313) · done elsewhere
-
#43853 fix(gateway): backfill missing assistant transcript rows · done elsewhere →
#56279,#46183,#43849
Show 7 more
- #43962 fix(agent): persist delivered assistant text on interrupted turns · PR volume cleanup
-
#44120 fix(gateway,agent): persist delivered responses that recovery paths drop from the transcript · partial fix →
#56279,#46183 -
#44344 fix(cli): flush conversation messages to SQLite on Ctrl+C exit · done elsewhere →
#50004,#6481,#26894 -
#45277 fix(agent): identity-based session-DB flush — stop positional assistant drops (#43936) · merged via another change →
#46071 -
#46471 fix: gateway persists transcript to state.db itself instead of relying on agent's internal flush · other →
#46088,#860,#42039,#47237 -
#55386 fix(cron): close stale cron sessions on startup after process restart · superseded by another PR →
#62663 -
#64056 fix(tui): _ensure_session_db_row surfaces create_session failures at WARNING (#63474) · done elsewhere →
#64056,#63474,#64050
Closed issues · 11
- Closed#5021Feature: Configurable per-turn session flush to prevent data loss on force-exit
- Closed#6481CLI: session messages lost on SIGTERM/SIGHUP/window close
- Closed#12029[Bug]: sessions still leak open rows and ghost stubs across gateway/cli/cron after completion or exception paths
- Closed#19816[Feature]: Session state not written to state.db until session end — agentic runs invisible to external tooling
- Closed#35313【Bug】Messages lost on process termination: no SIGTERM handler or turn-end persistence
- Closed#43849Gateway can deliver assistant response without persisting assistant row
- Closed#43936[Bug]: State.db drops assistant messages on interrupt; .jsonl session log no longer available as fallback
- Closed#44100Telegram: assistant responses not persisted to session DB (model re-answers old messages)
- Closed#44281CLI session messages not persisted to SQLite on Ctrl+C exit
- Closed#46088Gateway restart causes assistant messages to be lost from session transcript (state.db)
- Closed#63474Dashboard Chat tab sessions not persisted to state.db — data loss on process restart
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.06: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab