deepstrike_core/scheduler/
tcb.rs1use compact_str::CompactString;
10use serde::{Deserialize, Serialize};
11
12use crate::proc::ProcessState;
13use crate::scheduler::policy::SchedulerBudget;
14use crate::types::agent::{AgentIsolation, AgentRole, ContextInheritance, IsolationManifest};
15use crate::types::result::{SubAgentResult, TerminationReason};
16
17pub type TaskId = CompactString;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum TaskLifecycle {
27 Ready,
29 Running,
31 Suspended,
33 Done(TerminationReason),
35}
36
37impl TaskLifecycle {
38 pub fn label(self) -> &'static str {
39 match self {
40 Self::Ready => "ready",
41 Self::Running => "running",
42 Self::Suspended => "suspended",
43 Self::Done(_) => "done",
44 }
45 }
46
47 pub fn is_terminal(self) -> bool {
48 matches!(self, Self::Done(_))
49 }
50}
51
52impl From<ProcessState> for TaskLifecycle {
54 fn from(state: ProcessState) -> Self {
55 match state {
56 ProcessState::Running => TaskLifecycle::Running,
57 ProcessState::Joined => TaskLifecycle::Done(TerminationReason::Completed),
58 ProcessState::Failed => TaskLifecycle::Done(TerminationReason::Error),
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum WaitReason {
70 Approval,
72 SubAgentJoin(Vec<TaskId>),
74}
75
76impl WaitReason {
77 pub fn label(&self) -> &'static str {
78 match self {
79 Self::Approval => "approval",
80 Self::SubAgentJoin(_) => "sub_agent_join",
81 }
82 }
83}
84
85#[derive(Debug, Clone)]
88pub struct BudgetLedger {
89 pub limits: SchedulerBudget,
90 pub turns: u32,
91 pub total_tokens: u64,
92 pub started_at_ms: Option<u64>,
93}
94
95impl BudgetLedger {
96 pub fn new(limits: SchedulerBudget) -> Self {
97 Self { limits, turns: 0, total_tokens: 0, started_at_ms: None }
98 }
99
100 pub fn exceeded(&self, now_ms: Option<u64>) -> Option<&'static str> {
102 self.limits
103 .should_terminate(self.turns, self.total_tokens, now_ms, self.started_at_ms)
104 }
105}
106
107impl Default for BudgetLedger {
108 fn default() -> Self {
109 Self::new(SchedulerBudget::default())
110 }
111}
112
113#[derive(Debug, Clone)]
119pub struct ProcInfo {
120 pub parent_session_id: CompactString,
121 pub role: AgentRole,
122 pub isolation: AgentIsolation,
123 pub context_inheritance: ContextInheritance,
124 pub result: Option<SubAgentResult>,
126}
127
128#[derive(Debug, Clone)]
130pub struct Tcb {
131 pub id: TaskId,
132 pub parent: Option<TaskId>,
133 pub state: TaskLifecycle,
134 pub budget: BudgetLedger,
135 pub wait: Option<WaitReason>,
136 pub caps: Vec<CompactString>,
138 pub proc: Option<ProcInfo>,
140}
141
142impl Tcb {
143 pub fn root(id: impl Into<TaskId>, budget: SchedulerBudget) -> Self {
145 Self {
146 id: id.into(),
147 parent: None,
148 state: TaskLifecycle::Ready,
149 budget: BudgetLedger::new(budget),
150 wait: None,
151 caps: Vec::new(),
152 proc: None,
153 }
154 }
155
156 pub fn spawned(manifest: &IsolationManifest, budget: SchedulerBudget) -> Self {
159 Self {
160 id: manifest.agent_id.clone(),
161 parent: Some("root".into()),
162 state: TaskLifecycle::Running,
163 budget: BudgetLedger::new(budget),
164 wait: None,
165 caps: manifest.permitted_capability_ids.clone(),
166 proc: Some(ProcInfo {
167 parent_session_id: manifest.parent_session_id.clone(),
168 role: manifest.role,
169 isolation: manifest.isolation,
170 context_inheritance: manifest.context_inheritance,
171 result: None,
172 }),
173 }
174 }
175}
176
177#[derive(Debug, Clone, Default)]
180pub struct TaskTable {
181 tasks: Vec<Tcb>,
182}
183
184impl TaskTable {
185 pub fn new() -> Self {
186 Self::default()
187 }
188
189 pub fn insert(&mut self, tcb: Tcb) {
190 if let Some(existing) = self.tasks.iter_mut().find(|t| t.id == tcb.id) {
191 *existing = tcb;
192 } else {
193 self.tasks.push(tcb);
194 }
195 }
196
197 pub fn get(&self, id: &str) -> Option<&Tcb> {
198 self.tasks.iter().find(|t| t.id.as_str() == id)
199 }
200
201 pub fn get_mut(&mut self, id: &str) -> Option<&mut Tcb> {
202 self.tasks.iter_mut().find(|t| t.id.as_str() == id)
203 }
204
205 pub fn all(&self) -> &[Tcb] {
206 &self.tasks
207 }
208
209 pub fn children_of(&self, parent: &str) -> Vec<&Tcb> {
210 self.tasks
211 .iter()
212 .filter(|t| t.parent.as_deref() == Some(parent))
213 .collect()
214 }
215}
216
217pub fn budget_verdict(task: &Tcb, now_ms: Option<u64>) -> Option<TerminationReason> {
221 task.budget.exceeded(now_ms).map(|axis| match axis {
222 "max_turns" => TerminationReason::MaxTurns,
223 "wall_time" => TerminationReason::Timeout,
224 _ => TerminationReason::TokenBudget,
225 })
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn process_state_maps_to_lifecycle() {
234 assert_eq!(TaskLifecycle::from(ProcessState::Running), TaskLifecycle::Running);
235 assert_eq!(
236 TaskLifecycle::from(ProcessState::Joined),
237 TaskLifecycle::Done(TerminationReason::Completed)
238 );
239 assert_eq!(
240 TaskLifecycle::from(ProcessState::Failed),
241 TaskLifecycle::Done(TerminationReason::Error)
242 );
243 }
244
245 #[test]
246 fn budget_ledger_delegates_to_scheduler_budget() {
247 let mut ledger = BudgetLedger::new(SchedulerBudget {
248 max_turns: 2,
249 ..SchedulerBudget::default()
250 });
251 assert_eq!(ledger.exceeded(None), None);
252 ledger.turns = 2;
253 assert_eq!(ledger.exceeded(None), Some("max_turns"));
254 }
255
256 #[test]
257 fn task_table_insert_and_lineage() {
258 let mut table = TaskTable::new();
259 table.insert(Tcb::root("root", SchedulerBudget::default()));
260 let mut child = Tcb::root("child", SchedulerBudget::default());
261 child.parent = Some("root".into());
262 table.insert(child);
263
264 assert_eq!(table.children_of("root").len(), 1);
265 assert!(table.get("root").is_some());
266 }
267
268 #[test]
269 fn task_table_insert_is_idempotent_by_id() {
270 let mut table = TaskTable::new();
271 table.insert(Tcb::root("root", SchedulerBudget::default()));
272 let mut updated = Tcb::root("root", SchedulerBudget::default());
273 updated.state = TaskLifecycle::Running;
274 table.insert(updated);
275
276 assert_eq!(table.all().len(), 1);
277 assert_eq!(table.get("root").unwrap().state, TaskLifecycle::Running);
278 }
279
280 #[test]
281 fn budget_verdict_none_within_budget() {
282 let tcb = Tcb::root("root", SchedulerBudget { max_turns: 5, ..SchedulerBudget::default() });
283 assert_eq!(budget_verdict(&tcb, None), None);
284 }
285
286 #[test]
287 fn budget_verdict_matches_should_terminate_axis() {
288 let limits = SchedulerBudget { max_turns: 2, ..SchedulerBudget::default() };
289 let mut tcb = Tcb::root("root", limits.clone());
290 tcb.budget.turns = 2;
291 assert_eq!(limits.should_terminate(2, 0, None, None), Some("max_turns"));
293 assert_eq!(budget_verdict(&tcb, None), Some(TerminationReason::MaxTurns));
294 }
295
296 #[test]
297 fn budget_verdict_wall_time_maps_to_timeout() {
298 let limits = SchedulerBudget { max_wall_ms: Some(1_000), ..SchedulerBudget::default() };
299 let mut tcb = Tcb::root("root", limits);
300 tcb.budget.started_at_ms = Some(0);
301 assert_eq!(budget_verdict(&tcb, Some(2_000)), Some(TerminationReason::Timeout));
302 }
303
304 #[test]
305 fn baseline_token_budget_terminates() {
306 let limits = SchedulerBudget { max_total_tokens: 100, ..SchedulerBudget::default() };
307 let mut tcb = Tcb::root("root", limits);
308 tcb.budget.total_tokens = 200;
309 assert_eq!(budget_verdict(&tcb, None), Some(TerminationReason::TokenBudget));
310 }
311}