{"repo":"NousResearch/hermes-agent","public_id":"24187","display_label":"Session DB flush cursor bugs after message repair","summary":"These issues and fixes center on the session database flush cursor (_last_flushed_db_idx) becoming stale or overshooting after repair_message_sequence compacts or modifies the message list, causing assistant responses to be silently dropped or skipped during persistence. Several adjacent concerns about scaffolding pop, consecutive message merging, and identity-based flushing safety are also present.","subsystem":"agent","counts":{"members":19,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":7,"merged_prs":2,"attempt_prs":10},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":45260},"resolution":{"state":"partially_resolved","by":"pr-45260,pr-46071"},"freshness":{"state":"verified","last_verified_at":"2026-06-14T10:20:26+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.10339181286549705},"fix_edges":[{"issue_number":43936,"pr_number":46071,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":44837,"pr_number":45260,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":46053,"pr_number":46071,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":24187,"title":"[Bug]: SessionDB silently skips current turn when message repair shortens conversation history","url":null,"state":"closed"},{"kind":"issue","number":31269,"title":"Bug: Bridge-worker silently drops assistant replies from state.db (streamed responses not in messages list)","url":null,"state":"closed"},{"kind":"issue","number":31507,"title":"State.db loses assistant responses when _drop_trailing_empty_response_scaffolding causes _last_flushed_db_idx to overshoot len(messages)","url":null,"state":"closed"},{"kind":"issue","number":43062,"title":"Post-compaction message loss: assistant replies dropped, user messages merged into single turn","url":null,"state":"closed"},{"kind":"issue","number":44837,"title":"Session DB turn-end flush drops assistant after repair_message_sequence compacts list (orphan user → \\n\\n merge)","url":null,"state":"closed"},{"kind":"issue","number":46053,"title":"Bug: assistant responses silently dropped after repair_message_sequence (conversation_history vs messages mismatch)","url":null,"state":"closed"},{"kind":"issue","number":46122,"title":"Bug: _flush_messages_to_session_db stale cursor after repair_message_sequence (no rotation)","url":null,"state":"closed"},{"kind":"pr","number":24211,"title":"fix(agent): persist repaired current turn history","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#46071","number":46071},{"kind":"pr","value":"#24187","number":24187}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":24326,"title":"fix(agent): clamp flush_from to len(messages) after repair shortens the list","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#46071","number":46071}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":24419,"title":"fix(agent): rewrite SessionDB transcript after message-sequence repair","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#46071","number":46071},{"kind":"pr","value":"#24187","number":24187},{"kind":"pr","value":"#46071","number":46071}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":31314,"title":"fix(agent): inject streamed final_response into messages before persistence (#31269)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31269","number":31269},{"kind":"pr","value":"#31291","number":31291},{"kind":"pr","value":"#46071","number":46071},{"kind":"pr","value":"#34336","number":34336},{"kind":"pr","value":"#53987","number":53987},{"kind":"pr","value":"#53987","number":53987},{"kind":"pr","value":"#31291","number":31291}],"category":"partial_fix","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":31517,"title":"fix(run_agent): clamp _last_flushed_db_idx after scaffolding drop (#31507)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#46071","number":46071},{"kind":"pr","value":"#31507","number":31507}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":31531,"title":"fix: prevent _last_flushed_db_idx overshoot after scaffolding pop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31517","number":31517},{"kind":"pr","value":"#31507","number":31507},{"kind":"pr","value":"#31517","number":31517}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":43905,"title":"Fix/repair on api copy 24187","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#46071","number":46071},{"kind":"pr","value":"#46071","number":46071}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":44838,"title":"fix(session): persist assistant after repair compacts message list","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45260","number":45260},{"kind":"pr","value":"#44870","number":44870}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":44870,"title":"fix(agent): clamp flush cursor after repair_message_sequence compacts messages (#44837)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"5d0408d9f","number":0},{"kind":"pr","value":"#45260","number":45260}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":45076,"title":"fix: Session DB turn-end flush drops assistant after repair_message_sequence compacts list (orphan user → \\n\\n merge) (#44837)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44838","number":44838},{"kind":"pr","value":"#44837","number":44837}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":45260,"title":"fix(agent): clamp and rewind session flush cursor after repair_message_sequence compaction (#44837)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","agent/conversation_loop.py","run_agent.py","tests/run_agent/test_message_sequence_repair.py"]},{"kind":"pr","number":46071,"title":"fix(agent): persist repaired-turn responses","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_compression_persistence.py","tests/run_agent/test_identity_flush.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -1548,9 +1548,10 @@ def _repair_message_sequence(self, messages: List[Dict]) -> int:\n     def _flush_messages_to_session_db(self, messages: List[Dict], conversation_history: List[Dict] = None):\n         \"\"\"Persist any un-flushed messages to the SQLite session store.\n \n-        Uses _last_flushed_db_idx to track which messages have already been\n-        written, so repeated calls (from multiple exit paths) only write\n-        truly new messages — preventing the duplicate-write bug (#860).\n+        Uses per-session message identity tracking so repeated calls (from\n+        multiple exit paths) only write truly new messages — preventing the\n+        duplicate-write bug (#860) without relying on positional slices that\n+        can drift after message-sequence repair.\n         \"\"\"\n         if not self._session_db:\n             return","@@ -1559,14 +1560,41 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo\n             # Retry row creation if the earlier attempt failed transiently.\n             if not self._session_db_created:\n                 self._ensure_db_session()\n-            start_idx = len(conversation_history) if conversation_history else 0\n-            # Guard against the flush cursor overshooting the message list.\n-            # This can happen when repair_message_sequence compacts the list\n-            # (merging consecutive users, dropping stray tools) after the\n-            # cursor was set.  Fall back to start_idx so we don't skip\n-            # persisting the assistant/tool chain (#44837).\n-            flush_from = max(start_idx, min(self._last_flushed_db_idx, len(messages)))\n-            for msg in messages[flush_from:]:\n+            # Positional flushing used to slice at\n+            # max(len(conversation_history), _last_flushed_db_idx). That\n+            # assumes the live `messages` list is the original history plus a\n+            # new tail. repair_message_sequence can shrink/merge the history\n+            # copy before the final flush, making len(conversation_history)\n+            # larger than len(messages); the slice is then empty and delivered\n+            # assistant responses never reach state.db (#46053).\n+            #\n+            # Track object identities instead. `messages` is a shallow copy of\n+            # `conversation_history`, so history dicts are skipped by identity,\n+            # and new dicts appended during this turn are written once even if\n+            # repair compacts the list around them.\n+            current_session_id = getattr(self, \"session_id\", None)\n+            flushed_session_id = getattr(self, \"_flushed_db_message_session_id\", None)\n+            if flushed_session_id != current_session_id or self._last_flushed_db_idx == 0:\n+                self._flushed_db_message_ids = set()\n+                self._flushed_db_message_session_id = current_session_id\n+            flushed_ids = getattr(self, \"_flushed_db_message_ids\", None)\n+            if not isinstance(flushed_ids, set):\n+                flushed_ids = set()\n+                self._flushed_db_message_ids = flushed_ids\n+            history_ids = {\n+                id(item) for item in (conversation_history or [])\n+                if isinstance(item, dict)\n+            }\n+\n+            for msg in messages:\n+                if not isinstance(msg, dict):\n+                    continue\n+                msg_id = id(msg)\n+                if msg_id in flushed_ids:\n+                    continue\n+                if msg_id in history_ids:\n+                    flushed_ids.add(msg_id)\n+                    continue\n                 role = msg.get(\"role\", \"unknown\")\n                 content = msg.get(\"content\")\n                 # 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\n                     codex_reasoning_items=msg.get(\"codex_reasoning_items\") if role == \"assistant\" else None,\n                     codex_message_items=msg.get(\"codex_message_items\") if role == \"assistant\" else None,\n                 )\n+                flushed_ids.add(msg_id)\n             self._last_flushed_db_idx = len(messages)\n         except Exception as e:\n             logger.warning(\"Session DB append_message failed: %s\", e)"]},{"file":"tests/run_agent/test_compression_persistence.py","hunks":["@@ -101,7 +101,7 @@ def test_flush_after_compression_with_long_history(self):\n             )\n \n     def test_flush_with_stale_history_loses_messages(self):\n-        \"\"\"Demonstrates the bug condition: stale conversation_history causes data loss.\"\"\"\n+        \"\"\"Stale conversation_history no longer causes data loss.\"\"\"\n         from hermes_state import SessionDB\n \n         with tempfile.TemporaryDirectory() as tmpdir:","@@ -120,17 +120,14 @@ def test_flush_with_stale_history_loses_messages(self):\n                 {\"role\": \"assistant\", \"content\": \"continuing...\"},\n             ]\n \n-            # Bug: passing a conversation_history longer than compressed messages\n+            # Stale history longer than messages: the old positional flush\n+            # sliced past the end and dropped both messages (#46053).\n             stale_history = [{\"role\": \"user\", \"content\": f\"msg{i}\"} for i in range(100)]\n             agent._flush_messages_to_session_db(compressed, stale_history)\n \n             rows = db.get_messages(\"new-session\")\n-            # With the stale history, flush_from = max(100, 0) = 100\n-            # But compressed only has 2 entries → messages[100:] = empty\n-            assert len(rows) == 0, (\n-                \"Expected 0 messages with stale conversation_history \"\n-                \"(this test verifies the bug condition exists)\"\n-            )\n+            assert len(rows) == 2\n+            assert [row[\"content\"] for row in rows] == [\"summary\", \"continuing...\"]\n \n \n # ---------------------------------------------------------------------------"]},{"file":"tests/run_agent/test_identity_flush.py","hunks":["@@ -0,0 +1,150 @@\n+\"\"\"Regression tests for identity-based SessionDB flushing (#46053).\"\"\"\n+\n+import os\n+import tempfile\n+from pathlib import Path\n+from unittest.mock import patch\n+\n+SESSION_ID = \"test-identity-flush\"\n+\n+\n+def _make_agent(session_db, session_id=SESSION_ID):\n+    with patch.dict(os.environ, {\"OPENROUTER_API_KEY\": \"test-key\"}):\n+        from run_agent import AIAgent\n+\n+        agent = AIAgent(\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+            model=\"test/model\",\n+            quiet_mode=True,\n+            session_db=session_db,\n+            session_id=session_id,\n+            skip_context_files=True,\n+            skip_memory=True,\n+        )\n+    agent._ensure_db_session()\n+    return agent\n+\n+\n+def _contents(db, session_id=SESSION_ID):\n+    return [row[\"content\"] for row in db.get_messages(session_id)]\n+\n+\n+class TestIdentityFlush:\n+    def test_repair_shrunk_messages_below_history_length_still_persists_assistant(self):\n+        \"\"\"When repair shortens messages below conversation_history, don't slice empty.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            db = SessionDB(db_path=Path(tmpdir) / \"t.db\")\n+            try:\n+                agent = _make_agent(db)\n+\n+                # Simulate history already loaded from state.db.\n+                history = [{\"role\": \"user\", \"content\": f\"u{i}\"} for i in range(6)]\n+                for msg in history:\n+                    db.append_message(\n+                        session_id=SESSION_ID,\n+                        role=msg[\"role\"],\n+                        content=msg[\"content\"],\n+                    )\n+\n+                # repair_message_sequence merged the six history rows into one\n+                # dict before this turn appended the new user/assistant pair.\n+                messages = [\n+                    {\"role\": \"user\", \"content\": \"\\n\\n\".join(f\"u{i}\" for i in range(6))},\n+                    {\"role\": \"user\", \"content\": \"new question\"},\n+                    {\"role\": \"assistant\", \"content\": \"new answer\"},\n+                ]\n+                assert len(history) > len(messages)\n+\n+                # The old positional flush computed flush_from >= len(messages)\n+                # and dropped the assistant. Identity flush persists new dicts.\n+                agent._last_flushed_db_idx = len(history)\n+                agent._flush_messages_to_session_db(messages, history)\n+\n+                contents = _contents(db)\n+                assert \"new question\" in contents\n+                assert \"new answer\" in contents\n+            finally:\n+                db.close()\n+\n+    def test_overlapping_turn_stale_cursor_does_not_drop_assistant(self):\n+        \"\"\"A stale cached-agent cursor must not suppress this turn's new dicts.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            db = SessionDB(db_path=Path(tmpdir) / \"t.db\")\n+            try:\n+                agent = _make_agent(db)\n+                history = [\n+                    {\"role\": \"user\", \"content\": \"old question\"},\n+                    {\"role\": \"assistant\", \"content\": \"old answer\"},\n+                ]\n+                for msg in history:\n+                    db.append_message(\n+                        session_id=SESSION_ID,\n+                        role=msg[\"role\"],\n+                        content=msg[\"content\"],\n+                    )\n+\n+                messages = history + [\n+                    {\"role\": \"user\", \"content\": \"current question\"},\n+                    {\"role\": \"assistant\", \"content\": \"current answer\"},\n+                ]\n+                agent._last_flushed_db_idx = len(messages) + 10\n+                agent._flush_messages_to_session_db(messages, history)\n+\n+                assert _contents(db) == [\n+                    \"old question\",\n+                    \"old answer\",\n+                    \"current question\",\n+                    \"current answer\",\n+                ]\n+            finally:\n+                db.close()\n+\n+    def test_repeated_flush_same_turn_writes_once(self):\n+        \"\"\"Identity tracking preserves #860 same-turn dedup behavior.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            db = SessionDB(db_path=Path(tmpdir) / \"t.db\")\n+            try:\n+                agent = _make_agent(db)\n+                messages = [{\"role\": \"user\", \"content\": \"q\"}]\n+\n+                agent._flush_messages_to_session_db(messages, [])\n+                messages.append({\"role\": \"assistant\", \"content\": \"a\"})\n+                agent._flush_messages_to_session_db(messages, [])\n+                agent._flush_messages_to_session_db(messages, [])\n+\n+                assert _contents(db) == [\"q\", \"a\"]\n+            finally:\n+                db.close()\n+\n+    def test_cursor_reset_starts_new_turn_identity_window(self):\n+        \"\"\"Gateway resets _last_flushed_db_idx=0 before a cached-agent turn.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            db = SessionDB(db_path=Path(tmpdir) / \"t.db\")\n+            try:\n+                agent = _make_agent(db)\n+                first_turn = [\n+                    {\"role\": \"user\", \"content\": \"q1\"},\n+                    {\"role\": \"assistant\", \"content\": \"a1\"},\n+                ]\n+                agent._flush_messages_to_session_db(first_turn, [])\n+\n+                history = [dict(m) for m in first_turn]\n+                second_turn = history + [\n+                    {\"role\": \"user\", \"content\": \"q2\"},\n+                    {\"role\": \"assistant\", \"content\": \"a2\"},\n+                ]\n+                agent._last_flushed_db_idx = 0\n+                agent._flush_messages_to_session_db(second_turn, history)\n+\n+                assert _contents(db) == [\"q1\", \"a1\", \"q2\", \"a2\"]\n+            finally:\n+                db.close()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}