Skip to content

fix(delegation): timeout stuck async child runners - #60234

Closed
izumi0uu wants to merge 1 commit into
NousResearch:mainfrom
izumi0uu:fix/async-delegation-watchdog-60203
Closed

izumi0uu wants to merge 1 commit into
NousResearch:mainfrom
izumi0uu:fix/async-delegation-watchdog-60203

Conversation

@izumi0uu

@izumi0uu izumi0uu commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes a stuck lifecycle path for delegate_task(background=true): if the detached async delegation runner wedges before it returns and enqueues a completion event, gateway sessions can keep seeing only the original dispatched handle while the async slot remains occupied.

This PR makes the async delegation registry enforce the existing delegation.child_timeout_seconds setting for detached runners. When a still-running async delegation exceeds that timeout, the registry now emits one terminal timeout completion event, releases the async slot, drops the interrupt closure, and best-effort interrupts the detached child. Late worker returns are ignored so the parent cannot receive duplicate completion events.

Related Issue

Fixes #60203

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • Tests (adding or improving test coverage)

Changes Made

  • tools/async_delegation.py: add per-dispatch timeout watchdogs for single and batch async delegations.
  • tools/async_delegation.py: make normal finalizers no-op after timeout/interruption so a late runner cannot enqueue a second completion event.
  • tools/delegate_tool.py: pass the configured child timeout into the background async registry path.
  • tests/tools/test_async_delegation.py: cover stuck single runners, stuck batch runners, and the delegate-tool timeout handoff.

How to Test

  1. .venv/bin/python -m pytest tests/tools/test_async_delegation.py tests/tools/test_delegate_subagent_timeout_diagnostic.py tests/tools/test_delegate.py -q
  2. .venv/bin/python -m ruff check tools/async_delegation.py tools/delegate_tool.py tests/tools/test_async_delegation.py
  3. git diff --check upstream/main...HEAD

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS / Python venv targeted test suite

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Screenshots / Logs

Targeted validation:

181 passed in 13.93s
All checks passed!

@alt-glitch alt-glitch added type/bug Something isn't working tool/delegate Subagent delegation P2 Medium — degraded but workaround exists labels Jul 7, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for isolating the outer-runner lifecycle gap. The premise remains valid on current main: tools/async_delegation.py:722-745 only finalizes after the detached batch runner returns, while tools/delegate_tool.py:1917-1956 applies child_timeout_seconds only to the inner child future.

Problems

  • This branch predates current durable completion handling. Current tools/async_delegation.py:565-584 keeps records finalizing through terminal persistence, and tools/async_delegation.py:634 persists before queue publication. The watchdog must preserve that lifecycle rather than use the older direct terminal-state flow.
  • The new tests do not verify a timeout is durable and restart-recoverable, despite tools/async_delegation.py:266-290 restoring pending terminal events.

Suggested changes

  • Conflict-resolve the watchdog into the current finalization/persistence path and retain exactly-once terminal delivery.
  • Add an isolated durable-timeout/restart recovery test.

Automated hermes-sweeper review.

Comment thread tools/async_delegation.py
@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
Async background delegation can leave gateway sessions holding only a dispatched handle when the detached runner wedges before it can return and enqueue a completion. Enforce the configured child timeout in the async registry so the parent observes a terminal timeout event and the async slot is released.

Constraint: Issue NousResearch#60203 reports long-lived gateway processes with background child delegates that never produce completion events despite child_timeout_seconds being configured.

Rejected: Relying only on _run_single_child timeout handling | it cannot finalize the async registry when the outer runner thread itself never reaches normal completion.

Confidence: high

Scope-risk: narrow

Directive: Keep background delegation completion owned by the async registry whenever detached workers can outlive the caller's immediate control.

Tested: .venv/bin/python -m pytest tests/tools/test_async_delegation.py tests/tools/test_delegate_subagent_timeout_diagnostic.py tests/tools/test_delegate.py -q

Tested: .venv/bin/python -m ruff check tools/async_delegation.py tools/delegate_tool.py tests/tools/test_async_delegation.py

Tested: git diff --check

