Skip to main content

deepstrike_core/scheduler/
tcb.rs

1//! Primitive P2: Task Control Block + unified scheduling entity.
2//!
3//! See `.local-docs/specs/agent-os-three-primitives.md`. M1 收口 wired this in: the root loop and
4//! every sub-agent are a single `Tcb`, and the scattered `LoopPhase` lifecycle variants +
5//! `SchedulerBudget::should_terminate` + the former `ProcessTable` collapsed into the `TaskTable`
6//! plus the pure `schedule()` function (`schedule()` is now the sole budget decision point;
7//! `AgentProcess` is a derived view over child TCBs).
8//!
9//! Concept overlap this primitive collapses:
10//! - lifecycle written twice ([`crate::scheduler::state_machine::LoopPhase`] lifecycle variants /
11//!   [`SuspendReason`] / [`BlockReason`] vs [`crate::proc::ProcessState`]) → [`TaskState`];
12//! - suspend/block reasons (two enums) → [`WaitReason`].
13
14use 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
23/// Identity of a schedulable task. Task 0 is the root loop; children are sub-agents.
24/// Aligns with `AgentProcess.agent_id` so M1 can map process rows onto TCBs 1:1.
25pub type TaskId = CompactString;
26
27/// Schedulability of a task — orthogonal to the *intra-turn* step
28/// (`Reason/Act/Observe/Delta`), which stays on [`crate::scheduler::state_machine::LoopPhase`].
29///
30/// Unifies `LoopPhase::{Idle,Suspended,Blocked,Terminal}` and
31/// [`ProcessState::{Running,Joined,Failed}`].
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum TaskState {
35    /// Eligible to run, not yet picked by the scheduler (`LoopPhase::Idle`).
36    Ready,
37    /// Currently executing a turn (`LoopPhase::{Reason,Act,Observe,Delta}` / `ProcessState::Running`).
38    Running,
39    /// Blocked awaiting an in-flight continuation (tool suspend / milestone eval).
40    Blocked,
41    /// Suspended awaiting external resolution (human approval / sub-agent join / external).
42    Suspended,
43    /// Finished. Carries the termination reason (`ProcessState::{Joined,Failed}` + `LoopPhase::Terminal`).
44    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
63/// A successful join maps to `Done(Completed)`; any other termination is `Done(<reason>)`.
64impl 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            // Failed has no single reason at the process level; M1 carries the real reason
70            // from `SubAgentResult`. Scaffold maps to a generic error.
71            ProcessState::Failed => TaskState::Done(TerminationReason::Error),
72        }
73    }
74}
75
76/// Why a task is not runnable. Unifies [`SuspendReason`] and [`BlockReason`].
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum WaitReason {
80    /// Governance `AskUser` — waiting for SDK to resolve human approval.
81    Approval,
82    /// Parent blocked on child tasks' join results. Tracks pending child IDs.
83    /// W2-1: Changed from single TaskId to Vec to support workflow batches.
84    SubAgentJoin(Vec<TaskId>),
85    /// Awaiting a tool continuation (tool suspend pattern).
86    Tool,
87    /// Awaiting milestone evaluation result.
88    Milestone,
89    /// Awaiting a routed signal at a turn boundary.
90    ///
91    /// **Descoped (v0.2.11):** Signal→Schedule integration was explicitly descoped.
92    /// The variant is tested infrastructure (~6 tests) and deserialized by `snapshot.rs`,
93    /// but is not wired into any production code path. Retained for snapshot compatibility
94    /// and future reactivation.
95    Signal,
96    /// Externally requested suspension.
97    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    /// W2-1: Remove a completed child from the SubAgentJoin list.
113    /// Returns true if this was the last pending child (task should become runnable).
114    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    /// W2-1: Check if a specific child is in the pending list.
124    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            // The child id is not known at this scaffold boundary; M1 supplies it.
138            // W2-1: Changed to empty vec (will be populated with actual child IDs at spawn).
139            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/// Running budget counters + limits for a task. Wraps the existing [`SchedulerBudget`]
155/// limits so M1 can move `should_terminate` evaluation here without changing the axes.
156#[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    /// Delegates to the existing budget logic — single source of truth, no axis drift.
170    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/// The budget a task is granted for the next run step. M1's `schedule()` returns one of these.
183#[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/// Sub-agent-specific identity carried by a child [`Tcb`]; `None` on the root task.
191///
192/// This is what makes the `AgentProcess` view *derived* from the [`TaskTable`]: every child task
193/// whose `proc` is `Some` reconstructs exactly one [`crate::proc::AgentProcess`] (see
194/// [`crate::proc::AgentProcess::from_tcb`]). The formerly duplicated process storage collapses
195/// into these fields.
196#[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    /// The join result once the sub-agent has completed; `None` while running.
203    pub result: Option<SubAgentResult>,
204}
205
206/// One schedulable entity. The root loop and every sub-agent are uniform `Tcb`s.
207#[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    /// Capability ids permitted to this task (mirrors `AgentProcess.permitted_capability_ids`).
215    pub caps: Vec<CompactString>,
216    /// Sub-agent identity for child tasks; `None` for the root loop.
217    pub proc: Option<ProcInfo>,
218    /// W2-1: Tasks hitting quota/deferred conditions get a deferred timestamp.
219    /// When set, the task is considered Ready-but-deferred until `now_ms >= deferred_until`.
220    pub deferred_until: Option<u64>,
221}
222
223impl Tcb {
224    /// The root loop task (id 0). M1 constructs this from the runtime task at `Start`.
225    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    /// A sub-agent task spawned under the root, seeded `Running`, carrying the manifest's
239    /// process identity. The single source of truth for what the `AgentProcess` view exposes.
240    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    /// Whether this task is eligible to run now (Ready state + not deferred).
260    pub fn is_runnable(&self) -> bool {
261        self.is_runnable_at(None)
262    }
263
264    /// Whether this task is eligible to run at a given timestamp.
265    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, // Without time, deferred tasks are not runnable
273            },
274            None => true,
275        }
276    }
277}
278
279/// Unified registry of all tasks: the root loop plus one child per sub-agent. The sole source of
280/// truth for schedulability and lineage; the `AgentProcess` view is derived from it.
281#[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    /// Runnable tasks at a given timestamp (accounts for deferred tasks).
323    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/// Result of a pure scheduling pass.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub enum ScheduleDecision {
331    /// No tasks are runnable — scheduler is idle.
332    Idle,
333    /// Run the specified task with the given budget slice.
334    Run { task: TaskId, slice: BudgetSlice },
335    /// Suspend the specified task (e.g., awaiting external resolution).
336    Suspend { task: TaskId, reason: WaitReason },
337    /// Terminate the specified task.
338    Terminate { task: TaskId, reason: TerminationReason },
339}
340
341/// Pure scheduling decision for a single task's budget axes.
342///
343/// M1b spine: encodes the **same verdict** as [`SchedulerBudget::should_terminate`], expressed
344/// over a [`Tcb`]. It is wired into the state machine in parallel with the legacy path under a
345/// `debug_assert` (zero behavior change) so the equivalence is proven before it becomes the single
346/// decision point. Later milestones extend this to pick among multiple runnable tasks + apply
347/// signal preemption — at which point the legacy `should_terminate` call site is removed.
348pub fn schedule(task: &Tcb, now_ms: Option<u64>) -> ScheduleDecision {
349    if let Some(reason) = task.budget.exceeded(now_ms) {
350        // Same axis-name → TerminationReason mapping the state machine applies today.
351        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
368/// W2-1: Multi-task scheduler — picks one task to run from the TaskTable.
369///
370/// This is the "true scheduler" that:
371/// 1. Checks budget on all tasks and terminates any that exceeded
372/// 2. Filters runnable tasks (Ready + not deferred)
373/// 3. Applies signal-aware prioritization (TODO: W2-1 full signal integration)
374/// 4. Returns `Idle` if no runnable tasks, or `Run` for the selected task
375///
376/// For now, prioritization is simple FIFO (first runnable task wins).
377/// Future W2-1 work will integrate signal urgency and parent-child priority.
378///
379/// `highest_signal_urgency`: Optional urgency level (0-3) of the highest priority
380/// pending signal. When set, tasks waiting on Signal with matching or higher
381/// urgency are prioritized.
382///
383/// **Descoped (v0.2.11):** The `highest_signal_urgency` parameter is tested
384/// infrastructure only — no production caller supplies a `Some` value today.
385/// Signal→Schedule integration was explicitly descoped; the parameter is retained
386/// so the prioritization logic is exercised by unit tests and ready for future
387/// reactivation without an API change.
388pub fn schedule_multi(table: &TaskTable, now_ms: Option<u64>, highest_signal_urgency: Option<u8>) -> ScheduleDecision {
389    // First pass: check all tasks for budget termination
390    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    // Second pass: filter runnable tasks
402    let runnable = table.runnable_at(now_ms);
403
404    if runnable.is_empty() {
405        return ScheduleDecision::Idle;
406    }
407
408    // W2-1: Signal-aware prioritization
409    // If there's a high priority signal, prefer tasks that might be responsive to it
410    let selected = if let Some(urgency) = highest_signal_urgency {
411        // High urgency (Critical=3, High=2): prefer tasks with Signal wait reason
412        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        // schedule() and the legacy budget check must agree on both verdict and reason.
506        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    // W2-1: multi-task scheduler tests
541
542    #[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; // over budget
569        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); // deferred far into future
583        table.insert(root);
584
585        // With no timestamp, deferred tasks are not runnable
586        assert_eq!(table.runnable_at(None).len(), 0);
587
588        // With timestamp in the past, task becomes runnable
589        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        // Simple FIFO: first task in list wins
600        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        // Before defer time: not runnable
662        assert!(!tcb.is_runnable_at(Some(999)));
663
664        // At defer time: runnable
665        assert!(tcb.is_runnable_at(Some(1000)));
666
667        // After defer time: runnable
668        assert!(tcb.is_runnable_at(Some(1001)));
669    }
670
671    /// W2-1: Demonstrate how deferred tasks are skipped during scheduling.
672    /// This is the mechanism for quota backpressure: tasks that hit quota limits
673    /// get a `deferred_until` timestamp and are not scheduled until that time passes.
674    #[test]
675    fn schedule_multi_skips_deferred_and_returns_next_ready() {
676        let mut table = TaskTable::new();
677
678        // Task A is deferred
679        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        // Task B is ready
684        table.insert(Tcb::root("task-b", SchedulerBudget::default()));
685
686        // Task C is also ready
687        table.insert(Tcb::root("task-c", SchedulerBudget::default()));
688
689        // With no time context, deferred tasks are not runnable
690        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        // With time past defer threshold, task-a becomes runnable
698        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    // W2-1: Signal-aware prioritization tests
707
708    #[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        // No signal urgency: FIFO selection
715        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        // High urgency signal: prefer the task waiting on Signal
733        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        // Normal urgency signal: FIFO (no preference)
751        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        // High urgency signal (2): prefer signal-waiting
769        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        // Critical urgency (3): strongly prefer signal-waiting
788        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    // W2-1: Golden baseline tests for multi-task scheduler
797
798    #[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        // Golden baseline: single task should be selected with its budget limits
812        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        // Golden baseline: tasks should be selected in FIFO order (insertion order)
830        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        // After removing task-1, task-2 should be selected
837        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        // Golden baseline: no tasks means Idle
852        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; // Exceeded max_turns
865        table.insert(task);
866
867        let decision = schedule_multi(&table, None, None);
868
869        // Golden baseline: over-budget tasks should be terminated
870        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; // Exceeded max_total_tokens
888        table.insert(task);
889
890        let decision = schedule_multi(&table, None, None);
891
892        // Golden baseline: token budget exceeded should terminate
893        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        // Golden baseline: wall time exceeded should terminate with Timeout
916        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        // Add an over-budget task
929        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        // Add a healthy task
939        table.insert(Tcb::root("healthy", SchedulerBudget::default()));
940
941        // Monotonicity: termination should always be checked before selection
942        let decision = schedule_multi(&table, None, None);
943
944        // Should terminate the over-budget task, not run the healthy one
945        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        // Before defer time: deferred task should not be selected
963        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        // Only ready tasks should be selected
989        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        // With critical signal: signal-waiting task should be preferred
1009        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}