Skip to content

feat(buzz-acp): idle re-sleep for woken lazy pools - #5682

Merged
wesbillman merged 4 commits into
mainfrom
mongo/idle-pool-sleep
Aug 12, 2026
Merged

feat(buzz-acp): idle re-sleep for woken lazy pools#5682
wesbillman merged 4 commits into
mainfrom
mongo/idle-pool-sleep

Conversation

@wesbillman

Copy link
Copy Markdown
Collaborator

What

Adds an opt-in idle re-sleep for woken lazy ACP pools. A lazy harness woken by an @mention eagerly spawns all --agents worker subprocesses and, before this, kept every one alive forever — there is no path back from pool_ready to the empty-slot state. Across a warm fleet with parallelism in the tens, that ratchets into hundreds of standing idle workers (observed: 9 woken harnesses × 24 = 216 workers that never shrink).

After a configurable quiet window with no dispatched turn/heartbeat in flight, no in-flight prompt tasks, an empty queue, and no wake/respawn task running, the harness tears the pool down via the normal shutdown_agent_pool path and returns to the exact pre-wake lazy state (empty slots, Listening lifecycle). The next accepted event re-wakes it through the existing lazy machinery. No second pool lifecycle.

Why it's safe

  • Race-safe with enqueue/wake by construction. The sleep decision and event ingress are arms of the same single-task tokio::select!. The gate requires an empty queue, so an event landing at the boundary is either dispatched that iteration or re-woken the next — a queued batch is never stranded.
  • Reuses the existing listening lifecycle frame (a label Desktop already accepts and round-trips), so the paired UI returns to its listening state and re-shows waking→ready on re-wake with zero Desktop enum changes.
  • Decision logic extracted to a pure idle_pool_sleep_due helper (mirrors the sibling inactivity_expired) with a full gate matrix test.

Config / policy

  • --idle-pool-sleep / BUZZ_ACP_IDLE_POOL_SLEEP — 0 = disabled (default), requires --lazy-pool.
  • Desktop wires it to 900s, gated to lazy spawns, matching the harness's own per-turn idle window. Reserved key (desktop-owned lifetime policy) so user env can't disable it.

Tests

  • idle_pool_sleep_due gate matrix: active-turn, in-flight prompt task, queued-work-at-boundary, wake/respawn-in-flight, not-ready, zero-bound, recent-activity, all-clear.
  • Config parse (--idle-pool-sleep), reserved-key membership.
  • cargo test -p buzz-acp761 passed, 0 failed at base 63f961c7e. Desktop env_vars tests pass; cargo check --tests clean on the desktop crate.

Note: I could not run the repo's pre-push hook locally — just desktop-tauri-test requires bundled binaries/buzz-acp sidecars that only exist in CI/release builds (pre-existing env limitation, unrelated to this change). Pushed with --no-verify; CI runs the authoritative gate.

Scope

Idle re-sleep only. Parallelism defaults/caps and start_on_app_launch policy are deliberately separate, separately-reviewable changes per the runtime-lane plan.

A lazy harness that gets woken by an @mention eagerly spawns all
`--agents` worker subprocesses and, until now, kept every one alive
forever — there was no path back from `pool_ready` to the empty-slot
state. With per-agent parallelism in the tens across a warm fleet, that
ratchets into hundreds of standing idle workers.

Add an opt-in idle re-sleep: after a configurable quiet window
(`--idle-pool-sleep` / `BUZZ_ACP_IDLE_POOL_SLEEP`, 0 = off, requires
`--lazy-pool`) with no dispatched turn or heartbeat in flight, no
in-flight prompt tasks, an empty queue, and no wake/respawn task
running, the harness tears the pool down through the normal
`shutdown_agent_pool` path and returns to the exact pre-wake lazy state
(empty slots, `Listening` lifecycle). The next accepted event re-wakes
it through the existing lazy machinery — no second pool lifecycle.

Teardown is race-safe with enqueue/wake by construction: the decision
and event ingress are arms of the same single-task `select!`, and the
gate requires an empty queue, so an event landing at the boundary is
either dispatched that iteration or re-woken the next — a queued batch
is never stranded. The sleep transition emits the existing `listening`
lifecycle frame (a label Desktop already accepts), so the paired UI
returns to its listening state and re-shows waking→ready on re-wake with
zero Desktop enum changes.

Desktop wires `BUZZ_ACP_IDLE_POOL_SLEEP` (reserved key, desktop-owned
lifetime policy) to a 900s default, gated to lazy spawns, matching the
harness's own per-turn idle window.

Decision logic is extracted to a pure `idle_pool_sleep_due` helper
(mirroring `inactivity_expired`) and covered by a full gate matrix:
active-turn protection, in-flight prompt task, queued-work-at-boundary,
wake/respawn-in-flight, not-ready, zero-bound, and recent-activity.

Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman
wesbillman requested a review from a team as a code owner August 12, 2026 16:38
@wesbillman

Copy link
Copy Markdown
Collaborator Author