Not-tested: Multi-day real gateway degradation; covered with deterministic stuck-runner registry tests.
@izumi0uu
izumi0uu force-pushed the fix/async-delegation-watchdog-60203 branch from 556e08d to 5a3e153 Compare July 18, 2026 09:12
@izumi0uu

Copy link
Copy Markdown
Contributor Author

Addressed in 5a3e153.
I rebased the watchdog onto the current durable terminal lifecycle. Normal worker completion and watchdog timeout now share an atomic running -> finalizing transition, so only one path can claim finalization. The winning path remains finalizing through SQLite persistence and completion-queue publication, then transitions to its terminal status. A late worker therefore cannot emit a duplicate completion after the watchdog wins.
I also added:
a test proving timeout remains finalizing while persistence is blocked;
a real-process restart test proving the durable timeout is restored once, then no longer restored after acknowledgement;
single and batch timeout race coverage.

@DavidMetcalfe

Copy link
Copy Markdown
Contributor

Thanks for this fix @izumi0uu — the approach is exactly right.

Cross-vendor review results

I ran this through cross-vendor review (Gemini 3.6 Flash + GPT-OSS):

Gemini Flash: All 7 review questions rated ACCEPTABLE. The atomic _begin_finalization transition correctly prevents double-completion, the durability path integrates with current main's durable lifecycle, and the process-restart test proves recovery.

GPT-OSS: 6/7 ACCEPTABLE/NIT. One SHOULD-FIX about resource cleanup when the interrupted worker holds locks — however this is about pre-existing interrupt behavior in delegate_tool._run_single_child, not introduced by this PR. The PR ensures the interrupt actually fires in the previously-missing case.

Merge conflicts

The original branch had conflicts with current main (both tools/async_delegation.py and tools/delegate_tool.py gained new parameters upstream). PR #71096 is a cherry-pick of commit 5a3e153 onto current main with conflicts resolved (both upstream params + timeout_seconds kept). Authorship is preserved. All 197 targeted tests pass.

If you'd prefer to rebase your own branch instead, the conflicts are trivial — both are additive parameter additions that need to coexist. Happy to close #71096 if you push a rebase.

@izumi0uu

Copy link
Copy Markdown
Contributor Author

Thanks for this fix @izumi0uu — the approach is exactly right.

Cross-vendor review results

I ran this through cross-vendor review (Gemini 3.6 Flash + GPT-OSS):

Gemini Flash: All 7 review questions rated ACCEPTABLE. The atomic _begin_finalization transition correctly prevents double-completion, the durability path integrates with current main's durable lifecycle, and the process-restart test proves recovery.

GPT-OSS: 6/7 ACCEPTABLE/NIT. One SHOULD-FIX about resource cleanup when the interrupted worker holds locks — however this is about pre-existing interrupt behavior in delegate_tool._run_single_child, not introduced by this PR. The PR ensures the interrupt actually fires in the previously-missing case.

Merge conflicts

The original branch had conflicts with current main (both tools/async_delegation.py and tools/delegate_tool.py gained new parameters upstream). PR #71096 is a cherry-pick of commit 5a3e153 onto current main with conflicts resolved (both upstream params + timeout_seconds kept). Authorship is preserved. All 197 targeted tests pass.

If you'd prefer to rebase your own branch instead, the conflicts are trivial — both are additive parameter additions that need to coexist. Happy to close #71096 if you push a rebase.

Thanks for taking this forward. I’m happy for #71096 to supersede this PR. Since it preserves the original authorship, resolves the conflicts against current main, and passes the targeted tests, there’s no need for me to duplicate the rebase work here. Please feel free to close #60234 as superseded once #71096 lands.

teknium1 added a commit that referenced this pull request Jul 26, 2026
…nners

Replace the wall-clock timeout watchdog (from #60234) with progress-based
staleness detection, on by default with zero config:

- The async registry now accepts a progress_fn per dispatch; delegate_task
  wires a sampler over the batch's child agents (api_call_count +
  current_tool from get_activity_summary()).
