Skip to content

fix(task): propagate interruptions to active subagents - #4108

Open
bozhnyukAlex wants to merge 3 commits into
OpenHands:mainfrom
bozhnyukAlex:fix/task-subagent-interruption
Open

fix(task): propagate interruptions to active subagents#4108
bozhnyukAlex wants to merge 3 commits into
OpenHands:mainfrom
bozhnyukAlex:fix/task-subagent-interruption

Conversation

@bozhnyukAlex

@bozhnyukAlex bozhnyukAlex commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

HUMAN:
I reviewed the implementation. Need this thing for production use.


AGENT:

Why

Interrupting a parent LocalConversation while its task tool was running only paused the parent. TaskExecutor inherited the no-op ToolExecutor.interrupt(), so the active subagent continued consuming iterations and model calls.

Summary

  • Delegate TaskExecutor.interrupt() to TaskManager.
  • Interrupt every active subagent conversation without holding the task registry lock during callbacks.
  • Track interrupt generations so cancellation is not lost while a subagent is starting.
  • Run subagent conversations through arun() so an in-flight model request can be cancelled.
  • Interrupt active subagents during manager shutdown.
  • Add manager, executor, and end-to-end parent/subagent interruption coverage.

Issue Number

Closes #4107.

How to Test

  • uv run pre-commit run --all-files --show-diff-on-failure
  • uv run pytest tests/tools/task -q
  • uv run pytest tests/agent_server/test_openapi_discriminator.py tests/sdk/context/test_agent_context_serialization.py tests/sdk/conversation/local/test_conversation_core.py tests/sdk/conversation/local/test_state_serialization.py tests/tools/file_editor/test_memory_usage.py -q
  • uv run pytest tests/tools/terminal -q (311 passed; two environment-contaminated cases passed when rerun in isolation after cleaning a stale tmux server)

End-to-end evidence: test_parent_interrupt_stops_active_subagent starts a real parent conversation with TaskToolSet, waits for a blocking subagent LLM call, interrupts the parent, and verifies both cancellation of the child request and the parent's PAUSED state.

Video/Screenshots

Not applicable; this changes runtime cancellation behavior and is covered by an end-to-end test.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update

Notes

The synchronous TaskManager.start_task() contract is unchanged. Only the internal subagent run loop is asynchronous so SDK interruption can cancel in-flight work.

Cancellation propagation applies when the parent conversation is driven through LocalConversation.arun(). Parents using synchronous run() retain cooperative pause semantics and cannot interrupt an in-flight subagent call.

Task subagents now run through arun() and therefore use the separate LLM.acompletion() path instead of LLM.completion(). Custom LLM subclasses used by task subagents must support that asynchronous method.

Co-authored-by: openhands <openhands@all-hands.dev>
@all-hands-bot

Copy link
Copy Markdown
Collaborator

[Automatic Post]: I have assigned @enyst as a reviewer based on git blame information. Thanks in advance for the help!

This comment was created by an AI agent (OpenHands) on behalf of the user.

@VascoSch92 VascoSch92 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Went through this carefully and the bug is definitely real. I ran the repro from #4107 on both branches and got call_count 0 on main vs 1 here, and your new test_parent_interrupt_stops_active_subagent fails on main and passes on this branch. Task suite is green for me too (68/68).

One thing I want to highlight because it's easy to miss on a skim: swapping run() for arun() isn't cosmetic, it's the part that actually makes this work. LocalConversation.interrupt() only cancels _arun_task, and that's only ever set by arun(). If the child had stayed on sync run() you'd have fallen through to pause(), which doesn't take effect until between iterations, so it would never cancel an in-flight LLM call. Nice catch.

A few comments inline, none of them blocking.

Also worth recording two things I went looking for and didn't find: nesting asyncio.run() inside a running loop isn't a problem here, since tool calls always get dispatched to a worker thread via run_in_executor; and I wondered about _tasks_lock contention while _create_task holds it, but it only measures 1-3ms because tools initialize lazily outside the lock.

Happy to approve once the description picks up the sync-parent and acompletion points.

Comment thread openhands-tools/openhands/tools/task/manager.py
Comment thread openhands-tools/openhands/tools/task/manager.py
Comment thread openhands-tools/openhands/tools/task/impl.py
Comment thread tests/tools/task/test_task_manager.py

@VascoSch92 VascoSch92 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A couple of comments :-)

Александр Божнюк and others added 2 commits July 20, 2026 17:39
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>

@VascoSch92 VascoSch92 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One thing I want to confirm before approving: routing subagents through arun() means they now always use LLM.acompletion(), even when the parent runs synchronously. Since acompletion() is an independent litellm path and does not delegate to completion(), any custom LLM subclass that overrides only completion() will have that override silently bypassed for subagents.

