{"repo":"NousResearch/hermes-agent","public_id":"12029","display_label":"Session rows left open or stale across agent lifecycle paths","summary":"These items all concern session records (SQLite rows, stubs, cron sessions) that may remain open or stale after completion, exceptions, or process restarts, touching AIAgent.close(), gateway/cli/cron paths, and delegate status. One bug report and several fix-oriented PRs are included; the titles alone do not show whether any change addresses the issue fully.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50299,"merged_at":"2026-06-21T18:35:09+00:00"},"resolution":{"state":"resolved","by":"pr-50299"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T18:35:09+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":12029,"pr_number":50299,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-21T18:35:09+00:00"}],"members":[{"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":"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":50299,"title":"fix(session): finalize owned SQLite session rows on AIAgent.close() (#12029)","url":null,"state":"merged","close_reason":{"category":"merged"},"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}],"category":"superseded_by_pr","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}