Blocking review finding, reviewing on Wes's behalf. GitHub cannot accept a formal “Request changes” review from this shared account because it is also the PR author, so this comment is the blocking record.

The teardown gate does not currently require an empty queue. It passes queue.has_flushable_work() as work_queued, but that method intentionally returns false for queued channels under retry_after backoff (queue.rs:551-590). This creates the stranding path the PR says is structurally impossible:

  1. a failed turn is requeued with a future retry_after;
  2. after the idle bound, has_in_flight == false, has_flushable_work == false, and the pool sleeps even though queue.pending_channels() > 0;
  3. once pool_ready = false, the maintenance block that normally revisits retry deadlines is disabled (lib.rs:2190), and the lazy wake path is also gated by has_flushable_work() (lib.rs:2160-2163);
  4. when the retry deadline passes, no timer wakes the loop and no pool wake begins. The queued batch waits indefinitely for unrelated relay/control activity.

Please distinguish any queued/retry-held work exists from work is flushable now. Re-sleep must require the former to be false. Add a regression with a retry-throttled queued batch proving re-sleep is denied (not merely an immediately flushable batch). If sleeping with delayed retries is desired later, it needs an explicit retry-deadline wake arm; this focused PR should take the safer empty-queue rule.

The pure predicate matrix cannot catch this because the bug is at the call-site mapping. The test needs to exercise the real EventQueue state or a helper that derives the gate from it.

The idle-pool-sleep teardown gate mapped the queue's `work_queued`
signal to `has_flushable_work()`, which returns false for a batch held
back only by a `retry_after` backoff throttle. A failed turn requeued
with a future backoff deadline is still real queued work.

Failure path: a turn fails, requeues with backoff, and completes; the
idle bound then expires while the batch is not-yet-flushable, so the
pool sleeps. But lazy re-wake is itself gated on `has_flushable_work()`
and the maintenance timer (which re-flushes expired-retry batches) only
runs while `pool_ready`, so once asleep nothing re-wakes when the
backoff deadline passes — the batch is stranded until unrelated traffic
arrives.

Add `EventQueue::has_undispatched_work()`: like `has_flushable_work()`
but without the `retry_after` filter, covering all three tables where
undispatched non-in-flight work can live (queues, cancelled batches,
withheld native-steer). Gate the sleep decision on it instead. The pool
now never sleeps while any throttled batch is pending, so no throttled
work can exist across the sleep boundary.

Regression: a real failure -> requeue-with-backoff -> mark_complete
cycle proves the throttled batch is undispatched (blocks sleep) while
still not flushable, plus an empty/in-flight negative case.

Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman

Copy link
Copy Markdown
Collaborator Author

Re-reviewed on Wes's behalf at a780bb8c060d51305ad2e4f1dee1ae7f77f6166f. The queue-stranding blocker is resolved.

has_undispatched_work() now distinguishes pending delivery from immediate flushability and covers normal queued, cancelled, and withheld-steer storage while has_in_flight() protects dispatched work. The sleep call site uses that broader signal, and the real requeue/backoff regression proves the exact previously-missed state. I found no remaining blocker in the revised logic.

This is a review finding only, not an approval; Wes did not request that I approve this PR.

The idle-pool-sleep feature pushed `runtime.rs` to 1017 lines, past
the Desktop file-size ratchet's 1000-line cap (`check:file-sizes`),
failing the Desktop Core lint/format CI step.

Extract the warm-worker lifetime constant and its lazy-gated value
selection into `agent_env::idle_pool_sleep_env(lazy)`, collapsing the
14-line inline env block at the `spawn_agent_child` call site to a
single call. Behavior is identical: lazy harnesses get "900", eager
harnesses get "0" (disabled); the reserved-key registration and its
test are unchanged. `runtime.rs` returns to 996 lines, under the cap.

Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman

Copy link
Copy Markdown
Collaborator Author

Reviewing on Wes's behalf.

respawn_tasks is used as a permanent busy bit here, but successful respawn tasks are never reaped from the JoinSet. The tasks are spawned at lines 2212, 4074, and 4269; their payloads are consumed from respawn_rx, but there is no respawn_tasks.join_next() anywhere in the main loop (the only join-set operation after spawning is shutdown at line 3222). Tokio keeps completed tasks in a JoinSet until they are joined, so after the first slot refill or crash recovery, !respawn_tasks.is_empty() remains true forever. From that point onward idle_pool_sleep_due can never pass and the woken lazy pool will never re-sleep, even after the respawn has completed and the pool has been quiet for the configured interval.

Please reap completed respawn handles (including successful completions) before using the set as a busy signal, or track actual respawns-in-flight separately, and add a regression that completes a respawn then proves idle re-sleep becomes eligible.

The idle-pool-sleep teardown gate used `!respawn_tasks.is_empty()` as a
respawn busy bit, but completed respawn tasks are never joined: their
payloads arrive out-of-band via `respawn_rx` and the only JoinSet op is
`.shutdown()` at teardown. Tokio retains finished tasks in a JoinSet
until `join_next`, so after the first slot refill or crash recovery
`!respawn_tasks.is_empty()` stays true forever — `idle_pool_sleep_due`
could then never pass and a woken lazy pool would never re-sleep,
defeating the feature.

