Skip to content

Commit 6d81cfa

Browse files
committed
fix(subagent): governor wiring and pause self-heal
Review follow-up to the adaptive rate-limit governor: - Stamp the fleet governor on every manager-spawned runtime at the `spawn_background_with_assignment_options` chokepoint. Only the direct workflow root was stamped before, so the interactive `agent` tool chain (the primary swarm path) never reported 429s and the AIMD scheduler stayed inert there. - Time-driven recovery: queued launches periodically probe the governor so a pause lifts once the window drains even when the in-flight fleet finished without a final success. Without it the queue froze until each queued child hit its wall-time deadline. - `set_max_capacity` now applies the configured launch capacity to the live gate directly (raising or lowering) but keeps capacity 0 while paused, so runtime limit changes cannot silently lift a pause. `with_launch_concurrency`/`update_runtime_limits` route through it. - `DynamicGate` grants queued waiters an already-counted permit through a oneshot channel: a waiter cancelled after dispatch drops the permit, whose `Drop` re-dispatches the slot instead of losing the wakeup. - Drop the stale `#[allow(dead_code)]` on `rate_limit_governor` and fix the spawn-stamping doc comments. - Add `forkguard_rate_limit_governor_*` and `forkguard_dynamic_gate_*` behavior tests (window-drain recovery, pause survives a limit change, cancelled-waiter grant re-dispatch). Signed-off-by: asto <asto18089@126.com>
1 parent 8fd81f4 commit 6d81cfa

2 files changed

Lines changed: 243 additions & 89 deletions

File tree

crates/tui/src/tools/subagent/governor.rs

Lines changed: 185 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ pub(crate) fn rate_limit_retry_delay(retry_number: u32) -> Duration {
9797

9898
#[derive(Debug)]
9999
struct GateWaiter {
100-
sender: oneshot::Sender<()>,
100+
sender: oneshot::Sender<DynamicGatePermit>,
101101
}
102102

103103
#[derive(Debug)]
@@ -113,6 +113,13 @@ struct GateInner {
113113
/// and wakes one waiter. Reducing capacity below `active` is allowed: the
114114
/// surplus holders finish naturally and no new permit is granted until the
115115
/// active count drops under the new capacity.
116+
///
117+
/// Waiters receive an *already granted* permit through a oneshot channel, so
118+
/// a waiter future that is cancelled after the grant is dispatched simply
119+
/// drops the permit, whose `Drop` hands the slot to the next waiter. (A
120+
/// wake-and-recheck design would lose that wakeup — the cancelled waiter
121+
/// never re-checks, and with no remaining holders there is no later release
122+
/// to re-dispatch it.)
116123
#[derive(Debug)]
117124
pub(crate) struct DynamicGate {
118125
inner: Mutex<GateInner>,
@@ -141,83 +148,96 @@ impl DynamicGate {
141148
inner.capacity.saturating_sub(inner.active)
142149
}
143150

144-
/// Adjust the gate capacity. Raising it wakes as many queued waiters as
145-
/// the new headroom allows; lowering it simply stops new admissions until
151+
/// Adjust the gate capacity. Raising it grants queued waiters the new
152+
/// headroom immediately; lowering it simply stops new admissions until
146153
/// the active count drains below the new capacity.
147-
pub(crate) fn set_capacity(&self, capacity: usize) {
154+
pub(crate) fn set_capacity(self: &std::sync::Arc<Self>, capacity: usize) {
148155
let mut inner = self.inner.lock().expect("launch gate poisoned");
149156
inner.capacity = capacity;
150-
let headroom = capacity.saturating_sub(inner.active);
151-
for _ in 0..headroom {
152-
match inner.waiters.pop_front() {
153-
Some(waiter) => {
154-
// A dropped receiver means the waiter future was cancelled;
155-
// skip it and keep waking until headroom or queue ends.
156-
if waiter.sender.send(()).is_err() {
157-
continue;
158-
}
159-
}
160-
None => break,
161-
}
162-
}
157+
Self::wake_locked(self, &mut inner);
163158
}
164159

165-
fn grant_locked(inner: &mut GateInner) -> bool {
166-
if inner.active < inner.capacity {
167-
inner.active += 1;
168-
true
169-
} else {
170-
false
160+
/// Grant queued waiters while there is headroom. Called with the lock
161+
/// held; each waiter receives an already-counted permit, so a cancelled
162+
/// receiver's permit is disarmed (never `Drop`ped under the lock) and the
163+
/// slot flows to the next waiter.
164+
fn wake_locked(gate: &std::sync::Arc<Self>, inner: &mut GateInner) {
165+
while inner.active < inner.capacity {
166+
let Some(waiter) = inner.waiters.pop_front() else {
167+
break;
168+
};
169+
let permit = DynamicGatePermit {
170+
gate: Some(std::sync::Arc::clone(gate)),
171+
};
172+
match waiter.sender.send(permit) {
173+
Ok(()) => inner.active += 1,
174+
Err(mut returned) => {
175+
// The waiter future was cancelled before receiving the
176+
// grant. Disarm instead of dropping: `Drop` would call
177+
// `release()` and re-enter the lock we are holding.
178+
let _ = returned.disarm();
179+
}
180+
}
171181
}
172182
}
173183

174-
fn release(&self) {
184+
fn release(self: &std::sync::Arc<Self>) {
175185
let mut inner = self.inner.lock().expect("launch gate poisoned");
176186
inner.active = inner.active.saturating_sub(1);
177-
while let Some(waiter) = inner.waiters.pop_front() {
178-
if waiter.sender.send(()).is_ok() {
179-
// The woken waiter re-checks capacity under the lock; if the
180-
// capacity was lowered in the meantime it will re-queue.
181-
break;
182-
}
183-
}
187+
Self::wake_locked(self, &mut inner);
184188
}
185189

186190
/// Try to acquire a permit without waiting.
187191
pub(crate) fn try_acquire(self: &std::sync::Arc<Self>) -> Option<DynamicGatePermit> {
188192
let mut inner = self.inner.lock().expect("launch gate poisoned");
189-
Self::grant_locked(&mut inner).then(|| DynamicGatePermit {
190-
gate: std::sync::Arc::clone(self),
193+
(inner.active < inner.capacity).then(|| {
194+
inner.active += 1;
195+
DynamicGatePermit {
196+
gate: Some(std::sync::Arc::clone(self)),
197+
}
191198
})
192199
}
193200

194201
/// Acquire a permit, waiting until capacity is available. Cancellation
195-
/// safe: dropping the future leaves a stale queue entry that releasers
196-
/// skip.
202+
/// safe: a dropped future either leaves a stale queue entry (skipped and
203+
/// disarmed by the granter) or drops an already-dispatched permit (whose
204+
/// `Drop` re-releases the slot).
197205
pub(crate) async fn acquire(self: &std::sync::Arc<Self>) -> DynamicGatePermit {
198206
loop {
199207
let rx = {
200208
let mut inner = self.inner.lock().expect("launch gate poisoned");
201-
if Self::grant_locked(&mut inner) {
209+
if inner.active < inner.capacity {
210+
inner.active += 1;
202211
return DynamicGatePermit {
203-
gate: std::sync::Arc::clone(self),
212+
gate: Some(std::sync::Arc::clone(self)),
204213
};
205214
}
206215
let (tx, rx) = oneshot::channel();
207216
inner.waiters.push_back(GateWaiter { sender: tx });
208217
rx
209218
};
210-
// Ignore send failures: a cancelled waiter's entry is drained by
211-
// the releaser, and a capacity change wakes us spuriously — the
212-
// loop simply re-checks under the lock.
213-
let _ = rx.await;
219+
// A failed receive means the gate itself was dropped while we
220+
// were queued; the loop re-queues under the lock.
221+
if let Ok(permit) = rx.await {
222+
return permit;
223+
}
214224
}
215225
}
216226
}
217227

218228
/// One held launch slot. Released on drop.
229+
///
230+
/// The gate is an `Option` so the wake path can disarm a permit whose
231+
/// receiver vanished without running `Drop` (which would re-enter the locked
232+
/// `release()`).
219233
pub(crate) struct DynamicGatePermit {
220-
gate: std::sync::Arc<DynamicGate>,
234+
gate: Option<std::sync::Arc<DynamicGate>>,
235+
}
236+
237+
impl DynamicGatePermit {
238+
fn disarm(&mut self) -> Option<std::sync::Arc<DynamicGate>> {
239+
self.gate.take()
240+
}
221241
}
222242

223243
impl std::fmt::Debug for DynamicGatePermit {
@@ -228,7 +248,9 @@ impl std::fmt::Debug for DynamicGatePermit {
228248

229249
impl Drop for DynamicGatePermit {
230250
fn drop(&mut self) {
231-
self.gate.release();
251+
if let Some(gate) = self.gate.take() {
252+
gate.release();
253+
}
232254
}
233255
}
234256

@@ -279,13 +301,15 @@ impl RateLimitGovernor {
279301
std::sync::Arc::clone(&self.gate)
280302
}
281303

282-
/// Update the ceiling additive increase may climb to (the configured
283-
/// launch concurrency). Never lowers the live capacity directly; the
284-
/// AIMD loop converges on the new ceiling.
304+
/// Apply a new configured launch capacity: the AIMD ceiling and the gate
305+
/// capacity while not throttled. Applies to the live gate immediately
306+
/// (raising and lowering alike) unless the governor is paused — a pause
307+
/// keeps capacity 0 until recovery, so an external limit change cannot
308+
/// silently lift a rate-limit pause.
285309
pub(crate) fn set_max_capacity(&self, max_capacity: usize) {
286310
let mut state = self.state.lock().expect("rate limit governor poisoned");
287311
state.max_capacity = max_capacity.max(1);
288-
if !state.paused && self.gate.capacity() > state.max_capacity {
312+
if !state.paused {
289313
self.gate.set_capacity(state.max_capacity);
290314
}
291315
}
@@ -315,27 +339,45 @@ impl RateLimitGovernor {
315339
state.attempts.push_back(now);
316340
}
317341

342+
/// Lift a pause whose rate-limit events have all aged out of the window,
343+
/// resuming at a conservative quarter of the configured capacity so
344+
/// additive increase climbs the rest of the way. Callers must hold the
345+
/// state lock; `prune` first.
346+
fn unpause_if_window_drained(&self, state: &mut GovernorState) {
347+
if !state.paused || !state.limited.is_empty() {
348+
return;
349+
}
350+
state.paused = false;
351+
let capacity = (state.max_capacity / 4).max(1);
352+
self.gate.set_capacity(capacity);
353+
tracing::info!(
354+
target: "subagent",
355+
launch_capacity = capacity,
356+
max_capacity = state.max_capacity,
357+
"rate-limit governor resumed launches after window drained"
358+
);
359+
}
360+
361+
/// Time-driven recovery probe for queued launches. A pause is normally
362+
/// lifted by a successful LLM attempt from an in-flight child, but if the
363+
/// entire in-flight fleet finishes while 429 events are still inside the
364+
/// window, no success ever arrives — without this probe the queue would
365+
/// freeze until each queued child hits its wall-time deadline. Once every
366+
/// limit event has aged out, the next probe resumes launches.
367+
pub(crate) fn recover_if_window_drained(&self, now: Instant) {
368+
let mut state = self.state.lock().expect("rate limit governor poisoned");
369+
Self::prune(&mut state, now);
370+
self.unpause_if_window_drained(&mut state);
371+
}
372+
318373
/// Report a successful sub-agent LLM attempt. Drives AIMD additive
319374
/// increase and clears the pause once the window has drained.
320375
pub(crate) fn record_success(&self, now: Instant) {
321376
let mut state = self.state.lock().expect("rate limit governor poisoned");
322377
Self::prune(&mut state, now);
323378
state.consecutive_successes = state.consecutive_successes.saturating_add(1);
324379

325-
if state.paused && state.limited.is_empty() {
326-
// All observed limits aged out of the window: recover at a
327-
// conservative quarter of the configured capacity and let
328-
// additive increase climb the rest of the way.
329-
state.paused = false;
330-
let capacity = (state.max_capacity / 4).max(1);
331-
self.gate.set_capacity(capacity);
332-
tracing::info!(
333-
target: "subagent",
334-
launch_capacity = capacity,
335-
max_capacity = state.max_capacity,
336-
"rate-limit governor resumed launches after window drained"
337-
);
338-
}
380+
self.unpause_if_window_drained(&mut state);
339381

340382
if !state.paused
341383
&& state.consecutive_successes >= SUCCESS_PER_INCREASE_STEP
@@ -655,4 +697,86 @@ mod tests {
655697
// The cap holds for absurd retry numbers.
656698
assert_eq!(rate_limit_backoff_base(40), RATE_LIMIT_MAX_BACKOFF);
657699
}
700+
701+
/// A pause must lift via the time-driven probe even when no in-flight
702+
/// child ever reports another success (the in-flight fleet drained before
703+
/// the window did): otherwise queued children freeze until their
704+
/// wall-time deadline.
705+
#[test]
706+
fn forkguard_rate_limit_governor_pauses_and_time_recovers_after_window_drains() {
707+
let (governor, _gate) = RateLimitGovernor::new(8);
708+
let t0 = Instant::now();
709+
for i in 0..4 {
710+
governor.record_attempt(t0 + ms(i));
711+
governor.record_rate_limited(t0 + ms(i));
712+
}
713+
assert!(governor.is_paused(t0 + ms(10)));
714+
715+
// Probe while 429 events are still inside the window: stays paused.
716+
governor.recover_if_window_drained(t0 + ms(20));
717+
assert!(governor.is_paused(t0 + ms(30)));
718+
719+
// Once every limit event has aged out, the probe resumes launches at
720+
// a quarter of the configured capacity — no success event required.
721+
let late = t0 + RATE_LIMIT_WINDOW + ms(10);
722+
governor.recover_if_window_drained(late);
723+
assert!(!governor.is_paused(late));
724+
assert_eq!(governor.snapshot(late).launch_capacity, 2);
725+
}
726+
727+
/// A runtime launch-concurrency change must not silently lift a pause:
728+
/// the gate stays at capacity 0 until the window drains, then resumes at
729+
/// a quarter of the *new* configured capacity.
730+
#[test]
731+
fn forkguard_rate_limit_governor_limit_change_keeps_pause_capacity_zero() {
732+
let (governor, gate) = RateLimitGovernor::new(8);
733+
let t0 = Instant::now();
734+
for i in 0..4 {
735+
governor.record_attempt(t0 + ms(i));
736+
governor.record_rate_limited(t0 + ms(i));
737+
}
738+
assert!(governor.is_paused(t0 + ms(1)));
739+
740+
governor.set_max_capacity(4);
741+
assert_eq!(gate.capacity(), 0, "pause must keep capacity 0");
742+
743+
let late = t0 + RATE_LIMIT_WINDOW + ms(10);
744+
governor.recover_if_window_drained(late);
745+
assert_eq!(
746+
gate.capacity(),
747+
1,
748+
"resume at a quarter of the new capacity"
749+
);
750+
}
751+
752+
/// A waiter cancelled *after* its grant was dispatched must not swallow
753+
/// the slot: the permit is dropped with the cancelled future and its
754+
/// `Drop` re-releases it for the next waiter.
755+
#[test]
756+
fn forkguard_dynamic_gate_redispatches_grant_of_cancelled_waiter() {
757+
let (_governor, gate) = RateLimitGovernor::new(1);
758+
let rt = tokio::runtime::Builder::new_current_thread()
759+
.enable_time()
760+
.build()
761+
.expect("test runtime");
762+
rt.block_on(async move {
763+
let holder = gate.try_acquire().expect("holder");
764+
let g2 = std::sync::Arc::clone(&gate);
765+
let waiter = tokio::spawn(async move { g2.acquire().await });
766+
tokio::time::sleep(ms(20)).await;
767+
assert!(!waiter.is_finished(), "waiter must be queued");
768+
769+
// Releasing the holder dispatches the grant into the waiter's
770+
// channel; on a current-thread runtime the waiter has not polled
771+
// yet when we abort it, so the permit is dropped mid-flight.
772+
drop(holder);
773+
waiter.abort();
774+
tokio::time::sleep(ms(20)).await;
775+
776+
assert!(
777+
gate.try_acquire().is_some(),
778+
"grant of cancelled waiter must be re-released, not leaked"
779+
);
780+
});
781+
}
658782
}

0 commit comments

Comments
 (0)