{"repo":"NousResearch/hermes-agent","public_id":"5021","display_label":"Session transcript persistence and signal-handling gaps","summary":"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.","subsystem":"agent","counts":{"members":27,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":11,"merged_prs":4,"attempt_prs":12},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":46071},"resolution":{"state":"partially_resolved","by":"pr-50004,pr-50299,pr-56279"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T10:34:50+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.060740740740740734},"fix_edges":[{"issue_number":6481,"pr_number":50004,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":12029,"pr_number":50299,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":43849,"pr_number":56279,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":43936,"pr_number":46071,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":44281,"pr_number":50004,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":46053,"pr_number":46071,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":5021,"title":"Feature: Configurable per-turn session flush to prevent data loss on force-exit","url":null,"state":"closed"},{"kind":"issue","number":6481,"title":"CLI: session messages lost on SIGTERM/SIGHUP/window close","url":null,"state":"closed"},{"kind":"issue","number":12029,"title":"[Bug]: sessions still leak open rows and ghost stubs across gateway/cli/cron after completion or exception paths","url":null,"state":"closed"},{"kind":"issue","number":19816,"title":"[Feature]: Session state not written to state.db until session end — agentic runs invisible to external tooling","url":null,"state":"closed"},{"kind":"issue","number":35313,"title":"【Bug】Messages lost on process termination: no SIGTERM handler or turn-end persistence","url":null,"state":"closed"},{"kind":"issue","number":43849,"title":"Gateway can deliver assistant response without persisting assistant row","url":null,"state":"closed"},{"kind":"issue","number":43936,"title":"[Bug]:  State.db drops assistant messages on interrupt; .jsonl session log no longer available as fallback","url":null,"state":"closed"},{"kind":"issue","number":44100,"title":"Telegram: assistant responses not persisted to session DB (model re-answers old messages)","url":null,"state":"closed"},{"kind":"issue","number":44281,"title":"CLI session messages not persisted to SQLite on Ctrl+C exit","url":null,"state":"closed"},{"kind":"issue","number":46088,"title":"Gateway restart causes assistant messages to be lost from session transcript (state.db)","url":null,"state":"closed"},{"kind":"issue","number":63474,"title":"Dashboard Chat tab sessions not persisted to state.db — data loss on process restart","url":null,"state":"closed"},{"kind":"pr","number":6482,"title":"fix(cli): persist session messages on SIGTERM/SIGHUP/window close","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":12080,"title":"Fix leaked session rows and stale long-running delegate status","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b17180d95","number":0},{"kind":"pr","value":"#50299","number":50299}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":26894,"title":"fix(cli): persist sessions before shutdown","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22231","number":22231}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":35375,"title":"fix(agent): add SIGTERM/SIGINT handlers for emergency message persistence (#35313)","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":43853,"title":"fix(gateway): backfill missing assistant transcript rows","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56279","number":56279},{"kind":"pr","value":"#46183","number":46183},{"kind":"pr","value":"#43849","number":43849}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":43962,"title":"fix(agent): persist delivered assistant text on interrupted turns","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":44120,"title":"fix(gateway,agent): persist delivered responses that recovery paths drop from the transcript","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56279","number":56279},{"kind":"pr","value":"#46183","number":46183}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":44344,"title":"fix(cli): flush conversation messages to SQLite on Ctrl+C exit","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50004","number":50004},{"kind":"pr","value":"#6481","number":6481},{"kind":"pr","value":"#26894","number":26894},{"kind":"pr","value":"#50004","number":50004}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":45277,"title":"fix(agent): identity-based session-DB flush — stop positional assistant drops (#43936)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#46071","number":46071},{"kind":"pr","value":"#46071","number":46071}],"category":"merged_via","implication":"superseded"},"merged":false},{"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"]}]},{"kind":"pr","number":46471,"title":"fix: gateway persists transcript to state.db itself instead of relying on agent's internal flush","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#46088","number":46088},{"kind":"pr","value":"#860","number":860},{"kind":"pr","value":"#42039","number":42039},{"kind":"pr","value":"#42039","number":42039},{"kind":"pr","value":"#47237","number":47237}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":50004,"title":"fix(cli): persist interrupted session transcript on SIGHUP/SIGTERM/window-close","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/cli/test_cli_shutdown_memory_messages.py"]},{"kind":"pr","number":50299,"title":"fix(session): finalize owned SQLite session rows on AIAgent.close() (#12029)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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.py"],"fix_hunks":[{"file":"agent/agent_init.py","hunks":["@@ -1096,6 +1096,12 @@ def init_agent(\n     agent._parent_session_id = parent_session_id\n     agent._last_flushed_db_idx = 0  # tracks DB-write cursor to prevent duplicate writes\n     agent._session_db_created = False  # DB row deferred to run_conversation()\n+    # Most agents own their session row and should finalize it on close().\n+    # Some temporary helper agents (manual compression / session-hygiene /\n+    # background-review forks) rotate or share the session forward to a\n+    # continuation row that must remain open after the helper is torn down;\n+    # those callers explicitly set this flag to False.\n+    agent._end_session_on_close = True\n     agent._session_init_model_config = {\n         \"max_iterations\": agent.max_iterations,\n         \"reasoning_config\": reasoning_config,"]},{"file":"agent/background_review.py","hunks":["@@ -575,6 +575,13 @@ def _bg_review_auto_deny(command, description, **kwargs):\n             # if a future code path bypasses the cache.\n             review_agent.session_start = agent.session_start\n             review_agent.session_id = agent.session_id\n+            # The fork shares the parent's live session_id (pinned above for\n+            # prefix-cache parity). It is single-lifecycle and calls close()\n+            # right after this run_conversation(); without opting out, close()\n+            # would finalize the parent's still-active session row mid\n+            # conversation (the review fires every ~10 turns). Leave session\n+            # finalization to the real owner (CLI close / gateway reset / cron).\n+            review_agent._end_session_on_close = False\n             # Never let the review fork compress. It shares the parent's\n             # session_id, so if it won a compression race it would rotate the\n             # parent into a NEW child that the gateway never adopts (the fork"]},{"file":"gateway/run.py","hunks":["@@ -4686,6 +4686,27 @@ def _finalize_shutdown_agents(self, active_agents: Dict[str, Any]) -> None:\n                 pass\n             self._cleanup_agent_resources(agent)\n \n+    def _should_emit_long_running_notification(\n+        self,\n+        session_key: Optional[str],\n+        agent: Any,\n+        executor_task: Optional[Any],\n+    ) -> bool:\n+        \"\"\"Only emit the heartbeat while this task still owns the live run.\n+\n+        Guards against a stale ``running: delegate_task`` heartbeat outliving the\n+        run that started it: stop once the executor finishes, the agent is gone,\n+        or the session key has been rebound to a different live agent (e.g. the\n+        user sent ``/new`` and a fresh agent took the slot mid-run, #12029).\n+        \"\"\"\n+        if agent is None:\n+            return False\n+        if executor_task is not None and executor_task.done():\n+            return False\n+        if session_key and self._running_agents.get(session_key) is not agent:\n+            return False\n+        return True\n+\n     def _cleanup_agent_resources(self, agent: Any) -> None:\n         \"\"\"Best-effort cleanup for temporary or cached agent instances.\"\"\"\n         if agent is None:","@@ -9194,6 +9215,13 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                                     session_id=session_entry.session_id,\n                                 )\n                                 try:\n+                                    # The hygiene agent rotates the session\n+                                    # forward to a continuation id that becomes\n+                                    # the gateway session's live row. It must\n+                                    # never finalize on close() (today it has no\n+                                    # session_db so close() no-ops, but this\n+                                    # guards a future where one is wired in).\n+                                    _hyg_agent._end_session_on_close = False\n                                     _hyg_agent._print_fn = lambda *a, **kw: None\n \n                                     loop = asyncio.get_running_loop()","@@ -16274,6 +16302,20 @@ async def _notify_long_running():\n             _heartbeat_msg_id: Optional[str] = None\n             while True:\n                 await asyncio.sleep(_NOTIFY_INTERVAL)\n+                # Stop heartbeating once this run no longer owns the session\n+                # slot or the executor has finished — otherwise a stale\n+                # \"running: delegate_task\" bubble can outlive the run that\n+                # spawned it (#12029). _executor_task is a closure var bound\n+                # just after this task is scheduled; tolerate the brief window\n+                # before then (the first wake is _NOTIFY_INTERVAL away anyway).\n+                try:\n+                    _exec_ref = _executor_task\n+                except NameError:\n+                    _exec_ref = None\n+                if not self._should_emit_long_running_notification(\n+                    session_key, agent_holder[0], _exec_ref\n+                ):\n+                    break\n                 _elapsed_mins = int((time.time() - _notify_start) // 60)\n                 # Include agent activity context if available. Default\n                 # heartbeat is terse: elapsed + current tool. Verbose"]},{"file":"run_agent.py","hunks":["@@ -3250,6 +3250,22 @@ def close(self) -> None:\n         except Exception:\n             pass\n \n+        # 7. Finalize the owned SQLite session row unless this agent is only a\n+        # temporary helper that deliberately handed session ownership forward\n+        # (manual compression helpers that rotate to a continuation session_id,\n+        # or background-review forks that share the live parent's session_id and\n+        # must leave it open). end_session() is first-reason-wins and no-ops on\n+        # an already-ended row, so this never clobbers a 'compression' /\n+        # 'cron_complete' / 'cli_close' reason set by an earlier terminal path.\n+        try:\n+            if getattr(self, \"_end_session_on_close\", True):\n+                session_db = getattr(self, \"_session_db\", None)\n+                session_id = getattr(self, \"session_id\", None)\n+                if session_db and session_id:\n+                    session_db.end_session(session_id, \"agent_close\")\n+        except Exception:\n+            pass\n+\n     def _hydrate_todo_store(self, history: List[Dict[str, Any]]) -> None:\n         \"\"\"\n         Recover todo state from conversation history."]},{"file":"tests/gateway/test_busy_session_ack.py","hunks":["@@ -715,3 +715,62 @@ async def test_queue_mode_hint_points_to_interrupt(self, tmp_path, monkeypatch):\n         assert \"/busy interrupt\" in content\n         # Must NOT tell the user to /busy queue when they're already on queue.\n         assert \"/busy queue\" not in content\n+\n+\n+class TestLongRunningNotificationOwnership:\n+    \"\"\"The long-running heartbeat must stop once its run no longer owns the\n+    session slot or the executor finished — otherwise a stale\n+    'running: delegate_task' bubble outlives the run that spawned it (#12029).\n+    \"\"\"\n+\n+    def test_notification_stops_after_session_ownership_moves(self):\n+        from gateway.run import GatewayRunner\n+\n+        runner = object.__new__(GatewayRunner)\n+        runner._running_agents = {}\n+\n+        original_agent = MagicMock()\n+        replacement_agent = MagicMock()\n+        runner._running_agents[\"sess\"] = replacement_agent\n+\n+        assert runner._should_emit_long_running_notification(\n+            \"sess\", original_agent, executor_task=None\n+        ) is False\n+\n+    def test_notification_stops_after_executor_finishes(self):\n+        from gateway.run import GatewayRunner\n+\n+        runner = object.__new__(GatewayRunner)\n+        agent = MagicMock()\n+        runner._running_agents = {\"sess\": agent}\n+\n+        done_task = MagicMock()\n+        done_task.done.return_value = True\n+\n+        assert runner._should_emit_long_running_notification(\n+            \"sess\", agent, executor_task=done_task\n+        ) is False\n+\n+    def test_notification_stops_when_agent_is_gone(self):\n+        from gateway.run import GatewayRunner\n+\n+        runner = object.__new__(GatewayRunner)\n+        runner._running_agents = {}\n+\n+        assert runner._should_emit_long_running_notification(\n+            \"sess\", None, executor_task=None\n+        ) is False\n+\n+    def test_notification_continues_for_live_active_run(self):\n+        from gateway.run import GatewayRunner\n+\n+        runner = object.__new__(GatewayRunner)\n+        agent = MagicMock()\n+        runner._running_agents = {\"sess\": agent}\n+\n+        live_task = MagicMock()\n+        live_task.done.return_value = False\n+\n+        assert runner._should_emit_long_running_notification(\n+            \"sess\", agent, executor_task=live_task\n+        ) is True"]},{"file":"tests/run_agent/test_background_review.py","hunks":["@@ -76,6 +76,50 @@ def close(self):\n     ]\n \n \n+def test_background_review_fork_opts_out_of_session_finalization(monkeypatch):\n+    \"\"\"The review fork shares the parent's live session_id, so it must set\n+    ``_end_session_on_close = False``. Otherwise close() (now finalizing owned\n+    session rows) would end the still-active parent session mid-conversation\n+    every time the review fires (~every 10 turns). Regression for #12029.\n+    \"\"\"\n+    seen = {}\n+\n+    class FakeReviewAgent:\n+        def __init__(self, **kwargs):\n+            self._session_messages = []\n+            # Default matches AIAgent.__init__ (agent_init.py): owns its row.\n+            self._end_session_on_close = True\n+\n+        def __setattr__(self, name, value):\n+            object.__setattr__(self, name, value)\n+            if name == \"_end_session_on_close\":\n+                seen[\"end_session_on_close\"] = value\n+\n+        def run_conversation(self, **kwargs):\n+            # By the time the fork runs, the opt-out must already be applied.\n+            seen[\"at_run_time\"] = self._end_session_on_close\n+\n+        def shutdown_memory_provider(self):\n+            pass\n+\n+        def close(self):\n+            pass\n+\n+    monkeypatch.setattr(run_agent_module, \"AIAgent\", FakeReviewAgent)\n+    monkeypatch.setattr(run_agent_module.threading, \"Thread\", ImmediateThread)\n+\n+    agent = _bare_agent()\n+\n+    AIAgent._spawn_background_review(\n+        agent,\n+        messages_snapshot=[{\"role\": \"user\", \"content\": \"hello\"}],\n+        review_memory=True,\n+    )\n+\n+    assert seen.get(\"end_session_on_close\") is False\n+    assert seen.get(\"at_run_time\") is False\n+\n+\n def test_background_review_summarizer_receives_captured_messages_after_close(monkeypatch):\n     \"\"\"The action summarizer must see review messages even after close cleanup.\n "]},{"file":"tests/tools/test_zombie_process_cleanup.py","hunks":["@@ -155,6 +155,59 @@ def test_close_propagates_to_children(self):\n             child_2.close.assert_called_once()\n             assert agent._active_children == []\n \n+    def test_close_ends_owned_session_row(self):\n+        \"\"\"close() finalizes the agent's owned SQLite session row.\"\"\"\n+        from unittest.mock import MagicMock, patch\n+\n+        with patch(\"run_agent.AIAgent.__init__\", return_value=None):\n+            from run_agent import AIAgent\n+            agent = AIAgent.__new__(AIAgent)\n+            agent.session_id = \"test-close-session-row\"\n+            agent._active_children = []\n+            agent._active_children_lock = threading.Lock()\n+            agent.client = None\n+            agent._end_session_on_close = True\n+            agent._session_db = MagicMock()\n+\n+            agent.close()\n+\n+            agent._session_db.end_session.assert_called_once_with(\n+                \"test-close-session-row\", \"agent_close\"\n+            )\n+\n+    def test_close_skips_session_end_for_forwarded_continuation_agents(self):\n+        \"\"\"Helper agents that handed session ownership forward opt out.\"\"\"\n+        from unittest.mock import MagicMock, patch\n+\n+        with patch(\"run_agent.AIAgent.__init__\", return_value=None):\n+            from run_agent import AIAgent\n+            agent = AIAgent.__new__(AIAgent)\n+            agent.session_id = \"test-close-forwarded-session\"\n+            agent._active_children = []\n+            agent._active_children_lock = threading.Lock()\n+            agent.client = None\n+            agent._end_session_on_close = False\n+            agent._session_db = MagicMock()\n+\n+            agent.close()\n+\n+            agent._session_db.end_session.assert_not_called()\n+\n+    def test_close_session_end_noops_without_session_db(self):\n+        \"\"\"close() is a no-op for session finalization when no DB is wired in.\"\"\"\n+        from unittest.mock import patch\n+\n+        with patch(\"run_agent.AIAgent.__init__\", return_value=None):\n+            from run_agent import AIAgent\n+            agent = AIAgent.__new__(AIAgent)\n+            agent.session_id = \"test-close-no-db\"\n+            agent._active_children = []\n+            agent._active_children_lock = threading.Lock()\n+            agent.client = None\n+            # No _session_db / _end_session_on_close attributes at all —\n+            # getattr defaults must keep close() from raising.\n+            agent.close()  # must not raise\n+\n     def test_close_survives_partial_failures(self):\n         \"\"\"close() continues cleanup even if one step fails.\"\"\"\n         from unittest.mock import patch\n"]}]},{"kind":"pr","number":55386,"title":"fix(cron): close stale cron sessions on startup after process restart","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62663","number":62663},{"kind":"pr","value":"#62663","number":62663}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":56279,"title":"fix(agent): persist recovered final responses at the finalize_turn chokepoint","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/turn_finalizer.py","scripts/release.py","tests/agent/test_turn_finalizer_final_response_persistence.py"]},{"kind":"pr","number":64056,"title":"fix(tui): _ensure_session_db_row surfaces create_session failures at WARNING (#63474)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64056","number":64056},{"kind":"pr","value":"#63474","number":63474},{"kind":"pr","value":"#63474","number":63474},{"kind":"pr","value":"#64050","number":64050},{"kind":"pr","value":"#64050","number":64050},{"kind":"pr","value":"#64050","number":64050},{"kind":"pr","value":"#63474","number":63474},{"kind":"pr","value":"#64056","number":64056}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}