It's documented in the Notes, but should we also surface it in the changelog / release notes so downstream users with custom sync-only LLMs aren't caught out? @enyst

Otherwise LGTM

@all-hands-bot

Copy link
Copy Markdown
Collaborator

[Automatic Post]: It has been a while since there was any activity on this PR. @bozhnyukAlex, are you still working on it? If so, please go ahead, if not then please request review, close it, or request that someone else follow up.

This comment was created by an AI agent (OpenHands) on behalf of the user.

@bozhnyukAlex

Copy link
Copy Markdown
Contributor Author

Still active and ready to merge. All review comments have been addressed, the review threads are resolved, and CI is green.
@enyst, could you please confirm whether a changelog entry is needed for the LLM.acompletion() behavior change? If not, this should be ready to merge. Thanks!

@enyst enyst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👋 I'm an AI agent (Opus 5) reviewing this PR on behalf of Engel Nyst (@enyst).

🔴 Needs improvement - The direction is right, but the cancellation handoff still loses an interrupt in one real race window and the async conversion breaks an existing customization contract.

[CRITICAL ISSUES]

  • [openhands-tools/openhands/tools/task/manager.py, Line 379] Lost interrupt between the generation check and arun() registration: _was_interrupted() is checked before send_message() and _run_until_finished(). If TaskManager.interrupt() lands after that check but before LocalConversation.arun() sets _arun_task and _cancel_token, conversation.interrupt() falls back to pause(). arun() then treats PAUSED as resumable and sets the child back to RUNNING, so the interrupt this PR is meant to make durable is lost. Close the handoff by registering the async run before the final generation check (or otherwise carrying a durable cancellation token into startup), and add a deterministic barrier test for this exact interval; the current tests cover interruption before _run_task() and after the child LLM call starts, but not between them.
  • [openhands-tools/openhands/tools/task/manager.py, Line 430] Breaking change for sync-only custom LLM subclasses: every task subagent now goes through arun()/LLM.acompletion(). The inherited acompletion() path calls the async LiteLLM transport directly; it does not delegate to a subclass's overridden completion(). Existing custom task agents that intentionally implement only the long-standing synchronous override will now bypass their implementation and may fail or call an unintended provider. The PR description acknowledges the new requirement, but documentation does not make a userspace break acceptable. Preserve the old override contract through a compatibility fallback/adapter or a deprecation path, and add a regression test with an LLM subclass that overrides only completion().

[TESTING GAPS]

  • [Overall PR] Eval-sensitive task behavior: this changes tool execution and subagent orchestration, and I found no completed eval-monitor run plus human confirmation. Per this repository's review guide, a human maintainer should decide after lightweight eval coverage once the correctness issues above are fixed.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🔴 HIGH
    This touches core subagent orchestration and changes which public LLM method downstream customizations execute. The remaining startup race can leak model work after an interrupt, while the compatibility break can fail existing custom agents. Recommendation: Do not auto-merge. Request review from a human SDK architect and validate task-tool behavior with lightweight evals after fixing the startup handoff and sync-only LLM compatibility.

VERDICT:
Needs rework: The current implementation does not make startup cancellation fully durable and breaks existing custom LLM subclasses.

KEY INSIGHT:
Cancellation is only reliable when ownership transfers atomically from the manager's generation token to the child's registered async task.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: 7e9e5074bfe0ecf06ef512cc02f59065818adda6
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/853671da-bbf0-4fcb-897d-8aa2446e4229

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot 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.

Review: fix(task): propagate interruptions to active subagents

This PR addresses a real bug - parent interrupts were not reaching active subagents because TaskExecutor inherited the no-op ToolExecutor.interrupt(). The approach (delegate to TaskManager, run subagents through arun() so CancelledError can cancel in-flight LLM calls, and track interrupt generations for the pre-startup race) is sound and the test coverage is solid.

This review was created by an AI agent (OpenHands) on behalf of the repository reviewer.

Findings

1. Startup race window between the generation check and arun() registration (MEDIUM)

The generation check at _run_task line 379 closes the "interrupt before _run_task" race, but there is still a gap between that check and the point where LocalConversation.arun() sets _arun_task / _cancel_token (local_conversation.py lines 1916-1917). During this window, send_message() (line 384) and _ensure_agent_ready() (run via asyncio.to_thread inside arun(), which loads plugins and initializes the agent) execute. If TaskManager.interrupt() lands in this interval, conversation.interrupt() finds _arun_task is None and falls back to pause(), setting the subagent to PAUSED. When arun() then starts, its status normalization block resets PAUSED to RUNNING, and the interrupt is silently lost - the subagent proceeds to make model calls.

The existing tests cover interruption before _create_task completes (test_interrupt_before_registration_prevents_subagent_run) and after the child LLM call starts (test_parent_interrupt_stops_active_subagent), but not this intermediate window. A deterministic barrier test for the interval between the generation check and arun() registration would close the coverage gap. One possible fix: re-check _was_interrupted() inside _arun_until_finished right before (or immediately after) the first await conversation.arun(), or pre-register a cancellation token on the subagent conversation before entering arun().

2. arun() switch bypasses sync-only custom LLM subclasses (LOW-MEDIUM)

Subagents now go through arun() then Agent.astep() then amake_llm_completion() then LLM.acompletion(). The base LLM.acompletion() is a fully independent async implementation (litellm acompletion transport) and does not delegate to a subclass's overridden completion(). A custom LLM subclass that overrides only completion() - a previously valid pattern for task subagents since they used run() then step() then make_llm_completion() then completion() - will now have that override silently bypassed. TestLLM is unaffected because it overrides both methods (with acompletion() delegating to completion()). The PR description acknowledges this in the Notes section, which is good, but since the PR is classified as a non-breaking bug fix, consider whether a deprecation note or compatibility adapter is warranted for downstream users with sync-only custom LLMs.

3. close() calls interrupt() but does not evict conversations (LOW)

close() (line 497) now calls self.interrupt() before clearing _tasks. interrupt() schedules cancellation on subagent event loops via call_soon_threadsafe, but close() immediately proceeds to clear the _tasks dict without calling _evict_task() (which would pause() + close() each conversation). If a subagent's asyncio.run() is still in-flight when _tasks.clear() runs, the worker thread's _run_task finally block may attempt to evict a task that was already removed from the dict. The consequences are minor (at worst a logged warning), and this behavior is pre-existing for the non-interrupt path, but it is worth noting that interrupt() during close() is fire-and-forget - the subagent conversations are not guaranteed to be cleanly shut down before the manager is destroyed.

Positive aspects

  • The interrupt generation counter is a clean design for the pre-startup race.
  • Copying the active task snapshot outside the lock (lines 481-487) correctly avoids holding _tasks_lock during conversation.interrupt() callbacks.
  • InterruptibleSubagentLLM in the end-to-end test is a well-constructed fixture that verifies actual CancelledError propagation through the async LLM path.
  • Exception handling in interrupt() (lines 489-493) prevents one failing subagent interrupt from blocking others.

Risk Assessment: MEDIUM

The PR correctly fixes a real bug and the core cancellation path (interrupt then call_soon_threadsafe(task.cancel) then CancelledError then PAUSED) works reliably once arun() has registered. The startup race window (Finding 1) is the primary concern - it is narrow but non-trivial for fresh subagents where _ensure_agent_ready performs plugin/agent initialization. The sync-only LLM compatibility change (Finding 2) is acknowledged but could surprise downstream users. Neither is blocking for merging as a bug fix, but both should be tracked.

) -> None:
"""Run a sub-agent conversation to completion, handling confirmations."""
conversation.run()
asyncio.run(self._arun_until_finished(task_id, conversation))

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.

Startup race window: The generation check at line 379 passes, then send_message() (384) and _run_until_finished() execute before LocalConversation.arun() sets _arun_task and _cancel_token (local_conversation.py:1916-1917). If interrupt() lands in this interval, conversation.interrupt() finds _arun_task is None and falls back to pause() then PAUSED. arun() then normalizes PAUSED to RUNNING (local_conversation.py:1948-1953), silently losing the interrupt. The subagent proceeds to make model calls despite the parent being interrupted.

The generation token closes the pre-_run_task race but not this one. Consider re-checking _was_interrupted(interrupt_generation) inside _arun_until_finished right before the first await conversation.arun(), or pre-registering a cancellation token before entering arun(). A deterministic barrier test for this interval would also close the coverage gap - the existing tests cover before-_create_task and after-LLM-start, but not the window between the generation check and arun() registration.

self, task_id: str, conversation: LocalConversation
) -> None:
"""Run a sub-agent asynchronously so in-flight work can be interrupted."""
await conversation.arun()

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.

Sync-only LLM compatibility: Switching to arun() routes subagent LLM calls through Agent.astep() then amake_llm_completion() then LLM.acompletion(). The base LLM.acompletion() is an independent async implementation (litellm acompletion) and does not delegate to a subclass's overridden completion(). Custom LLM subclasses that override only completion() - valid before since subagents used run() then step() then completion() - will now have that override silently bypassed for task subagents. TestLLM is unaffected (it overrides both, with acompletion() delegating to completion()). The PR description acknowledges this, but since the change is classified as non-breaking, consider whether a compatibility adapter or deprecation note is warranted for downstream sync-only custom LLMs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: TaskToolSet does not propagate parent interruption to active subagents

4 participants