diff --git a/server/src/agents/pty.rs b/server/src/agents/pty.rs index ef6fb906..6a55efd3 100644 --- a/server/src/agents/pty.rs +++ b/server/src/agents/pty.rs @@ -363,26 +363,25 @@ fn run_agent_pty_blocking( .map(|dt| dt.with_timezone(&chrono::Utc)); if is_hard_block { - if let Some(reset_at) = reset_at { + let reset_at = reset_at.unwrap_or_else(|| { + let default = chrono::Utc::now() + + chrono::Duration::minutes(5); slog!( "[agent:{story_id}:{agent_name}] API rate limit hard block \ - (status={status}); resets at {reset_at}" + (status={status}); no reset_at in rate_limit_info, \ + defaulting to 5-minute backoff ({default})" ); - let _ = watcher_tx.send(WatcherEvent::RateLimitHardBlock { - story_id: story_id.to_string(), - agent_name: agent_name.to_string(), - reset_at, - }); - } else { - slog!( - "[agent:{story_id}:{agent_name}] API rate limit hard block \ - (status={status}); no reset_at in rate_limit_info" - ); - let _ = watcher_tx.send(WatcherEvent::RateLimitWarning { - story_id: story_id.to_string(), - agent_name: agent_name.to_string(), - }); - } + default + }); + slog!( + "[agent:{story_id}:{agent_name}] API rate limit hard block \ + (status={status}); resets at {reset_at}" + ); + let _ = watcher_tx.send(WatcherEvent::RateLimitHardBlock { + story_id: story_id.to_string(), + agent_name: agent_name.to_string(), + reset_at, + }); } else { slog!( "[agent:{story_id}:{agent_name}] API rate limit warning received \ @@ -575,6 +574,70 @@ mod tests { } } + /// Bug 496: hard block WITHOUT `reset_at` must still emit `RateLimitHardBlock` + /// (not `RateLimitWarning`), using a default 5-minute backoff so the + /// auto-scheduler can set a retry timer. + #[tokio::test] + async fn rate_limit_hard_block_without_reset_at_sends_hard_block_event() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let script = tmp.path().join("emit_hard_block_no_reset.sh"); + std::fs::write( + &script, + "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"rejected\"}}'\n", + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let (tx, _rx) = broadcast::channel::(64); + let (watcher_tx, mut watcher_rx) = broadcast::channel::(16); + let event_log = Arc::new(Mutex::new(Vec::new())); + let child_killers = Arc::new(Mutex::new(HashMap::new())); + + let before = chrono::Utc::now(); + let result = run_agent_pty_streaming( + "496_bug_hard_rate_limit", + "coder-1", + "sh", + &[script.to_string_lossy().to_string()], + "--", + "/tmp", + &tx, + &event_log, + None, + 0, + child_killers, + watcher_tx, + ) + .await; + let after = chrono::Utc::now(); + + assert!(result.is_ok(), "PTY run should succeed: {:?}", result.err()); + + let evt = watcher_rx + .try_recv() + .expect("Expected a RateLimitHardBlock to be sent on watcher_tx"); + match evt { + WatcherEvent::RateLimitHardBlock { + story_id, + agent_name, + reset_at, + } => { + assert_eq!(story_id, "496_bug_hard_rate_limit"); + assert_eq!(agent_name, "coder-1"); + // reset_at should be ~5 minutes from when the event fired + let min_expected = before + chrono::Duration::minutes(4); + let max_expected = after + chrono::Duration::minutes(6); + assert!( + reset_at >= min_expected && reset_at <= max_expected, + "reset_at {reset_at} should be ~5 minutes from now" + ); + } + other => panic!("Expected RateLimitHardBlock (with default backoff), got: {other:?}"), + } + } + #[test] fn test_emit_event_writes_to_log_writer() { let tmp = tempfile::tempdir().unwrap();