{"repo":"NousResearch/hermes-agent","public_id":"6582","display_label":"Disable delegation toolset in cron to prevent timeout","summary":"These issues address disabling the delegation toolset during cron jobs to prevent inactivity timeout kills, with a related fix to propagate child activity to the parent during task delegation.","subsystem":"cron","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":7295,"merged_at":"2026-04-10T19:51:31+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-10T19:51:31+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":6582,"pr_number":7295,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-10T19:51:31+00:00"}],"members":[{"kind":"issue","number":6582,"title":"fix: disable delegation toolset in cron jobs to prevent inactivity timeout kill","url":null,"state":"closed"},{"kind":"pr","number":6584,"title":"fix: disable delegation toolset in cron to prevent inactivity timeout kill","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a093eb47f75dd26ad0f771a378ff978714d3d988","number":0},{"kind":"pr","value":"#7295","number":7295}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":7295,"title":"fix: propagate child activity to parent during delegate_task","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_delegate.py","tools/delegate_tool.py"],"fix_hunks":[{"file":"tests/tools/test_delegate.py","hunks":["@@ -13,6 +13,7 @@\n import os\n import sys\n import threading\n+import time\n import unittest\n from unittest.mock import MagicMock, patch\n ","@@ -1052,5 +1053,159 @@ def test_run_single_child_releases_lease_after_failure(self):\n         child._credential_pool.release_lease.assert_called_once_with(\"cred-a\")\n \n \n+class TestDelegateHeartbeat(unittest.TestCase):\n+    \"\"\"Heartbeat propagates child activity to parent during delegation.\n+\n+    Without the heartbeat, the gateway inactivity timeout fires because the\n+    parent's _last_activity_ts freezes when delegate_task starts.\n+    \"\"\"\n+\n+    def test_heartbeat_touches_parent_activity_during_child_run(self):\n+        \"\"\"Parent's _touch_activity is called while child.run_conversation blocks.\"\"\"\n+        from tools.delegate_tool import _run_single_child\n+\n+        parent = _make_mock_parent()\n+        touch_calls = []\n+        parent._touch_activity = lambda desc: touch_calls.append(desc)\n+\n+        child = MagicMock()\n+        child.get_activity_summary.return_value = {\n+            \"current_tool\": \"terminal\",\n+            \"api_call_count\": 3,\n+            \"max_iterations\": 50,\n+            \"last_activity_desc\": \"executing tool: terminal\",\n+        }\n+\n+        # Make run_conversation block long enough for heartbeats to fire\n+        def slow_run(**kwargs):\n+            time.sleep(0.25)\n+            return {\"final_response\": \"done\", \"completed\": True, \"api_calls\": 3}\n+\n+        child.run_conversation.side_effect = slow_run\n+\n+        # Patch the heartbeat interval to fire quickly\n+        with patch(\"tools.delegate_tool._HEARTBEAT_INTERVAL\", 0.05):\n+            _run_single_child(\n+                task_index=0,\n+                goal=\"Test heartbeat\",\n+                child=child,\n+                parent_agent=parent,\n+            )\n+\n+        # Heartbeat should have fired at least once during the 0.25s sleep\n+        self.assertGreater(len(touch_calls), 0,\n+                           \"Heartbeat did not propagate activity to parent\")\n+        # Verify the description includes child's current tool detail\n+        self.assertTrue(\n+            any(\"terminal\" in desc for desc in touch_calls),\n+            f\"Heartbeat descriptions should include child tool info: {touch_calls}\")\n+\n+    def test_heartbeat_stops_after_child_completes(self):\n+        \"\"\"Heartbeat thread is cleaned up when the child finishes.\"\"\"\n+        from tools.delegate_tool import _run_single_child\n+\n+        parent = _make_mock_parent()\n+        touch_calls = []\n+        parent._touch_activity = lambda desc: touch_calls.append(desc)\n+\n+        child = MagicMock()\n+        child.get_activity_summary.return_value = {\n+            \"current_tool\": None,\n+            \"api_call_count\": 1,\n+            \"max_iterations\": 50,\n+            \"last_activity_desc\": \"done\",\n+        }\n+        child.run_conversation.return_value = {\n+            \"final_response\": \"done\", \"completed\": True, \"api_calls\": 1,\n+        }\n+\n+        with patch(\"tools.delegate_tool._HEARTBEAT_INTERVAL\", 0.05):\n+            _run_single_child(\n+                task_index=0,\n+                goal=\"Test cleanup\",\n+                child=child,\n+                parent_agent=parent,\n+            )\n+\n+        # Record count after completion, wait, and verify no more calls\n+        count_after = len(touch_calls)\n+        time.sleep(0.15)\n+        self.assertEqual(len(touch_calls), count_after,\n+                         \"Heartbeat continued firing after child completed\")\n+\n+    def test_heartbeat_stops_after_child_error(self):\n+        \"\"\"Heartbeat thread is cleaned up even when the child raises.\"\"\"\n+        from tools.delegate_tool import _run_single_child\n+\n+        parent = _make_mock_parent()\n+        touch_calls = []\n+        parent._touch_activity = lambda desc: touch_calls.append(desc)\n+\n+        child = MagicMock()\n+        child.get_activity_summary.return_value = {\n+            \"current_tool\": \"web_search\",\n+            \"api_call_count\": 2,\n+            \"max_iterations\": 50,\n+            \"last_activity_desc\": \"executing tool: web_search\",\n+        }\n+\n+        def slow_fail(**kwargs):\n+            time.sleep(0.15)\n+            raise RuntimeError(\"network timeout\")\n+\n+        child.run_conversation.side_effect = slow_fail\n+\n+        with patch(\"tools.delegate_tool._HEARTBEAT_INTERVAL\", 0.05):\n+            result = _run_single_child(\n+                task_index=0,\n+                goal=\"Test error cleanup\",\n+                child=child,\n+                parent_agent=parent,\n+            )\n+\n+        self.assertEqual(result[\"status\"], \"error\")\n+\n+        # Verify heartbeat stopped\n+        count_after = len(touch_calls)\n+        time.sleep(0.15)\n+        self.assertEqual(len(touch_calls), count_after,\n+                         \"Heartbeat continued firing after child error\")\n+\n+    def test_heartbeat_includes_child_activity_desc_when_no_tool(self):\n+        \"\"\"When child has no current_tool, heartbeat uses last_activity_desc.\"\"\"\n+        from tools.delegate_tool import _run_single_child\n+\n+        parent = _make_mock_parent()\n+        touch_calls = []\n+        parent._touch_activity = lambda desc: touch_calls.append(desc)\n+\n+        child = MagicMock()\n+        child.get_activity_summary.return_value = {\n+            \"current_tool\": None,\n+            \"api_call_count\": 5,\n+            \"max_iterations\": 90,\n+            \"last_activity_desc\": \"API call #5 completed\",\n+        }\n+\n+        def slow_run(**kwargs):\n+            time.sleep(0.15)\n+            return {\"final_response\": \"done\", \"completed\": True, \"api_calls\": 5}\n+\n+        child.run_conversation.side_effect = slow_run\n+\n+        with patch(\"tools.delegate_tool._HEARTBEAT_INTERVAL\", 0.05):\n+            _run_single_child(\n+                task_index=0,\n+                goal=\"Test desc fallback\",\n+                child=child,\n+                parent_agent=parent,\n+            )\n+\n+        self.assertGreater(len(touch_calls), 0)\n+        self.assertTrue(\n+            any(\"API call #5 completed\" in desc for desc in touch_calls),\n+            f\"Heartbeat should include last_activity_desc: {touch_calls}\")\n+\n+\n if __name__ == \"__main__\":\n     unittest.main()"]},{"file":"tools/delegate_tool.py","hunks":["@@ -20,6 +20,7 @@\n import logging\n logger = logging.getLogger(__name__)\n import os\n+import threading\n import time\n from concurrent.futures import ThreadPoolExecutor, as_completed\n from typing import Any, Dict, List, Optional","@@ -37,6 +38,7 @@\n MAX_CONCURRENT_CHILDREN = 3\n MAX_DEPTH = 2  # parent (0) -> child (1) -> grandchild rejected (2)\n DEFAULT_MAX_ITERATIONS = 50\n+_HEARTBEAT_INTERVAL = 30  # seconds between parent activity heartbeats during delegation\n DEFAULT_TOOLSETS = [\"terminal\", \"file\", \"web\"]\n \n ","@@ -369,6 +371,44 @@ def _run_single_child(\n             except Exception as exc:\n                 logger.debug(\"Failed to bind child to leased credential: %s\", exc)\n \n+    # Heartbeat: periodically propagate child activity to the parent so the\n+    # gateway inactivity timeout doesn't fire while the subagent is working.\n+    # Without this, the parent's _last_activity_ts freezes when delegate_task\n+    # starts and the gateway eventually kills the agent for \"no activity\".\n+    _heartbeat_stop = threading.Event()\n+\n+    def _heartbeat_loop():\n+        while not _heartbeat_stop.wait(_HEARTBEAT_INTERVAL):\n+            if parent_agent is None:\n+                continue\n+            touch = getattr(parent_agent, '_touch_activity', None)\n+            if not touch:\n+                continue\n+            # Pull detail from the child's own activity tracker\n+            desc = f\"delegate_task: subagent {task_index} working\"\n+            try:\n+                child_summary = child.get_activity_summary()\n+                child_tool = child_summary.get(\"current_tool\")\n+                child_iter = child_summary.get(\"api_call_count\", 0)\n+                child_max = child_summary.get(\"max_iterations\", 0)\n+                if child_tool:\n+                    desc = (f\"delegate_task: subagent running {child_tool} \"\n+                            f\"(iteration {child_iter}/{child_max})\")\n+                else:\n+                    child_desc = child_summary.get(\"last_activity_desc\", \"\")\n+                    if child_desc:\n+                        desc = (f\"delegate_task: subagent {child_desc} \"\n+                                f\"(iteration {child_iter}/{child_max})\")\n+            except Exception:\n+                pass\n+            try:\n+                touch(desc)\n+            except Exception:\n+                pass\n+\n+    _heartbeat_thread = threading.Thread(target=_heartbeat_loop, daemon=True)\n+    _heartbeat_thread.start()\n+\n     try:\n         result = child.run_conversation(user_message=goal)\n ","@@ -479,6 +519,11 @@ def _run_single_child(\n         }\n \n     finally:\n+        # Stop the heartbeat thread so it doesn't keep touching parent activity\n+        # after the child has finished (or failed).\n+        _heartbeat_stop.set()\n+        _heartbeat_thread.join(timeout=5)\n+\n         if child_pool is not None and leased_cred_id is not None:\n             try:\n                 child_pool.release_lease(leased_cred_id)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}