1use compact_str::CompactString;
15use serde::{Deserialize, Serialize};
16
17use crate::proc::ProcessState;
18use crate::scheduler::policy::SchedulerBudget;
19use crate::scheduler::state_machine::{BlockReason, SuspendReason};
20use crate::types::agent::{AgentIsolation, AgentRole, ContextInheritance, IsolationManifest};
21use crate::types::result::{SubAgentResult, TerminationReason};
22
23pub type TaskId = CompactString;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum TaskState {
35 Ready,
37 Running,
39 Blocked,
41 Suspended,
43 Done(TerminationReason),
45}
46
47impl TaskState {
48 pub fn label(self) -> &'static str {
49 match self {
50 Self::Ready => "ready",
51 Self::Running => "running",
52 Self::Blocked => "blocked",
53 Self::Suspended => "suspended",
54 Self::Done(_) => "done",
55 }
56 }
57
58 pub fn is_terminal(self) -> bool {
59 matches!(self, Self::Done(_))
60 }
61}
62
63impl From<ProcessState> for TaskState {
65 fn from(state: ProcessState) -> Self {
66 match state {
67 ProcessState::Running => TaskState::Running,
68 ProcessState::Joined => TaskState::Done(TerminationReason::Completed),
69 ProcessState::Failed => TaskState::Done(TerminationReason::Error),
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum WaitReason {
80 Approval,
82 SubAgentJoin(Vec<TaskId>),
85 Tool,
87 Milestone,
89 Signal,
96 External,
98}
99
100impl WaitReason {
101 pub fn label(&self) -> &'static str {
102 match self {
103 Self::Approval => "approval",
104 Self::SubAgentJoin(_) => "sub_agent_join",
105 Self::Tool => "tool",
106 Self::Milestone => "milestone",
107 Self::Signal => "signal",
108 Self::External => "external",
109 }
110 }
111
112 pub fn remove_child(&mut self, child_id: &str) -> bool {
115 if let Self::SubAgentJoin(children) = self {
116 children.retain(|id| id.as_str() != child_id);
117 children.is_empty()
118 } else {
119 false
120 }
121 }
122
123 pub fn has_child(&self, child_id: &str) -> bool {
125 if let Self::SubAgentJoin(children) = self {
126 children.iter().any(|id| id.as_str() == child_id)
127 } else {
128 false
129 }
130 }
131}
132
133impl From<SuspendReason> for WaitReason {
134 fn from(reason: SuspendReason) -> Self {
135 match reason {
136 SuspendReason::AskUser => WaitReason::Approval,
137 SuspendReason::SubAgentAwait => WaitReason::SubAgentJoin(Vec::new()),
140 SuspendReason::External => WaitReason::External,
141 }
142 }
143}
144
145impl From<BlockReason> for WaitReason {
146 fn from(reason: BlockReason) -> Self {
147 match reason {
148 BlockReason::ToolSuspend => WaitReason::Tool,
149 BlockReason::MilestoneAwait => WaitReason::Milestone,
150 }
151 }
152}
153
154#[derive(Debug, Clone)]
157pub struct BudgetLedger {
158 pub limits: SchedulerBudget,
159 pub turns: u32,
160 pub total_tokens: u64,
161 pub started_at_ms: Option<u64>,
162}
163
164impl BudgetLedger {
165 pub fn new(limits: SchedulerBudget) -> Self {
166 Self { limits, turns: 0, total_tokens: 0, started_at_ms: None }
167 }
168
169 pub fn exceeded(&self, now_ms: Option<u64>) -> Option<&'static str> {
171 self.limits
172 .should_terminate(self.turns, self.total_tokens, now_ms, self.started_at_ms)
173 }
174}
175
176impl Default for BudgetLedger {
177 fn default() -> Self {
178 Self::new(SchedulerBudget::default())
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub struct BudgetSlice {
185 pub max_turns: u32,
186 pub max_total_tokens: u64,
187 pub max_wall_ms: Option<u64>,
188}
189
190#[derive(Debug, Clone)]
197pub struct ProcInfo {
198 pub parent_session_id: CompactString,
199 pub role: AgentRole,
200 pub isolation: AgentIsolation,
201 pub context_inheritance: ContextInheritance,
202 pub result: Option<SubAgentResult>,
204}
205
206#[derive(Debug, Clone)]
208pub struct Tcb {
209 pub id: TaskId,
210 pub parent: Option<TaskId>,
211 pub state: TaskState,
212 pub budget: BudgetLedger,
213 pub wait: Option<WaitReason>,
214 pub caps: Vec<CompactString>,
216 pub proc: Option<ProcInfo>,
218 pub deferred_until: Option<u64>,
221}
222
223impl Tcb {
224 pub fn root(id: impl Into<TaskId>, budget: SchedulerBudget) -> Self {
226 Self {
227 id: id.into(),
228 parent: None,
229 state: TaskState::Ready,
230 budget: BudgetLedger::new(budget),
231 wait: None,
232 caps: Vec::new(),
233 proc: None,
234 deferred_until: None,
235 }
236 }
237
238 pub fn spawned(manifest: &IsolationManifest, budget: SchedulerBudget) -> Self {
241 Self {
242 id: manifest.agent_id.clone(),
243 parent: Some("root".into()),
244 state: TaskState::Running,
245 budget: BudgetLedger::new(budget),
246 wait: None,
247 caps: manifest.permitted_capability_ids.clone(),
248 proc: Some(ProcInfo {
249 parent_session_id: manifest.parent_session_id.clone(),
250 role: manifest.role,
251 isolation: manifest.isolation,
252 context_inheritance: manifest.context_inheritance,
253 result: None,
254 }),
255 deferred_until: None,
256 }
257 }
258
259 pub fn is_runnable(&self) -> bool {
261 self.is_runnable_at(None)
262 }
263
264 pub fn is_runnable_at(&self, now_ms: Option<u64>) -> bool {
266 if !matches!(self.state, TaskState::Ready) {
267 return false;
268 }
269 match self.deferred_until {
270 Some(deferred) => match now_ms {
271 Some(now) => now >= deferred,
272 None => false, },
274 None => true,
275 }
276 }
277}
278
279#[derive(Debug, Clone, Default)]
282pub struct TaskTable {
283 tasks: Vec<Tcb>,
284}
285
286impl TaskTable {
287 pub fn new() -> Self {
288 Self::default()
289 }
290
291 pub fn insert(&mut self, tcb: Tcb) {
292 if let Some(existing) = self.tasks.iter_mut().find(|t| t.id == tcb.id) {
293 *existing = tcb;
294 } else {
295 self.tasks.push(tcb);
296 }
297 }
298
299 pub fn get(&self, id: &str) -> Option<&Tcb> {
300 self.tasks.iter().find(|t| t.id.as_str() == id)
301 }
302
303 pub fn get_mut(&mut self, id: &str) -> Option<&mut Tcb> {
304 self.tasks.iter_mut().find(|t| t.id.as_str() == id)
305 }
306
307 pub fn all(&self) -> &[Tcb] {
308 &self.tasks
309 }
310
311 pub fn children_of(&self, parent: &str) -> Vec<&Tcb> {
312 self.tasks
313 .iter()
314 .filter(|t| t.parent.as_deref() == Some(parent))
315 .collect()
316 }
317
318 pub fn runnable(&self) -> Vec<&Tcb> {
319 self.runnable_at(None)
320 }
321
322 pub fn runnable_at(&self, now_ms: Option<u64>) -> Vec<&Tcb> {
324 self.tasks.iter().filter(|t| t.is_runnable_at(now_ms)).collect()
325 }
326}
327
328#[derive(Debug, Clone, PartialEq, Eq)]
330pub enum ScheduleDecision {
331 Idle,
333 Run { task: TaskId, slice: BudgetSlice },
335 Suspend { task: TaskId, reason: WaitReason },
337 Terminate { task: TaskId, reason: TerminationReason },
339}
340
341pub fn schedule(task: &Tcb, now_ms: Option<u64>) -> ScheduleDecision {
349 if let Some(reason) = task.budget.exceeded(now_ms) {
350 let term = match reason {
352 "max_turns" => TerminationReason::MaxTurns,
353 "wall_time" => TerminationReason::Timeout,
354 _ => TerminationReason::TokenBudget,
355 };
356 return ScheduleDecision::Terminate { task: task.id.clone(), reason: term };
357 }
358 ScheduleDecision::Run {
359 task: task.id.clone(),
360 slice: BudgetSlice {
361 max_turns: task.budget.limits.max_turns,
362 max_total_tokens: task.budget.limits.max_total_tokens,
363 max_wall_ms: task.budget.limits.max_wall_ms,
364 },
365 }
366}
367
368pub fn schedule_multi(table: &TaskTable, now_ms: Option<u64>, highest_signal_urgency: Option<u8>) -> ScheduleDecision {
389 for task in table.all() {
391 if let Some(reason) = task.budget.exceeded(now_ms) {
392 let term = match reason {
393 "max_turns" => TerminationReason::MaxTurns,
394 "wall_time" => TerminationReason::Timeout,
395 _ => TerminationReason::TokenBudget,
396 };
397 return ScheduleDecision::Terminate { task: task.id.clone(), reason: term };
398 }
399 }
400
401 let runnable = table.runnable_at(now_ms);
403
404 if runnable.is_empty() {
405 return ScheduleDecision::Idle;
406 }
407
408 let selected = if let Some(urgency) = highest_signal_urgency {
411 if urgency >= 2 {
413 runnable
414 .iter()
415 .find(|t| matches!(t.wait, Some(WaitReason::Signal)))
416 .unwrap_or_else(|| runnable.first().expect("runnable non-empty"))
417 } else {
418 runnable.first().expect("runnable non-empty")
419 }
420 } else {
421 runnable.first().expect("runnable non-empty")
422 };
423
424 ScheduleDecision::Run {
425 task: selected.id.clone(),
426 slice: BudgetSlice {
427 max_turns: selected.budget.limits.max_turns,
428 max_total_tokens: selected.budget.limits.max_total_tokens,
429 max_wall_ms: selected.budget.limits.max_wall_ms,
430 },
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn process_state_maps_to_task_state() {
440 assert_eq!(TaskState::from(ProcessState::Running), TaskState::Running);
441 assert_eq!(
442 TaskState::from(ProcessState::Joined),
443 TaskState::Done(TerminationReason::Completed)
444 );
445 assert_eq!(
446 TaskState::from(ProcessState::Failed),
447 TaskState::Done(TerminationReason::Error)
448 );
449 }
450
451 #[test]
452 fn suspend_reason_maps_to_wait_reason() {
453 assert_eq!(WaitReason::from(SuspendReason::AskUser), WaitReason::Approval);
454 assert_eq!(WaitReason::from(SuspendReason::External), WaitReason::External);
455 assert!(matches!(
456 WaitReason::from(SuspendReason::SubAgentAwait),
457 WaitReason::SubAgentJoin(_)
458 ));
459 }
460
461 #[test]
462 fn block_reason_maps_to_wait_reason() {
463 assert_eq!(WaitReason::from(BlockReason::ToolSuspend), WaitReason::Tool);
464 assert_eq!(
465 WaitReason::from(BlockReason::MilestoneAwait),
466 WaitReason::Milestone
467 );
468 }
469
470 #[test]
471 fn budget_ledger_delegates_to_scheduler_budget() {
472 let mut ledger = BudgetLedger::new(SchedulerBudget {
473 max_turns: 2,
474 ..SchedulerBudget::default()
475 });
476 assert_eq!(ledger.exceeded(None), None);
477 ledger.turns = 2;
478 assert_eq!(ledger.exceeded(None), Some("max_turns"));
479 }
480
481 #[test]
482 fn task_table_insert_and_lineage() {
483 let mut table = TaskTable::new();
484 table.insert(Tcb::root("root", SchedulerBudget::default()));
485 let mut child = Tcb::root("child", SchedulerBudget::default());
486 child.parent = Some("root".into());
487 table.insert(child);
488
489 assert_eq!(table.children_of("root").len(), 1);
490 assert!(table.get("root").unwrap().is_runnable());
491 assert_eq!(table.runnable().len(), 2);
492 }
493
494 #[test]
495 fn schedule_runs_when_within_budget() {
496 let tcb = Tcb::root("root", SchedulerBudget { max_turns: 5, ..SchedulerBudget::default() });
497 assert!(matches!(schedule(&tcb, None), ScheduleDecision::Run { .. }));
498 }
499
500 #[test]
501 fn schedule_terminates_and_matches_should_terminate_axis() {
502 let limits = SchedulerBudget { max_turns: 2, ..SchedulerBudget::default() };
503 let mut tcb = Tcb::root("root", limits.clone());
504 tcb.budget.turns = 2;
505 let legacy = limits.should_terminate(2, 0, None, None);
507 assert_eq!(legacy, Some("max_turns"));
508 match schedule(&tcb, None) {
509 ScheduleDecision::Terminate { reason, .. } => {
510 assert_eq!(reason, TerminationReason::MaxTurns)
511 }
512 other => panic!("expected Terminate, got {other:?}"),
513 }
514 }
515
516 #[test]
517 fn schedule_terminates_on_wall_time_as_timeout() {
518 let limits = SchedulerBudget { max_wall_ms: Some(1_000), ..SchedulerBudget::default() };
519 let mut tcb = Tcb::root("root", limits);
520 tcb.budget.started_at_ms = Some(0);
521 match schedule(&tcb, Some(2_000)) {
522 ScheduleDecision::Terminate { reason, .. } => {
523 assert_eq!(reason, TerminationReason::Timeout)
524 }
525 other => panic!("expected Terminate, got {other:?}"),
526 }
527 }
528
529 #[test]
530 fn task_table_insert_is_idempotent_by_id() {
531 let mut table = TaskTable::new();
532 table.insert(Tcb::root("root", SchedulerBudget::default()));
533 let mut updated = Tcb::root("root", SchedulerBudget::default());
534 updated.state = TaskState::Running;
535 table.insert(updated);
536 assert_eq!(table.all().len(), 1);
537 assert_eq!(table.get("root").unwrap().state, TaskState::Running);
538 }
539
540 #[test]
543 fn schedule_multi_returns_idle_when_no_runnable() {
544 let table = TaskTable::new();
545 match schedule_multi(&table, None, None) {
546 ScheduleDecision::Idle => {}
547 other => panic!("expected Idle, got {:?}", other),
548 }
549 }
550
551 #[test]
552 fn schedule_multi_runs_single_ready_task() {
553 let mut table = TaskTable::new();
554 table.insert(Tcb::root("root", SchedulerBudget { max_turns: 5, ..SchedulerBudget::default() }));
555 match schedule_multi(&table, None, None) {
556 ScheduleDecision::Run { task, .. } => {
557 assert_eq!(task.as_str(), "root");
558 }
559 other => panic!("expected Run, got {:?}", other),
560 }
561 }
562
563 #[test]
564 fn schedule_multi_terminates_over_budget_tasks() {
565 let mut table = TaskTable::new();
566 let limits = SchedulerBudget { max_turns: 2, ..SchedulerBudget::default() };
567 let mut root = Tcb::root("root", limits);
568 root.budget.turns = 2; table.insert(root);
570 match schedule_multi(&table, None, None) {
571 ScheduleDecision::Terminate { reason, .. } => {
572 assert_eq!(reason, TerminationReason::MaxTurns);
573 }
574 other => panic!("expected Terminate, got {:?}", other),
575 }
576 }
577
578 #[test]
579 fn schedule_multi_skips_deferred_tasks() {
580 let mut table = TaskTable::new();
581 let mut root = Tcb::root("root", SchedulerBudget::default());
582 root.deferred_until = Some(999_999); table.insert(root);
584
585 assert_eq!(table.runnable_at(None).len(), 0);
587
588 assert_eq!(table.runnable_at(Some(1_000_000)).len(), 1);
590 }
591
592 #[test]
593 fn schedule_multi_picks_first_runnable_fifo() {
594 let mut table = TaskTable::new();
595 table.insert(Tcb::root("task-a", SchedulerBudget::default()));
596 table.insert(Tcb::root("task-b", SchedulerBudget::default()));
597 table.insert(Tcb::root("task-c", SchedulerBudget::default()));
598
599 match schedule_multi(&table, None, None) {
601 ScheduleDecision::Run { task, .. } => {
602 assert_eq!(task.as_str(), "task-a");
603 }
604 other => panic!("expected Run, got {:?}", other),
605 }
606 }
607
608 #[test]
609 fn schedule_multi_ignores_blocked_tasks() {
610 let mut table = TaskTable::new();
611 let mut blocked = Tcb::root("blocked", SchedulerBudget::default());
612 blocked.state = TaskState::Blocked;
613 table.insert(blocked);
614 table.insert(Tcb::root("ready", SchedulerBudget::default()));
615
616 match schedule_multi(&table, None, None) {
617 ScheduleDecision::Run { task, .. } => {
618 assert_eq!(task.as_str(), "ready");
619 }
620 other => panic!("expected Run, got {:?}", other),
621 }
622 }
623
624 #[test]
625 fn schedule_multi_ignores_suspended_tasks() {
626 let mut table = TaskTable::new();
627 let mut suspended = Tcb::root("suspended", SchedulerBudget::default());
628 suspended.state = TaskState::Suspended;
629 table.insert(suspended);
630 table.insert(Tcb::root("ready", SchedulerBudget::default()));
631
632 match schedule_multi(&table, None, None) {
633 ScheduleDecision::Run { task, .. } => {
634 assert_eq!(task.as_str(), "ready");
635 }
636 other => panic!("expected Run, got {:?}", other),
637 }
638 }
639
640 #[test]
641 fn schedule_multi_ignores_done_tasks() {
642 let mut table = TaskTable::new();
643 let mut done = Tcb::root("done", SchedulerBudget::default());
644 done.state = TaskState::Done(TerminationReason::Completed);
645 table.insert(done);
646 table.insert(Tcb::root("ready", SchedulerBudget::default()));
647
648 match schedule_multi(&table, None, None) {
649 ScheduleDecision::Run { task, .. } => {
650 assert_eq!(task.as_str(), "ready");
651 }
652 other => panic!("expected Run, got {:?}", other),
653 }
654 }
655
656 #[test]
657 fn deferred_task_becomes_runnable_after_time() {
658 let mut tcb = Tcb::root("root", SchedulerBudget::default());
659 tcb.deferred_until = Some(1000);
660
661 assert!(!tcb.is_runnable_at(Some(999)));
663
664 assert!(tcb.is_runnable_at(Some(1000)));
666
667 assert!(tcb.is_runnable_at(Some(1001)));
669 }
670
671 #[test]
675 fn schedule_multi_skips_deferred_and_returns_next_ready() {
676 let mut table = TaskTable::new();
677
678 let mut task_a = Tcb::root("task-a", SchedulerBudget::default());
680 task_a.deferred_until = Some(999_999);
681 table.insert(task_a);
682
683 table.insert(Tcb::root("task-b", SchedulerBudget::default()));
685
686 table.insert(Tcb::root("task-c", SchedulerBudget::default()));
688
689 match schedule_multi(&table, None, None) {
691 ScheduleDecision::Run { task, .. } => {
692 assert_eq!(task.as_str(), "task-b");
693 }
694 other => panic!("expected Run, got {:?}", other),
695 }
696
697 match schedule_multi(&table, Some(1_000_000), None) {
699 ScheduleDecision::Run { task, .. } => {
700 assert_eq!(task.as_str(), "task-a");
701 }
702 other => panic!("expected Run, got {:?}", other),
703 }
704 }
705
706 #[test]
709 fn signal_aware_prioritization_with_no_signal() {
710 let mut table = TaskTable::new();
711 table.insert(Tcb::root("task-a", SchedulerBudget::default()));
712 table.insert(Tcb::root("task-b", SchedulerBudget::default()));
713
714 match schedule_multi(&table, None, None) {
716 ScheduleDecision::Run { task, .. } => {
717 assert_eq!(task.as_str(), "task-a");
718 }
719 other => panic!("expected Run, got {:?}", other),
720 }
721 }
722
723 #[test]
724 fn signal_aware_prioritization_prefers_signal_waiting_task() {
725 let mut table = TaskTable::new();
726 table.insert(Tcb::root("normal-task", SchedulerBudget::default()));
727
728 let mut waiting = Tcb::root("signal-waiting", SchedulerBudget::default());
729 waiting.wait = Some(WaitReason::Signal);
730 table.insert(waiting);
731
732 match schedule_multi(&table, None, Some(3)) {
734 ScheduleDecision::Run { task, .. } => {
735 assert_eq!(task.as_str(), "signal-waiting");
736 }
737 other => panic!("expected Run signal-waiting, got {:?}", other),
738 }
739 }
740
741 #[test]
742 fn signal_aware_prioritization_normal_signal_no_prefer() {
743 let mut table = TaskTable::new();
744 table.insert(Tcb::root("task-a", SchedulerBudget::default()));
745
746 let mut waiting = Tcb::root("signal-waiting", SchedulerBudget::default());
747 waiting.wait = Some(WaitReason::Signal);
748 table.insert(waiting);
749
750 match schedule_multi(&table, None, Some(1)) {
752 ScheduleDecision::Run { task, .. } => {
753 assert_eq!(task.as_str(), "task-a");
754 }
755 other => panic!("expected Run task-a, got {:?}", other),
756 }
757 }
758
759 #[test]
760 fn signal_aware_prioritization_high_signal_prefer() {
761 let mut table = TaskTable::new();
762 table.insert(Tcb::root("task-a", SchedulerBudget::default()));
763
764 let mut waiting = Tcb::root("signal-waiting", SchedulerBudget::default());
765 waiting.wait = Some(WaitReason::Signal);
766 table.insert(waiting);
767
768 match schedule_multi(&table, None, Some(2)) {
770 ScheduleDecision::Run { task, .. } => {
771 assert_eq!(task.as_str(), "signal-waiting");
772 }
773 other => panic!("expected Run signal-waiting, got {:?}", other),
774 }
775 }
776
777 #[test]
778 fn signal_aware_prioritization_critical_signal_strongly_prefer() {
779 let mut table = TaskTable::new();
780 table.insert(Tcb::root("first", SchedulerBudget::default()));
781 table.insert(Tcb::root("second", SchedulerBudget::default()));
782
783 let mut waiting = Tcb::root("critical-waiting", SchedulerBudget::default());
784 waiting.wait = Some(WaitReason::Signal);
785 table.insert(waiting);
786
787 match schedule_multi(&table, None, Some(3)) {
789 ScheduleDecision::Run { task, .. } => {
790 assert_eq!(task.as_str(), "critical-waiting");
791 }
792 other => panic!("expected Run critical-waiting, got {:?}", other),
793 }
794 }
795
796 #[test]
799 fn baseline_single_task_selection() {
800 let mut table = TaskTable::new();
801 let task = Tcb::root("root", SchedulerBudget {
802 max_tokens: 1000,
803 max_turns: 10,
804 max_total_tokens: 5000,
805 max_wall_ms: None,
806 });
807 table.insert(task);
808
809 let decision = schedule_multi(&table, None, None);
810
811 match decision {
813 ScheduleDecision::Run { task: id, slice } => {
814 assert_eq!(id.as_str(), "root");
815 assert_eq!(slice.max_turns, 10);
816 assert_eq!(slice.max_total_tokens, 5000);
817 }
818 other => panic!("Expected Run, got {:?}", other),
819 }
820 }
821
822 #[test]
823 fn baseline_fifo_selection_order() {
824 let mut table = TaskTable::new();
825 table.insert(Tcb::root("task-1", SchedulerBudget::default()));
826 table.insert(Tcb::root("task-2", SchedulerBudget::default()));
827 table.insert(Tcb::root("task-3", SchedulerBudget::default()));
828
829 let decision1 = schedule_multi(&table, None, None);
831 match decision1 {
832 ScheduleDecision::Run { task, .. } => assert_eq!(task.as_str(), "task-1"),
833 _ => panic!("Expected Run task-1"),
834 }
835
836 table.tasks.remove(0);
838 let decision2 = schedule_multi(&table, None, None);
839 match decision2 {
840 ScheduleDecision::Run { task, .. } => assert_eq!(task.as_str(), "task-2"),
841 _ => panic!("Expected Run task-2"),
842 }
843 }
844
845 #[test]
846 fn baseline_idle_when_no_runnable() {
847 let table = TaskTable::new();
848
849 let decision = schedule_multi(&table, None, None);
850
851 assert!(matches!(decision, ScheduleDecision::Idle));
853 }
854
855 #[test]
856 fn baseline_terminates_over_budget() {
857 let mut table = TaskTable::new();
858 let mut task = Tcb::root("over-budget", SchedulerBudget {
859 max_turns: 5,
860 max_total_tokens: 1000,
861 max_wall_ms: None,
862 max_tokens: 1000,
863 });
864 task.budget.turns = 10; table.insert(task);
866
867 let decision = schedule_multi(&table, None, None);
868
869 match decision {
871 ScheduleDecision::Terminate { reason, .. } => {
872 assert_eq!(reason, TerminationReason::MaxTurns);
873 }
874 other => panic!("Expected Terminate, got {:?}", other),
875 }
876 }
877
878 #[test]
879 fn baseline_token_budget_terminates() {
880 let mut table = TaskTable::new();
881 let mut task = Tcb::root("token-over", SchedulerBudget {
882 max_turns: 100,
883 max_total_tokens: 100,
884 max_wall_ms: None,
885 max_tokens: 1000,
886 });
887 task.budget.total_tokens = 200; table.insert(task);
889
890 let decision = schedule_multi(&table, None, None);
891
892 match decision {
894 ScheduleDecision::Terminate { reason, .. } => {
895 assert_eq!(reason, TerminationReason::TokenBudget);
896 }
897 other => panic!("Expected Terminate, got {:?}", other),
898 }
899 }
900
901 #[test]
902 fn baseline_wall_time_timeout() {
903 let mut table = TaskTable::new();
904 let mut task = Tcb::root("timeout", SchedulerBudget {
905 max_turns: 100,
906 max_total_tokens: 10000,
907 max_wall_ms: Some(1000),
908 max_tokens: 1000,
909 });
910 task.budget.started_at_ms = Some(0);
911 table.insert(task);
912
913 let decision = schedule_multi(&table, Some(2000), None);
914
915 match decision {
917 ScheduleDecision::Terminate { reason, .. } => {
918 assert_eq!(reason, TerminationReason::Timeout);
919 }
920 other => panic!("Expected Terminate, got {:?}", other),
921 }
922 }
923
924 #[test]
925 fn monotonicity_termination_first_before_selection() {
926 let mut table = TaskTable::new();
927
928 let mut over_budget = Tcb::root("over-budget", SchedulerBudget {
930 max_turns: 5,
931 max_total_tokens: 1000,
932 max_wall_ms: None,
933 max_tokens: 1000,
934 });
935 over_budget.budget.turns = 10;
936 table.insert(over_budget);
937
938 table.insert(Tcb::root("healthy", SchedulerBudget::default()));
940
941 let decision = schedule_multi(&table, None, None);
943
944 match decision {
946 ScheduleDecision::Terminate { task, .. } => {
947 assert_eq!(task.as_str(), "over-budget");
948 }
949 other => panic!("Expected Terminate over-budget, got {:?}", other),
950 }
951 }
952
953 #[test]
954 fn monotonicity_deferred_not_selected_before_time() {
955 let mut table = TaskTable::new();
956 table.insert(Tcb::root("ready", SchedulerBudget::default()));
957
958 let mut deferred = Tcb::root("deferred", SchedulerBudget::default());
959 deferred.deferred_until = Some(999_999);
960 table.insert(deferred);
961
962 let decision = schedule_multi(&table, Some(0), None);
964
965 match decision {
966 ScheduleDecision::Run { task, .. } => {
967 assert_eq!(task.as_str(), "ready");
968 }
969 other => panic!("Expected Run ready, got {:?}", other),
970 }
971 }
972
973 #[test]
974 fn monotonicity_blocked_suspended_not_selected() {
975 let mut table = TaskTable::new();
976 table.insert(Tcb::root("ready", SchedulerBudget::default()));
977
978 let mut blocked = Tcb::root("blocked", SchedulerBudget::default());
979 blocked.state = TaskState::Blocked;
980 blocked.wait = Some(WaitReason::Tool);
981 table.insert(blocked);
982
983 let mut suspended = Tcb::root("suspended", SchedulerBudget::default());
984 suspended.state = TaskState::Suspended;
985 suspended.wait = Some(WaitReason::Approval);
986 table.insert(suspended);
987
988 let decision = schedule_multi(&table, None, None);
990
991 match decision {
992 ScheduleDecision::Run { task, .. } => {
993 assert_eq!(task.as_str(), "ready");
994 }
995 other => panic!("Expected Run ready, got {:?}", other),
996 }
997 }
998
999 #[test]
1000 fn baseline_signal_aware_selection() {
1001 let mut table = TaskTable::new();
1002 table.insert(Tcb::root("normal", SchedulerBudget::default()));
1003
1004 let mut signal_waiting = Tcb::root("signal-task", SchedulerBudget::default());
1005 signal_waiting.wait = Some(WaitReason::Signal);
1006 table.insert(signal_waiting);
1007
1008 let decision = schedule_multi(&table, None, Some(3));
1010
1011 match decision {
1012 ScheduleDecision::Run { task, .. } => {
1013 assert_eq!(task.as_str(), "signal-task");
1014 }
1015 other => panic!("Expected Run signal-task, got {:?}", other),
1016 }
1017 }
1018}