fix(buzz-acp): bound busy-owner hold to prevent cross-channel starvation - #7337
Conversation
The busy-owner hold added with per-thread sessions (#6732) applied to every scope with no time bound. Under the default channel policy, a channel's batch could starve behind another channel's in-flight turn on a shared worker for the full max-turn deadline, with only a DEBUG log. Conversation scopes (channel policy + DMs) now never hold — a busy owner forks onto an idle worker as before #6732. Thread scopes hold at most HOLD_BUSY_OWNER_TIMEOUT (10s) before forking a fresh session. Holds log at INFO, expiries at WARN, and both emit observer events. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
🔐 Codex Security Review
Review SummaryOverall Risk: MEDIUM
Findings[MEDIUM]
|
salman1993
left a comment
There was a problem hiding this comment.
🤖
-
P2 — The timeout has no independent wakeup. The “30s maintenance tick” is an elapsed-time check at the top of the loop, not a timer. With typing, presence, heartbeat and reaper timers disabled, a held thread can still wait for the unrelated busy turn to finish despite an idle worker. Add a scheduled deadline and a quiet-loop regression. Sources:
pool.rs:873–897;lib.rs:2880–2883, 3018–3061, 3122–3710undercrates/buzz-acp/src/. -
P2 — A fork can later resume the old session. Worker 0 owns A, runs B; A forks to worker 1; both return. The next A claim picks worker 0’s stale session by slot order, despite the directory naming worker 1. The old copy is not actually orphaned. Fence stale session state and test fork → both return → next claim. Sources:
lib.rs:4425–4450, 4528–4530;pool.rs:909–955. Independently found by Maldini and checked against the source.
salman1993
left a comment
There was a problem hiding this comment.
🤖 These are P2 correctness issues, not critical stop-ship failures. My “request changes” was too broad for an urgent restoration of widely used channel sessions.
- Missing timeout wakeup: affects the new thread-scope timeout, not the channel-scope bypass. It should not block a channel-only hotfix.
- Stale session after a fork: more consequential because a later turn can resume an older provider context. But for channel scopes, this restores the pre-#6732 behavior rather than introducing a wholly new risk. Extending that behavior to thread scopes is the additional risk here.
My recommendation: ship the channel-scope bypass now, keep the existing thread hold, and fix bounded thread dispatch separately. That restores the broken default without coupling it to the unfinished thread-timeout behavior. Require a focused live-local check of the affected channel workflow before shipping.
…ion (block#7337) ## Problem block#6732 added a busy-owner hold to the ACP harness: when a scope's recorded session owner (`session_owners`) is checked out on **any** turn, `dispatch_pending` holds the scope's batch instead of dispatching it. The hold was added to keep one provider session per thread — but it is unconditional: it applies to `Conversation` scopes too, and it has no time bound. Under the default `session_policy=channel`, every channel collapses to a single `Conversation` scope, so once two channels' sessions land on the same worker (pass 2 of `try_claim` picks the first idle worker by index, so this happens quickly after any restart), channel A's mention starves behind channel B's in-flight turn — for up to the full `max_turn_duration` (7200s by default) — while other workers sit idle. The only signal is a DEBUG-level log, and the 👀 seen-reaction is added at queue admission *before* the hold decision, so the user sees the agent acknowledge the mention and then nothing. Observed in production on the first day of the v0.5.22 rollout: three separate incidents where a mention got 👀 but no turn started until an unrelated channel's turn ended on the shared worker (in the worst case the blocking turn sat in a single tool call for 6+ minutes). ## Fix One new seam, `AgentPool::hold_decision`, replaces the raw `should_hold_for_busy_owner` check in `dispatch_pending` (the predicate itself is unchanged and remains the inner check): - **`Conversation` scopes never hold.** Channel-policy channels and all DMs dispatch immediately; a busy owner means forking onto an idle worker, exactly the pre-block#6732 behavior. This removes the cross-channel head-of-line blocking entirely for the default policy. - **`Thread` scopes hold for a bounded window.** `HOLD_BUSY_OWNER_TIMEOUT` (10s) is measured from the first time the batch is held (`held_since` stamp); once elapsed, the batch stops holding and forks a fresh session on an idle worker, rebuilding thread context from the relay. This preserves block#6732's session-continuity intent for the momentary-busy case while capping the worst-case wait. No new timer is needed: held batches are requeued with preserved timestamps and re-evaluated on every dispatch trigger (turn end, relay event, 30s maintenance tick), so the effective worst-case re-check gap on a fully silent system is one maintenance tick. - **Holds are observable.** Holding logs at INFO and a hold expiry logs at WARN (previously DEBUG-only), and both emit observer-feed events (`busy_owner_hold`, `busy_owner_hold_forked`) with the scope, owner index, and held duration. `held_since` is derived state and is cleared on every removal path: dispatch/fork (inside `hold_decision`), `invalidate_channel_sessions`, `invalidate_scope_session`, and `switch_idle_agent_model`. ## Accepted trade-offs - A fork after an expired hold leaves the old owner's now-orphaned thread session in its session map until natural rotation/invalidation — benign, and identical to pre-block#6732 fork semantics (`loadSession: false`; sessions are worker-pinned, so migration is not an option). - Under sustained pool exhaustion the hold stamp is cleared on the fork attempt and re-stamped next cycle, so the bound is effectively "timeout after a worker frees up," not absolute wall clock. ## Tests - New table test `hold_decision_covers_variant_session_busy_and_timeout` over the full input space (scope variant × idle-session presence × owner busyness × elapsed vs. window). The `Conversation` + busy-owner row is the cross-channel regression guard; the past-window row guards the bound. Both were mutation-checked: removing the variant gate or the timeout branch fails the suite. - `busy_session_owner_holds_batch_instead_of_forking_session` extended with the Hold → ForkAfterHold transition, the `Conversation` dispatch guard, and `held_since` pruning on channel invalidation. - Scope-invalidation and idle-model-switch tests extended to cover `held_since` cleanup alongside the existing `session_owners` assertions. Signed-off-by: Will Pfleger <pfleger.will@gmail.com> (cherry picked from commit b17c077) Signed-off-by: nambse <sefa.esendemir@gmail.com>
Problem
#6732 added a busy-owner hold to the ACP harness: when a scope's recorded session owner (
session_owners) is checked out on any turn,dispatch_pendingholds the scope's batch instead of dispatching it. The hold was added to keep one provider session per thread — but it is unconditional: it applies toConversationscopes too, and it has no time bound.Under the default
session_policy=channel, every channel collapses to a singleConversationscope, so once two channels' sessions land on the same worker (pass 2 oftry_claimpicks the first idle worker by index, so this happens quickly after any restart), channel A's mention starves behind channel B's in-flight turn — for up to the fullmax_turn_duration(7200s by default) — while other workers sit idle. The only signal is a DEBUG-level log, and the 👀 seen-reaction is added at queue admission before the hold decision, so the user sees the agent acknowledge the mention and then nothing.Observed in production on the first day of the v0.5.22 rollout: three separate incidents where a mention got 👀 but no turn started until an unrelated channel's turn ended on the shared worker (in the worst case the blocking turn sat in a single tool call for 6+ minutes).
Fix
One new seam,
AgentPool::hold_decision, replaces the rawshould_hold_for_busy_ownercheck indispatch_pending(the predicate itself is unchanged and remains the inner check):Conversationscopes never hold. Channel-policy channels and all DMs dispatch immediately; a busy owner means forking onto an idle worker, exactly the pre-feat(buzz-acp): give each channel thread its own agent session #6732 behavior. This removes the cross-channel head-of-line blocking entirely for the default policy.Threadscopes hold for a bounded window.HOLD_BUSY_OWNER_TIMEOUT(10s) is measured from the first time the batch is held (held_sincestamp); once elapsed, the batch stops holding and forks a fresh session on an idle worker, rebuilding thread context from the relay. This preserves feat(buzz-acp): give each channel thread its own agent session #6732's session-continuity intent for the momentary-busy case while capping the worst-case wait. No new timer is needed: held batches are requeued with preserved timestamps and re-evaluated on every dispatch trigger (turn end, relay event, 30s maintenance tick), so the effective worst-case re-check gap on a fully silent system is one maintenance tick.busy_owner_hold,busy_owner_hold_forked) with the scope, owner index, and held duration.held_sinceis derived state and is cleared on every removal path: dispatch/fork (insidehold_decision),invalidate_channel_sessions,invalidate_scope_session, andswitch_idle_agent_model.Accepted trade-offs
loadSession: false; sessions are worker-pinned, so migration is not an option).Tests
hold_decision_covers_variant_session_busy_and_timeoutover the full input space (scope variant × idle-session presence × owner busyness × elapsed vs. window). TheConversation+ busy-owner row is the cross-channel regression guard; the past-window row guards the bound. Both were mutation-checked: removing the variant gate or the timeout branch fails the suite.busy_session_owner_holds_batch_instead_of_forking_sessionextended with the Hold → ForkAfterHold transition, theConversationdispatch guard, andheld_sincepruning on channel invalidation.held_sincecleanup alongside the existingsession_ownersassertions.