- A single monitor thread sweeps running delegations: a child whose
  progress token keeps advancing is never touched, no matter how long it
  runs. A frozen token past the stale threshold (450s idle / 1200s
  in-tool, mirroring the sync-path heartbeat monitor) marks the record
  'stalling' and interrupts the child.
- A stalling child that unwinds within the grace window (120s) finalizes
  through the NORMAL path, preserving its partial results. One that never
  returns is force-finalized with a terminal 'stalled' completion event so
  the owning session hears an outcome and the async slot frees.
- Late runner returns after force-finalization are deduped by the
  begin/push/finish finalization split (kept from #60234).

Why not a timeout: delegation.child_timeout_seconds defaults to 0 by
deliberate design (DEFAULT_CHILD_TIMEOUT rationale) — a timeout-based
watchdog never arms for default configs, leaving the reported silent-
profile symptom (#60203) unfixed, and when armed it kills legitimately
slow heavy subagents mid-task. Progress detection distinguishes 'wedged
at first API call' from 'grinding through a 2h review'.

Builds on izumi0uu's finalization-atomicity work from #60234.
@teknium1

Copy link
Copy Markdown
Collaborator

Merged via PR #72227 — thank you for this fix! Your finalization-atomicity work (the atomic running→finalizing claim and late-return dedup) was cherry-picked onto current main with your authorship preserved in git history (commit 65420cd).

During review we replaced the wall-clock timeout watchdog half with progress-based staleness detection: delegation.child_timeout_seconds defaults to 0 by deliberate design, so a timeout-based watchdog would never arm for default configs (leaving the #60203 silent-profile symptom unfixed), and when armed it would kill legitimately slow heavy subagents mid-task. The merged version samples per-child progress (API calls, tool activity, streamed tokens) and only finalizes children that are truly frozen — on by default with zero config. Your begin/push/finish finalization split is the foundation that makes the force-finalize path safe.

@teknium1 teknium1 closed this Jul 26, 2026
zipzob added a commit to zipzob/hermes-agent that referenced this pull request Aug 6, 2026
Emit and persist completed concurrent tool results while the rest of the batch is still running. This reduces head-of-line blocking where one wedged tool hides already-finished tool output from the TUI/session DB until timeout.

Refs upstream tool-hang/session-state issues:
- NousResearch#57765
- NousResearch#60234
- NousResearch#57180
- NousResearch#60183
- NousResearch#39737

(cherry picked from commit 13d60bd7f26d9ad0ceec79d5b2717ad08bae5ae1)
(cherry picked from commit f62b365e9cd32f739ca18545f8285428003fbffd)
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…nners

Replace the wall-clock timeout watchdog (from NousResearch#60234) with progress-based
staleness detection, on by default with zero config:

- The async registry now accepts a progress_fn per dispatch; delegate_task
  wires a sampler over the batch's child agents (api_call_count +
  current_tool from get_activity_summary()).
- A single monitor thread sweeps running delegations: a child whose
  progress token keeps advancing is never touched, no matter how long it
  runs. A frozen token past the stale threshold (450s idle / 1200s
  in-tool, mirroring the sync-path heartbeat monitor) marks the record
  'stalling' and interrupts the child.
- A stalling child that unwinds within the grace window (120s) finalizes
  through the NORMAL path, preserving its partial results. One that never
  returns is force-finalized with a terminal 'stalled' completion event so
  the owning session hears an outcome and the async slot frees.
- Late runner returns after force-finalization are deduped by the
  begin/push/finish finalization split (kept from NousResearch#60234).

Why not a timeout: delegation.child_timeout_seconds defaults to 0 by
deliberate design (DEFAULT_CHILD_TIMEOUT rationale) — a timeout-based
watchdog never arms for default configs, leaving the reported silent-
profile symptom (NousResearch#60203) unfixed, and when armed it kills legitimately
slow heavy subagents mid-task. Progress detection distinguishes 'wedged
at first API call' from 'grinding through a 2h review'.

Builds on izumi0uu's finalization-atomicity work from NousResearch#60234.
prmartinow pushed a commit to prmartinow/hermes-agent that referenced this pull request Aug 26, 2026
…nners

Replace the wall-clock timeout watchdog (from NousResearch#60234) with progress-based
staleness detection, on by default with zero config:

- The async registry now accepts a progress_fn per dispatch; delegate_task
  wires a sampler over the batch's child agents (api_call_count +
  current_tool from get_activity_summary()).
- A single monitor thread sweeps running delegations: a child whose
  progress token keeps advancing is never touched, no matter how long it
  runs. A frozen token past the stale threshold (450s idle / 1200s
  in-tool, mirroring the sync-path heartbeat monitor) marks the record
  'stalling' and interrupts the child.
- A stalling child that unwinds within the grace window (120s) finalizes
  through the NORMAL path, preserving its partial results. One that never
  returns is force-finalized with a terminal 'stalled' completion event so
  the owning session hears an outcome and the async slot frees.
- Late runner returns after force-finalization are deduped by the
  begin/push/finish finalization split (kept from NousResearch#60234).

Why not a timeout: delegation.child_timeout_seconds defaults to 0 by
deliberate design (DEFAULT_CHILD_TIMEOUT rationale) — a timeout-based
watchdog never arms for default configs, leaving the reported silent-
profile symptom (NousResearch#60203) unfixed, and when armed it kills legitimately
slow heavy subagents mid-task. Progress detection distinguishes 'wedged
at first API call' from 'grinding through a 2h review'.

Builds on izumi0uu's finalization-atomicity work from NousResearch#60234.
zipzob added a commit to zipzob/hermes-agent that referenced this pull request Aug 30, 2026
Emit and persist completed concurrent tool results while the rest of the batch is still running. This reduces head-of-line blocking where one wedged tool hides already-finished tool output from the TUI/session DB until timeout.

Refs upstream tool-hang/session-state issues:
- NousResearch#57765
- NousResearch#60234
- NousResearch#57180
- NousResearch#60183
- NousResearch#39737

(cherry picked from commit 13d60bd7f26d9ad0ceec79d5b2717ad08bae5ae1)
zipzob added a commit to zipzob/hermes-agent that referenced this pull request Sep 3, 2026
Emit and persist completed concurrent tool results while the rest of the batch is still running. This reduces head-of-line blocking where one wedged tool hides already-finished tool output from the TUI/session DB until timeout.

Refs upstream tool-hang/session-state issues:
- NousResearch#57765
- NousResearch#60234
- NousResearch#57180
- NousResearch#60183
- NousResearch#39737

(cherry picked from commit 13d60bd7f26d9ad0ceec79d5b2717ad08bae5ae1)
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…nners

Replace the wall-clock timeout watchdog (from NousResearch#60234) with progress-based
staleness detection, on by default with zero config:

- The async registry now accepts a progress_fn per dispatch; delegate_task
  wires a sampler over the batch's child agents (api_call_count +
  current_tool from get_activity_summary()).
- A single monitor thread sweeps running delegations: a child whose
  progress token keeps advancing is never touched, no matter how long it
  runs. A frozen token past the stale threshold (450s idle / 1200s
  in-tool, mirroring the sync-path heartbeat monitor) marks the record
  'stalling' and interrupts the child.
- A stalling child that unwinds within the grace window (120s) finalizes
  through the NORMAL path, preserving its partial results. One that never
  returns is force-finalized with a terminal 'stalled' completion event so
  the owning session hears an outcome and the async slot frees.
- Late runner returns after force-finalization are deduped by the
  begin/push/finish finalization split (kept from NousResearch#60234).

Why not a timeout: delegation.child_timeout_seconds defaults to 0 by
deliberate design (DEFAULT_CHILD_TIMEOUT rationale) — a timeout-based
watchdog never arms for default configs, leaving the reported silent-
profile symptom (NousResearch#60203) unfixed, and when armed it kills legitimately
slow heavy subagents mid-task. Progress detection distinguishes 'wedged
at first API call' from 'grinding through a 2h review'.

Builds on izumi0uu's finalization-atomicity work from NousResearch#60234.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state tool/delegate Subagent delegation type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: delegate_task children hang at first API call after days of gateway uptime; process restart restores

4 participants