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 PendingLaunch,
35 Starting,
39 Ready,
41 Running,
43 Suspended,
45 Done(TerminationReason),
47}
48
49impl TaskLifecycle {
50 pub fn label(self) -> &'static str {
51 match self {
52 Self::PendingLaunch => "pending_launch",
53 Self::Starting => "starting",
54 Self::Ready => "ready",
55 Self::Running => "running",
56 Self::Suspended => "suspended",
57 Self::Done(_) => "done",
58 }
59 }
60
61 pub fn is_terminal(self) -> bool {
62 matches!(self, Self::Done(_))
63 }
64
65 pub fn occupies_slot(self) -> bool {
72 matches!(self, Self::PendingLaunch | Self::Starting | Self::Running)
73 }
74}
75
76impl From<ProcessState> for TaskLifecycle {
78 fn from(state: ProcessState) -> Self {
79 match state {
80 ProcessState::Running => TaskLifecycle::Running,
81 ProcessState::Joined => TaskLifecycle::Done(TerminationReason::Completed),
82 ProcessState::Failed => TaskLifecycle::Done(TerminationReason::Error),
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum WaitReason {
94 Approval,
96 SubAgentJoin(Vec<TaskId>),
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 }
106 }
107}
108
109#[derive(Debug, Clone)]
112pub struct BudgetLedger {
113 pub limits: SchedulerBudget,
114 pub turns: u32,
115 pub total_tokens: u64,
116 pub started_at_ms: Option<u64>,
117}
118
119impl BudgetLedger {
120 pub fn new(limits: SchedulerBudget) -> Self {
121 Self {
122 limits,
123 turns: 0,
124 total_tokens: 0,
125 started_at_ms: None,
126 }
127 }
128
129 pub fn exceeded(&self, now_ms: Option<u64>) -> Option<&'static str> {
131 self.limits
132 .should_terminate(self.turns, self.total_tokens, now_ms, self.started_at_ms)
133 }
134}
135
136impl Default for BudgetLedger {
137 fn default() -> Self {
138 Self::new(SchedulerBudget::default())
139 }
140}
141
142#[derive(Debug, Clone)]
151pub struct ProcInfo {
152 pub role: AgentRole,
153 pub isolation: AgentIsolation,
154 pub context_inheritance: ContextInheritance,
155 pub result: Option<SubAgentResult>,
157}
158
159#[derive(Debug, Clone)]
161pub struct Tcb {
162 pub id: TaskId,
163 pub parent: Option<TaskId>,
164 pub state: TaskLifecycle,
165 pub budget: BudgetLedger,
166 pub wait: Option<WaitReason>,
167 pub caps: Vec<CompactString>,
169 pub proc: Option<ProcInfo>,
171}
172
173impl Tcb {
174 pub fn root(id: impl Into<TaskId>, budget: SchedulerBudget) -> Self {
176 Self {
177 id: id.into(),
178 parent: None,
179 state: TaskLifecycle::Ready,
180 budget: BudgetLedger::new(budget),
181 wait: None,
182 caps: Vec::new(),
183 proc: None,
184 }
185 }
186
187 pub fn spawned(manifest: &IsolationManifest, budget: SchedulerBudget) -> Self {
190 Self::spawned_in(manifest, budget, TaskLifecycle::Running)
191 }
192
193 pub fn spawned_in(
199 manifest: &IsolationManifest,
200 budget: SchedulerBudget,
201 state: TaskLifecycle,
202 ) -> Self {
203 Self {
204 id: manifest.agent_id.clone(),
205 parent: Some("root".into()),
206 state,
207 budget: BudgetLedger::new(budget),
208 wait: None,
209 caps: manifest.permitted_capability_ids.clone(),
210 proc: Some(ProcInfo {
211 role: manifest.role,
212 isolation: manifest.isolation,
213 context_inheritance: manifest.context_inheritance,
214 result: None,
215 }),
216 }
217 }
218}
219
220#[derive(Debug, Clone, Default)]
223pub struct TaskTable {
224 tasks: Vec<Tcb>,
225}
226
227impl TaskTable {
228 pub fn new() -> Self {
229 Self::default()
230 }
231
232 pub fn insert(&mut self, tcb: Tcb) {
233 if let Some(existing) = self.tasks.iter_mut().find(|t| t.id == tcb.id) {
234 *existing = tcb;
235 } else {
236 self.tasks.push(tcb);
237 }
238 }
239
240 pub fn get(&self, id: &str) -> Option<&Tcb> {
241 self.tasks.iter().find(|t| t.id.as_str() == id)
242 }
243
244 pub fn get_mut(&mut self, id: &str) -> Option<&mut Tcb> {
245 self.tasks.iter_mut().find(|t| t.id.as_str() == id)
246 }
247
248 pub fn all(&self) -> &[Tcb] {
249 &self.tasks
250 }
251
252 pub fn children_of(&self, parent: &str) -> Vec<&Tcb> {
253 self.tasks
254 .iter()
255 .filter(|t| t.parent.as_deref() == Some(parent))
256 .collect()
257 }
258}
259
260pub fn budget_verdict(task: &Tcb, now_ms: Option<u64>) -> Option<TerminationReason> {
264 task.budget.exceeded(now_ms).map(|axis| match axis {
265 "max_turns" => TerminationReason::MaxTurns,
266 "wall_time" => TerminationReason::Timeout,
267 _ => TerminationReason::TokenBudget,
268 })
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn process_state_maps_to_lifecycle() {
277 assert_eq!(
278 TaskLifecycle::from(ProcessState::Running),
279 TaskLifecycle::Running
280 );
281 assert_eq!(
282 TaskLifecycle::from(ProcessState::Joined),
283 TaskLifecycle::Done(TerminationReason::Completed)
284 );
285 assert_eq!(
286 TaskLifecycle::from(ProcessState::Failed),
287 TaskLifecycle::Done(TerminationReason::Error)
288 );
289 }
290
291 #[test]
292 fn budget_ledger_delegates_to_scheduler_budget() {
293 let mut ledger = BudgetLedger::new(SchedulerBudget {
294 max_turns: 2,
295 ..SchedulerBudget::default()
296 });
297 assert_eq!(ledger.exceeded(None), None);
298 ledger.turns = 2;
299 assert_eq!(ledger.exceeded(None), Some("max_turns"));
300 }
301
302 #[test]
303 fn task_table_insert_and_lineage() {
304 let mut table = TaskTable::new();
305 table.insert(Tcb::root("root", SchedulerBudget::default()));
306 let mut child = Tcb::root("child", SchedulerBudget::default());
307 child.parent = Some("root".into());
308 table.insert(child);
309
310 assert_eq!(table.children_of("root").len(), 1);
311 assert!(table.get("root").is_some());
312 }
313
314 #[test]
315 fn task_table_insert_is_idempotent_by_id() {
316 let mut table = TaskTable::new();
317 table.insert(Tcb::root("root", SchedulerBudget::default()));
318 let mut updated = Tcb::root("root", SchedulerBudget::default());
319 updated.state = TaskLifecycle::Running;
320 table.insert(updated);
321
322 assert_eq!(table.all().len(), 1);
323 assert_eq!(table.get("root").unwrap().state, TaskLifecycle::Running);
324 }
325
326 #[test]
327 fn budget_verdict_none_within_budget() {
328 let tcb = Tcb::root(
329 "root",
330 SchedulerBudget {
331 max_turns: 5,
332 ..SchedulerBudget::default()
333 },
334 );
335 assert_eq!(budget_verdict(&tcb, None), None);
336 }
337
338 #[test]
339 fn budget_verdict_matches_should_terminate_axis() {
340 let limits = SchedulerBudget {
341 max_turns: 2,
342 ..SchedulerBudget::default()
343 };
344 let mut tcb = Tcb::root("root", limits.clone());
345 tcb.budget.turns = 2;
346 assert_eq!(limits.should_terminate(2, 0, None, None), Some("max_turns"));
348 assert_eq!(
349 budget_verdict(&tcb, None),
350 Some(TerminationReason::MaxTurns)
351 );
352 }
353
354 #[test]
355 fn budget_verdict_wall_time_maps_to_timeout() {
356 let limits = SchedulerBudget {
357 max_wall_ms: Some(1_000),
358 ..SchedulerBudget::default()
359 };
360 let mut tcb = Tcb::root("root", limits);
361 tcb.budget.started_at_ms = Some(0);
362 assert_eq!(
363 budget_verdict(&tcb, Some(2_000)),
364 Some(TerminationReason::Timeout)
365 );
366 }
367
368 #[test]
369 fn baseline_token_budget_terminates() {
370 let limits = SchedulerBudget {
371 max_total_tokens: 100,
372 ..SchedulerBudget::default()
373 };
374 let mut tcb = Tcb::root("root", limits);
375 tcb.budget.total_tokens = 200;
376 assert_eq!(
377 budget_verdict(&tcb, None),
378 Some(TerminationReason::TokenBudget)
379 );
380 }
381}