fix(code-review): retire the review pool when the app is disabled - #6802
fix(code-review): retire the review pool when the app is disabled#6802leonlaiyc wants to merge 1 commit into
Conversation
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 1 of 1 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- src/kiro_crew/apps/builtins/code_review_sage/backend/routes.py:2569 -- disable does not close posting admission Adjudication (Opus 4.8) — is blocking on each finding proportionate?F1 concerns the
Harm rung: UNBOUNDED — writing to an external pull request after the operator withdrew authority; externally visible and not self-correcting. Recovery path: none (comments on the PR would need manual deletion). The required combination — a finished, unposted review + disable + a [ADJUDICATION] 9da06b6 total=0 uphold=0 downgrade=0 |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound, evidence-driven layering on existing seams — but invariant B as tested is narrower than as stated: one interleaving still rebuilds the pool post-disable. WatchThe [DESIGN-REVIEWED] 9da06b6 |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All claims verified against the base tree. I have what I need to write the review. Verification summary before the verdict:
First-Principles-Verdict: CONCERNS The admission fix condemns the unserialized Not justified as shipped
What this change shipsIntent: make disabling the code-review app actually stop its review runtime, not just its routes — a FIX.
Watch
SubtractionsDrop the [FIRST-PRINCIPLES-REVIEWED] 9da06b6 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsAdvisory only: the disable hook marks posting runs cancelled but no path ever clears them. FINDING — src/kiro_crew/apps/builtins/code_review_sage/backend/routes.py:2562 — [OPUS-REVIEWED] 9da06b6 |
Head branch was pushed to by a user without write access
|
Accepted, reproduced, and fixed on I reproduced your race before changing anything, driving the exact interleaving on the real Why not the revert. The original gap was real too:
The fix, in two halves, using seams that already exist:
On the guard I deliberately did not use. The obvious version is to re-read the authoritative enabled flag after the wait. It is stale exactly when it matters: No enable-side hook was added. Evidence. Against this PR's own previous head The PR description is re-synced to what now ships. |
e9fa957 to
e67a294
Compare
e67a294 to
70f7cfa
Compare
70f7cfa to
2513f0b
Compare
d94e114 to
f155298
Compare
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
@leonlaiyc thanks for this. A repo-wide audit of open PRs found that it overlaps #8143 ("feat(sage): safely parallelize run-scoped reviews", @Premshay). Shared files: The collision is in one block, There is a semantic point too. Your guard exists because a run can wait on the whole-run lock for as long as the previous review takes. #8143 removes that wait, so after it the recheck belongs just before Suggested order: land yours first. It is 4 files, while #8143 is 12 files and still owes a reconciliation with #9077 and #9087 before review; #8143 then re-places the recheck against its new lock scope. Two things to do first: rebase, because your branch is far behind main and main rewrote this block in #7240 and #8186, so the recheck must be re-placed rather than reapplied; and drop the Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
`review_pool.shutdown_pool` says it is "called on app disable / gateway shutdown". Only the second was wired: `register_routes` registered `_shutdown_pool` on `app.on_cleanup`, which aiohttp fires when the gateway stops and at no other time. A disable refused the routes and left the pool's worker sessions alive, holding an agent runtime up and running review turns for a permission the operator had already withdrawn. Register the same teardown on `apps.teardown`'s disable seam, feature-detected with `getattr` so a core build whose teardown module predates the registry keeps loading. `shutdown_pool` is idempotent and drops the singleton, so a re-enable builds a fresh one. Retiring the pool alone is not withdrawal: `get_pool()` rebuilds the singleton on the next call, so a run already accepted and waiting on `_RUN_LOCK` woke up after the disable and stood a fresh runtime back up. The hook now marks every live run in the app's existing `_CANCELLED` set BEFORE it retires the pool, synchronously, and `_run_review_bg` re-checks that set immediately after it acquires `_RUN_LOCK` and before `get_pool()`. `_CANCELLED` and not `is_app_enabled`, because `notify_app_disabled` fires before `disable_app` writes the flag. A snapshot cannot speak for a run that does not exist yet, so admission itself is the boundary. Both review entry points go through `_admit`, which takes the `enabled` read and the `_RUNS` insert under ONE hold of `app_lifecycle_lock(_APP_NAME)` — the same lock `handle_disable_app` holds across both `teardown_app_runtime` and `disable_app`. Serializing on it makes the admission decision and the disable transition mutually exclusive in either order: a disable that gets there first has already persisted `enabled=False`, so the request is refused with the `403 app_disabled` the frontend knows; a review that gets there first is in `_RUNS` before the hook's scan and is cancelled like any other live run. Reading the flag and then sampling a separate signal cannot close this — the read is off-loop, so a disable can start, complete and release inside that one `to_thread` round-trip, after which the sample finds nothing in flight. The blocking read stays in `to_thread` inside the lock, the shape `notifications_push` already uses for the same enablement-then-mutate pair. Lock order is `app_lifecycle_lock` → `_LOCK`, the outermost-lock convention in `apps/routes.py`; nothing takes them the other way round, and the disable hook touches `_RUNS` without `_LOCK`. Tests drive each interleaving with events and barriers, never sleeps: the queued-run race on the real `_RUN_LOCK`, a new review arriving while the disable holds the lifecycle lock, and a completed disable racing an in-flight `enabled` read — the last recording the order of the two events as they happen and asserting no run enters `_RUNS` after `enabled=False` is persisted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f155298 to
9da06b6
Compare
|
Rebased onto main Conflicts: none, clean rebase. I did verify the one production hunk still lands correctly after main rewrote this block twice: Gates run locally (changed files only): black, isort, flake8 clean on the new test file; the three pre-existing files stay on Two notes for you: the Please review the rebase. A maintainer push makes the maintainer the last pusher, so a second approver is needed under the repo's last-push rule. Reply if anything looks wrong. |
Problem / Motivation
review_pool.shutdown_poolsays what it is for:It is not called on app disable.
grep shutdown_poolacrosssrc/finds exactly oneproduction caller —
_shutdown_pool, registered onapp.on_cleanupinregister_routes. aiohttp fireson_cleanupwhen the gateway stops and at no othertime, so the first half of that docstring describes something nothing does.
What a disable actually does is refuse the routes. The pool's worker sessions stay alive,
holding an agent runtime up and continuing to run review turns — for a permission the
operator has already withdrawn, while every route answers 403 and reports nothing.
This is the same shape as the auto-improvement app's disable gap, and it is the second
half of a closed pair:
register_routesthere stands its workers down on shutdown and nowon disable too. Here only shutdown was wired.
Why it matters
The seam's own docstring states the harm, as a security property rather than a tidiness
one:
A review worker is exactly that: it holds a live agent runtime and spends budget on turns
that read the operator's code and can post to their pull requests.
teardown.pyalso runsnotify_app_disabledfirst, ahead of the app's ownonDisablescript and backendteardown, "so a worker holding something time-bounded would [not] keep that authority for
the duration" — an app that registers nothing there silently opts out of that ordering
guarantee.
And because the docstring already claims the disable path is covered, a reader auditing
this app's teardown finds a function that says it handles disable, one caller, and no
reason to look further.
What changed (motivation → approach → change)
Symptom: disabling the app leaves the review pool running. Root cause:
shutdown_pooliswired to one of the two ways the app can be switched off. Fix: wire it to the other one
too, through the mechanism that exists for it, with the same body.
register_routesnow also registers an app-disable hook:Same call the
on_cleanuphook makes.shutdown_poolis idempotent (a no-op when no poolhas started) and drops the singleton, so a later re-enable builds a fresh one. Nothing new
is introduced: no state, no config key, no constant, no route, no change to the pool.
Registration is feature-detected with
getattr(teardown, "register_app_disable_hook", None), copying Issue Radar's pattern verbatim and for its stated reason — a core buildwhose
teardownmodule predates the registry keeps loading, and there the cleanup hook isthe only teardown there is, which is the pre-registry behaviour rather than a regression.
It sits in its own
try, matching the existing hook registration, so it can never breakgateway startup.
The
shutdown_pooldocstring is left alone deliberately: this change makes its existingclaim true, which is the better of the two ways to resolve the mismatch.
The remaining siblings, counted and declared. Filtering the builtin apps to those that
register a worker teardown on
on_cleanupgives six; two already register a disable hook(
issue_radar, andauto_improvementas of its own disable fix). The four that do not arecode_review_sage(this PR),dev_fleet(dev_fleet_cleanup),md_notebook(
syncer.stop_syncer) andmeetings(_on_cleanup). The other three are not foldedin here because they are not mechanical:
dev_fleet's teardown drains a prune workermid-flight through
_run_uninterruptiblebecause its git mutations must not be torn inhalf, so what a disable should do to an in-progress destructive mutation is a decision,
not a copy of this hook. Each of the three needs its own answer to "what does this worker
hold, and what does stopping it mean", which is a judgment per app rather than one sweep.
Second half, after review: retiring the pool was necessary but not sufficient
GPT's exact-head review blocked this, correctly.
shutdown_pool()drops thesingleton and
get_pool()recreates it on the very next call:So a run already accepted and only waiting its turn on
_RUN_LOCKbehindanother run woke up after the disable and simply built a new pool — a fresh agent
runtime, and a posted review, for a permission the operator had withdrawn while
it waited. Reproduced before changing anything; the trace on the previous head
is literally
['shutdown_pool', 'get_pool'].I did not take the suggested revert of the disable hook. The original gap was
real too — leaving the pool alive after disable is its own defect — so both
invariants have to hold at once:
after it.
Two halves, using mechanisms that already exist:
retires the pool, marking them in
_CANCELLED— the app's existingcancellation set. The marks are made synchronously, with no
awaitbetweenthe loop and the marks landing, so a run waiting on
_RUN_LOCKcannot slipthrough in between. The predicate is
_is_live, already in this file, whichalso covers the posting phase — a run mid-delivery reports a terminal status
while still writing to the pull request, so
status == "running"alone wouldmiss it;
_run_review_bgre-checks_CANCELLEDimmediately after acquiring_RUN_LOCK, before it claims any change and beforeget_pool().Why
_CANCELLEDand notis_app_enabled. The obvious-looking guard is tore-read the authoritative enabled flag after the wait. It is stale exactly when it
matters.
notify_app_disabledfires beforedisable_appwrites theenabledflag —
apps/teardown.pysays so in terms, and says a hook "must not wait on thatflag to decide it has been switched off" — and before the app's own third-party
onDisablescript, which that same comment notes can take real time. So for thewhole window this bug lives in,
is_app_enabledstill returnsTrue, while_CANCELLEDis already correct at that instant.No enable-side hook is added, and none is needed.
apps.teardownhas noenable seam, and inventing one would be a new lifecycle framework for a
one-invariant fix. Because the withdrawal is recorded per run, a genuinely new
review submitted after re-enable carries a run id that was never cancelled and
builds its own pool. A test pins exactly that, with nothing un-set by hand.
Closing admission for the whole transition (added after the second block)
The exact-head GPT review found the remaining half, and it is correct:
_CANCELLEDis a point-in-time snapshot. The hook marks the runs that are livewhen it fires, so it can only speak for runs that already exist. A review posted
after that scan is registered with an uncancelled id, walks past the
_RUN_LOCKrecheck and calls
get_pool()— authority rebuilt after the operator withdrew it.Reproduced before designing anything (see Tests): with the disable already under
way and the hook's snapshot already taken, a new POST is admitted
200, its id isabsent from the snapshot, and driving that run through the real
_run_review_bgreaches
get_pool().Two things were open, not one, and neither covers the other:
_handle_reviewhad no enabled gate at all._require_enabledwraps the twochat handlers; the route that starts a review was never wrapped. A fully
disabled app accepted reviews.
section above already explains
is_app_enabledcannot speak for.Both are now checked at the app's ONE admission chokepoint. Both review entry
points (
_handle_review,_handle_review_repo) reach the registry through_record, so the boundary is that call and not each route:The enabled read and the insert are ONE critical section, taken under a lock
that already exists.
handle_disable_appholdsapp_lifecycle_lock(name)across both
teardown_app_runtime(which fires the disable hook) anddisable_app(which writes the flag). Holding the same lock across the read andthe
_RUNS.insertmakes the admission decision and the disable transitionmutually exclusive, in either order:
enabled=Falsebefore this read happens, so the request is refused;
_RUNSbefore the hook's scan runs, so itis marked cancelled like any other live run.
Correction to this PR's previous head.
_admitused to read the flag andthen consult a separate point-in-time sample of the same lock
(
_lifecycle_is_stable, evaluated under_LOCK). GPT's exact-head review blockedthat, correctly: those are two checks with a suspension between them. The read is
off-loop, so a disable can acquire the lock, run the teardown, persist
enabled=Falseand release it entirely inside that oneto_threadround-trip — after which the sample finds the lock free and admits a run whose
authority has already been withdrawn. The previous body described that as a
residual "strictly narrower than the window this closes". It was not narrower; it
was the window. It is now closed rather than documented, because the interleaving
has to be removed, not detected.
_lifecycle_is_stableand theguard=parameterare deleted, and
_recordis byte-for-byte its state onmain.No module-global flag, and no new lifecycle state. A
DISABLED = Truewouldneed an enable-side seam to reset it, and there is none — an interrupted disable
would leave the app permanently refusing reviews with nothing to clear it. Here
the disable request owns the lock, so there is no shadow state, nothing latched,
and no way to brick a re-enabled app.
app_lifecycle_lockis also held duringinstall/update/enable of this app; a review submitted then waits for that
transition and is decided on the flag the transition left behind, which is the
conservative answer and is deliberate.
Lock order, inspected rather than assumed. The order taken here is
app_lifecycle_lock→_LOCK, matching the outermost-lock convention alreadystated in
apps/routes.py. Nothing takes them the other way round:_LOCKislocal to this module, no path holds it while acquiring a lifecycle lock, and the
disable hook — which runs while
handle_disable_appholds the lifecycle lock —scans
_RUNSwithout_LOCKand awaits onlyreview_pool.shutdown_pool, whichnever reaches this module. Holding the lifecycle lock across admission also makes
that scan and the insert mutually exclusive, which is what the deleted
under-
_LOCKguard existed to approximate.The blocking read stays off the event loop. It is still
asyncio.to_thread(is_app_enabled, ...), now inside the lock — the sameenablement-then-mutate shape
dashboard/handlers/notifications_push.pyalreadyuses, where the lock is held across a
to_threadmanifest read and the channelregistration it guards.
Not adjusted, and recorded instead: reviewers also noted that a posting-phase
run can be added to
_CANCELLEDwithout ever consuming the marker. Nothing in thischange depends on that, and no executable evidence made it necessary for the
admission invariant, so it stays a separate residual rather than growing this PR
into a cleanup.
Tests
New
src/kiro_crew/apps/builtins/code_review_sage/tests/test_disable_retires_pool.py,4 tests, each driving the real
register_routeson a bare aiohttp application.teardown.notify_app_disabled("code-review-sage"),which is what the disable request calls and where it calls it, and asserts
shutdown_poolran.shutdown_poolis made to raise and the hook isinvoked directly, not through
notify_app_disabled. That matters: the notifierswallows hook exceptions itself, so calling through it would pass whether or not the
hook contained its own failure. The disable must not depend on the caller's leniency.
app that has never run a review, or disable it twice. This is the over-fix guard, and
it is the one test that also passes on
main.register_routeswires a disable hook — a structural guard, so an edit that dropsthe registration fails here instead of silently letting a disabled app keep reviewing.
An autouse fixture unregisters the hook before and after each test: the registry is
process memory shared by every test in the worker, so a hook left behind would leak into
unrelated tests.
Red-before, with
routes.pyrestored to currentorigin/main(72e429791) andnothing else changed:
Green on the branch: 4 passed. Whole app suite: 754 passed, 32 skipped, with
test_adapters.py::TestEnterpriseHosts::test_malformed_links_are_rejected_not_crashed_ondeselected — that one fails identically on pristine
origin/mainwith this branch's filesremoved (
UnsupportedPlatform not raised), so it is inherited on this host and notattributable to this diff.
Gates:
check_black_formatting.pypass,flake8 src/kiro_crew/apps/builtins/code_review_sage/clean,
isort --check-only src/kiro_crew testclean,mypy src/kiro_crew— "Success: noissues found in 1170 source files".
routes.pyis in.github/black-baseline.txtand stays there: the diff is 30 added linesand 0 deletions, with no reformatting riding along.
The queued-review race (added after the block)
Four more tests in the same file, driving the exact interleaving on the real
_RUN_LOCKwithasyncio.Events rather than sleeps, so the ordering isdeterministic: B is only released once the disable has actually completed.
..._a_review_queued_before_disable_cannot_rebuild_the_pool_after_itget_poolwas never called, no review work ran, and B endscancelled..._a_queued_review_still_runs_when_nothing_is_disabledget_poolandrun_reviewboth happen. Stops the check being a blanket refusal..._a_new_review_after_re_enable_gets_a_fresh_pool..._the_disable_hook_withdraws_authority_from_every_live_runposting=True) is marked; a finished run is notThe assertions are on what B actually did — pool creation and review work —
not on whether
shutdown_pool()was called. The blocker is specifically aboutwhat happens after shutdown, so a test that only checked the shutdown would pass
on the broken code.
Red-before, against this PR's own previous head
66906d4e2(the disable hookpresent, the race fix reverted): 3 failed, 5 passed. The failure message is
the mechanism verbatim — "a run queued before the disable rebuilt the pool after
it". The 5 that pass include the no-disable control and the four original disable
tests, which must pass on both sides.
Green after: the file's 8 passed. Directly relevant suites —
test_disable_retires_pool,test_review_pool,test_run_endpoints,test_backend_routes→ 269 passed, 16 skipped.flake8,isortandmypyclean on the changed files. No repository-wide sweep.
The admission race (added after the second block)
Four more tests in the same file. The disable transition is reproduced the way
production runs it —
app_lifecycle_lockheld, the app's real hook firedthrough
teardown.notify_app_disabled, and the persistedenabledflagdeliberately still
True, because that is its real value in this window. Orderingis by lock and by await point; there are no sleeps, and the background work an
admitted review spawns is drained by gathering the module's own task set rather
than by yielding and hoping.
..._a_new_review_cannot_be_admitted_once_disable_has_begun_CANCELLEDwould pass on a fix that admitted the run and cancelled it afterwards, which is a weaker promise..._the_admitted_run_would_otherwise_reach_the_pool_seam_run_review_bgtoget_pool(). Skips once admission is closed, since there is then no such run..._a_new_review_is_admitted_when_no_disable_is_in_flight..._admission_reopens_after_the_transition_completesasync withblock exitsA fifth test lands in
test_backend_routes.pyfor the steady state: a disabled apprefuses
_handle_reviewwith403 app_disabledand registers nothing.Red-before, against this PR's own previous head
70f7cfa41with the new testspresent and
routes.pyrestored from it: 2 failed, 10 passed. The failuremessage is the mechanism verbatim — "a new review was admitted after the
operator's disable had already begun (HTTP 200, …)" — and the pool-seam test
passes there, which is the executable proof of step 7: the admitted run really
does reach
get_pool().Green after: the file's 11 passed, 1 skipped (the skip is the pool-seam
drive, by design). Directly relevant suites —
test_disable_retires_pool,test_backend_routes,test_run_endpoints,test_activity_progress,test_record_adoption,test_post_comments,test_review_driver,test_store_selfheal,test_followup→ 412 passed, 26 skipped. All fouroriginal queued-race tests still pass, so invariant B is not regressed.
Two test fixtures gain one line each,
is_app_enabled = lambda name: True, copiedfrom the followup-route class's own setUp:
TestHandlers.setUphere, and_SageRoutesBase.setUpintest/test_sage_backend_routes_coverage.py. Those casesare about what an enabled app does; the route simply has a gate now that it never
had, and the precondition is stated rather than depending on a real
installed.json. Without it, four pre-existing request-guard cases in that file(
TestReviewKickoffGuards,TestReviewRepoHandler) answered403on theenablement check instead of on the guard each one is written to exercise. That base
loads a fresh routes module per test, so the line is local to it, and the app's own
disabled-path coverage is untouched — the file has no case that asserts the disabled
response.
The new test file's stub request and response body are typed the way the sibling
builtin's tests already do (
cast(web.Request, ...)at construction, anisinstancenarrowing before
json.loads). Its handlers are reached through a real import ratherthan a dynamically loaded module, so unlike the existing
_Reqcall sites they arenot
Anyand mypy checks them.Gates, re-run on the current head against all four changed files:
scripts/check_black_formatting.pypassed in scope (4 files in scope, nothingunformatted outside the baseline);
flake8andisortclean;mypy— "Success: noissues found in 1 source file".
routes.pystays in the black baseline and is notreformatted. Touched-surface suites only —
test_sage_backend_routes_coverage.py111 passed, and
test_disable_retires_pool.py+test_backend_routes.py214 passed, 17 skipped. No repository-wide sweep, and no local reviewer agents.
Correction to an earlier claim in this section. The
mypyline above previouslydescribed a single-file local run;
Backend Lint & Type Checkrunsmypy src/kiro_crew/, which found three errors in the new test file (twoarg-typeon thestub request, one
union-attronresponse.body) that the narrower local invocationdid not surface. Those are the typing changes described above, and the gate is now run
the way CI runs it.
The completed-disable race (added after the third block)
One more test in the same file, plus a restructure of the four admission tests
above so that they model the
enabledflag the way production writes it...._a_completed_disable_cannot_be_overtaken_by_an_in_flight_enabled_readdrivesthe exact interleaving the exact-head review named:
enabledflag and getsTrue;app_lifecycle_lock, runs the teardown, persistsenabled=False, releases;The property asserted is an order, recorded as each step happens: no run may
enter
_RUNSafterenabled=Falsehas been persisted. That keeps the test blindto how the code holds the line — it fails whenever the two events interleave,
and passes whenever they are serialized, in either direction. Ordering is driven
by events and barriers only: the flag read is parked in its own worker thread on a
threading.Eventand released by the disable, and the timeouts are watchdogs so aregression fails the suite instead of hanging it.
Red-before, against this PR's own previous head
54f4389e8, with the new testpresent and
routes.pyrestored from it. The event log is the mechanism verbatim:Green after, same test, same harness — the two events are serialized:
The 200 branch additionally asserts the admitted run is in
_CANCELLED, becauseordering alone is only half the invariant: a run admitted ahead of the disable
must still be caught by the hook's scan.
The other two reds on that head are the two admission tests whose helper now
models the flag write.
_post_review_mid_disablepreviously pinnedis_app_enabledto a constantTrueand called the handler inline inside thedisable's
async with— with admission taking that same lock, an inline callcould only self-deadlock, and a constant flag is not what a completed disable
leaves behind. It now serves the request on its own task, the way the gateway
does; signals "the request has reached the admission boundary" from
change_id_for, the last call before_admit, so there is no sleep and nopolling; and then writes
enabled=Falseinside the lock, the orderhandle_disable_appuses. The refusal assertions are unchanged — HTTP 403,nothing registered, no work started — and the request now arrives during the
transition and waits for it, rather than sampling it.
Green on the current head.
test_disable_retires_pool.py12 passed, 1skipped (the skip is the pool-seam drive, by design). With its directly relevant
siblings
test_backend_routes.pyandtest/test_sage_backend_routes_coverage.py:326 passed, 17 skipped. Touched-file gates:
flake8,isortandgit diff --checkclean;mypyon both changed files — "Success: no issues foundin 2 source files" — and over the whole app package, "Success: no issues found in
46 source files";
scripts/check_black_formatting.pypassed in scope (4 filesin scope, nothing unformatted outside the baseline). No repository-wide sweep and
no local reviewer agents; server CI owns the broad validation.
The branch is now one commit.
PR Hygienewas red for "PR must contain 1 or 2commits (has 3)" — a required readiness item, unrelated to any reviewer finding.
The commits are squashed into one;
git patch-id --stableis identical before andafter the squash, so no content changed with it.
Manual verification
N/A — unit coverage sufficient: the tests exercise the real registration and fire the
disable notification through the same function the disable request calls, so the whole
path this change adds is covered mechanically.
Related Issues
None. Found by auditing which gateway-mounted apps stand a worker down on shutdown but not
on disable, after the same gap was fixed in the auto-improvement app.
Pattern harvest
Rule candidate: review-prompt
Pattern: teardown drops a lazily-recreated singleton, and a consumer queued
before the teardown re-creates it afterwards — authority is withdrawn, then
rebuilt by work that was already in flight.
Generalizes beyond this app. The shape is any
get_x()that readsif _X is None: _X = X()paired with ashutdown_x()that sets_X = None:retiring the singleton is not withdrawal while any caller can still reach the
getter. A grep cannot see it — the defect is the ordering between a lock wait
and a lifecycle event — which is why this is a review-prompt candidate rather
than a semgrep rule. The generalizable question a reviewer can ask is: "after
this teardown runs, what is still queued that will call the getter?"
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement