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`. The root loop and every
4//! sub-agent are a single [`Tcb`]; the [`TaskTable`] is the sole source of truth for
5//! schedulability and lineage, and the `AgentProcess` view is derived from it.
6//! [`budget_verdict`] is the single budget decision point (turn/token/wall axes),
7//! delegating to [`SchedulerBudget::should_terminate`] via [`BudgetLedger`].
8
9use 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
17/// Identity of a schedulable task. Task 0 is the root loop; children are sub-agents.
18/// Aligns with `AgentProcess.agent_id` so process rows map onto TCBs 1:1.
19pub type TaskId = CompactString;
20
21/// Schedulability lifecycle of a task — orthogonal to the *intra-turn* step,
22/// which stays on [`crate::scheduler::state_machine::LoopPhase`], and distinct from
23/// the task-goal blackboard [`crate::context::task_state::TaskState`].
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum TaskLifecycle {
27    /// Spec §10.4 · the kernel has minted this task's identity (task/attempt/launch token) but the
28    /// launch effect that carries it is not published yet. A child exists as a committed fact
29    /// before any host is asked to start it — that is what stops a resolution from *creating*
30    /// process identity.
31    ///
32    /// Every spawn constructs this state; publication advances it to [`Self::Starting`] and only a
33    /// correlated launch acknowledgement advances it to [`Self::Running`].
34    PendingLaunch,
35    /// Spec §10.4 · the launch effect is published and the host has been asked to start the child,
36    /// but no acknowledgement has arrived. Distinct from [`Self::Running`] because "we asked" and
37    /// "it is running" are different facts, and only the second may be reported as one.
38    Starting,
39    /// Eligible to run, not yet picked by the scheduler.
40    Ready,
41    /// Currently executing a turn (`ProcessState::Running`).
42    Running,
43    /// Suspended awaiting external resolution (human approval / sub-agent join).
44    Suspended,
45    /// Finished. Carries the termination reason (`ProcessState::{Joined,Failed}`).
46    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    /// Whether this task holds one of the operation's concurrency slots.
66    ///
67    /// A launch the kernel has already decided on occupies a slot even before the host confirms it
68    /// — otherwise every in-flight launch would be invisible to the spawn quota and an ack-gated
69    /// run could overshoot `max_concurrent_subagents` by the size of its launch window. Legacy runs
70    /// never construct the two launch states, so this reads exactly as `== Running` for them.
71    pub fn occupies_slot(self) -> bool {
72        matches!(self, Self::PendingLaunch | Self::Starting | Self::Running)
73    }
74}
75
76/// A successful join maps to `Done(Completed)`; any other termination is `Done(<reason>)`.
77impl 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            // Failed has no single reason at the process level; the real reason travels
83            // in `SubAgentResult`. This projection maps to a generic error.
84            ProcessState::Failed => TaskLifecycle::Done(TerminationReason::Error),
85        }
86    }
87}
88
89/// Why a suspended task is not runnable. Only the reasons production actually
90/// constructs exist; new wait states earn a variant when they earn a producer.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum WaitReason {
94    /// Governance `AskUser` — waiting for SDK to resolve human approval.
95    Approval,
96    /// Parent blocked on child tasks' join results. Tracks pending child IDs.
97    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/// Running budget counters + limits for a task. Wraps the existing [`SchedulerBudget`]
110/// limits so budget evaluation lives here without changing the axes.
111#[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    /// Delegates to the existing budget logic — single source of truth, no axis drift.
130    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/// Sub-agent-specific identity carried by a child [`Tcb`]; `None` on the root task.
143///
144/// This is what makes the `AgentProcess` view *derived* from the [`TaskTable`]: every child task
145/// whose `proc` is `Some` reconstructs exactly one [`crate::proc::AgentProcess`] (see
146/// [`crate::proc::AgentProcess::from_tcb`]).
147///
148/// § Task 11 · carries no session. A child's lineage is the logical `Tcb::parent` task id; the host
149/// keeps its own child-task → child-session mapping (§5.2) and the kernel never sees it.
150#[derive(Debug, Clone)]
151pub struct ProcInfo {
152    pub role: AgentRole,
153    pub isolation: AgentIsolation,
154    pub context_inheritance: ContextInheritance,
155    /// The join result once the sub-agent has completed; `None` while running.
156    pub result: Option<SubAgentResult>,
157}
158
159/// One schedulable entity. The root loop and every sub-agent are uniform `Tcb`s.
160#[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    /// Capability ids permitted to this task (mirrors `AgentProcess.permitted_capability_ids`).
168    pub caps: Vec<CompactString>,
169    /// Sub-agent identity for child tasks; `None` for the root loop.
170    pub proc: Option<ProcInfo>,
171}
172
173impl Tcb {
174    /// The root loop task. Constructed from the runtime task at `Start`.
175    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    /// A sub-agent task spawned under the root, seeded `Running`, carrying the manifest's
188    /// process identity. The single source of truth for what the `AgentProcess` view exposes.
189    pub fn spawned(manifest: &IsolationManifest, budget: SchedulerBudget) -> Self {
190        Self::spawned_in(manifest, budget, TaskLifecycle::Running)
191    }
192
193    /// The same child, seeded in an explicit lifecycle state.
194    ///
195    /// §10.4's spawn arc is `PendingLaunch → Starting → Running(ack)`; the legacy path collapses it
196    /// to `Running` at insert time, which is why [`Self::spawned`] exists unchanged and this is the
197    /// entry point the ack-gated canonical path uses.
198    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/// Unified registry of all tasks: the root loop plus one child per sub-agent. The sole source of
221/// truth for schedulability and lineage; the `AgentProcess` view is derived from it.
222#[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
260/// Pure budget verdict for one task: `Some(reason)` when a budget axis (turn/token/wall)
261/// is exhausted, mapped to the same `TerminationReason` the state machine applies.
262/// The single budget decision point — evaluated at each turn boundary.
263pub 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        // budget_verdict and the underlying budget check must agree on verdict and reason.
347        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}