Skip to content

fix(harness): subagent event stream never closes when the parent is cancelled - #2481

Merged
jujn merged 3 commits into
agentscope-ai:mainfrom
gxgeek-n:fix/subagent-end-event-on-cancel
Jul 29, 2026
Merged

fix(harness): subagent event stream never closes when the parent is cancelled#2481
jujn merged 3 commits into
agentscope-ai:mainfrom
gxgeek-n:fix/subagent-end-event-on-cancel

Conversation

@gxgeek-n

Copy link
Copy Markdown
Contributor

Fixes #2480.

Problem

AgentSpawnTool emits AgentStartEvent unconditionally before subscribing to a sync-spawned child, then emits the matching AgentEndEvent from doOnTerminate. doOnTerminate fires on onComplete / onError but not on cancel, so a parent cancel (user "Stop", outer timeout(), Reactor dispose()) leaves the child's event stream open — consumers see a start with no end and keep rendering the subagent as running.

Verified Reactor semantics before touching anything (reactor-core 3.7.6):

terminal signal doOnTerminate doFinally
complete 1 1
error 1 1
cancel 0 1

Change

One call swapped, doOnTerminatedoFinally, so all three terminal signals close the stream.

Two things suggest this was an oversight rather than intent: doOnTerminate appears exactly once in the file, and the timeout-promotion path a few lines below already uses doFinally with a comment explicitly reasoning about doFinally(CANCEL).

Path 2 (deprecated SubagentEventBus) pairs events via the child's own isLast() and Path 3 is non-streaming, so neither needed changing.

Verification

AgentSpawnToolCancelEndEventTest:

case without fix with fix
parent cancel emits AgentEndEvent FAIL — expected 1, got 0 PASS
normal completion emits exactly one start + one end PASS PASS

The second case passing both ways is the point: it pins that the fix does not start emitting duplicate end events on the happy path.

Full agentscope-harness suite: 693 tests, 0 failures, 3 skipped. Worth running given the change makes the cancel path emit an event it previously did not.

Relationship to #2408 / #2412

Complementary, not overlapping. #2412 fixes whether the child execution actually stops (interruptAgent being a no-op for HarnessAgent, plus the child RuntimeContext mismatch); this fixes whether the emitted event stream closes. I checked #2412's diff — it does not reference doOnTerminate, AgentStartEvent or AgentEndEvent. Both touch AgentSpawnTool.java but different lines, so if #2412 lands first I am happy to rebase.

spotless:apply has been run.

… cancel

AgentSpawnTool emits an AgentStartEvent unconditionally before subscribing to
the child, then emits the matching AgentEndEvent from doOnTerminate. Reactor's
doOnTerminate fires on onComplete/onError but not on cancel, so cancelling the
parent (user "Stop", an outer timeout(), or any upstream Reactor cancel) leaves
the child's event stream open: consumers saw a start with no end and kept
rendering the subagent as running.

Switched to doFinally so all three terminal signals close the stream. The same
file already uses doFinally for the timeout-promotion path (and its comment
explicitly reasons about doFinally(CANCEL)), so this was an oversight rather
than intent.

Verified Reactor semantics directly before changing anything:

  complete   doOnTerminate=1  doFinally=1
  error      doOnTerminate=1  doFinally=1
  cancel     doOnTerminate=0  doFinally=1

Adds AgentSpawnToolCancelEndEventTest with two cases:
- parent cancel must still emit AgentEndEvent — fails without this change
  (expected 1, got 0)
- normal completion emits exactly one start and one end — passes both ways,
  so the fix does not introduce duplicate events on the happy path

Full agentscope-harness suite: 693 tests, 0 failures.

Scope note: distinct from agentscope-ai#2408/agentscope-ai#2412, which fix whether the child execution
actually stops (interruptAgent being a no-op for HarnessAgent). Those touch
interruptAgent and the child RuntimeContext; this touches only the event
pairing. Even with the child correctly interrupted, the event stream still has
to close.
Copilot AI review requested due to automatic review settings July 29, 2026 09:16
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a harness event-stream pairing bug where sync-spawned subagents could emit AgentStartEvent without a matching AgentEndEvent when the parent subscription is cancelled (because Reactor doOnTerminate does not run on cancel). The change ensures downstream consumers always see a closing end event and don’t keep rendering subagents as “running” after a parent cancel.

Changes:

  • Replace doOnTerminate with doFinally in AgentSpawnTool so AgentEndEvent is emitted on complete/error/cancel.
  • Add a regression test covering the cancel path (and a “happy path” assertion to prevent duplicate end events).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java Ensures AgentEndEvent is emitted even when the parent cancels, by switching to doFinally.
agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolCancelEndEventTest.java Adds coverage for parent-cancel event pairing and confirms normal completion emits exactly one start/end pair.

Comment on lines +113 to +116
subscription.dispose();

// Give the cancel signal a moment to run the terminate/finally callbacks.
Thread.sleep(300);

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review — Approved ✅

Summary

One-line fix: doOnTerminatedoFinally in AgentSpawnTool#execLocalSync() Path 1, ensuring AgentEndEvent is emitted on all three terminal signals (complete, error, cancel).

Analysis

Correctness: The Reactor semantics are accurately described. doOnTerminate skips cancel; doFinally handles all three. This is the standard pattern for cleanup that must run regardless of termination reason.

Scope: Minimal and precise — only the affected call site is changed. Path 2 (deprecated) and Path 3 (non-streaming) are correctly left untouched.

Comment quality: The inline comment clearly explains why doFinally is used instead of doOnTerminate, preventing future regressions. Good practice.

Test coverage: AgentSpawnToolCancelEndEventTest (187 lines) covers:

  • Cancel path emits AgentEndEvent (the bug fix)
  • Happy path still emits exactly one start + one end (no duplicates)
  • Uses Mono.never() + dispose() for deterministic cancel simulation

CI: License ✅, Module Sync ✅, builds pending (just triggered). Previous run on the same commit passed all checks.

Verdict

Clean, well-tested fix for a real event-stream leak. LGTM.

Review feedback: Thread.sleep(300) after dispose() makes the test slower than
needed and can still be flaky if a slow CI machine delays the cancel callbacks.

Now polls until AgentEndEvent is observed, with a 5 s ceiling and a 20 ms
interval, so it returns as soon as the event lands.
@gxgeek-n

Copy link
Copy Markdown
Contributor Author

Thanks @copilot — good point, fixed.

Thread.sleep(300) replaced with a bounded wait loop that returns as soon as the AgentEndEvent is observed (5 s ceiling, 20 ms interval):

long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (emitter.count(AgentEndEvent.class) == 0 && System.nanoTime() < deadline) {
    Thread.sleep(20);
}

Both cases still pass, and the cancel case no longer spends a fixed 300 ms waiting. It also still fails without the doFinally change in AgentSpawnTool (expected 1, got 0), so the bounded wait did not weaken the assertion into a no-op.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@gxgeek-n gxgeek-n changed the title fix(harness): emit AgentEndEvent for sync-spawned subagents on parent cancel fix(harness): subagent event stream never closes when the parent is cancelled Jul 29, 2026
@jujn
jujn merged commit d6787cc into agentscope-ai:main Jul 29, 2026
6 checks passed
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]: Parent cancel leaves a sync-spawned subagent's event stream open (AgentStartEvent with no AgentEndEvent)

4 participants