Repository navigation
fix(subagents): fence batch poller start while stop drains - #6517
WillemJiang merged 5 commits into
Conversation
willem-bd
left a comment
There was a problem hiding this comment.
Reviewed at ea52eb7. The poller ownership pin (kept until the drain completes) plus the _stopping fence does close the start-during-stop race within the single event loop, the overlapping-stop drain counting is the right idea, and the new regression test is deterministic. Two blocking concerns: (1) the new _stop_drains attribute raises AttributeError in the existing __new__-constructed service test in test_subagent_batch_shutdown_timeout.py (that test then hangs), and (2) a cancelled stop permanently latches _stopping — a successful retry stop cannot release the fence because the aborted drain's count is never reconciled. One coverage suggestion for the new concurrency paths.
| async def stop(self) -> None: | ||
| # Keep poller ownership visible until the entire drain finishes. | ||
| # Otherwise start() could create a fresh poller during this await. | ||
| self._stop_drains += 1 |
There was a problem hiding this comment.
Critical: this breaks the existing test backend/tests/test_subagent_batch_shutdown_timeout.py::test_batch_stop_requests_owned_cancellation_before_poller_drain. That test builds the service with SubagentBatchService.__new__(SubagentBatchService) (line 65 there) and sets only _stop, _poller, _executions, _execution_ids, _item_batches — it never runs __init__, so _stop_drains and _stopping do not exist on the instance and there is no class-level default. The new first statement of stop() reads self._stop_drains, which raises AttributeError: 'SubagentBatchService' object has no attribute '_stop_drains' before poller.cancel() ever runs, so that test then hangs forever at its await poller_cancelled.wait() (line 80) instead of asserting the cancellation ordering.
Fix options: give the class body defaults (_stopping: bool = False and _stop_drains: int = 0 at class scope) so __new__-constructed instances keep working, or update that test to set both attributes. Note the PR description says local/adjacent tests were not run — this is a case CI would have caught.
There was a problem hiding this comment.
Fixed: class defaults for _stopping and _stop_drains now support the existing __new__ shutdown fixture (4d0519b). Fresh CI pending.
| if self._poller is poller: | ||
| self._poller = None | ||
| self._stop_drains -= 1 | ||
| if self._stop_drains == 0: |
There was a problem hiding this comment.
Important: the drain counter never reconciles an aborted stop, so the fence can latch permanently. If stop() is cancelled at either await asyncio.gather(...) — the Gateway shutdown hook does exactly this via asyncio.wait_for(service.stop(), timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS) in _shutdown_subagent_batch_service (app/gateway/app.py) — the self._stop_drains -= 1 line is skipped and the counter stays above zero forever. A later fully-successful retry stop() only moves it from 1 to 2 and back to 1, so _stopping never returns to False even though _poller was cleared: start() raises RuntimeError for the rest of the process, with no poller running.
That undercuts the stated design ('Keep the fence after cancelled/failed stop, so a subsequent stop() can retry cleanup rather than silently releasing ownership'): cleanup retries fine, but the retry cannot release the fence. Suggested shape: wrap the drain section so cancellation is observable, e.g. set aborted = True in an except asyncio.CancelledError: raise, then in a finally do self._stop_drains -= 1 and clear _stopping only when not aborted and self._stop_drains == 0. That preserves the keep-fenced-until-a-stop-actually-completes intent while letting a successful retry restore startability.
Severity context: SubagentRuntime.stop() deliberately shields service.stop() from caller cancellation, so today the realistic trigger is the Gateway shutdown-timeout path (where the process exits anyway); but any future direct caller inherits the lockout, and neither behavior is pinned by a test.
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_start_cannot_replace_poller_while_stop_is_draining(monkeypatch: pytest.MonkeyPatch) -> None: |
There was a problem hiding this comment.
Suggestion: the _stop_drains counting added for overlapping stops (a second concurrent stop() must not let the first completed one reopen start()) has no coverage — this regression only exercises a single stop with one fenced start(). With the same cancellation-resistant poller you can deterministically cover (a) two concurrent stop() calls where the fence must stay up until the second drain finishes, and (b) a cancelled-then-retried stop, pinning what _stopping/_poller should be afterwards (see my comment on batch_service.py line 112 — the current counter scheme and the PR description disagree there, so a test would force that decision).
There was a problem hiding this comment.
Added cancelled-stop/retry regression in 7cb0424. Overlapping-stop protection remains in place; an additional overlapping-stop deterministic case can follow after validation.
Why
SubagentBatchService.stop()clearsself._pollerbefore awaiting the old poller's teardown. A concurrentstart()therefore sees no poller and can start another worker while shutdown is still draining. Since the same stop event is cleared by start, that replacement may continue running after the originalstop()returns, violating lifecycle ownership and allowing overlapping durable batch claims.What changed
start()while a stop is in progress, raising an explicit lifecycle error rather than spawning a second poller.stop()can retry cleanup rather than silently releasing ownership.Surface area
Bug fix verification
Test:
backend/tests/test_subagent_batch_service.py::test_start_cannot_replace_poller_while_stop_is_draining.Code-level RED mechanism on current main
5ae953fc2f4464a453ce50aee7e76e6e4600acc7:stop()sets_poller=Noneimmediately, before its firstawait asyncio.gather(poller,...); a competingstart()clears the stop flag and spawns a new worker. The regression captures this ordering and checks that no new worker starts until the first poller actually drains.The regression was not executed on unchanged main or this branch; RED/GREEN is not run, not asserted as observed.
Collision audit
Checked open/recently closed PRs, issue search and related prior implementation before writing and again before public submission, using batch poller/start/stop and SubagentBatchService lifecycle terms. #5525 addresses
SubagentRuntime.stop()caller-cancellation ownership, and #6434 bounds the Gateway wait on batch shutdown; neither fences the batch service's concurrentstart()during its ownstop(). No same-mechanism PR or maintainer handoff found in those searches. Branch is based directly on current upstream main and has two focused commits; compare ahead 2, behind 0.Validation
fix/subagent-batch-start-stop-poller-fence-20261008.7cb0424cd8ef311e25848df43af698f31caf9f2d.ea52eb77adds overlapping-stop drain counting so the first completed stop cannot reopen start while another drain remains; Follow-up4d0519badds class-level defaults for new-constructed shutdown fixtures and reconciles abandoned drain counts in finally;7cb0424adds cancelled-stop/retry regression. Latest-head CI pending, local tests not run.AI assistance
Tool(s) used: ChatGPT
How you used it: Read current main's batch-service lifecycle, compared related upstream work, drafted a minimal poller ownership fence and concurrent regression using GitHub-connected tools. Full local execution and human review were not performed.