feat(423): auto-schedule timer on rate limit to resume after reset

- pty.rs: detect rate_limit_event hard blocks, parse reset_at, emit
  WatcherEvent::RateLimitHardBlock with story_id, agent_name, reset_at
- watcher.rs: add RateLimitHardBlock variant to WatcherEvent enum
- timer.rs: add TimerStore::upsert (add-or-update-to-later) and
  spawn_rate_limit_auto_scheduler (listens for RateLimitHardBlock,
  upserts timer for the blocked story)
- notifications.rs: handle RateLimitHardBlock events with a debounced
  chat notification including the scheduled resume time;
  add format_rate_limit_hard_block_notification helper
- matrix/mod.rs: subscribe second watcher_rx for auto-scheduler,
  pass it to run_bot
- matrix/bot/run.rs: wire spawn_rate_limit_auto_scheduler into bot startup

Tests cover: AC1 (hard block detection in pty), AC2 (auto-scheduler
adds timer), AC3 (upsert deduplication), AC5 (chat notification sent),
AC6 (worktree preserved — timer fires start_agent on existing worktree)
This commit is contained in:
dave
2026-03-28 09:18:58 +00:00
parent 57407aed51
commit b44f3a33e3
6 changed files with 374 additions and 8 deletions
+188
View File
@@ -83,6 +83,31 @@ impl TimerStore {
self.timers.lock().unwrap().clone()
}
/// Add or update a timer for `story_id`.
///
/// - If no timer exists for `story_id`, adds it.
/// - If a timer already exists and `scheduled_at` is **later**, updates it.
/// - If a timer already exists and `scheduled_at` is earlier or equal, no-op.
///
/// Use this instead of [`add`] when auto-scheduling from rate-limit events to
/// avoid creating duplicates and to always keep the latest reset time.
pub fn upsert(&self, story_id: String, scheduled_at: DateTime<Utc>) -> Result<(), String> {
let mut timers = self.timers.lock().unwrap();
if let Some(existing) = timers.iter_mut().find(|t| t.story_id == story_id) {
if scheduled_at > existing.scheduled_at {
existing.scheduled_at = scheduled_at;
Self::save_locked(&self.path, &timers)?;
}
} else {
timers.push(TimerEntry {
story_id,
scheduled_at,
});
Self::save_locked(&self.path, &timers)?;
}
Ok(())
}
/// Remove and return all timers whose `scheduled_at` is ≤ `now`.
/// Persists the updated list to disk if any timers were removed.
pub fn take_due(&self, now: DateTime<Utc>) -> Vec<TimerEntry> {
@@ -150,6 +175,58 @@ pub fn spawn_timer_tick_loop(
});
}
/// Spawn a background task that listens for [`WatcherEvent::RateLimitHardBlock`]
/// events and auto-schedules a timer for the blocked story.
///
/// If a timer already exists for the story, it is updated to the later reset time
/// rather than creating a duplicate (via [`TimerStore::upsert`]).
pub fn spawn_rate_limit_auto_scheduler(
store: Arc<TimerStore>,
mut watcher_rx: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>,
) {
tokio::spawn(async move {
loop {
match watcher_rx.recv().await {
Ok(crate::io::watcher::WatcherEvent::RateLimitHardBlock {
story_id,
agent_name,
reset_at,
}) => {
crate::slog!(
"[timer] Auto-scheduling timer for story {story_id} \
(agent {agent_name}) to resume at {reset_at}"
);
match store.upsert(story_id.clone(), reset_at) {
Ok(()) => {
crate::slog!(
"[timer] Timer upserted for story {story_id}; \
scheduled at {reset_at}"
);
}
Err(e) => {
crate::slog!(
"[timer] Failed to upsert timer for story {story_id}: {e}"
);
}
}
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
crate::slog!(
"[timer] Rate-limit auto-scheduler lagged, skipped {n} events"
);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
crate::slog!(
"[timer] Watcher channel closed, stopping rate-limit auto-scheduler"
);
break;
}
}
}
});
}
// ── Command types ──────────────────────────────────────────────────────────
/// A parsed `timer` command.
@@ -602,6 +679,117 @@ mod tests {
assert_eq!(store.list()[0].story_id, "future_story");
}
// ── AC3: upsert ─────────────────────────────────────────────────────
#[test]
fn upsert_adds_new_timer_when_none_exists() {
let dir = TempDir::new().unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let t = Utc::now() + Duration::hours(1);
store.upsert("story_1".to_string(), t).unwrap();
let list = store.list();
assert_eq!(list.len(), 1);
assert_eq!(list[0].story_id, "story_1");
assert_eq!(list[0].scheduled_at, t);
}
#[test]
fn upsert_updates_to_later_time() {
let dir = TempDir::new().unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let early = Utc::now() + Duration::hours(1);
let later = Utc::now() + Duration::hours(2);
store.upsert("story_1".to_string(), early).unwrap();
store.upsert("story_1".to_string(), later).unwrap();
let list = store.list();
assert_eq!(list.len(), 1, "should not create duplicate");
assert_eq!(list[0].scheduled_at, later, "should update to later time");
}
#[test]
fn upsert_does_not_downgrade_to_earlier_time() {
let dir = TempDir::new().unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let later = Utc::now() + Duration::hours(2);
let earlier = Utc::now() + Duration::hours(1);
store.upsert("story_1".to_string(), later).unwrap();
store.upsert("story_1".to_string(), earlier).unwrap();
let list = store.list();
assert_eq!(list.len(), 1);
assert_eq!(
list[0].scheduled_at, later,
"should keep the later time, not downgrade"
);
}
// ── AC2: spawn_rate_limit_auto_scheduler ────────────────────────────
/// AC2: a RateLimitHardBlock event causes the auto-scheduler to add a timer.
#[tokio::test]
async fn rate_limit_auto_scheduler_adds_timer_on_hard_block() {
use crate::io::watcher::WatcherEvent;
let dir = TempDir::new().unwrap();
let store = Arc::new(TimerStore::load(dir.path().join("timers.json")));
let (watcher_tx, watcher_rx) = tokio::sync::broadcast::channel::<WatcherEvent>(16);
spawn_rate_limit_auto_scheduler(Arc::clone(&store), watcher_rx);
let reset_at = Utc::now() + Duration::hours(1);
watcher_tx
.send(WatcherEvent::RateLimitHardBlock {
story_id: "423_story_test".to_string(),
agent_name: "coder-1".to_string(),
reset_at,
})
.unwrap();
// Give the spawned task time to process the event.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let list = store.list();
assert_eq!(list.len(), 1, "expected one timer after hard block");
assert_eq!(list[0].story_id, "423_story_test");
assert_eq!(list[0].scheduled_at, reset_at);
}
/// AC3 integration: a second hard block with a later reset_at updates the
/// existing timer rather than creating a duplicate.
#[tokio::test]
async fn rate_limit_auto_scheduler_upserts_on_repeated_hard_block() {
use crate::io::watcher::WatcherEvent;
let dir = TempDir::new().unwrap();
let store = Arc::new(TimerStore::load(dir.path().join("timers.json")));
let (watcher_tx, watcher_rx) = tokio::sync::broadcast::channel::<WatcherEvent>(16);
spawn_rate_limit_auto_scheduler(Arc::clone(&store), watcher_rx);
let first = Utc::now() + Duration::hours(1);
let second = Utc::now() + Duration::hours(2);
watcher_tx
.send(WatcherEvent::RateLimitHardBlock {
story_id: "423_story_test".to_string(),
agent_name: "coder-1".to_string(),
reset_at: first,
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
watcher_tx
.send(WatcherEvent::RateLimitHardBlock {
story_id: "423_story_test".to_string(),
agent_name: "coder-1".to_string(),
reset_at: second,
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let list = store.list();
assert_eq!(list.len(), 1, "should not create a duplicate timer");
assert_eq!(list[0].scheduled_at, second, "should update to later time");
}
#[test]
fn multiple_timers_same_time_all_returned() {
let dir = TempDir::new().unwrap();