fix(acp): abort the turn immediately when close() lands mid-prompt - #4334
fix(acp): abort the turn immediately when close() lands mid-prompt#4334onatozmenn wants to merge 2 commits into
Conversation
close() runs on whatever thread calls it and drops _executor while a concurrent step()/astep() may be between its own None check and the call, so the turn died with a confusing AttributeError: 'NoneType' object has no attribute 'run_async'. Killing the subprocess also surfaces as a retriable connection error first, so the turn slept through a full retry delay (5s by default, 50s across all attempts on the async path) before failing, even though a closed agent can never succeed. close() is the only way to abort a step() that is blocked for the whole turn, so that delay defeats it. Read the executor once behind _require_executor() and stop retrying as soon as _closed is set. The turn now fails immediately with ACPAgentClosedError, surfaced as an ACPAgentClosed error event. Fixes OpenHands#4329 Signed-off-by: onatozmenn <onatozmen44@gmail.com>
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Verdict: Approve (low risk)
The change correctly closes the teardown race described in #4329. I read the full step()/astep() retry loops, the _shutdown_runtime ordering, the new _require_executor() helper, and the error classifier, and ran the new and pre-existing TestACPPromptRetry suite (11/11 pass) plus the full tests/sdk/agent/test_acp_agent.py (436/436 pass).
Correctness
close()sets_closed = Trueunder_file_credential_lockbefore_shutdown_runtime()kills the subprocess/drops_executor. Because theConnectionErrorthat surfaces to the prompt loop is a consequence ofprocess.terminate(),_closedis alreadyTruewhen the retriable error is caught. Both retry loops read_closedand raiseACPAgentClosedErrorimmediately, so the doomed retry delay (and the subsequentNonederef) are gone. This ordering is the load-bearing part and it holds._require_executor()reads_executoronce into a local, closing the check-then-call TOCTOU for the initialrun_async/portalaccess. The post-call teardown window is then caught by the_closedguard inside the_RETRIABLE_CONNECTION_ERRORShandler. Layered correctly.ACPAgentClosedError(RuntimeError)keeps existingRuntimeErrorhandling working, and_classify_acp_turn_errorsurfaces a distinctACPAgentClosedcode. Good for clients that want to branch on "closed by someone" vs. a generic prompt failure._require_executor()returnsAny, which matches the existing_executor: AnyPrivateAttr typing; no new# type: ignoreintroduced.
Tests
Four new tests pin the two guards (sync + async) and the post-teardown None-executor case. The author states they neutralised both guards and confirmed all four fail (the async one visibly sleeping 5+15+30s), which is the right way to prove the tests exercise the new code rather than passing for incidental reasons. The sync test also asserts time.sleep is never called, which directly pins the "no retry delay" behaviour.
Non-blocking observations (not requesting changes)
-
Captured-portal asymmetry on the async path.
astepreadsportal = self._require_executor().portalonce before the retry loop, whilestepre-reads the executor (self._require_executor().run_async(...)) on every attempt. If a torn-down portal'sstart_task_soonraised a retriable error, the async loop would still abort via the_closedcheck; butanyioraises a plainRuntimeError("This portal is not running")there, which is not in_RETRIABLE_CONNECTION_ERRORS, so it would bypass the_closedguard and be classified asACPPromptErrorrather thanACPAgentClosed. I verified this empirically with a synthetic portal. In the realclose()flow this window is unreachable —_closedis set before the teardown that produces the firstConnectionError, so the first retriable error already hits the_closedcheck and the loop never reaches a second attempt against a stale portal. So this is defensive, not a live bug. If you ever want symmetry, re-readingself._require_executor().portalper attempt (or addingRuntimeErrorfrom a closed portal to the closed-agent path) would make the async path match the sync one, but it is not required for correctness today. -
Async test doesn't assert
asyncio.sleepwas not called.test_close_during_astep_aborts_without_retry_delayassertscall_count == 1, which transitively proves no sleep ran (sleep is inside the post-_closed-check branch that raises). The sync counterpart is more explicit (patch+mock_sleep.assert_not_called()). Minor inconsistency only; not worth a change. -
ACPAgentClosedErroris module-level but not exported fromopenhands.sdk. That's fine for now (callers catchRuntimeError/ConversationRunError), but if it is intended to be part of the public catch surface, consider documenting it. Not a blocker.
The fix is appropriately scoped to the teardown race and deliberately avoids the shared-lock alternative (which would make close() block for the duration of the turn it is meant to abort) — that trade-off is the right call.
astep read the portal once before the retry loop while step re-read the executor on every attempt. Teardown landing between two attempts would then hit a dead portal, and anyio raises a plain RuntimeError there, which is not retriable and so bypasses the _closed check and classifies as ACPPromptError rather than ACPAgentClosed. Unreachable through close() today, because _closed is set before the teardown that produces the first retriable error, so the loop never reaches a second attempt. Making both paths read the executor the same way removes the asymmetry rather than relying on that ordering. Raised by the automated review on OpenHands#4334. Signed-off-by: onatozmenn <onatozmen44@gmail.com>
|
Thanks for the read, and for checking the Took observation 1 in Added On 2, I left it alone for the reason you gave, plus a practical one: On 3, Investigated and implemented with AI assistance. |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Verdict: Approve (low risk)
The follow-up commit (b296caa) cleanly addresses the prior review's non-blocking observation about the async/sync portal-read asymmetry: astep now re-reads self._require_executor().portal per attempt inside the retry loop, mirroring step's per-attempt self._require_executor().run_async(...). I re-read the full step()/astep() retry loops, _shutdown_runtime ordering, _require_executor, _emit_turn_error, _classify_acp_turn_error, and classify_error, and ran the new + pre-existing TestACPPromptRetry suite (12/12 pass) and the full tests/sdk/agent/test_acp_agent.py (437/437 pass). ruff check/ruff format --check are clean on both touched files.
Correctness
- The per-attempt re-read in
astepcloses the only remaining gap. A teardown that lands after a retriable failure but before the next attempt now hits_require_executor()(which sees_executor is Noneand raisesACPAgentClosedError) instead of reaching a stale portal whosestart_task_soonwould raise anyio'sRuntimeError("This portal is not running")— a plainRuntimeErrorthat is not in_RETRIABLE_CONNECTION_ERRORSand would have bypassed the_closedguard. The new regression testtest_astep_reports_closed_agent_when_teardown_lands_between_retriespins exactly this. _require_executor()reads_executoronce into a local and also checks_closed, so the check-then-call TOCTOU on the initial access is closed, and the post-call teardown window is caught by the_closedguard inside the_RETRIABLE_CONNECTION_ERRORShandler.ACPAgentClosedError(RuntimeError)is not a member of_RETRIABLE_CONNECTION_ERRORS, so when_require_executor()raises it (between retries), it propagates straight to the outerexcept Exception→_emit_turn_error→_classify_acp_turn_error→"ACPAgentClosed". Correct, no accidental retry.close()sets_closed = Trueunder_file_credential_lockbefore_shutdown_runtime()kills the process/drops_executor, so theConnectionErrorthat surfaces to the prompt loop is a consequence of teardown and_closedis alreadyTruewhen it is caught. Both loops short-circuit immediately. The ordering is the load-bearing part and it holds.- Deliberately avoiding the shared-lock alternative (which would make
close()block for the duration of the turn it is meant to abort) is the right trade-off; Python's GIL makes the unlocked_closed/_executorreads acceptable here.
Tests
Five new tests pin both guards (sync + async), the post-teardown None-executor case, and the new between-retries re-read case. The author states they neutralised the guards and confirmed all fail (the async one visibly sleeping 5+15+30s), which proves the tests exercise the new code. The sync test additionally asserts time.sleep is never called, directly pinning the "no retry delay" behaviour.
Non-blocking observations (not requesting changes)
-
ACPAgentClosedis not mapped inclassify_error, so itsErrorClassificationisUNKNOWN(non-retryable). I verified this empirically:classify_error("ACPAgentClosed", "ACP agent was closed while a prompt was in flight")→UNKNOWN,retryable=False. For a closed agent, non-retryable is the correct semantic — you must not retry a torn-down agent — so this is fine and arguably desirable. The note is only that consumers branching onclassification.kindrather thancodeget no structured signal beyondUNKNOWN; if that matters for UX,ACPAgentClosedcould be added to the code-based table. Not a blocker. -
The async abort test does not assert
asyncio.sleepwas not called.test_close_during_astep_aborts_without_retry_delayassertscall_count == 1, which transitively proves no sleep ran (the sleep sits inside the post-_closed-check branch that raises). The sync counterpart is more explicit (patch+assert_not_called()). Minor inconsistency only.
The fix is appropriately scoped to the teardown race and is sound.
HUMAN:
I addressed this because closing the agent during an active prompt could trigger unnecessary retries and eventually fail with an unrelated
AttributeError. I verified the fix with the original reproducer and new regression tests, confirming that the turn now aborts immediately with the expected closed-agent error.AGENT:
Why
Fixes #4329.
ACPAgent.close()runs on whatever thread calls it and drops_executor/_process, while aConversation.run()on another thread can be insidestep()between its ownNonecheck and the call. Two things go wrong when that happens:Connection closed, which the prompt loop treats as a retriable connection error. So the turn sleeps a full retry delay (5s by default on the sync path, up to 5+15+30s on the async path) before it can fail, even though a closed agent can never succeed.close()from another thread is the only way to abort astep()that is blocked for the whole turn (pause()/interrupt()only take effect between steps), so that delay defeats the entire point of calling it.self._executor, which is nowNone, and the turn dies withAttributeError: 'NoneType' object has no attribute 'run_async'instead of anything a caller can act on.One correction to the issue report, which was filed against 1.38.0: on current
mainthe exception is not swallowed andrun()does exit, sincestep()re-raises after_emit_turn_error(added in #4126), soConversationRunErrorpropagates. What the reporter measured as "run() never returned within 5s" is the retry delay landing exactly on their 5s bound. The race itself and theAttributeErrorare real, and the abort latency is the substantive part.Summary
ACPAgent._require_executor()reads_executoronce so a concurrentclose()cannot null it out between the check and the call, and raisesACPAgentClosedError(aRuntimeErrorsubclass, so existingRuntimeErrorhandling still applies) when teardown has already started. Used by the sync prompt loop and by theastep()portal read._closedis set instead of sleeping through a retry delay._closedis assigned before teardown begins, so it also covers the window where_executoris still assigned but its portal is going away.astep()re-readsself._require_executor().portalon every attempt instead of capturing it once before the loop, mirroring what the sync path already does with the executor. Without that, a teardown landing between two attempts reaches a stale portal and anyio raisesRuntimeError("This portal is not running"), which is not in_RETRIABLE_CONNECTION_ERRORSand slips past the_closedguard as a genericACPPromptError.ACPAgentClosedcode on the emittedConversationErrorEvent, so a client can tell "someone closed this agent" apart from a generic prompt failure.Issue Number
Fixes #4329
How to Test
End-to-end, using the reporter's own reproducer (stdlib-only ACP stub whose
session/promptsleeps forever;close()is called from the main thread 1.5s into a blocked turn). I widened theirfuture.result(timeout=5)to 20s so the outcome is measured rather than clipped by the bound, and printed how long the abort actually took.Before, on
main@8ce9300(3/3):After, with this branch (3/3), no retry warning and no
AttributeError:Unit coverage, five new tests in
TestACPPromptRetry:test_close_during_prompt_aborts_without_retry_delay, one attempt,time.sleepnever called,ACPAgentClosedError, error event codeACPAgentClosedtest_close_during_astep_aborts_without_retry_delay, same for the async looptest_step_after_teardown_reports_closed_agent/test_astep_after_teardown_reports_closed_agent, a torn-down executor is reported as a closed agent, not aNonedereferencetest_astep_reports_closed_agent_when_teardown_lands_between_retries, the portal drops the executor after the first attempt and returns a retriable failure, so the loop reaches a second attempt against dead state. With the per-attempt read: one attempt,ACPAgentClosedError, codeACPAgentClosed. With the read hoisted back out of the loop: four attempts and aConnectionError.I checked the tests actually pin the behaviour by neutralising the guards: all of them fail (the async ones visibly sleeping 5+15+30s), and the seven pre-existing retry tests still pass.
tests/sdk/agent445 passed,tests/sdk/conversation764 passed / 1 skipped. Five tests were excluded as pre-existing Windows failures unrelated to this change, verified failing identically on unmodifiedmain: the fourTestACPFileSecretMaterialisation/TestACPDataDirIsolationcases (POSIX0o600expectations) andtest_conversation_large_event_handling(disk-speed timeout on this host).ruff format,ruff check,pycodestyle --max-line-length=88, andscripts/check_import_rules.pyare clean on both touched files.Video/Screenshots
The before/after console output above is the evidence; this path has no UI surface.
Type
Notes
_closedis set now fails asACPAgentClosedErrorrather than being retried. That only affects agents that are already being torn down, where every retry was doomed anyway.close()and the whole prompt path, would makeclose()block for the duration of the turn it is trying to abort, which is the opposite of what the reporter needs.ACPAgentClosedis deliberately not added toclassify_error's code table, so it classifies asunknown/ non-retryable.ACPStartupTimeoutandUsagePolicyRefusalcome out of the same module and are not in that table either, so adding only this one would make it the odd one out. Happy to do all three in a follow-up if that is wanted.LocalConversation.run()breaks onPAUSED/STUCK/FINISHEDbut not onERROR. It does not bite here becauseACPAgent.step()re-raises, but an agent that setsERRORwithout raising would be stepped again. Happy to open a separate issue if that is not intentional.Investigated and implemented with AI assistance.