Subagent delegation hangs: timeouts, progress, and background mode
Cluster 1599 · agent. Data as of
2 open items. Fix merged in #13770 on 22 Apr 2026. Check it covers your case.
The short answer
- Known?
- Yes. 13 related items grouped here, 5 issues already closed.
- Fix event
- A fix was merged to main in PR #13770. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #13770. That is separate from the fix event. - Still open
- 1 issue and 1 PR.
Next step: read #13770, then check whether your report is the one still open.
Read #13770Pick up a Lab taskMost 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.
Open issues1
- Issue#45496[Bug]: delegate_task blocks parent session silently with no progress output when children hang on tool calls
Open PRs1
- PR#46186fix(delegate): surface progress while children are waiting
Fixes on record
-
#13770 fix(delegation): add hard timeout and stale detection for subagent execution
files:
scripts/release.py,tools/delegate_tool.pyShow the diff
-
scripts/release.py@@ -333,6 +333,7 @@ "[redacted:email]": "Aslaaen", "[redacted:email]": "pinion05", "[email protected]": "VTRiot", + "[redacted:email]": "iamagenius00", } -
tools/delegate_tool.py@@ -23,7 +23,7 @@ import os import threading import time -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError, as_completed from typing import Any, Dict, List, Optional from toolsets import TOOLSETS @@ -112,6 +112,31 @@ def _get_max_concurrent_children() -> int: return _DEFAULT_MAX_CONCURRENT_CHILDREN +def _get_child_timeout() -> float: + """Read delegation.child_timeout_seconds from config. + + Returns the number of seconds a single child agent is allowed to run + before being considered stuck. Default: 300 s (5 minutes). + """ + cfg = _load_config() + val = cfg.get("child_timeout_seconds") + if val is not None: + try: + return max(30.0, float(val)) + except (TypeError, ValueError): + logger.warning( + "delegation.child_timeout_seconds=%r is not a valid number; " + "using default %d", val, DEFAULT_CHILD_TIMEOUT, + ) + env_val = os.getenv("DELEGATION_CHILD_TIMEOUT_SECONDS") + if env_val: + try: + return max(30.0, float(env_val)) + except (TypeError, ValueError): + pass + return float(DEFAULT_CHILD_TIMEOUT) + + def _get_max_spawn_depth() -> int: """Read delegation.max_spawn_depth from config, clamped to [1, 3]. @@ -165,7 +190,9 @@ def _get_orchestrator_enabled() -> bool: DEFAULT_MAX_ITERATIONS = 50 +DEFAULT_CHILD_TIMEOUT = 300 # seconds before a child agent is considered stuck _HEARTBEAT_INTERVAL = 30 # seconds between parent activity heartbeats during delegation +_HEARTBEAT_STALE_CYCLES = 5 # mark child stale after this many heartbeats with no iteration progress DEFAULT_TOOLSETS = ["terminal", "file", "web"] @@ -689,6 +716,8 @@ def _run_single_child( # Without this, the parent's _last_activity_ts freezes when delegate_task # starts and the gateway eventually kills the agent for "no activity". _heartbeat_stop = threading.Event() + _last_seen_iter = [0] # mutable container for heartbeat stale detection + _stale_count = [0] def _heartbeat_loop(): while not _heartbeat_stop.wait(_HEARTBEAT_INTERVAL): @@ -704,6 +733,25 @@ def _heartbeat_loop(): child_tool = child_summary.get("current_tool") child_iter = child_summary.get("api_call_count", 0) child_max = child_summary.get("max_iterations", 0) + + # Stale detection: if iteration count hasn't advanced, + # increment stale counter. After N cycles with no + # progress, stop masking the hang so the gateway + # inactivity timeout can fire as a last resort. + if child_iter <= _last_seen_iter[0]: + _stale_count[0] += 1 + else: + _last_seen_iter[0] = child_iter + _stale_count[0] = 0 + + if _stale_count[0] >= _HEARTBEAT_STALE_CYCLES: + logger.warning( + "Subagent %d appears stale (no iteration progress " + "for %d heartbeat cycles) — stopping heartbeat", + task_index, _stale_count[0], + ) + break # stop touching parent, let gateway timeout fire + if child_tool: desc = (f"delegate_task: subagent running {child_tool} " f"(iteration {child_iter}/{child_max})") @@ -744,7 +792,63 @@ def _heartbeat_loop(): if parent_task_id else [] ) - result = child.run_conversation(user_message=goal, task_id=child_task_id) + # Run child with a hard timeout to prevent indefinite blocking + # when the child's API call or tool-level HTTP request hangs. + child_timeout = _get_child_timeout() + _timeout_executor = ThreadPoolExecutor(max_workers=1) + _child_future = _timeout_executor.submit( + child.run_conversation, user_message=goal, task_id=child_task_id, + ) + try: + result = _child_future.result(timeout=child_timeout) + except Exception as _timeout_exc: + # Signal the child to stop so its thread can exit cleanly. + try: + if hasattr(child, 'interrupt'): + child.interrupt() + elif hasattr(child, '_interrupt_requested'): + child._interrupt_requested = True + except Exception: + pass + + is_timeout = isinstance(_timeout_exc, (FuturesTimeoutError, TimeoutError)) + duration = round(time.monotonic() - child_start, 2) + logger.warning( + "Subagent %d %s after %.1fs", + task_index, + "timed out" if is_timeout else f"raised {type(_timeout_exc).__name__}", + duration, + ) + + if child_progress_cb: + try: + child_progress_cb( + "subagent.complete", + preview=f"Timed out after {duration}s" if is_timeout else str(_timeout_exc), + status="timeout" if is_timeout else "error", + duration_seconds=duration, + summary="", + ) + except Exception: + pass + + return { + "task_index": task_index, + "status": "timeout" if is_timeout else "error", + "summary": None, + "error": ( + f"Subagent timed out after {child_timeout}s with no response. " + "The child may be stuck on a slow API call or unresponsive network request." + ) if is_timeout else str(_timeout_exc), + "exit_reason": "timeout" if is_timeout else "error", + "api_calls": 0, + "duration_seconds": duration, + "_child_role": getattr(child, "_delegate_role", None), + } + finally: + # Shut down executor without waiting — if the child thread + # is stuck on blocking I/O, wait=True would hang forever. + _timeout_executor.shutdown(wait=False) # Flush any remaining batched progress to gateway if child_progress_cb and hasattr(child_progress_cb, '_flush'):
-
5 PRs closed without merging
From the close comments: closed without comment 1 · done elsewhere 1 · superseded by another PR 1 · other 2
-
#1600 feat(delegation): add background=true mode for fire-and-forget subagent tasks · other →
#1599 - #2567 feat(delegation): add background=true for fire-and-forget subagent tasks · closed without comment
- #2570 feat(delegation): add background=true for fire-and-forget subagent tasks · superseded by another PR
- #5587 feat(async-delegation): non-blocking background agent tools · other
-
#13797 fix(delegate): add timeout to subagent run_conversation() (Closes #13768) · done elsewhere →
#13770,#13768
Closed issues · 5
- Closed#1599feat(delegation): add background=true mode for fire-and-forget subagent tasks
- Closed#5586feat: non-blocking background agent delegation (async_delegation toolset)
- Closed#10678delegate_task can hang indefinitely while child run_conversation stalls with lingering CLOSE-WAIT sockets
- Closed#13768[Delegation] Subagent `run_conversation()` has no timeout — can block indefinitely on slow API/network
- Closed#47302Non-blocking subagent delegation: a stuck/slow child freezes the whole parent turn
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.11: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #13770
- Closed issues
- 5
- Attempts
- 5 PRs closed without merging
- Fix link checked
- 22 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.