{"repo":"NousResearch/hermes-agent","public_id":"1599","display_label":"Subagent delegation hangs: timeouts, progress, and background mode","summary":"Most members concern delegate_task blocking the parent turn when a child subagent's run_conversation() stalls or is slow. They split between timeout/stale-detection and progress-visibility changes and proposals for non-blocking background (fire-and-forget) delegation.","subsystem":"agent","counts":{"members":13,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":5,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":13770,"merged_at":"2026-04-22T03:20:17+00:00"},"resolution":{"state":"partially_resolved","by":"pr-13770"},"freshness":{"state":"stale","last_verified_at":"2026-04-22T03:20:17+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.11282051282051284},"fix_edges":[{"issue_number":13768,"pr_number":13770,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-22T03:20:17+00:00"}],"members":[{"kind":"issue","number":1599,"title":"feat(delegation): add background=true mode for fire-and-forget subagent tasks","url":null,"state":"closed"},{"kind":"issue","number":5586,"title":"feat: non-blocking background agent delegation (async_delegation toolset)","url":null,"state":"closed"},{"kind":"issue","number":10678,"title":"delegate_task can hang indefinitely while child run_conversation stalls with lingering CLOSE-WAIT sockets","url":null,"state":"closed"},{"kind":"issue","number":13768,"title":"[Delegation] Subagent `run_conversation()` has no timeout — can block indefinitely on slow API/network","url":null,"state":"closed"},{"kind":"issue","number":45496,"title":"[Bug]: delegate_task blocks parent session silently with no progress output when children hang on tool calls","url":null,"state":"open"},{"kind":"issue","number":47302,"title":"Non-blocking subagent delegation: a stuck/slow child freezes the whole parent turn","url":null,"state":"closed"},{"kind":"pr","number":1600,"title":"feat(delegation): add background=true mode for fire-and-forget subagent tasks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1599","number":1599}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":2567,"title":"feat(delegation): add background=true for fire-and-forget subagent tasks","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":2570,"title":"feat(delegation): add background=true for fire-and-forget subagent tasks","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":5587,"title":"feat(async-delegation): non-blocking background agent tools","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":13770,"title":"fix(delegation): add hard timeout and stale detection for subagent execution","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tools/delegate_tool.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -333,6 +333,7 @@\n     \"[redacted:email]\": \"Aslaaen\",\n     \"[redacted:email]\": \"pinion05\",\n     \"105142614+VTRiot@users.noreply.github.com\": \"VTRiot\",\n+    \"[redacted:email]\": \"iamagenius00\",\n }\n \n "]},{"file":"tools/delegate_tool.py","hunks":["@@ -23,7 +23,7 @@\n import os\n import threading\n import time\n-from concurrent.futures import ThreadPoolExecutor, as_completed\n+from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError, as_completed\n from typing import Any, Dict, List, Optional\n \n from toolsets import TOOLSETS","@@ -112,6 +112,31 @@ def _get_max_concurrent_children() -> int:\n     return _DEFAULT_MAX_CONCURRENT_CHILDREN\n \n \n+def _get_child_timeout() -> float:\n+    \"\"\"Read delegation.child_timeout_seconds from config.\n+\n+    Returns the number of seconds a single child agent is allowed to run\n+    before being considered stuck.  Default: 300 s (5 minutes).\n+    \"\"\"\n+    cfg = _load_config()\n+    val = cfg.get(\"child_timeout_seconds\")\n+    if val is not None:\n+        try:\n+            return max(30.0, float(val))\n+        except (TypeError, ValueError):\n+            logger.warning(\n+                \"delegation.child_timeout_seconds=%r is not a valid number; \"\n+                \"using default %d\", val, DEFAULT_CHILD_TIMEOUT,\n+            )\n+    env_val = os.getenv(\"DELEGATION_CHILD_TIMEOUT_SECONDS\")\n+    if env_val:\n+        try:\n+            return max(30.0, float(env_val))\n+        except (TypeError, ValueError):\n+            pass\n+    return float(DEFAULT_CHILD_TIMEOUT)\n+\n+\n def _get_max_spawn_depth() -> int:\n     \"\"\"Read delegation.max_spawn_depth from config, clamped to [1, 3].\n ","@@ -165,7 +190,9 @@ def _get_orchestrator_enabled() -> bool:\n \n \n DEFAULT_MAX_ITERATIONS = 50\n+DEFAULT_CHILD_TIMEOUT = 300  # seconds before a child agent is considered stuck\n _HEARTBEAT_INTERVAL = 30  # seconds between parent activity heartbeats during delegation\n+_HEARTBEAT_STALE_CYCLES = 5  # mark child stale after this many heartbeats with no iteration progress\n DEFAULT_TOOLSETS = [\"terminal\", \"file\", \"web\"]\n \n ","@@ -689,6 +716,8 @@ def _run_single_child(\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+    _last_seen_iter = [0]  # mutable container for heartbeat stale detection\n+    _stale_count = [0]\n \n     def _heartbeat_loop():\n         while not _heartbeat_stop.wait(_HEARTBEAT_INTERVAL):","@@ -704,6 +733,25 @@ def _heartbeat_loop():\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+\n+                # Stale detection: if iteration count hasn't advanced,\n+                # increment stale counter.  After N cycles with no\n+                # progress, stop masking the hang so the gateway\n+                # inactivity timeout can fire as a last resort.\n+                if child_iter <= _last_seen_iter[0]:\n+                    _stale_count[0] += 1\n+                else:\n+                    _last_seen_iter[0] = child_iter\n+                    _stale_count[0] = 0\n+\n+                if _stale_count[0] >= _HEARTBEAT_STALE_CYCLES:\n+                    logger.warning(\n+                        \"Subagent %d appears stale (no iteration progress \"\n+                        \"for %d heartbeat cycles) — stopping heartbeat\",\n+                        task_index, _stale_count[0],\n+                    )\n+                    break  # stop touching parent, let gateway timeout fire\n+\n                 if child_tool:\n                     desc = (f\"delegate_task: subagent running {child_tool} \"\n                             f\"(iteration {child_iter}/{child_max})\")","@@ -744,7 +792,63 @@ def _heartbeat_loop():\n             if parent_task_id else []\n         )\n \n-        result = child.run_conversation(user_message=goal, task_id=child_task_id)\n+        # Run child with a hard timeout to prevent indefinite blocking\n+        # when the child's API call or tool-level HTTP request hangs.\n+        child_timeout = _get_child_timeout()\n+        _timeout_executor = ThreadPoolExecutor(max_workers=1)\n+        _child_future = _timeout_executor.submit(\n+            child.run_conversation, user_message=goal, task_id=child_task_id,\n+        )\n+        try:\n+            result = _child_future.result(timeout=child_timeout)\n+        except Exception as _timeout_exc:\n+            # Signal the child to stop so its thread can exit cleanly.\n+            try:\n+                if hasattr(child, 'interrupt'):\n+                    child.interrupt()\n+                elif hasattr(child, '_interrupt_requested'):\n+                    child._interrupt_requested = True\n+            except Exception:\n+                pass\n+\n+            is_timeout = isinstance(_timeout_exc, (FuturesTimeoutError, TimeoutError))\n+            duration = round(time.monotonic() - child_start, 2)\n+            logger.warning(\n+                \"Subagent %d %s after %.1fs\",\n+                task_index,\n+                \"timed out\" if is_timeout else f\"raised {type(_timeout_exc).__name__}\",\n+                duration,\n+            )\n+\n+            if child_progress_cb:\n+                try:\n+                    child_progress_cb(\n+                        \"subagent.complete\",\n+                        preview=f\"Timed out after {duration}s\" if is_timeout else str(_timeout_exc),\n+                        status=\"timeout\" if is_timeout else \"error\",\n+                        duration_seconds=duration,\n+                        summary=\"\",\n+                    )\n+                except Exception:\n+                    pass\n+\n+            return {\n+                \"task_index\": task_index,\n+                \"status\": \"timeout\" if is_timeout else \"error\",\n+                \"summary\": None,\n+                \"error\": (\n+                    f\"Subagent timed out after {child_timeout}s with no response. \"\n+                    \"The child may be stuck on a slow API call or unresponsive network request.\"\n+                ) if is_timeout else str(_timeout_exc),\n+                \"exit_reason\": \"timeout\" if is_timeout else \"error\",\n+                \"api_calls\": 0,\n+                \"duration_seconds\": duration,\n+                \"_child_role\": getattr(child, \"_delegate_role\", None),\n+            }\n+        finally:\n+            # Shut down executor without waiting — if the child thread\n+            # is stuck on blocking I/O, wait=True would hang forever.\n+            _timeout_executor.shutdown(wait=False)\n \n         # Flush any remaining batched progress to gateway\n         if child_progress_cb and hasattr(child_progress_cb, '_flush'):\n"]}]},{"kind":"pr","number":13797,"title":"fix(delegate): add timeout to subagent run_conversation() (Closes #13768)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#13770","number":13770},{"kind":"pr","value":"#13768","number":13768}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":46186,"title":"fix(delegate): surface progress while children are waiting","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"cd9f5cc6718050819df17d4a3c07e199faf6e284","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}