1use std::collections::VecDeque;
7
8use crate::RosterSlot;
9use crate::collaboration::CollaborationContext;
10
11pub const MAX_QUEUED_PROMPTS: usize = 100;
12pub const STOP_TOKEN: &str = "[CODESWARM:STOP]";
13pub const DEFAULT_STOP_ACKNOWLEDGMENT: &str = "👍";
14
15pub fn is_usage_limit_response(text: &str) -> bool {
19 let haystack = text.to_lowercase();
20 let exhausted_usage = haystack.contains("usage limit")
21 && ["hit", "reached", "exceeded"]
22 .iter()
23 .any(|marker| haystack.contains(marker));
24 exhausted_usage
25 || haystack.contains("insufficient_quota")
26 || haystack.contains("quota exceeded")
27 || haystack.contains("insufficient credits")
28}
29
30pub fn strip_stop_token(response: &str) -> (String, bool) {
31 let trimmed = response.trim_end();
32 let requested = trimmed.ends_with(STOP_TOKEN);
33 let visible = if requested {
34 trimmed[..trimmed.len() - STOP_TOKEN.len()]
35 .trim_end()
36 .to_owned()
37 } else {
38 response.to_owned()
39 };
40 (visible, requested)
41}
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum QueuedKind {
45 Steering,
46 Direct,
47}
48
49#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
57pub enum CollaborationStrategy {
58 #[default]
59 Roster,
60 Manual,
61 Pair,
62}
63
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct QueuedPrompt {
66 pub slot: RosterSlot,
67 pub prompt: String,
68 pub kind: QueuedKind,
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub enum RelayDecision {
73 Dispatch {
74 slot: RosterSlot,
75 prompt: String,
76 direct: bool,
77 can_stop: bool,
78 },
79 Paused,
80 Collapsed,
81 Complete,
82}
83
84#[derive(Clone, Debug, Eq, PartialEq)]
85pub struct Relay {
86 active: Vec<bool>,
87 limited: Vec<bool>,
91 max_rounds: usize,
92 rounds: usize,
93 stopped: bool,
94 paused: bool,
95 last_active: RosterSlot,
96 next: Option<RosterSlot>,
97 steering: VecDeque<QueuedPrompt>,
98 direct: VecDeque<QueuedPrompt>,
99 previous_slot: Option<RosterSlot>,
100 participated: Vec<bool>,
101 context: CollaborationContext,
102 strategy: CollaborationStrategy,
103 pair_partner: Option<RosterSlot>,
104}
105
106impl Relay {
107 pub fn new(roster_size: usize, max_rounds: usize) -> Self {
108 assert!(roster_size >= 1);
109 assert!(max_rounds >= 1);
110 Self {
111 active: vec![true; roster_size],
112 limited: vec![false; roster_size],
113 max_rounds,
114 rounds: 0,
115 stopped: false,
116 paused: false,
117 last_active: 0,
118 next: None,
119 steering: VecDeque::new(),
120 direct: VecDeque::new(),
121 previous_slot: None,
122 participated: vec![false; roster_size],
123 context: CollaborationContext::new(roster_size),
124 strategy: CollaborationStrategy::Roster,
125 pair_partner: None,
126 }
127 }
128
129 pub fn strategy(&self) -> CollaborationStrategy {
130 self.strategy
131 }
132
133 pub fn set_strategy(&mut self, strategy: CollaborationStrategy) {
137 if self.strategy != strategy {
138 self.strategy = strategy;
139 self.pair_partner = None;
140 }
141 }
142
143 pub fn active_slots(&self) -> impl Iterator<Item = RosterSlot> + '_ {
144 self.active
145 .iter()
146 .enumerate()
147 .filter_map(|(slot, active)| active.then_some(slot))
148 }
149
150 pub fn pause(&mut self) {
151 self.paused = true;
152 }
153
154 pub fn resume(&mut self) {
155 self.paused = false;
156 }
157
158 pub fn tombstone(&mut self, slot: RosterSlot) -> Result<(), &'static str> {
159 let active = self.active.get_mut(slot).ok_or("slot out of range")?;
160 *active = false;
161 Ok(())
162 }
163
164 pub fn mark_limited(&mut self, slot: RosterSlot) -> Result<(), &'static str> {
167 let limited = self.limited.get_mut(slot).ok_or("slot out of range")?;
168 *limited = true;
169 Ok(())
170 }
171
172 pub fn clear_limited(&mut self, slot: RosterSlot) -> Result<(), &'static str> {
174 let limited = self.limited.get_mut(slot).ok_or("slot out of range")?;
175 *limited = false;
176 Ok(())
177 }
178
179 pub fn is_limited(&self, slot: RosterSlot) -> bool {
180 self.limited.get(slot).copied().unwrap_or(false)
181 }
182
183 fn routable(&self, slot: RosterSlot) -> bool {
185 self.active.get(slot).copied().unwrap_or(false)
186 && !self.limited.get(slot).copied().unwrap_or(false)
187 }
188
189 pub fn routable_slots(&self) -> impl Iterator<Item = RosterSlot> + '_ {
191 (0..self.active.len()).filter(|slot| self.routable(*slot))
192 }
193
194 fn any_routable_except(&self, excluded: RosterSlot) -> bool {
196 self.routable_slots().any(|slot| slot != excluded)
197 }
198
199 pub fn reactivate(&mut self, slot: RosterSlot) -> Result<(), &'static str> {
200 let active = self.active.get_mut(slot).ok_or("slot out of range")?;
201 *active = true;
202 self.participated[slot] = false;
203 self.context.rewind(slot);
204 Ok(())
205 }
206
207 pub fn drop_agent(&mut self, slot: RosterSlot) -> Result<(), &'static str> {
208 if !self.active.get(slot).copied().ok_or("slot out of range")? {
209 return Ok(());
210 }
211 if self.active_slots().count() == 1 {
212 return Err("last active agent cannot be dropped");
213 }
214 self.tombstone(slot)?;
215 self.direct.retain(|queued| queued.slot != slot);
216 self.steering.retain(|queued| queued.slot != slot);
217 Ok(())
218 }
219
220 pub fn swap_agents(
223 &mut self,
224 first: RosterSlot,
225 second: RosterSlot,
226 ) -> Result<(), &'static str> {
227 if first == second {
228 return Ok(());
229 }
230 if first >= self.active.len() || second >= self.active.len() {
231 return Err("roster slot out of range");
232 }
233 if !self.active[first] || !self.active[second] {
234 return Err("both roster slots must be active");
235 }
236 fn swap_targets(queue: &mut VecDeque<QueuedPrompt>, first: usize, second: usize) {
237 for queued in queue {
238 if queued.slot == first {
239 queued.slot = second;
240 } else if queued.slot == second {
241 queued.slot = first;
242 }
243 }
244 }
245 swap_targets(&mut self.direct, first, second);
246 swap_targets(&mut self.steering, first, second);
247 if self.last_active == first {
248 self.last_active = second;
249 } else if self.last_active == second {
250 self.last_active = first;
251 }
252 fn swap_option(cursor: &mut Option<usize>, first: usize, second: usize) {
253 if *cursor == Some(first) {
254 *cursor = Some(second);
255 } else if *cursor == Some(second) {
256 *cursor = Some(first);
257 }
258 }
259 swap_option(&mut self.next, first, second);
260 swap_option(&mut self.previous_slot, first, second);
261 swap_option(&mut self.pair_partner, first, second);
262 self.active.swap(first, second);
263 self.limited.swap(first, second);
264 self.participated.swap(first, second);
265 self.context.swap_agents(first, second);
266 Ok(())
267 }
268
269 pub fn enqueue_human(
270 &mut self,
271 prompt: impl Into<String>,
272 selected: Option<RosterSlot>,
273 ) -> bool {
274 let prompt = prompt.into();
275 if prompt.trim().is_empty() || self.queued_count() >= MAX_QUEUED_PROMPTS {
276 return false;
277 }
278 let slot = selected.unwrap_or(self.last_active);
279 if !self.active.get(slot).copied().unwrap_or(false) {
280 return false;
281 }
282 self.steering.push_back(QueuedPrompt {
283 slot,
284 prompt,
285 kind: QueuedKind::Steering,
286 });
287 true
288 }
289
290 pub fn enqueue_direct(
291 &mut self,
292 slot: RosterSlot,
293 prompt: impl Into<String>,
294 ) -> Result<bool, &'static str> {
295 let prompt = prompt.into();
296 if !self.active.get(slot).copied().unwrap_or(false) {
297 return Err("direct target is not active");
298 }
299 if prompt.trim().is_empty() || self.queued_count() >= MAX_QUEUED_PROMPTS {
300 return Ok(false);
301 }
302 self.direct.push_back(QueuedPrompt {
303 slot,
304 prompt,
305 kind: QueuedKind::Direct,
306 });
307 Ok(true)
308 }
309
310 pub fn queued_count(&self) -> usize {
311 self.direct.len() + self.steering.len()
312 }
313
314 pub fn set_shared_task(&mut self, task: impl Into<String>) {
315 self.context.set_shared_task(task);
316 }
317
318 pub fn shared_task(&self) -> Option<&str> {
319 self.context.shared_task()
320 }
321
322 pub fn record_public(&mut self, speaker: impl Into<String>, text: impl Into<String>) {
323 self.context.record(speaker, text, &self.active);
324 }
325
326 pub fn mark_context_seen(&mut self, slot: RosterSlot) {
327 self.context.mark_seen(slot);
328 }
329
330 pub fn unseen_context(&mut self, slot: RosterSlot) -> String {
331 self.context.unseen(slot)
332 }
333
334 pub fn add_agent(&mut self) {
335 self.active.push(true);
336 self.limited.push(false);
337 self.participated.push(false);
338 self.context.add_agent();
339 }
340
341 pub fn begin(&mut self, initial_prompt: impl Into<String>, first: RosterSlot) -> RelayDecision {
343 if self.paused {
344 return RelayDecision::Paused;
345 }
346 if self.active_slots().next().is_none() {
347 return RelayDecision::Collapsed;
348 }
349 if !self.any_routable_except(usize::MAX) {
352 self.stopped = true;
353 return RelayDecision::Paused;
354 }
355 let queued = Self::pop_routable(&self.active, &self.limited, &mut self.direct)
356 .or_else(|| Self::pop_routable(&self.active, &self.limited, &mut self.steering));
357 if self.stopped {
361 if queued.is_none() {
362 return RelayDecision::Complete;
363 }
364 self.stopped = false;
365 self.rounds = 0;
366 }
367 if self.rounds >= self.max_rounds {
368 if queued.is_none() {
371 return RelayDecision::Complete;
372 }
373 self.rounds = 0;
374 }
375 if self.strategy == CollaborationStrategy::Manual
380 && queued.is_none()
381 && self.previous_slot.is_some()
382 {
383 return RelayDecision::Complete;
384 }
385 let (slot, prompt, direct, human_prompt) = match queued {
386 Some(queued) => (
387 queued.slot,
388 queued.prompt,
389 queued.kind == QueuedKind::Direct,
390 queued.kind == QueuedKind::Steering,
391 ),
392 None => {
393 let slot = self.next_automatic_slot(first);
394 (slot, initial_prompt.into(), false, false)
395 }
396 };
397 if human_prompt {
403 self.participated.fill(false);
404 }
405 let roster_reviewed = self.strategy != CollaborationStrategy::Roster
406 || self.active_slots().all(|candidate| {
407 candidate == slot || !self.routable(candidate) || self.participated[candidate]
408 });
409 let can_stop = !direct
410 && !human_prompt
411 && roster_reviewed
412 && self.previous_slot.is_some_and(|previous| previous != slot);
413 self.last_active = slot;
414 self.rounds += 1;
415 RelayDecision::Dispatch {
416 slot,
417 prompt,
418 direct,
419 can_stop,
420 }
421 }
422
423 pub fn finish(&mut self, slot: RosterSlot, direct: bool, accepted_stop: bool) {
426 self.next = Some(self.next_active(slot));
427 if !direct {
428 self.previous_slot = Some(slot);
429 self.participated[slot] = true;
430 }
431 if accepted_stop && self.direct.is_empty() && self.steering.is_empty() {
432 self.stopped = true;
433 }
434 }
435
436 fn pop_routable(
439 active: &[bool],
440 limited: &[bool],
441 queue: &mut VecDeque<QueuedPrompt>,
442 ) -> Option<QueuedPrompt> {
443 let position = queue.iter().position(|queued| {
444 active.get(queued.slot).copied().unwrap_or(false)
445 && !limited.get(queued.slot).copied().unwrap_or(false)
446 })?;
447 queue.remove(position)
448 }
449
450 fn first_active_from(&self, start: RosterSlot) -> RosterSlot {
451 (0..self.active.len())
452 .map(|offset| (start + offset) % self.active.len())
453 .find(|slot| self.routable(*slot))
454 .expect("callers require a routable roster")
455 }
456
457 fn next_active(&self, slot: RosterSlot) -> RosterSlot {
458 (1..=self.active.len())
459 .map(|offset| (slot + offset) % self.active.len())
460 .find(|candidate| self.routable(*candidate))
461 .expect("callers require a routable roster")
462 }
463
464 fn next_automatic_slot(&mut self, first: RosterSlot) -> RosterSlot {
465 match self.strategy {
466 CollaborationStrategy::Roster | CollaborationStrategy::Manual => self
467 .next
468 .filter(|slot| self.routable(*slot))
469 .unwrap_or_else(|| self.first_active_from(first)),
470 CollaborationStrategy::Pair => {
471 let primary = self.first_active_from(0);
472 let partner = if let Some(partner) = self.pair_partner {
473 if self.routable(partner) && partner != primary {
474 partner
475 } else {
476 let partner = self.next_active(primary);
477 self.pair_partner = Some(partner);
478 partner
479 }
480 } else {
481 let partner = if first != primary && self.routable(first) {
482 first
483 } else {
484 self.next_active(primary)
485 };
486 self.pair_partner = Some(partner);
487 partner
488 };
489 match self.previous_slot {
490 Some(previous) if previous == primary && self.routable(partner) => partner,
491 Some(previous) if previous == partner && self.routable(primary) => primary,
492 Some(previous) if previous == primary || previous == partner => primary,
493 _ => self.first_active_from(first),
494 }
495 }
496 }
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::{CollaborationStrategy, Relay, RelayDecision, STOP_TOKEN, strip_stop_token};
503
504 #[test]
505 fn relay_moves_around_the_ring_without_self_review() {
506 let mut relay = Relay::new(3, 10);
507 let first = relay.begin("task", 0);
508 assert!(matches!(
509 first,
510 RelayDecision::Dispatch {
511 slot: 0,
512 can_stop: false,
513 ..
514 }
515 ));
516 relay.finish(0, false, false);
517 let second = relay.begin("response", 0);
518 assert!(matches!(
519 second,
520 RelayDecision::Dispatch {
521 slot: 1,
522 can_stop: false,
523 ..
524 }
525 ));
526 }
527
528 #[test]
529 fn roster_stop_tracks_replacement_missing_and_reordered_agents() {
530 let mut relay = Relay::new(4, 100);
531 relay.begin("task", 0);
532 relay.finish(0, false, false);
533 relay.swap_agents(0, 2).unwrap();
534 assert_eq!(relay.participated, [false, false, true, false]);
535 assert!(relay.reactivate(99).is_err());
536 relay.tombstone(3).unwrap();
537 relay.mark_limited(0).unwrap();
538 assert!(matches!(
539 relay.begin("", 0),
540 RelayDecision::Dispatch {
541 slot: 1,
542 can_stop: true,
543 ..
544 }
545 ));
546 relay.finish(1, false, false);
547 relay.reactivate(3).unwrap();
548 relay.clear_limited(0).unwrap();
549 assert!(matches!(
550 relay.begin("", 0),
551 RelayDecision::Dispatch {
552 slot: 2,
553 can_stop: false,
554 ..
555 }
556 ));
557 relay.finish(2, false, false);
558 assert!(relay.enqueue_human("new task", Some(1)));
559 relay.begin("", 0);
560 assert!(relay.participated.iter().all(|seen| !seen));
561 }
562
563 #[test]
564 fn explicit_human_target_beats_ring_order() {
565 let mut relay = Relay::new(3, 10);
566 relay.begin("task", 0);
567 assert!(relay.enqueue_human("correction", Some(2)));
568 relay.finish(0, false, false);
569 assert!(matches!(
570 relay.begin("response", 0),
571 RelayDecision::Dispatch { slot: 2, prompt, direct: false, can_stop: false } if prompt == "correction"
572 ));
573 }
574
575 #[test]
576 fn human_prompt_cannot_stop_even_after_a_previous_relay_batch() {
577 let mut relay = Relay::new(2, 10);
578 assert!(matches!(
579 relay.begin("first task", 0),
580 RelayDecision::Dispatch {
581 slot: 0,
582 can_stop: false,
583 ..
584 }
585 ));
586 relay.finish(0, false, false);
587 assert!(matches!(
588 relay.begin("review", 0),
589 RelayDecision::Dispatch {
590 slot: 1,
591 can_stop: true,
592 ..
593 }
594 ));
595 relay.finish(1, false, false);
596
597 assert!(relay.enqueue_human("new task", Some(0)));
601 assert!(matches!(
602 relay.begin("", 0),
603 RelayDecision::Dispatch {
604 slot: 0,
605 can_stop: false,
606 ..
607 }
608 ));
609 }
610
611 #[test]
612 fn direct_work_has_priority_and_any_agent_except_the_last_can_be_dropped() {
613 let mut relay = Relay::new(3, 10);
614 relay.enqueue_human("ordinary", Some(1));
615 assert_eq!(relay.enqueue_direct(2, "private"), Ok(true));
616 assert!(matches!(
617 relay.begin("task", 0),
618 RelayDecision::Dispatch {
619 slot: 2,
620 direct: true,
621 ..
622 }
623 ));
624 assert_eq!(relay.drop_agent(0), Ok(()));
625 assert_eq!(relay.active_slots().collect::<Vec<_>>(), vec![1, 2]);
626 assert_eq!(relay.drop_agent(1), Ok(()));
627 assert_eq!(
628 relay.drop_agent(2),
629 Err("last active agent cannot be dropped")
630 );
631 }
632
633 #[test]
634 fn relay_context_tracks_public_updates_per_slot() {
635 let mut relay = Relay::new(2, 10);
636 relay.set_shared_task("refactor");
637 relay.record_public("Agent 0", "first answer");
638 relay.mark_context_seen(0);
639 assert_eq!(relay.unseen_context(0), "");
640 assert_eq!(relay.unseen_context(1), "Agent 0:\nfirst answer");
641 assert_eq!(relay.shared_task(), Some("refactor"));
642 relay.add_agent();
643 assert_eq!(relay.active_slots().count(), 3);
644 }
645
646 #[test]
647 fn stop_token_is_stripped_only_from_the_response_suffix() {
648 let (visible, requested) = strip_stop_token(&format!("looks good\n{STOP_TOKEN}"));
649 assert_eq!(visible, "looks good");
650 assert!(requested);
651 let (visible, requested) = strip_stop_token("ordinary response");
652 assert_eq!(visible, "ordinary response");
653 assert!(!requested);
654 }
655
656 #[test]
657 fn accepted_stop_ends_the_batch_but_a_new_prompt_can_start_one() {
658 let mut relay = Relay::new(2, 10);
659 assert!(matches!(
660 relay.begin("task", 0),
661 RelayDecision::Dispatch { slot: 0, .. }
662 ));
663 relay.finish(0, false, false);
664 assert!(matches!(
665 relay.begin("review", 0),
666 RelayDecision::Dispatch {
667 slot: 1,
668 can_stop: true,
669 ..
670 }
671 ));
672 relay.finish(1, false, true);
673 assert_eq!(relay.begin("", 0), RelayDecision::Complete);
674
675 assert!(relay.enqueue_human("new task", Some(0)));
676 assert!(matches!(
677 relay.begin("", 0),
678 RelayDecision::Dispatch { slot: 0, prompt, .. } if prompt == "new task"
679 ));
680 }
681
682 #[test]
683 fn live_slot_swap_follows_queued_targets_and_runtime_cursors() {
684 let mut relay = Relay::new(3, 10);
685 relay.record_public("Agent 0", "first work");
686 relay.mark_context_seen(0);
687 assert!(matches!(
688 relay.begin("task", 0),
689 RelayDecision::Dispatch { slot: 0, .. }
690 ));
691 relay.finish(0, false, false);
692 relay.enqueue_human("to first", Some(0));
693 relay.enqueue_direct(2, "to third").expect("queue direct");
694
695 relay.swap_agents(0, 2).expect("swap live slots");
696 assert_eq!(relay.active_slots().collect::<Vec<_>>(), vec![0, 1, 2]);
697 assert!(matches!(
698 relay.begin("", 0),
699 RelayDecision::Dispatch { slot: 0, direct: true, prompt, .. }
700 if prompt == "to third"
701 ));
702 relay.finish(0, true, false);
703 assert!(matches!(
704 relay.begin("", 0),
705 RelayDecision::Dispatch { slot: 2, direct: false, prompt, .. }
706 if prompt == "to first"
707 ));
708 assert_eq!(relay.unseen_context(0), "Agent 0:\nfirst work");
709 }
710
711 #[test]
712 fn stop_token_is_not_allowed_on_the_first_response() {
713 let mut relay = Relay::new(2, 10);
714 assert!(matches!(
715 relay.begin("task", 0),
716 RelayDecision::Dispatch {
717 slot: 0,
718 can_stop: false,
719 ..
720 }
721 ));
722 relay.finish(0, false, false);
726 assert!(matches!(
727 relay.begin("", 0),
728 RelayDecision::Dispatch { slot: 1, .. }
729 ));
730 }
731
732 #[test]
733 fn a_healthy_peer_continues_after_the_other_slot_is_tombstoned() {
734 let mut relay = Relay::new(2, 10);
735 relay.tombstone(0).expect("first agent failure");
736 assert!(matches!(
737 relay.begin("continue", 0),
738 RelayDecision::Dispatch {
739 slot: 1,
740 can_stop: false,
741 ..
742 }
743 ));
744 }
745
746 #[test]
747 fn manual_strategy_requires_an_explicit_follow_up_prompt() {
748 let mut relay = Relay::new(3, 10);
749 relay.set_strategy(CollaborationStrategy::Manual);
750 assert!(matches!(
751 relay.begin("task", 0),
752 RelayDecision::Dispatch { slot: 0, .. }
753 ));
754 relay.finish(0, false, false);
755 assert_eq!(
756 relay.begin("would auto advance", 0),
757 RelayDecision::Complete
758 );
759 assert!(relay.enqueue_human("review", Some(2)));
760 assert!(
761 matches!(relay.begin("", 0), RelayDecision::Dispatch { slot: 2, prompt, .. } if prompt == "review")
762 );
763 }
764
765 #[test]
766 fn pair_strategy_alternates_the_first_two_active_agents() {
767 let mut relay = Relay::new(4, 10);
768 relay.set_strategy(CollaborationStrategy::Pair);
769 assert!(matches!(
770 relay.begin("task", 2),
771 RelayDecision::Dispatch { slot: 2, .. }
772 ));
773 relay.finish(2, false, false);
774 assert!(matches!(
775 relay.begin("review", 2),
776 RelayDecision::Dispatch { slot: 0, .. }
777 ));
778 relay.finish(0, false, false);
779 assert!(matches!(
780 relay.begin("next", 2),
781 RelayDecision::Dispatch { slot: 2, .. }
782 ));
783
784 relay.drop_agent(0).expect("remove first agent");
785 relay.finish(2, false, false);
786 assert!(matches!(
787 relay.begin("after removal", 2),
788 RelayDecision::Dispatch { slot: 1, .. }
789 ));
790 }
791
792 #[test]
793 fn usage_limit_detection_matches_provider_copy() {
794 assert!(super::is_usage_limit_response(
795 "You've hit your usage limit. Visit chatgpt.com to purchase more credits."
796 ));
797 assert!(super::is_usage_limit_response("Error: insufficient_quota"));
798 assert!(super::is_usage_limit_response(
799 "Monthly quota exceeded for this plan"
800 ));
801 assert!(!super::is_usage_limit_response(
802 "The rate limit on the build job slowed things down."
803 ));
804 assert!(!super::is_usage_limit_response(
805 "I updated the usage-limit documentation and billing upgrade flow."
806 ));
807 assert!(!super::is_usage_limit_response("Ready to review the diff."));
808 }
809
810 #[test]
811 fn hot_added_and_swapped_slots_keep_limit_state_with_the_agent() {
812 let mut relay = Relay::new(2, 10);
813 relay.add_agent();
814 relay.mark_limited(2).expect("mark hot-added slot limited");
815 assert!(relay.is_limited(2));
816
817 relay.swap_agents(0, 2).expect("swap limited agent");
818 assert!(relay.is_limited(0));
819 assert!(!relay.is_limited(2));
820 assert!(matches!(
821 relay.begin("task", 0),
822 RelayDecision::Dispatch { slot: 1, .. }
823 ));
824 }
825
826 #[test]
827 fn a_limited_slot_is_routed_around_until_cleared() {
828 let mut relay = Relay::new(2, 10);
829 relay.mark_limited(0).expect("mark limited");
830 assert!(relay.is_limited(0));
831 assert!(matches!(
832 relay.begin("task", 0),
833 RelayDecision::Dispatch {
834 slot: 1,
835 can_stop: false,
836 ..
837 }
838 ));
839 relay.finish(1, false, false);
840 assert!(matches!(
842 relay.begin("again", 1),
843 RelayDecision::Dispatch { slot: 1, .. }
844 ));
845 relay.clear_limited(0).expect("clear limited");
846 assert!(!relay.is_limited(0));
847 relay.finish(1, false, false);
848 assert!(matches!(
849 relay.begin("next", 1),
850 RelayDecision::Dispatch { slot: 0, .. }
851 ));
852 }
853
854 #[test]
855 fn prompts_targeting_a_limited_slot_wait_for_recovery() {
856 let mut relay = Relay::new(2, 10);
857 relay.mark_limited(1).expect("mark limited");
858 assert_eq!(relay.enqueue_direct(1, "private work"), Ok(true));
859 assert!(matches!(
860 relay.begin("task", 0),
861 RelayDecision::Dispatch { slot: 0, .. }
862 ));
863 relay.finish(0, false, false);
864 assert!(matches!(
866 relay.begin("", 0),
867 RelayDecision::Dispatch { slot: 0, .. }
868 ));
869 relay.finish(0, false, false);
870 relay.clear_limited(1).expect("clear limited");
872 assert!(matches!(
873 relay.begin("", 0),
874 RelayDecision::Dispatch { slot: 1, direct: true, prompt, .. } if prompt == "private work"
875 ));
876 }
877
878 #[test]
879 fn an_all_limited_roster_pauses_instead_of_spinning() {
880 let mut relay = Relay::new(2, 10);
881 relay.mark_limited(0).expect("mark limited");
882 relay.mark_limited(1).expect("mark limited");
883 assert_eq!(relay.begin("task", 0), RelayDecision::Paused);
884 assert!(relay.enqueue_human("queued", Some(1)));
886 relay.clear_limited(1).expect("recharge one agent");
887 assert!(matches!(
888 relay.begin("", 0),
889 RelayDecision::Dispatch { slot: 1, prompt, .. } if prompt == "queued"
890 ));
891 }
892}