Skip to content

fix(task): keep idle timers alive during blocking code-explorer subagents - #4385

Open
rainbowgore wants to merge 1 commit into
OpenHands:mainfrom
rainbowgore:fix/16341-code-explorer-task-activity-heartbeat
Open

fix(task): keep idle timers alive during blocking code-explorer subagents#4385
rainbowgore wants to merge 1 commit into
OpenHands:mainfrom
rainbowgore:fix/16341-code-explorer-task-activity-heartbeat

Conversation

@rainbowgore

@rainbowgore rainbowgore commented Aug 6, 2026

Copy link
Copy Markdown

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:

  1. Crash recovery: unmatched task ActionEvent with subagent_type="code-explorer" and execution_status=RUNNING on EventService.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.
  2. Activity gap: while TaskExecutor blocks on a subagent, the parent conversation emits no further events, so idle activity (update_last_execution_time / conversation touch) stalls for the whole delegation.

Commands run:

OTEL_SDK_DISABLED=true uv run pytest \
  tests/tools/task/test_issue_16341_code_explorer_restart.py \
  tests/agent_server/test_conversation_service.py::TestActivityHeartbeatWiring \
  tests/agent_server/test_event_service.py::TestEventServiceStartWithRunningStatus \
  -q --tb=short

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-explorer subagent via TaskToolSet, the parent conversation event log goes quiet for the whole blocking call. Subagent events live on a child LocalConversation, so EventService idle trackers stop advancing. After several minutes a runtime/process restart reloads the conversation with RUNNING and an unmatched task action, 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

  • Add throttled LocalConversation.set_on_activity / notify_activity for host idle trackers
  • Wire EventService activity heartbeats for all agents (not only ACP), including touch() + update_last_execution_time
  • Pulse parent activity from TaskManager during subagent work (force pulse at task start; event callbacks while the child runs)
  • Propagate TaskExecutor.interrupt() to in-flight subagent conversations
  • Add regression coverage for the #16341 activity gap, interrupt path, and crash-recovery error text

Issue Number

OpenHands/OpenHands#16341

How to Test

  1. From a software-agent-sdk checkout with make build already done:
    OTEL_SDK_DISABLED=true uv run pytest \
      tests/tools/task/test_issue_16341_code_explorer_restart.py \
      tests/agent_server/test_conversation_service.py::TestActivityHeartbeatWiring \
      tests/agent_server/test_event_service.py::TestEventServiceStartWithRunningStatus::test_issue_16341_code_explorer_task_restart_recovery \
      -q --tb=short
  2. Optional live check with Agent Canvas against this SDK checkout (OH_AGENT_SERVER_LOCAL_PATH=/abs/path/to/software-agent-sdk):
    • Enable sub-agents
    • Start a conversation that triggers code-explorer
    • Confirm the task completes (or fails with a real task error) instead of hanging for minutes and then showing the crash-recovery "restart occurred" agent error

Video/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

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

  • Fix belongs in software-agent-sdk (agent-server + TaskToolSet). Agent Canvas only renders the resulting AgentErrorEvent.
  • After this ships in a released openhands-agent-server, Agent Canvas may need a versions.agentServer bump in @openhands/agent-canvas to pick it up by default.
  • Related: [Bug]: TaskToolSet does not propagate parent interruption to active subagents #4107 (TaskToolSet interrupt propagation); this PR adds TaskExecutor.interrupt() as part of hardening mid-task failure handling.

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 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.

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()

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.

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

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.

you can do

    if conversation := task.cionversation is not None:

if parent is not None:
parent.notify_activity()

def interrupt_running_tasks(self) -> None:

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.

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

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.

_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.

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.

Included this script in another test script.

)
return sub_agent

def _pulse_parent_activity(self, _event: object = None) -> None:

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.

_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

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.

_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.

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.

2 participants