fix(harness): subagent event stream never closes when the parent is cancelled - #2481
Conversation
… 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.
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
There was a problem hiding this comment.
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
doOnTerminatewithdoFinallyinAgentSpawnToolsoAgentEndEventis 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. |
| subscription.dispose(); | ||
|
|
||
| // Give the cancel signal a moment to run the terminate/finally callbacks. | ||
| Thread.sleep(300); |
oss-maintainer
left a comment
There was a problem hiding this comment.
Review — Approved ✅
Summary
One-line fix: doOnTerminate → doFinally 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.
|
Thanks @copilot — good point, fixed.
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 |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Fixes #2480.
Problem
AgentSpawnToolemitsAgentStartEventunconditionally before subscribing to a sync-spawned child, then emits the matchingAgentEndEventfromdoOnTerminate.doOnTerminatefires ononComplete/onErrorbut not on cancel, so a parent cancel (user "Stop", outertimeout(), Reactordispose()) 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):
doOnTerminatedoFinallyChange
One call swapped,
doOnTerminate→doFinally, so all three terminal signals close the stream.Two things suggest this was an oversight rather than intent:
doOnTerminateappears exactly once in the file, and the timeout-promotion path a few lines below already usesdoFinallywith a comment explicitly reasoning aboutdoFinally(CANCEL).Path 2 (deprecated
SubagentEventBus) pairs events via the child's ownisLast()and Path 3 is non-streaming, so neither needed changing.Verification
AgentSpawnToolCancelEndEventTest:AgentEndEventThe 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-harnesssuite: 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 (
interruptAgentbeing a no-op forHarnessAgent, plus the childRuntimeContextmismatch); this fixes whether the emitted event stream closes. I checked #2412's diff — it does not referencedoOnTerminate,AgentStartEventorAgentEndEvent. Both touchAgentSpawnTool.javabut different lines, so if #2412 lands first I am happy to rebase.spotless:applyhas been run.