test(esc): pin async-subagent abort-controller isolation invariants#153
Merged
Conversation
After the REJECT_MESSAGE override fix in PR #150, a critic flagged a defensive concern: when the user presses ESC and then types resume, the engine's reset_abort_controller swaps in a fresh controller. If a subagent spawned in the previous turn held a stale (aborted) parent controller snapshot, _dispatch_single_tool would emit REJECT_MESSAGE for every tool the subagent ran — spurious user-rejected results. Verification confirmed the existing Python code already mirrors TS at typescript/src/tools/AgentTool/runAgent.ts:533-541: * Async agents get a fresh, unlinked AbortController at src/agent/run_agent.py:281-284. * Sync agents share params.parent_context.abort_controller at line 287 (current reference, mutated in place by engine.reset_abort_controller so the field reflects the new controller without snapshot staleness). * Engine.reset_abort_controller mutates the controller on the SAME tool_context object, so any holder of the context reference sees the new controller automatically. This is pure regression-test coverage — no production-code change — to lock down the invariants so a future refactor that switches the async path to a child-linked controller (which WOULD propagate parent abort and re-introduce the bug) is caught immediately. The 4 tests cover: 1. async path: explicit override → child controller IS the fresh instance, NOT a child-linked wrapper; parent abort does NOT propagate to child. 2. sync path: shared controller → child.abort_controller IS the parent's controller (same object); parent ESC reaches child for free. 3. End-to-end: parent ESC fires, parent controller swapped for a fresh one (simulating engine.reset_abort_controller), async subagent's controller remains untouched throughout. 4. Default fallback: no override, no share → child-linked branch fires (parent-abort propagates down one-way; child-abort does NOT propagate up). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
please resume, the engine'sreset_abort_controllerswaps in a fresh controller. If a subagent spawned in the previous turn held a stale (aborted) parent controller snapshot, the REJECT_MESSAGE override in_dispatch_single_toolwould fire for every tool the subagent ran — spurious user-rejected results.src/agent/run_agent.py:281-287already mirrors TStypescript/src/tools/AgentTool/runAgent.ts:533-541. Async agents get a fresh, unlinkedAbortController(); sync agents shareparams.parent_context.abort_controller(a current reference, mutated in place byengine.reset_abort_controller— no snapshot staleness possible).Tests
test_async_subagent_context_has_fresh_unlinked_controller— async path: explicit override IS the fresh instance, parent abort does NOT propagate.test_sync_subagent_shares_parent_controller_reference— sync path: child.abort_controller IS the parent's controller (same object).test_async_subagent_unaffected_by_parent_reset— end-to-end: parent ESC + parent controller swap (simulating engine reset), async subagent controller remains untouched.test_create_subagent_context_default_path_is_child_linked— default fallback: parent-abort propagates down (one-way); child-abort does NOT propagate up.TS parity
runAgent.ts:533-541→new AbortController()run_agent.py:281-284→AbortController()runAgent.ts:537→toolUseContext.abortControllerrun_agent.py:287→params.parent_context.abort_controllerforkedAgent.ts:354→createChildAbortController(parent)subagent_context.py:86→create_child_abort_controller(parent.abort_controller)Test plan
🤖 Generated with Claude Code