1use std::sync::Arc;
11use std::time::SystemTime;
12
13use forge_primitives::tasks::TaskStatus;
14use tracing::Instrument;
15
16use crate::SessionSlot;
17use crate::chase::{Deliver, Delivery, PromptDeliver};
18use crate::mcp::workers::types::WorkerEntry;
19use crate::workspace::Workspace;
20
21pub const IDLE_SWEEP_INTERVAL_SECS: u64 = 60;
23
24pub const IDLE_SWEEP_MIN_GAP_SECS: u64 = 2 * 60 * 60;
26
27pub const IDLE_SWEEP_FALLBACK_SECS: u64 = 6 * 60 * 60;
31
32pub const IDLE_SWEEP_PROMPT: &str = "Nothing is in flight on this board. Sweep it now, and end \
34 with the next three picks from the backlog.";
35
36pub const IDLE_SWEEP_FALLBACK_PROMPT: &str = "Six hours have passed since the last sweep. Sweep \
39 the board when you can, and end with the next three picks from the backlog.";
40
41#[derive(Debug, Clone, Copy)]
44pub(crate) struct SweepClock {
45 pub(crate) first_seen: SystemTime,
49 pub(crate) last_delivered: Option<SystemTime>,
53 pub(crate) failing: bool,
56}
57
58fn age_secs(since: SystemTime, now: SystemTime) -> u64 {
62 now.duration_since(since).map_or(0, |d| d.as_secs())
63}
64
65impl Workspace {
66 pub fn idle_sweep(&self, deliver: &dyn Deliver, now: SystemTime) -> usize {
70 let views = self.list_projects();
71 self.idle_sweeps.lock().retain(|slot, _| {
74 views.iter().any(|view| view.org == slot.org() && view.name == slot.project())
75 });
76 let mut delivered = 0;
77 for view in views {
78 let lead = SessionSlot::lead(&view.org, &view.name);
79 if !self.seat_is_live(&lead) {
84 if let Some(clock) = self.idle_sweeps.lock().get_mut(&lead) {
85 clock.first_seen = now;
86 }
87 continue;
88 }
89 let idle = lead_is_idle(self, &lead);
90 let due = {
91 let mut clocks = self.idle_sweeps.lock();
92 let clock = clocks.entry(lead.clone()).or_insert(SweepClock {
93 first_seen: now,
94 last_delivered: None,
95 failing: false,
96 });
97 let age = clock
98 .last_delivered
99 .map_or(age_secs(clock.first_seen, now), |last| age_secs(last, now));
100 match clock.last_delivered {
101 None => idle || age >= IDLE_SWEEP_FALLBACK_SECS,
102 Some(_) => {
103 (idle && age >= IDLE_SWEEP_MIN_GAP_SECS) || age >= IDLE_SWEEP_FALLBACK_SECS
104 }
105 }
106 };
107 if !due {
108 continue;
109 }
110 let text = if idle { IDLE_SWEEP_PROMPT } else { IDLE_SWEEP_FALLBACK_PROMPT };
111 match deliver.to_seat(&lead, text) {
112 Delivery::Delivered => {
113 let recovered = {
114 let mut clocks = self.idle_sweeps.lock();
115 clocks.get_mut(&lead).is_some_and(|clock| {
116 clock.last_delivered = Some(now);
117 std::mem::replace(&mut clock.failing, false)
118 })
119 };
120 delivered += 1;
121 tracing::debug!(
122 target: "forge_workspace::idle_sweep",
123 event_name = "idle_sweep_injected",
124 slot = %lead.display(),
125 fallback = !idle,
126 "injected the board sweep",
127 );
128 if recovered {
129 tracing::info!(
130 target: "forge_workspace::idle_sweep",
131 event_name = "idle_sweep_recovered",
132 slot = %lead.display(),
133 "the board sweep reached the lead again",
134 );
135 }
136 }
137 Delivery::NoSession(reason) => {
138 let first_failure = {
139 let mut clocks = self.idle_sweeps.lock();
140 match clocks.get_mut(&lead) {
141 Some(clock) => !std::mem::replace(&mut clock.failing, true),
142 None => true,
143 }
144 };
145 if first_failure {
146 tracing::warn!(
147 target: "forge_workspace::idle_sweep",
148 event_name = "idle_sweep_not_delivered",
149 slot = %lead.display(),
150 reason = %reason,
151 "the board sweep could not be delivered; retrying each tick",
152 );
153 }
154 }
155 }
156 }
157 delivered
158 }
159
160 pub fn start_idle_sweep(self: &Arc<Self>) {
166 let weak = Arc::downgrade(self);
167 let span = tracing::info_span!("idle_sweep");
168 self.supervisor.spawn("idle_sweep", forge_supervise::Policy::backoff(), move || {
169 let weak = weak.clone();
170 let span = span.clone();
171 async move {
172 let period = std::time::Duration::from_secs(IDLE_SWEEP_INTERVAL_SECS);
173 let mut interval =
174 tokio::time::interval_at(tokio::time::Instant::now() + period, period);
175 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
176 loop {
177 interval.tick().await;
178 let Some(workspace) = weak.upgrade() else {
179 return;
180 };
181 workspace.idle_sweep(
182 &PromptDeliver { workspace: Arc::clone(&workspace) },
183 SystemTime::now(),
184 );
185 }
186 }
187 .instrument(span)
188 });
189 }
190}
191
192fn lead_is_idle(ws: &Workspace, lead: &SessionSlot) -> bool {
196 if !lead.is_lead() || !ws.seat_is_live(lead) {
197 return false;
198 }
199 if ws.domain_session_for(lead).is_some_and(|domain| domain.lock().turn_in_flight()) {
200 return false;
201 }
202 let busy_row = ws
203 .tasks_for_project(lead.project())
204 .iter()
205 .any(|task| task.status == TaskStatus::InProgress && task.owner.as_ref() == Some(lead));
206 if busy_row {
207 return false;
208 }
209 let Some(view) = ws.list_projects().into_iter().find(|view| view.name == lead.project()) else {
210 return false;
211 };
212 !ws.list_live_workers(&view.key).iter().any(WorkerEntry::is_live)
216}
217
218#[cfg(test)]
219mod tests {
220 use std::sync::Arc;
221 use std::time::Duration;
222
223 use parking_lot::Mutex;
224 use tempfile::tempdir;
225
226 use super::*;
227 use crate::SessionSlot;
228 use crate::chase::{Deliver, Delivery};
229 use forge_primitives::tasks::{Task, TaskId, TaskStatus};
230
231 const PROJECT: &str = "proj";
232
233 fn epoch(secs: u64) -> SystemTime {
234 SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
235 }
236
237 fn fixture(dir: &std::path::Path) -> Arc<Workspace> {
238 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.to_owned());
239 ws.seed_test_project(PROJECT, "/tmp/tp-idle-sweep");
240 let db = crate::store::Db::open(&dir.join("db.redb")).expect("open db");
241 ws.install_db_for_test(db);
242 ws
243 }
244
245 fn lead() -> SessionSlot {
246 SessionSlot::lead("TestOrg", PROJECT)
247 }
248
249 fn live_lead_for(
252 ws: &Workspace,
253 project: &str,
254 ) -> tokio::sync::mpsc::UnboundedReceiver<forge_primitives::AgentCommand> {
255 ws.install_testing_stub(&SessionSlot::lead("TestOrg", project))
256 }
257
258 fn live_lead(
259 ws: &Workspace,
260 ) -> tokio::sync::mpsc::UnboundedReceiver<forge_primitives::AgentCommand> {
261 live_lead_for(ws, PROJECT)
262 }
263
264 fn sleep(ws: &Workspace) {
266 ws.domain_handles.lock().remove(&lead());
267 }
268
269 fn task(id: &str, status: TaskStatus, owner: Option<SessionSlot>) -> Task {
270 Task {
271 id: TaskId::from(id),
272 project_name: PROJECT.to_owned(),
273 subject: format!("subject {id}"),
274 active_form: None,
275 detail: None,
276 status,
277 owner,
278 parent: None,
279 waiting_on: None,
280 estimate: None,
281 rank: None,
282 verify: None,
283 attested: None,
284 reserved: None,
285 links: Vec::new(),
286 attempt: 0,
287 archived_at: None,
288 created_at: epoch(0),
289 updated_at: epoch(0),
290 }
291 }
292
293 fn live_worker(ws: &Workspace, label: &str) {
294 let key = ws
295 .list_projects()
296 .into_iter()
297 .find(|view| view.name == PROJECT)
298 .expect("view")
299 .key
300 .clone();
301 ws.insert_live_worker(
302 &key,
303 crate::WorkerEntry {
304 label: label.to_owned(),
305 charter: "charter".to_owned(),
306 slot: SessionSlot::worker("TestOrg", PROJECT, label),
307 session_id: None,
308 status: forge_primitives::WorkerLiveness::Running,
309 spawned_at: epoch(0),
310 spawned_by: lead(),
311 needs_tag: false,
312 is_git_repo_at_spawn: false,
313 cwd: None,
314 diagnostic: None,
315 kick: None,
316 },
317 );
318 }
319
320 #[derive(Default)]
321 struct Recording {
322 seen: Mutex<Vec<(SessionSlot, String)>>,
323 }
324
325 impl Deliver for Recording {
326 fn to_seat(&self, slot: &SessionSlot, text: &str) -> Delivery {
327 self.seen.lock().push((slot.clone(), text.to_owned()));
328 Delivery::Delivered
329 }
330 }
331
332 #[derive(Default)]
335 struct Flaky {
336 open: std::sync::atomic::AtomicBool,
337 seen: Mutex<usize>,
338 }
339
340 impl Deliver for Flaky {
341 fn to_seat(&self, _slot: &SessionSlot, _text: &str) -> Delivery {
342 *self.seen.lock() += 1;
343 if self.open.load(std::sync::atomic::Ordering::SeqCst) {
344 Delivery::Delivered
345 } else {
346 Delivery::NoSession("the seat's channel closed".to_owned())
347 }
348 }
349 }
350
351 fn sweep(ws: &Arc<Workspace>, deliver: &dyn Deliver, at: u64) -> usize {
352 ws.idle_sweep(deliver, epoch(at))
353 }
354
355 #[derive(Clone, Default)]
357 struct Caught(std::sync::Arc<std::sync::Mutex<Vec<(tracing::Level, String, String)>>>);
358
359 #[derive(Default)]
360 struct CaughtFields(String);
361
362 impl tracing::field::Visit for CaughtFields {
363 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
364 if !self.0.is_empty() {
365 self.0.push(' ');
366 }
367 let _ = std::fmt::write(&mut self.0, format_args!("{}={value:?}", field.name()));
368 }
369 }
370
371 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Caught {
372 fn on_event(
373 &self,
374 event: &tracing::Event<'_>,
375 _ctx: tracing_subscriber::layer::Context<'_, S>,
376 ) {
377 let mut fields = CaughtFields::default();
378 event.record(&mut fields);
379 self.0.lock().expect("capture").push((
380 *event.metadata().level(),
381 event.metadata().target().to_owned(),
382 fields.0,
383 ));
384 }
385 }
386
387 fn warnings(caught: &Caught) -> Vec<(tracing::Level, String, String)> {
388 caught
389 .0
390 .lock()
391 .expect("capture")
392 .iter()
393 .filter(|(level, target, _)| {
394 *level == tracing::Level::WARN && target == "forge_workspace::idle_sweep"
395 })
396 .cloned()
397 .collect()
398 }
399
400 fn recoveries(caught: &Caught) -> Vec<(tracing::Level, String, String)> {
401 caught
402 .0
403 .lock()
404 .expect("capture")
405 .iter()
406 .filter(|(level, target, fields)| {
407 *level == tracing::Level::INFO
408 && target == "forge_workspace::idle_sweep"
409 && fields.contains("idle_sweep_recovered")
410 })
411 .cloned()
412 .collect()
413 }
414
415 #[test]
416 fn a_live_lead_with_nothing_in_flight_is_idle() {
417 let dir = tempdir().expect("tempdir");
418 let ws = fixture(dir.path());
419 let _rx = live_lead(&ws);
420
421 assert!(lead_is_idle(&ws, &lead()));
422 }
423
424 #[test]
425 fn a_running_turn_is_not_idle() {
426 let dir = tempdir().expect("tempdir");
427 let ws = fixture(dir.path());
428 let _rx = live_lead(&ws);
429 let domain = ws.domain_session_for(&lead()).expect("domain");
430 domain.lock().commit_turn();
431
432 assert!(!lead_is_idle(&ws, &lead()), "a lead with a turn running has something in flight");
433 }
434
435 #[test]
436 fn a_row_in_progress_under_the_lead_is_not_idle() {
437 let dir = tempdir().expect("tempdir");
438 let ws = fixture(dir.path());
439 let _rx = live_lead(&ws);
440 ws.seed_test_task(task("t-1", TaskStatus::InProgress, Some(lead())));
441
442 assert!(
443 !lead_is_idle(&ws, &lead()),
444 "a lead holding an in-progress row has work in flight",
445 );
446 }
447
448 #[test]
449 fn a_row_in_progress_under_a_worker_leaves_the_lead_idle() {
450 let dir = tempdir().expect("tempdir");
451 let ws = fixture(dir.path());
452 let _rx = live_lead(&ws);
453 ws.seed_test_task(task(
454 "t-1",
455 TaskStatus::InProgress,
456 Some(SessionSlot::worker("TestOrg", PROJECT, "w-1")),
457 ));
458
459 assert!(lead_is_idle(&ws, &lead()), "a worker's row is the worker's work, not the lead's");
460 }
461
462 #[test]
463 fn a_live_worker_in_the_project_is_not_idle() {
464 let dir = tempdir().expect("tempdir");
465 let ws = fixture(dir.path());
466 let _rx = live_lead(&ws);
467 live_worker(&ws, "w-1");
468
469 assert!(!lead_is_idle(&ws, &lead()), "a project with a live worker has work in flight");
470 }
471
472 #[test]
473 fn a_worker_seat_is_not_idle() {
474 let dir = tempdir().expect("tempdir");
475 let ws = fixture(dir.path());
476 let worker = SessionSlot::worker("TestOrg", PROJECT, "w-1");
477 let _rx = ws.install_testing_stub(&worker);
478
479 assert!(
480 !lead_is_idle(&ws, &worker),
481 "the sweep is for the project's own agent, never a worker",
482 );
483 }
484
485 #[test]
486 fn a_sleeping_lead_is_skipped_and_never_woken() {
487 let dir = tempdir().expect("tempdir");
488 let ws = fixture(dir.path());
489 let deliver = Recording::default();
490
491 assert_eq!(sweep(&ws, &deliver, 1_000), 0);
492 assert!(deliver.seen.lock().is_empty(), "a sleeping lead is never woken");
493 }
494
495 #[test]
496 fn a_lead_that_was_swept_and_then_slept_is_not_woken() {
497 let dir = tempdir().expect("tempdir");
498 let ws = fixture(dir.path());
499 let _rx = live_lead(&ws);
500 let deliver = Recording::default();
501 assert_eq!(sweep(&ws, &deliver, 1_000), 1, "the first sweep lands");
502
503 sleep(&ws);
504 assert_eq!(
505 sweep(&ws, &deliver, 1_000 + IDLE_SWEEP_FALLBACK_SECS + 60),
506 0,
507 "a sleeping lead is skipped even when its fallback is long overdue",
508 );
509 assert_eq!(deliver.seen.lock().len(), 1);
510 }
511
512 #[test]
513 fn a_live_idle_lead_is_swept_once() {
514 let dir = tempdir().expect("tempdir");
515 let ws = fixture(dir.path());
516 let _rx = live_lead(&ws);
517 let deliver = Recording::default();
518
519 assert_eq!(sweep(&ws, &deliver, 1_000), 1);
520 let seen = deliver.seen.lock();
521 assert_eq!(seen.len(), 1);
522 assert_eq!(seen[0].0, lead(), "the prompt goes to the project's lead");
523 assert_eq!(
524 seen[0].1,
525 "Nothing is in flight on this board. Sweep it now, and end with the next three picks \
526 from the backlog.",
527 "the idle path carries the plan's prompt verbatim",
528 );
529 }
530
531 #[test]
532 fn two_ticks_inside_the_gap_deliver_one_prompt() {
533 let dir = tempdir().expect("tempdir");
534 let ws = fixture(dir.path());
535 let _rx = live_lead(&ws);
536 let deliver = Recording::default();
537
538 assert_eq!(sweep(&ws, &deliver, 1_000), 1);
539 assert_eq!(sweep(&ws, &deliver, 1_060), 0, "inside the two-hour gap");
540 assert_eq!(sweep(&ws, &deliver, 1_000 + IDLE_SWEEP_MIN_GAP_SECS), 1, "the gap is spent");
541 assert_eq!(
542 sweep(&ws, &deliver, 1_000 + IDLE_SWEEP_MIN_GAP_SECS + 60),
543 0,
544 "a delivery re-arms the gap from the delivery, not from the first sighting",
545 );
546 assert_eq!(deliver.seen.lock().len(), 2);
547 }
548
549 #[test]
550 fn a_lead_that_slept_keeps_its_gap_across_the_restart() {
551 let dir = tempdir().expect("tempdir");
552 let ws = fixture(dir.path());
553 let lead_rx = live_lead(&ws);
554 let deliver = Recording::default();
555 assert_eq!(sweep(&ws, &deliver, 1_000), 1);
556
557 drop(lead_rx);
558 sleep(&ws);
559 assert_eq!(sweep(&ws, &deliver, 1_100), 0, "asleep, nothing is delivered");
560
561 let _rx = live_lead(&ws);
562 assert_eq!(
563 sweep(&ws, &deliver, 1_200),
564 0,
565 "the wake does not reset the lead's two-hour gap",
566 );
567 assert_eq!(
568 sweep(&ws, &deliver, 1_000 + IDLE_SWEEP_MIN_GAP_SECS),
569 1,
570 "the gap still runs from the delivery before the sleep",
571 );
572 }
573
574 #[test]
575 fn a_busy_lead_is_swept_after_six_hours() {
576 let dir = tempdir().expect("tempdir");
577 let ws = fixture(dir.path());
578 let _rx = live_lead(&ws);
579 let domain = ws.domain_session_for(&lead()).expect("domain");
580 domain.lock().commit_turn();
581 let deliver = Recording::default();
582
583 assert_eq!(sweep(&ws, &deliver, 1_000), 0, "a busy lead is not idle");
584 assert_eq!(
585 sweep(&ws, &deliver, 1_000 + IDLE_SWEEP_FALLBACK_SECS - 1),
586 0,
587 "the fallback is not due yet",
588 );
589 assert_eq!(
590 sweep(&ws, &deliver, 1_000 + IDLE_SWEEP_FALLBACK_SECS),
591 1,
592 "the fallback lands even mid-turn",
593 );
594 {
595 let seen = deliver.seen.lock();
596 assert_eq!(seen.len(), 1);
597 assert_eq!(
598 seen[0].1,
599 "Six hours have passed since the last sweep. Sweep the board when you can, and \
600 end with the next three picks from the backlog.",
601 "the fallback does not claim the board is quiet",
602 );
603 }
604 assert_eq!(
605 sweep(&ws, &deliver, 1_000 + IDLE_SWEEP_FALLBACK_SECS + 60),
606 0,
607 "one fallback, not a loop",
608 );
609 assert_eq!(
610 sweep(&ws, &deliver, 1_000 + 2 * IDLE_SWEEP_FALLBACK_SECS),
611 1,
612 "the next six-hour boundary delivers again",
613 );
614 }
615
616 #[test]
617 fn a_lead_busy_on_its_own_row_is_swept_after_six_hours() {
618 let dir = tempdir().expect("tempdir");
619 let ws = fixture(dir.path());
620 let _rx = live_lead(&ws);
621 ws.seed_test_task(task("t-1", TaskStatus::InProgress, Some(lead())));
622 let deliver = Recording::default();
623
624 assert_eq!(sweep(&ws, &deliver, 1_000), 0, "a lead on its own row is not idle");
625 assert_eq!(
626 sweep(&ws, &deliver, 1_000 + IDLE_SWEEP_FALLBACK_SECS),
627 1,
628 "the fallback covers a busy row as well as a busy turn",
629 );
630 }
631
632 #[test]
633 fn a_backwards_clock_does_not_sweep() {
634 let dir = tempdir().expect("tempdir");
635 let ws = fixture(dir.path());
636 let _rx = live_lead(&ws);
637 let deliver = Recording::default();
638
639 assert_eq!(sweep(&ws, &deliver, 2_000), 1);
640 assert_eq!(
641 sweep(&ws, &deliver, 1_900),
642 0,
643 "a clock that went backwards is unknown, not infinitely old",
644 );
645 assert_eq!(deliver.seen.lock().len(), 1);
646 }
647
648 #[test]
649 fn a_failed_delivery_is_retried_on_the_next_tick() {
650 let dir = tempdir().expect("tempdir");
651 let ws = fixture(dir.path());
652 let _rx = live_lead(&ws);
653 let deliver = Flaky::default();
654
655 assert_eq!(sweep(&ws, &deliver, 1_000), 0, "the failed attempt delivers nothing");
656 assert_eq!(*deliver.seen.lock(), 1);
657 assert_eq!(
658 sweep(&ws, &deliver, 1_060),
659 0,
660 "the failing seat is retried, not stamped as swept",
661 );
662 deliver.open.store(true, std::sync::atomic::Ordering::SeqCst);
663 assert_eq!(sweep(&ws, &deliver, 1_120), 1, "the retry succeeds");
664 assert_eq!(*deliver.seen.lock(), 3);
665 }
666
667 #[test]
670 fn the_product_clocks_are_the_shipped_ones() {
671 assert_eq!(IDLE_SWEEP_INTERVAL_SECS, 60, "the lead is swept within a minute");
672 assert_eq!(IDLE_SWEEP_MIN_GAP_SECS, 2 * 60 * 60, "the idle gap is two hours");
673 assert_eq!(IDLE_SWEEP_FALLBACK_SECS, 6 * 60 * 60, "the fallback is six hours");
674 }
675
676 #[test]
677 fn each_project_keeps_its_own_clock_and_a_removed_project_is_pruned() {
678 let dir = tempdir().expect("tempdir");
679 let ws = fixture(dir.path());
680 let _proj_rx = live_lead(&ws);
681 let deliver = Recording::default();
682 assert_eq!(sweep(&ws, &deliver, 1_000), 1);
683
684 ws.seed_test_project("other", "/tmp/tp-idle-sweep-other");
685 let other_lead = SessionSlot::lead("TestOrg", "other");
686 let _other_rx = live_lead_for(&ws, "other");
687 assert_eq!(
688 sweep(&ws, &deliver, 1_060),
689 1,
690 "the new project's lead sweeps on its own clock",
691 );
692 {
693 let seen = deliver.seen.lock();
694 assert_eq!(seen.len(), 2);
695 assert_eq!(seen[1].0, other_lead, "the second prompt goes to the other lead");
696 }
697
698 ws.remove_test_project("other");
699 assert_eq!(sweep(&ws, &deliver, 1_120), 0);
700 let clocks = ws.idle_sweeps.lock();
701 assert_eq!(
702 clocks.len(),
703 1,
704 "a removed project's clock is pruned: {:?}",
705 clocks.keys().collect::<Vec<_>>(),
706 );
707 assert!(clocks.contains_key(&lead()));
708 }
709
710 #[test]
711 fn a_wake_re_arms_the_fallback_from_the_wake() {
712 let dir = tempdir().expect("tempdir");
713 let ws = fixture(dir.path());
714 let _rx = live_lead(&ws);
715 let domain = ws.domain_session_for(&lead()).expect("domain");
716 domain.lock().commit_turn();
717 let deliver = Recording::default();
718
719 assert_eq!(sweep(&ws, &deliver, 1_000), 0, "a busy lead is not idle");
720 sleep(&ws);
721 assert_eq!(sweep(&ws, &deliver, 30_000), 0, "asleep, nothing is delivered");
722
723 let _rx = live_lead(&ws);
724 let domain = ws.domain_session_for(&lead()).expect("domain");
725 domain.lock().commit_turn();
726 assert_eq!(
727 sweep(&ws, &deliver, 30_060),
728 0,
729 "the fallback re-arms from the wake, not from before the sleep",
730 );
731 assert_eq!(
732 sweep(&ws, &deliver, 30_000 + IDLE_SWEEP_FALLBACK_SECS),
733 1,
734 "a full fallback after the wake delivers",
735 );
736 }
737
738 #[test]
741 fn a_failure_episode_warns_once_and_its_recovery_is_recorded() {
742 use tracing_subscriber::layer::SubscriberExt;
743
744 let dir = tempdir().expect("tempdir");
745 let ws = fixture(dir.path());
746 let _rx = live_lead(&ws);
747 let deliver = Flaky::default();
748 let caught = Caught::default();
749 let subscriber = tracing_subscriber::Registry::default().with(caught.clone());
750 let _default = tracing::subscriber::set_default(subscriber);
751
752 assert_eq!(sweep(&ws, &deliver, 1_000), 0);
753 assert_eq!(sweep(&ws, &deliver, 1_060), 0);
754 let warns = warnings(&caught);
755 assert_eq!(warns.len(), 1, "two failed ticks are one episode: {warns:?}");
756 assert!(warns[0].2.contains("idle_sweep_not_delivered"));
757 assert!(warns[0].2.contains("TestOrg/proj/lead"), "the warning names the lead");
758 assert!(
759 warns[0].2.contains("the seat's channel closed"),
760 "the warning carries the dispatch's reason: {}",
761 warns[0].2,
762 );
763
764 deliver.open.store(true, std::sync::atomic::Ordering::SeqCst);
765 assert_eq!(sweep(&ws, &deliver, 1_120), 1);
766 assert_eq!(recoveries(&caught).len(), 1, "the recovery is its own record");
767 assert_eq!(warnings(&caught).len(), 1, "a delivery does not add a warning");
768
769 deliver.open.store(false, std::sync::atomic::Ordering::SeqCst);
770 assert_eq!(
771 sweep(&ws, &deliver, 1_120 + IDLE_SWEEP_MIN_GAP_SECS),
772 0,
773 "the next due attempt fails",
774 );
775 assert_eq!(
776 warnings(&caught).len(),
777 2,
778 "a failure after a delivered sweep is a new episode",
779 );
780 assert_eq!(recoveries(&caught).len(), 1);
781 }
782
783 #[test]
786 fn a_sleep_does_not_split_a_failure_episode() {
787 use tracing_subscriber::layer::SubscriberExt;
788
789 let dir = tempdir().expect("tempdir");
790 let ws = fixture(dir.path());
791 let _rx = live_lead(&ws);
792 let deliver = Flaky::default();
793 let caught = Caught::default();
794 let subscriber = tracing_subscriber::Registry::default().with(caught.clone());
795 let _default = tracing::subscriber::set_default(subscriber);
796
797 assert_eq!(sweep(&ws, &deliver, 1_000), 0);
798 assert_eq!(warnings(&caught).len(), 1);
799 sleep(&ws);
800 assert_eq!(sweep(&ws, &deliver, 1_060), 0);
801 let _rx = live_lead(&ws);
802 assert_eq!(sweep(&ws, &deliver, 1_120), 0);
803 assert_eq!(
804 warnings(&caught).len(),
805 1,
806 "a sleep is not a recovery, so the episode keeps its one warning",
807 );
808 }
809
810 #[tokio::test(start_paused = true)]
813 async fn the_boot_member_injects_the_sweep_on_its_tick() {
814 let dir = tempdir().expect("tempdir");
815 let ws = fixture(dir.path());
816 let _rx = live_lead(&ws);
817 ws.enable_test_dispatch_intercept();
818 ws.start_idle_sweep();
819
820 let names: Vec<String> =
821 ws.supervisor().snapshot().into_iter().map(|member| member.name).collect();
822 assert!(
823 names.contains(&"idle_sweep".to_owned()),
824 "the sweep is a supervised member: {names:?}"
825 );
826
827 for _ in 0..10 {
830 tokio::task::yield_now().await;
831 }
832 tokio::time::advance(Duration::from_secs(IDLE_SWEEP_INTERVAL_SECS - 1)).await;
833 for _ in 0..10 {
834 tokio::task::yield_now().await;
835 }
836 assert!(
837 ws.drain_test_dispatch_buffer().is_empty(),
838 "no eager pass lands before the interval",
839 );
840
841 tokio::time::advance(Duration::from_secs(1)).await;
842 for _ in 0..10 {
843 tokio::task::yield_now().await;
844 }
845 let prompts: Vec<(SessionSlot, String)> = ws
846 .drain_test_dispatch_buffer()
847 .iter()
848 .filter_map(|command| match command {
849 crate::protocol::Command::PromptUnder { key, text, .. } => {
850 Some((key.clone(), text.clone()))
851 }
852 _ => None,
853 })
854 .collect();
855 assert_eq!(
856 prompts,
857 vec![(lead(), IDLE_SWEEP_PROMPT.to_owned())],
858 "one sweep, addressed to the project's lead, on the interval",
859 );
860 }
861}