fix(run): cancel sibling work when a concurrent run-path task fails - #4142
fix(run): cancel sibling work when a concurrent run-path task fails#4142PranavMishra28 wants to merge 8 commits into
Conversation
openai#4005 established that a failing branch of a concurrent fan-out must not leave its siblings running, and shipped `gather_with_cancel` for it. That fix was applied to enablement checks; the run path still used bare `asyncio.gather`, which propagates the first exception while siblings keep executing detached. The sharpest case is `_execute_tool_plan`, which gathers six tool-execution categories with no surrounding try. When one arm raises — for example `execute_computer_actions` on an unacknowledged safety check — the shell and apply-patch arms keep running, so commands execute and patches are written after the run has already failed. This also bypasses the batch executor's own cancel-and-drain logic, because the failure originates in a sibling arm rather than inside the batch. The same pair of user-supplied callables makes the inconsistency concrete: `realtime/openai_realtime.py` gathers `agent.get_system_prompt(...)` with `gather_with_cancel`, while both run-path turn functions gathered the same call with a bare `asyncio.gather`. `run.py` hand-rolls cancel-and-drain inline for the guardrail/model race, so the invariant is applied in three places and absent from `run_internal/`. Switch the 25 `run_internal/` gathers that lack `return_exceptions=True` to the existing helper. No new abstraction, no behavior change on the success path; `execute_mcp_approval_requests` wraps the result in `list()` to keep its declared return type. Deliberately unchanged: `run.py`'s guardrail/model race, which already cancels and drains and whose tripwire branch has distinct semantics; and the `voice/` and `sandbox/` gathers, which are separate subsystems.
seratch
left a comment
There was a problem hiding this comment.
Thanks for the contribution. The cancel-and-drain direction and the use of the existing helper look sound.
Before we merge this, please add one controlled cross-category regression test at the _execute_tool_plan boundary. Have an async shell or apply-patch operation start and block, then make another category fail, for example through a rejected computer safety check. The test should verify that the original exception is preserved, the sibling receives cancellation, and its finally block completes before the run returns.
The current prompt tests cover the duplicated prompt-resolution paths, but they do not lock down the side-effecting tool boundary that motivates the PR. One test at the shared planning boundary is sufficient; a per-call-site matrix is not needed.
|
also, please fix the errors of |
|
yep on it rn just saw the failed ci as well |
…oads Review feedback from openai#4142. `gather_with_cancel` only declared overloads for two and three awaitables, so the six-argument call in `_execute_tool_plan` matched the variadic `*awaitables: Awaitable[T]` overload and every unpacked element came back as the union of all six result types instead of its own. That is the `make typecheck` failure at tool_planning.py:677. Add overloads for four, five and six, matching the shape typeshed uses for `asyncio.gather`. Add one regression test at the shared planning boundary rather than per call site. A shell executor sets `shell_started`, blocks, records cancellation and sets `shell_finished` in its `finally`; the computer tool's `on_safety_check` waits on `shell_started` before rejecting, so the safety-check failure is guaranteed to land while the shell arm is in flight instead of racing it. The test asserts the original `UserError` propagates and that the sibling was cancelled and finished unwinding by the time `_execute_tool_plan` returns.
|
both pushed. typecheck was my fault, and the cause is a bit non obvious: for the test, it fails on main with "sibling shell command was not cancelled", so it pins the planning boundary rather than just the helper. kept it to the one test, no per call site matrix. green locally: lint, typecheck (mypy + pyright, 835 files), and 6232 + 38 tests. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 67346bee48
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
One scope question while you are in it, since it borders on what you asked me to trim. There are 7 remaining bare |
|
Thanks for checking. Please leave those outside this PR, and no follow-up PR is needed based on the call-site count alone.
Let's keep this PR scoped to |
_FunctionToolBatchExecutor spawns a task per function-tool call. On external cancellation it cancelled those tasks, attached loop-level result callbacks and returned immediately. That is correct for parent cancellation, which must not wait on tool cleanup, but it is wrong for the cross-category case: the _execute_tool_plan fan-out treats the arm's return as "this category is drained", so a handler was still mid-unwind (and still landing side effects) after the run had already raised. gather_with_cancel now records whether it is the one cancelling, so an arm can tell a failing sibling from an ancestor going away, and the executor waits a bounded 0.25s for its cancelled handlers only in the sibling-failure case. Tests: the new drain test fails without the executor change; the negative test pins that the flag stays False outside a failing gather, which is what keeps test_parent_cancellation_does_not_wait_for_tool_cleanup passing.
|
you're right, it wasn't. i had read that finding as already covered by the sibling cancel and it isn't, so thanks for holding it. reproduced it before touching anything: that's a mixed plan with one function tool and one computer action whose safety check rejects once the function tool is in flight. the function tool observes cancellation but has not finished unwinding by the time the mechanism is the one you flagged. what made this more than a one-liner: my first attempt just awaited the cancelled tasks there, and that broke so except BaseException as error:
scope.cancelling = not isinstance(error, asyncio.CancelledError)a real sibling failure is a non-cancellation exception, so it drains. an ancestor cancelling the gather, or an arm being cancelled directly, leaves the flag False and keeps today's prompt teardown. the executor waits a bounded 0.25s and only in the sibling-failure case; the callbacks are still attached first so anything outliving the window is still reported rather than dropped. two tests. on the other finding in that review, the terminal-event one on full gate green locally, both mypy and pyright, 6237 tests. and understood on scope, i've left |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c10e0ad87e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
one thing that needs you rather than me: the Tests workflow on both this and #4143 is sitting at |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1f79f1da9b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…pe, and keep post-invoke work Two follow-ups on the previous commit. The `not isinstance(error, CancelledError)` discriminator was wrong for an arm that raises CancelledError itself, which a shell, computer or custom executor does to report tool-local cancellation. The caller is not going away in that case, so the other arms still owe the fan-out a drained result, but the flag stayed false and they took the prompt teardown path. gather_with_cancel now waits on FIRST_COMPLETED and decides from which arm finished: an arm that ends badly comes back as a finished task, which proves this frame is alive and the cancellation originates here, while an ancestor cancelling us raises out of the wait itself. FIRST_EXCEPTION cannot be used because a cancelled task does not satisfy it, which is what let the old classification through. The cross-category drain also cancelled tasks that had already left the handler and entered their post-invoke phase (output guardrails, custom output extraction, on_tool_end). That lands the tool's side effect while skipping the lifecycle work the tool contract requires after it. It now uses the same _partition_pending_tasks split the in-batch sibling-failure path uses, and only for sibling failure; a parent cancellation is tearing the run down, so there is no lifecycle left to protect. Tests: four new cases. The arm-raised CancelledError case and the post-invoke case both fail without their respective changes; the other two pin the negative (ancestor cancellation stays classified as such) and positional result ordering through the new wait loop.
…ang the run gather_with_cancel drained cancelled arms with an unbounded await, so a shell, custom, computer, apply-patch or local-shell executor that catches CancelledError and keeps cleaning up stopped the failure that triggered the cancellation from ever propagating. Reproduced directly: the run hung and the sibling's RuntimeError never surfaced. The drain is now bounded by _ARM_DRAIN_SECONDS, chosen above the function-tool teardown window so an arm can still finish its own bounded cleanup. Arms that outlive the window are reported through a done callback rather than waited on, and results of arms that finished inside it are retrieved so a teardown failure is not logged as an exception that was never retrieved. Test uses the real drain window rather than a patched one, so on an unbounded drain it fails by timing out, which is the property being pinned.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9b404fa86
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| error = task.exception() | ||
| if error is not None: | ||
| logger.warning("Cancelled concurrent task failed after teardown: %s", error) |
There was a problem hiding this comment.
Redact late concurrent-task exceptions
When a cancellation-resistant arm—such as a shell/custom tool executor or prompt/model callback—finishes after the drain window with an exception containing user data, this callback stores and renders that exception as a logging argument even though the model/tool data logging flags default to redacted. This can expose secrets in the resulting LogRecord; use the redaction-aware mixed model/tool logging helper and cover both redacted and diagnostic modes.
AGENTS.md reference: AGENTS.md:L95-L95
Useful? React with 👍 / 👎.
| await _cancel_and_drain(tasks) | ||
| task.result() # re-raises the arm's exception | ||
| except BaseException: | ||
| await _cancel_and_drain(tasks) |
There was a problem hiding this comment.
Avoid cancelling sibling cleanup twice
When a sibling catches the first CancelledError to perform cleanup lasting longer than _ARM_DRAIN_SECONDS, the first drain returns with that task pending, then task.result() raises into this handler and invokes _cancel_and_drain() again. That second call sends another cancellation into the cleanup coroutine, commonly interrupting resource release rather than detaching it with the promised late-result callback. Fresh evidence after the earlier unbounded-drain comment is that the new bounded helper is called both before task.result() and again by the surrounding exception handler; re-raise an already-drained arm without entering the second drain.
AGENTS.md reference: AGENTS.md:L128-L128
Useful? React with 👍 / 👎.
Summary
#4005 established that a failing branch of a concurrent fan-out must not leave its siblings running, and shipped
gather_with_cancel(util/_asyncio_tasks.py) for it. That fix was applied to enablement checks. The run path still used bareasyncio.gather, which propagates the first exception while the siblings keep executing, detached, after the run has already unwound.The sharpest instance is
_execute_tool_plan(run_internal/tool_planning.py), which gathers six tool-execution categories with no surroundingtry:If
execute_computer_actionsraises — e.g.UserError("Computer tool safety check was not acknowledged")— the shell and apply-patch arms are not cancelled, so commands run and patches are written to disk after the run has failed. It also bypasses_FunctionToolBatchExecutor._raise_failure_after_draining_siblings, which exists to prevent exactly this one level down, because the failure originates in a sibling gather arm rather than inside the batch.The inconsistency is concrete for one specific pair of user-supplied callables:
realtime/openai_realtime.pygathersagent.get_system_prompt(...)withgather_with_cancel, while both run-path turn functions (run_single_turnandrun_single_turn_streamed) gathered the same call with a bareasyncio.gather. Separately,run.pyhand-rolls cancel-and-drain inline for the guardrail/model race, with a comment describing the invariant. So the rule is applied in three places in the codebase and is absent fromrun_internal/.This switches the 25
run_internal/gathers that lackreturn_exceptions=Trueto the existing helper — the tool-execution fan-out, the two prompt-resolution pairs, and the lifecycle-hook pairs. Mechanical call-site change, no new abstraction, no behavior change on the success path.execute_mcp_approval_requestswraps the result inlist()becausegather_with_cancelreturns a tuple and the function declares-> list[RunItem].Deliberately unchanged:
run.py's guardrail/model race (already cancels and drains, and its tripwire branch cancels conditionally — a mechanical swap would change semantics); the 7voice/andsandbox/gathers (separate subsystems); and everygather(..., return_exceptions=True)call, which already drains rather than orphaning.Test plan
Two regression tests in
tests/test_agent_prompt.py, one per loop, following the pattern #4005 established intests/test_handoff_tool.py(threeasyncio.Events:slow_started/slow_cancelled/slow_finished).An agent is given an async
instructionscallable that blocks forever and records whether it was cancelled, plus an asyncpromptcallable that waits for the first to start and then raises. Sinceget_system_promptandget_promptare gathered concurrently, the failing prompt must cancel the in-flight instructions callable:test_run_cancels_sibling_instructions_when_prompt_resolution_fails— non-streamed looptest_run_streamed_cancels_sibling_instructions_when_prompt_resolution_fails— streamed loopBoth assert
slow_cancelled.is_set()andslow_finished.is_set(). Onmainboth fail withassert Falseonslow_cancelled— the callable is still running when the run raises. With this change both pass.Verification (mandatory local run order from
AGENTS.md):Issue number
N/A — searched open and closed issues and PRs for
gather_with_cancel,asyncio.gather,noop_coroutine,sibling, andon_tool_start; the only related hits are #4005 (merged, established the helper) and #3406 (adds anon_tool_progresshook, which introduces another instance of the pattern rather than changing it). No duplicate found.Checks
.agents/skills/code-change-verification/scripts/run.sh/reviewbefore submitting this PRDeveloped with Claude Code; reviewed and tested by Pranav before marking ready for review.