Repository navigation
fix(subagents): preserve batch stop cleanup across cancellation - #6552
Conversation
| cleanup_task = getattr(self, "_stop_cleanup_task", None) | ||
| if cleanup_task is not None: | ||
| cleanup_task.result() |
There was a problem hiding this comment.
[P2] Reset the completed cleanup task before restarting
After start → stop → start → stop, the final stop() reuses the first generation's completed _stop_cleanup_task. It returns without setting _stop, requesting cancellation, or draining the restarted poller and item tasks, so owned work can outlive the runtime's shutdown.
I reproduced this with an event-based regression: the restarted poller is drained on base 5ecc1c2a, but remains pending on PR head 8fc403b0. All 30 existing related unit tests pass; the restart tests currently call the final stop() without asserting that the new poller stopped.
Clear _stop_cleanup_task after cleanup_task.result() succeeds, before starting the new generation, and assert that the second stop terminates the restarted poller and clears ownership. That reset passes both the timeout-preservation and restart probes in an isolated candidate implementation.
Why
The durable batch stop added by #6517 directly awaited the poller and item tasks after cancelling them.
asyncio.wait_for()in the Gateway cancels itsstop()caller at the shutdown deadline; that cancellation propagates throughgather()into the service-owned tasks while they are already draining their own cancellation cleanup.The result is not just a slow shutdown. Caller cancellation can mutate or terminate the only cleanup operation, so a later
stop()does not join the original drain, item bookkeeping can remain owned after the child has been torn down, and a restart has no reliable lifecycle boundary. The existing cancellation-retry regression also hung on current main because the test waited for cleanup that its own cancelled waiter had re-cancelled.What changed
wait_for().stop()callers join the same cleanup task rather than issuing competing cancellations.start()fenced until the shared cleanup completes successfully; cleanup failures remain visible to later lifecycle calls.__new__shutdown fixture, and the real Gateway timeout boundary.This keeps the Gateway's five-second latency bound: the shutdown hook still returns after its deadline, while the service-owned cleanup continues under explicit ownership instead of being corrupted by that timeout.
Surface area
frontend/backend/appdocker/or sandboxed executionskills/backend/pyproject.tomlorfrontend/package.jsonScreenshots / Recording
N/A — backend lifecycle fix.
Bug fix verification
Tests:
backend/tests/test_subagent_batch_service.py::test_cancelled_batch_stop_allows_successful_retrybackend/tests/test_subagent_batch_service.py::test_concurrent_batch_stops_share_one_cleanupbackend/tests/test_subagent_batch_service.py::test_batch_stop_cleanup_failure_is_reported_to_retrybackend/tests/test_subagent_batch_shutdown_timeout.py::test_gateway_shutdown_timeout_does_not_cancel_batch_cleanupRed on current main
5ecc1c2a: yes. The pre-existing cancelled-stop test does not complete under an external deadline.faulthandler_timeout=10shows the event loop still waiting after cancellation. The Gateway regression likewise fails before the fix because its timeout cancels the owned cleanup instead of leaving a joinable cleanup generation.Green on this branch: yes. The focused lifecycle matrix is 30 passed, including the existing runtime-owned cancellation tests and the
__new__shutdown fixture.Collision audit immediately before submission:
gather()stop; it does not preserve a retryable cleanup owner. Its four backend shards pass because the hanging regression is deleted.SubagentBatchService.stop().Validation
Python 3.12.12, locked backend dependencies:
PYTHONPATH=. uv run pytest tests/test_subagent_batch_service.py tests/test_subagent_batch_shutdown_timeout.py tests/test_subagent_runtime.py -q— 30 passedmake lint— Ruff check clean; 1,895 files formattedmake test-blocking-io— 235 passedmake test— all four shards passed:git diff --check— cleanThe first local full-suite attempt exposed an incomplete environment-only
readabilipyNode dependency install. Its unrelated link-extraction file reproduced 22 failures alone. Restoring the lock-identical package's missing Node files made that file 48/48 green; the final two complete four-shard runs then passed.python3 scripts/check_agent_guidance.pystill reports the unchanged inheritedbackend/packages/harness/deerflow/agents/middlewares/AGENTS.mdchain at 98,404 bytes versus the 98,304-byte limit. This PR changes no guidance file;make lint, which checks the committed changed-file path policy, passes.AI assistance
Tool(s) used: Claude Code
How you used it: I used Claude Code to trace the asyncio cancellation propagation, reproduce the current-main hang, implement the service-owned cleanup generation and deterministic regressions, audit overlapping PRs, and run the focused and complete validation matrix. I reviewed the final three-file diff and validation evidence.