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 {
98 limits,
99 turns: 0,
100 total_tokens: 0,
101 started_at_ms: None,
102 }
103 }
104
105 pub fn exceeded(&self, now_ms: Option<u64>) -> Option<&'static str> {
107 self.limits
108 .should_terminate(self.turns, self.total_tokens, now_ms, self.started_at_ms)
109 }
110}
111
112impl Default for BudgetLedger {
113 fn default() -> Self {
114 Self::new(SchedulerBudget::default())
115 }
116}
117
118#[derive(Debug, Clone)]
124pub struct ProcInfo {
125 pub parent_session_id: CompactString,
126 pub role: AgentRole,
127 pub isolation: AgentIsolation,
128 pub context_inheritance: ContextInheritance,
129 pub result: Option<SubAgentResult>,
131}
132
133#[derive(Debug, Clone)]
135pub struct Tcb {
136 pub id: TaskId,
137 pub parent: Option<TaskId>,
138 pub state: TaskLifecycle,
139 pub budget: BudgetLedger,
140 pub wait: Option<WaitReason>,
141 pub caps: Vec<CompactString>,
143 pub proc: Option<ProcInfo>,
145}
146
147impl Tcb {
148 pub fn root(id: impl Into<TaskId>, budget: SchedulerBudget) -> Self {
150 Self {
151 id: id.into(),
152 parent: None,
153 state: TaskLifecycle::Ready,
154 budget: BudgetLedger::new(budget),
155 wait: None,
156 caps: Vec::new(),
157 proc: None,
158 }
159 }
160
161 pub fn spawned(manifest: &IsolationManifest, budget: SchedulerBudget) -> Self {
164 Self {
165 id: manifest.agent_id.clone(),
166 parent: Some("root".into()),
167 state: TaskLifecycle::Running,
168 budget: BudgetLedger::new(budget),
169 wait: None,
170 caps: manifest.permitted_capability_ids.clone(),
171 proc: Some(ProcInfo {
172 parent_session_id: manifest.parent_session_id.clone(),
173 role: manifest.role,
174 isolation: manifest.isolation,
175 context_inheritance: manifest.context_inheritance,
176 result: None,
177 }),
178 }
179 }
180}
181
182#[derive(Debug, Clone, Default)]
185pub struct TaskTable {
186 tasks: Vec<Tcb>,
187}
188
189impl TaskTable {
190 pub fn new() -> Self {
191 Self::default()
192 }
193
194 pub fn insert(&mut self, tcb: Tcb) {
195 if let Some(existing) = self.tasks.iter_mut().find(|t| t.id == tcb.id) {
196 *existing = tcb;
197 } else {
198 self.tasks.push(tcb);
199 }
200 }
201
202 pub fn get(&self, id: &str) -> Option<&Tcb> {
203 self.tasks.iter().find(|t| t.id.as_str() == id)
204 }
205
206 pub fn get_mut(&mut self, id: &str) -> Option<&mut Tcb> {
207 self.tasks.iter_mut().find(|t| t.id.as_str() == id)
208 }
209
210 pub fn all(&self) -> &[Tcb] {
211 &self.tasks
212 }
213
214 pub fn children_of(&self, parent: &str) -> Vec<&Tcb> {
215 self.tasks
216 .iter()
217 .filter(|t| t.parent.as_deref() == Some(parent))
218 .collect()
219 }
220}
221
222pub fn budget_verdict(task: &Tcb, now_ms: Option<u64>) -> Option<TerminationReason> {
226 task.budget.exceeded(now_ms).map(|axis| match axis {
227 "max_turns" => TerminationReason::MaxTurns,
228 "wall_time" => TerminationReason::Timeout,
229 _ => TerminationReason::TokenBudget,
230 })
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn process_state_maps_to_lifecycle() {
239 assert_eq!(
240 TaskLifecycle::from(ProcessState::Running),
241 TaskLifecycle::Running
242 );
243 assert_eq!(
244 TaskLifecycle::from(ProcessState::Joined),
245 TaskLifecycle::Done(TerminationReason::Completed)
246 );
247 assert_eq!(
248 TaskLifecycle::from(ProcessState::Failed),
249 TaskLifecycle::Done(TerminationReason::Error)
250 );
251 }
252
253 #[test]
254 fn budget_ledger_delegates_to_scheduler_budget() {
255 let mut ledger = BudgetLedger::new(SchedulerBudget {
256 max_turns: 2,
257 ..SchedulerBudget::default()
258 });
259 assert_eq!(ledger.exceeded(None), None);
260 ledger.turns = 2;
261 assert_eq!(ledger.exceeded(None), Some("max_turns"));
262 }
263
264 #[test]
265 fn task_table_insert_and_lineage() {
266 let mut table = TaskTable::new();
267 table.insert(Tcb::root("root", SchedulerBudget::default()));
268 let mut child = Tcb::root("child", SchedulerBudget::default());
269 child.parent = Some("root".into());
270 table.insert(child);
271
272 assert_eq!(table.children_of("root").len(), 1);
273 assert!(table.get("root").is_some());
274 }
275
276 #[test]
277 fn task_table_insert_is_idempotent_by_id() {
278 let mut table = TaskTable::new();
279 table.insert(Tcb::root("root", SchedulerBudget::default()));
280 let mut updated = Tcb::root("root", SchedulerBudget::default());
281 updated.state = TaskLifecycle::Running;
282 table.insert(updated);
283
284 assert_eq!(table.all().len(), 1);
285 assert_eq!(table.get("root").unwrap().state, TaskLifecycle::Running);
286 }
287
288 #[test]
289 fn budget_verdict_none_within_budget() {
290 let tcb = Tcb::root(
291 "root",
292 SchedulerBudget {
293 max_turns: 5,
294 ..SchedulerBudget::default()
295 },
296 );
297 assert_eq!(budget_verdict(&tcb, None), None);
298 }
299
300 #[test]
301 fn budget_verdict_matches_should_terminate_axis() {
302 let limits = SchedulerBudget {
303 max_turns: 2,
304 ..SchedulerBudget::default()
305 };
306 let mut tcb = Tcb::root("root", limits.clone());
307 tcb.budget.turns = 2;
308 assert_eq!(limits.should_terminate(2, 0, None, None), Some("max_turns"));
310 assert_eq!(
311 budget_verdict(&tcb, None),
312 Some(TerminationReason::MaxTurns)
313 );
314 }
315
316 #[test]
317 fn budget_verdict_wall_time_maps_to_timeout() {
318 let limits = SchedulerBudget {
319 max_wall_ms: Some(1_000),
320 ..SchedulerBudget::default()
321 };
322 let mut tcb = Tcb::root("root", limits);
323 tcb.budget.started_at_ms = Some(0);
324 assert_eq!(
325 budget_verdict(&tcb, Some(2_000)),
326 Some(TerminationReason::Timeout)
327 );
328 }
329
330 #[test]
331 fn baseline_token_budget_terminates() {
332 let limits = SchedulerBudget {
333 max_total_tokens: 100,
334 ..SchedulerBudget::default()
335 };
336 let mut tcb = Tcb::root("root", limits);
337 tcb.budget.total_tokens = 200;
338 assert_eq!(
339 budget_verdict(&tcb, None),
340 Some(TerminationReason::TokenBudget)
341 );
342 }
343}