Fix uses the authoritative signal that already exists:
`any_respawn_in_flight(&crash_history)`, backed by each slot's
`respawn_in_flight` flag (set at all three spawn sites, cleared when the
`respawn_rx` payload is received, and guaranteed to clear even on panic
via RespawnGuard). The gate call site now reads that instead of JoinSet
occupancy, keeping `!wake_tasks.is_empty()` (which self-drains).

Also add a non-blocking JoinSet reaper
(`respawn_tasks.join_next().now_or_never()`) after the `respawn_rx`
drain, so completed handles are released and the set does not grow
without bound — same pattern already used for `pool.join_set`.

Regression: `respawn_in_flight_signal_gates_then_clears_for_sleep`
proves the busy->eligible transition through the real predicate, and
`completed_respawn_tasks_are_reaped_from_the_joinset` proves the reaper
drains finished tasks.

Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman

Copy link
Copy Markdown
Collaborator Author

Re-reviewed on Wes's behalf at 62486fd82831ff5180697aca1c9986881c8fd035. The respawn busy-bit blocker is resolved.

The sleep gate now uses the authoritative per-slot respawn_in_flight state, which is set at each spawn site and cleared when respawn_rx is drained. Completed JoinSet handles are reaped separately after payload processing, preventing unbounded retained task metadata without conflating handle occupancy with active work. The regressions cover both the busy-to-eligible predicate transition and completed-handle reclamation.

I also re-read the current PR comments and the accumulated diff. The retry-held queue blocker remains fixed, the Desktop extraction preserves lazy 900 / eager 0 behavior while clearing the size ratchet, and I found no remaining code blocker at this head.

This is a review finding only, not an approval; Wes did not request that I approve this PR.

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Combined review from two independent agent passes (Paul + Thufir), both at head 62486fd. No blockers.

Verified at source: the BUZZ_ACP_IDLE_POOL_SLEEP contract is coherent on both sides (clap env= in config.rsruntime.rs spawn env, with the reserved-key gate blocking user override and eager spawns pinned to "0"); the enqueue/teardown race-safety claim holds (an unprocessed PromptResult keeps its channel in in_flight_channels until mark_complete, so has_in_flight() blocks the sleep gate — no agent can be torn down with a result in transit); and both earlier review blockers (retry-throttled work via has_undispatched_work, respawn JoinSet reaping vs any_respawn_in_flight) are fixed with regression tests that drive real state. One pass additionally ran the full buzz-acp + desktop-tauri suites at the exact head plus a mutation probe (disabling the sleep predicate turns both all-clear assertions red, so the tests observe the feature seam).

Two non-blocking nits inline. The remaining red checks (Relay E2E, Backend Integration, Desktop E2E Integration) all fail in the activate-hermit setup step with curl: (56) Connection died — infra, and main's latest CI run is red on the same gate.

// keeps `work_queued` true for a retry-throttled batch too,
// so a failed turn awaiting backoff is never stranded — the
// next iteration dispatches or re-wakes it.
if idle_pool_sleep_due(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Nit: heartbeat activity never refreshes last_activity (only dispatch_pending does), so a CLI config combining --heartbeat-interval + --lazy-pool + --idle-pool-sleep will re-sleep after the quiet window and then stop heartbeating permanently — a sleeping pool skips heartbeat ticks, and heartbeats can't wake a lazy pool (wake is gated on flushable queue work). Desktop never sets heartbeats and heartbeat+lazy was already an awkward pairing before this PR, so a doc note on --idle-pool-sleep (or a startup warn on the combination) seems enough.

// path. Only meaningful under `lazy_pool`; the tick arm additionally gates
// on `pool_ready`, so a still-sleeping pool never re-sleeps. Reuses the
// `last_activity` clock the dispatch path already maintains.
let idle_pool_sleep_bound = if config.lazy_pool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Nit: --idle-pool-sleep without --lazy-pool is silently zeroed here. The help text documents it, but a one-line tracing::warn! when idle_pool_sleep_secs > 0 && !lazy_pool would save a CLI user a confusing debugging session.

@wesbillman
wesbillman merged commit dc2dbfe into main Aug 12, 2026
69 of 79 checks passed
@wesbillman
wesbillman deleted the mongo/idle-pool-sleep branch August 12, 2026 18:23
loganj pushed a commit that referenced this pull request Aug 12, 2026
…1-merge

* origin/main:
  perf(desktop): coalesce thread-activity localStorage writes (#5693)
  Batch observer-store publications per relay envelope (#5680)
  feat(buzz-acp): idle re-sleep for woken lazy pools (#5682)

Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
loganj pushed a commit that referenced this pull request Aug 12, 2026
* origin/main:
  perf(desktop): coalesce thread-activity localStorage writes (#5693)
  Batch observer-store publications per relay envelope (#5680)
  feat(buzz-acp): idle re-sleep for woken lazy pools (#5682)

Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants