fix(task): keep idle timers alive during blocking code-explorer subagents - #4385
Conversation
Pulse parent activity while TaskToolSet runs code-explorer and other subagents so runtime idle killers do not restart mid-delegation and surface a false crash-recovery AgentErrorEvent. Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Cursor <cursoragent@cursor.com>
VascoSch92
left a comment
There was a problem hiding this comment.
Left a couple of comments.
Can you also trimmer the comments to be minimal and coincise. THanks
| if conversation is None: | ||
| continue | ||
| try: | ||
| conversation.interrupt() |
There was a problem hiding this comment.
interrupt_running_tasks() calls subagent conversation.interrupt() synchronously from the event loop; since subagents only ever run via blocking .run(), this falls into pause()'s unbounded blocking lock
| if task.status == TaskStatus.RUNNING and task.conversation is not None | ||
| ] | ||
| for task in running: | ||
| conversation = task.conversation |
There was a problem hiding this comment.
you can do
if conversation := task.cionversation is not None:| if parent is not None: | ||
| parent.notify_activity() | ||
|
|
||
| def interrupt_running_tasks(self) -> None: |
There was a problem hiding this comment.
If interrupt fires before the subagent's conversation.run() has started, pause() sets PAUSED, but run()'s startup logic unconditionally flips it back to RUNNING — the interrupt is silently swallowed.
| @@ -224,6 +224,7 @@ def _resume_task(self, resume: str, subagent_type: str) -> Task: | |||
| task_id=resume, subagent_type=subagent_type, link=link | |||
There was a problem hiding this comment.
_tasks_lock only protects the dict scan in interrupt_running_tasks(); _run_task/_evict_task mutate status/conversation and call pause()/close() on the same conversation from another thread without holding the lock. A race with a concurrent interrupt() call.
| @@ -0,0 +1,154 @@ | |||
| """Reproduce / guard OpenHands/OpenHands#16341 activity gap during tasks. | |||
There was a problem hiding this comment.
Included this script in another test script.
| ) | ||
| return sub_agent | ||
|
|
||
| def _pulse_parent_activity(self, _event: object = None) -> None: |
There was a problem hiding this comment.
_pulse_parent_activity(self, _event: object = None) has a dead default — it's only ever invoked via the callback dispatch, which always passes an event.
|
|
||
| # Minimum interval between activity heartbeat signals (seconds). Matches the | ||
| # ACP bridge throttle so idle trackers stay warm without flooding callbacks. | ||
| _ACTIVITY_SIGNAL_INTERVAL_SECONDS: Final[float] = 30.0 |
There was a problem hiding this comment.
_ACTIVITY_SIGNAL_INTERVAL_SECONDS = 30.0 duplicates _ACTIVITY_SIGNAL_INTERVAL in acp_agent.py — two independently hand-tuned copies of the same idle-kill throttle budget.
HUMAN:
I reviewed this locally and the focused pytest suite for the task idle-heartbeat fix passes. Ready for review.
AGENT:
Reproduced the OpenHands/OpenHands#16341 failure path on current SDK main:
taskActionEvent withsubagent_type="code-explorer"andexecution_status=RUNNINGonEventService.start()emits exactly:A restart occurred while this tool was in progress. This may indicate a fatal memory error or system crash. The tool execution was interrupted and did not complete.TaskExecutorblocks on a subagent, the parent conversation emits no further events, so idle activity (update_last_execution_time/ conversationtouch) stalls for the whole delegation.Commands run:
Focused suites passed (activity-pulse + interrupt regression, heartbeat wiring, screenshot-matching crash-recovery case).
Not run: full Agent Canvas + live minimax multi-minute hang. Verification is the SDK idle-gap + crash-recovery path that produces the reported UI error.
Why
When the parent agent delegates to the built-in
code-explorersubagent viaTaskToolSet, the parent conversation event log goes quiet for the whole blocking call. Subagent events live on a childLocalConversation, so EventService idle trackers stop advancing. After several minutes a runtime/process restart reloads the conversation withRUNNINGand an unmatchedtaskaction, and crash recovery surfaces the generic "restart occurred / fatal memory error"AgentErrorEvent— matching the #16341 screenshot — even though the real issue was missing activity heartbeats during long delegation (same class of problem ACP already solved with_on_activity).Summary
LocalConversation.set_on_activity/notify_activityfor host idle trackerstouch()+update_last_execution_timeTaskManagerduring subagent work (force pulse at task start; event callbacks while the child runs)TaskExecutor.interrupt()to in-flight subagent conversationsIssue Number
OpenHands/OpenHands#16341
How to Test
software-agent-sdkcheckout withmake buildalready done:OH_AGENT_SERVER_LOCAL_PATH=/abs/path/to/software-agent-sdk):code-explorerVideo/Screenshots
N/A — covered by SDK unit/integration tests that assert the exact crash-recovery error string and parent activity pulses during a blocking task. Reporter screenshot for the pre-fix UI error is on OpenHands/OpenHands#16341.
Type
Notes
AgentErrorEvent.openhands-agent-server, Agent Canvas may need aversions.agentServerbump in@openhands/agent-canvasto pick it up by default.TaskExecutor.interrupt()as part of hardening mid-task failure handling.