fix(session): pool health sweep heals an under-filled warm pool - #9307
Conversation
_pool_health_loop promises to 'discard dead/expired providers and refill the pool', but the refill half only fired when the sweep itself removed an in-queue entry. Population lost outside the sweep's accounting was never healed: - an empty pool early-returned before any accounting, so zero was an absorbing state (spawn-failure break during fill, or a claim-path exception draining the last entry, turned the warm pool off until gateway restart); - a short-but-healthy pool computed removed=0 and skipped the replenish arm, so deficits from out-of-queue kills (claim-path exception arm hard-kills the claimed provider before the success-path replenish is reached) persisted for idle pools. Replace removal-triggered refill with target-triggered refill: after the sweep, schedule a replenish whenever len(healthy) < pool_size. The disabled-pool (pool_size=0) no-op guard is unchanged, and _fill_warm_pool is already idempotent, lock-guarded, and target-capped, so redundant replenish signals are safe by construction.
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix: the sweep now triggers off the invariant it owns (target deficit) instead of its own removal count; redundant refills are safe by the fill loop's existing lock and target cap. Verified against the base tree: the [DESIGN-REVIEWED] 8cb7950 |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All mechanism claims in the description verify against the base tree: the empty-queue early return at First-Principles-Verdict: PASS A health loop that couldn't heal its worst state now triggers off the invariant it owns; every item is the fix or a pin on it. What this change shipsIntent: make the warm-pool health sweep actually restore the pool to target, including from empty — a FIX.
No new config, flag, or public symbol; consumer-count and duplicate-mechanism lenses have nothing to bite. The fix sits at cause level: the trigger is now computed from the owned invariant (target − observed), not the sweep's own removal ledger, which is exactly the decision gap that produced all three decay paths. Subtractions
[FIRST-PRINCIPLES-REVIEWED] 8cb7950 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've independently verified the change. The removed early return ( No findings. [OPUS-REVIEWED] 8cb7950 |
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: the warm-pool health sweep returned early whenever the queue was empty, so an under-filled pool never scheduled a replenish; the sweep now computes a deficit against pool_size and schedules a refill on either removal or deficit, with pool_size=0 still a no-op. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
fix(session): pool health sweep heals an under-filled warm pool
Problem / Motivation
_pool_health_loop(src/kiro_crew/session_pool.py) documents its job as"Periodically discard dead/expired providers and refill the pool." The refill
half of that contract only executed when the sweep itself removed an in-queue
entry, so population lost outside the sweep's own accounting was never
healed:
An empty pool was an absorbing state.
_sweep_warm_pool_onceearly-returned at
if not qsize: returnbefore any accounting ran, so apool at zero was never swept and never refilled. A pool can reach zero with
no compensating replenish signal: the fill loop
breaks on a spawn failure(provider binary briefly unavailable, auth hiccup), and the claim path's
exception arm hard-kills a claimed provider and re-raises before the
success-path
_schedule_replenish()is reached. Once at zero, everysubsequent session cold-starts — the warm-pool feature silently turns off
until gateway restart. The
miss_emptydecision path records the miss butnever schedules a refill.
A short-but-healthy pool never replenished from the sweep. The sweep
computed
removed = qsize - len(healthy)and only scheduled a replenishinside
if removed:. A deficit created by out-of-queue kills is invisibleto that arithmetic (
removed == 0), so an idle pool sat under targetindefinitely — the component whose docstring owns the healing job could not
perform it.
Under ongoing claim traffic a deficit heals as a side effect of the next
successful claim (
_schedule_replenish()on the success path, and_fill_warm_poolloopswhile qsize < pool_size, healing the full deficit).The genuinely stuck cases are exactly the ones the health loop exists for:
a deterministic claim-path exception draining the pool to empty (no claim
ever succeeds again to fire the success-path heal), an idle pool with no
further claims, and
pool_size=1where a single exception empties the poolimmediately.
Why it matters
The warm pool exists to hide provider spawn latency. When it silently decays
to empty, the degradation is invisible — nothing errors, sessions just get
slower — and it is sticky until a restart. A health loop that cannot heal the
empty state inverts its purpose: the worst decay state is the one state it
refuses to touch.
What changed (motivation → approach → change)
Replace removal-triggered refill with target-triggered refill in
_sweep_warm_pool_once:if not qsize: returnfast path (theif not self._pool_size: returnguard stays — a disabled pool remains a no-op; the sweep-loop bodyis skipped naturally when the queue is empty).
(
self._pool_size - len(healthy)) and call_schedule_replenish()whenever the pool is under target — covering
removed > 0, pre-existingshortfall with zero removals, and the empty pool.
gets a debug line; the all-healthy-at-target case keeps its debug line.
_fill_warm_poolis already idempotent, lock-guarded, and target-capped(
while ... qsize < pool_size), so redundant replenish signals are safe byconstruction; the fix adds a trigger, not a new fill mechanism. The deny
direction is preserved:
pool_size=0schedules nothing.Tests
All in
test/test_session_pool.py(TestPoolHealthLoop):test_empty_pool_schedules_replenishtest_under_target_pool_schedules_replenishremoved == 0arm skipped replenish)test_at_target_pool_does_not_replenishtest_disabled_pool_sweep_is_nooptest_keeps_healthy_provider(updated)The dead-provider removal path (
removed > 0→ replenish) keeps its existingcoverage in the suite. Full file: 82 passed, 0 failed.
Manual verification
Reproduced the absorbing state deterministically via the failing tests
against the unchanged tree: with the fix stashed, the empty-pool and
under-target sweeps complete without ever calling
_schedule_replenish(2 failed, 3 passed); with the fix restored, the full module suite is green
(82 passed). Gates run locally: targeted + neighboring pytest (effort/pool
suites, 310 passed),
mypyon the changed source file (clean),flake8,isort --check-only, the repo black gate (scripts/check_black_formatting.py,passes — the test file is baseline-listed, no unrelated reformat churn
included), and
scripts/docs_lint.py(all checks passed).Related Issues
No open issue tracks this decay; filing as a direct fix. Related population
accounting: #8835 capped unbounded pool growth on the eager-spawn path —
this PR is the complementary direction, healing unreplenished shrinkage.
(#2264 asks for per-agent standby counts — a feature on a different seam,
not covered or affected here.)
Pattern harvest
The pattern is a maintenance loop that triggers its remediation off its own
bookkeeping instead of the invariant it owns. The sweep measured "how much
did I remove this pass" when the docstring's promise is "the pool is at
target" — so every decay path that bypassed the loop's ledger (fill-time
spawn failure, claim-path exception kill) produced drift the loop could not
see, and the deepest decay state (empty) was excluded by a fast path before
measurement even began. Harvested across this seam: the claim path already
heals opportunistically on success, which masks the loop's gap under
traffic and makes the defect present only in idle/poisoned/size-1 regimes —
the regimes where a background loop is the only actor left.
Rule candidate: a self-heal loop must compute its trigger from the owned
invariant (target minus observed state), never solely from its own
removal/mutation count — and fast-path exits must be proven not to skip the
states the loop exists to repair (an early return on "nothing to sweep" must
not also mean "nothing to refill").
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)_pool_health_loopdocstring)Contribution License Agreement
Per the template placeholder (CLA text pending): offered under the same terms as my prior merged contributions to this repository (#8835).
Why no screenshot: no visual delta — backend-only change to the session
pool's health sweep; no UI surface is touched.