From a06acb1be53caf150a702c40143720bddbc1dfd4 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 2 Aug 2026 01:36:39 -0400 Subject: [PATCH] fix(buzz-acp): clamp idle_timeout to max_turn_duration instead of hard-erroring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A config with idle_timeout >= max_turn_duration was rejected outright (ConfigError), taking the agent process offline. Under a restart-on-failure supervisor (Docker restart: always, systemd, k8s) that becomes a crash-loop the instant a rolling image tightens the invariant while an existing, previously-valid config sets a low max_turn_duration and leaves idle_timeout at its 900s default — which is exactly what happened to a fleet of agents on a pull_policy: always image. idle_timeout only matters if it fires before the absolute wall-clock cap; when it is >= max_turn_duration the cap fires first and idle_timeout is a dead letter (harmless). So clamp idle_timeout down to the lesser of the requested value and max_turn_duration and warn, rather than failing the whole config. Config-handling should degrade gracefully here, not take the agent down. Test updated to assert clamping (idle 900 + max 300 -> idle 300, no error). Co-Authored-By: SageOx Signed-off-by: Ryan Snodgrass --- crates/buzz-acp/src/config.rs | 54 +++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index dab61be30a..48c1849573 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -983,15 +983,23 @@ impl Config { } }; - // idle_timeout must be strictly less than max_turn_duration. If idle_timeout - // >= max_turn_duration, the absolute wall-clock cap would fire before the idle - // timeout ever could, making idle_timeout a dead letter. - if idle_timeout_secs >= max_turn_duration_secs { - return Err(ConfigError::ConfigFile(format!( - "idle_timeout ({}s) must be less than max_turn_duration ({}s)", - idle_timeout_secs, max_turn_duration_secs - ))); - } + // idle_timeout only bites if it fires before the absolute wall-clock cap. If it is + // >= max_turn_duration, the cap fires first and idle_timeout is a dead letter. + // Rather than reject the config outright — which turns an otherwise-valid setup into + // a crash-loop the instant a rolling image tightens this invariant — clamp + // idle_timeout down to the lesser of the requested value and max_turn_duration, and + // warn. A dead-letter idle_timeout is harmless; a hard config error takes the agent + // offline. + let idle_timeout_secs = if idle_timeout_secs >= max_turn_duration_secs { + tracing::warn!( + idle_timeout_secs, + max_turn_duration_secs, + "idle_timeout >= max_turn_duration; clamping idle_timeout down to max_turn_duration" + ); + max_turn_duration_secs + } else { + idle_timeout_secs + }; let respond_to_allowlist = if args.respond_to == RespondTo::Allowlist { let raw = args.respond_to_allowlist.unwrap_or_default(); @@ -2588,17 +2596,27 @@ channels = "ALL" } #[test] - fn idle_timeout_must_be_less_than_max_turn_duration() { - // The guard in Config::from_args rejects idle >= max_turn. - // Exercise the same logic: if idle >= max_turn, it's invalid. - let idle = 3600u64; - let max_turn = 3600u64; - assert!( - idle >= max_turn, - "test precondition: idle must be >= max_turn to trigger guard" + fn idle_timeout_clamped_to_max_turn_duration() { + // idle >= max is no longer a hard error: Config::from_args clamps idle_timeout down + // to max_turn_duration (the lesser of the two) and warns, instead of rejecting the + // whole config — which would take the agent offline / crash-loop it. + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--idle-timeout", + "900", + "--max-turn-duration", + "300", + ]) + .expect("clap should parse args"); + let config = Config::from_args(args).expect("idle >= max should clamp, not error"); + assert_eq!( + config.idle_timeout_secs, 300, + "idle_timeout should be clamped down to max_turn_duration" ); - // And the valid case (const assertion so clippy doesn't flag it): + // The common case is untouched: the default idle stays below the default cap. const { assert!(DEFAULT_IDLE_TIMEOUT_SECS < DEFAULT_MAX_TURN_DURATION_SECS); }