1use serde::{Deserialize, Serialize};
21
22use crate::scheduler::tcb::{TaskId, TaskState, Tcb, TaskTable};
23use crate::types::agent::{AgentIsolation, AgentRole, ContextInheritance};
24use crate::types::message::Message;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct TcbSnapshot {
29 pub id: TaskId,
30 pub parent: Option<TaskId>,
31 pub state: TaskState,
32 pub turns: u32,
33 pub total_tokens: u64,
34 pub started_at_ms: Option<u64>,
35 pub max_tokens: u32,
36 pub max_turns: u32,
37 pub max_total_tokens: u64,
38 pub max_wall_ms: Option<u64>,
39 pub wait_reason: Option<String>, pub wait_children: Option<Vec<TaskId>>, pub deferred_until: Option<u64>,
42 pub caps: Vec<TaskId>,
43 pub proc: Option<ProcInfoSnapshot>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ProcInfoSnapshot {
49 pub parent_session_id: TaskId,
50 pub role: AgentRole,
51 pub isolation: AgentIsolation,
52 pub context_inheritance: ContextInheritance,
53 pub result: Option<ResultSnapshot>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ResultSnapshot {
59 pub termination: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct KnowledgeEntryMeta {
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub key: Option<String>,
68 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
69 pub pinned: bool,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, Default)]
74pub struct ContextSnapshot {
75 pub system_messages: Vec<Message>,
76 pub knowledge_messages: Vec<Message>,
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
81 pub knowledge_entries_meta: Vec<KnowledgeEntryMeta>,
82 pub task_goal: Option<String>,
83 pub task_plan: Option<String>,
84 pub task_progress: Option<String>,
85 pub task_open_steps: Vec<String>,
86 #[serde(default)]
88 pub task_directives: Vec<String>,
89 pub history_messages: Vec<Message>,
90 pub signals: Vec<String>,
91 pub max_tokens: u32,
92 pub sprint: u32,
93}
94
95impl ContextSnapshot {
96 pub fn from_context(ctx: &crate::context::manager::ContextManager) -> Self {
98 let task_plan = if ctx.partitions.task_state.plan.is_empty() {
100 None
101 } else {
102 serde_json::to_string(&ctx.partitions.task_state.plan).ok()
103 };
104
105 Self {
106 system_messages: ctx.partitions.system.messages.clone(),
107 knowledge_messages: ctx.partitions.knowledge.messages().cloned().collect(),
108 knowledge_entries_meta: ctx
109 .partitions
110 .knowledge
111 .entries
112 .iter()
113 .map(|e| KnowledgeEntryMeta {
114 key: e.key.as_ref().map(|k| k.to_string()),
115 pinned: e.pinned,
116 })
117 .collect(),
118 task_goal: Some(ctx.partitions.task_state.goal.clone()),
119 task_plan,
120 task_progress: Some(ctx.partitions.task_state.progress.clone()),
121 task_open_steps: ctx.partitions.task_state.open_steps(),
122 task_directives: ctx.partitions.task_state.directives.clone(),
123 history_messages: ctx.partitions.history.messages.clone(),
124 signals: ctx.partitions.signals.clone(),
125 max_tokens: ctx.max_tokens,
126 sprint: ctx.sprint,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct KernelSnapshot {
134 pub turn: u32,
135 pub total_tokens: u64,
136 pub tasks: Vec<TcbSnapshot>,
137 pub context: ContextSnapshot,
138 pub run_spec: Option<String>, }
140
141impl KernelSnapshot {
142 pub fn from_state(
144 turn: u32,
145 total_tokens: u64,
146 tasks: &TaskTable,
147 context: &ContextSnapshot,
148 run_spec: Option<&crate::AgentRunSpec>,
149 ) -> Self {
150 Self {
151 turn,
152 total_tokens,
153 tasks: tasks.all().iter().map(TcbSnapshot::from).collect(),
154 context: context.clone(),
155 run_spec: run_spec.and_then(|s| serde_json::to_string(s).ok()),
156 }
157 }
158
159 pub fn run_spec(&self) -> Option<crate::AgentRunSpec> {
161 self.run_spec.as_ref().and_then(|s| serde_json::from_str(s).ok())
162 }
163
164 pub fn to_os_process_records(&self) -> Vec<crate::runtime::replay::ProcessRecord> {
168 use crate::runtime::replay::ProcessRecord;
169 let mut records = Vec::new();
170 for tcb_snap in &self.tasks {
171 if let Some(proc) = &tcb_snap.proc {
173 records.push(ProcessRecord {
174 turn: tcb_snap.turns,
175 agent_id: tcb_snap.id.to_string(),
176 parent_session_id: proc.parent_session_id.to_string(),
177 state: tcb_snap.state.label().to_string(),
178 });
179 }
180 }
181 records
182 }
183
184 pub fn restore_tcb(&self, snapshot: &TcbSnapshot) -> Option<Tcb> {
186 use crate::scheduler::policy::SchedulerBudget;
187
188 let limits = SchedulerBudget {
190 max_tokens: snapshot.max_tokens,
191 max_turns: snapshot.max_turns,
192 max_total_tokens: snapshot.max_total_tokens,
193 max_wall_ms: snapshot.max_wall_ms,
194 };
195
196 let wait = snapshot.wait_reason.as_ref().and_then(|label| match label.as_str() {
198 "approval" => Some(crate::scheduler::tcb::WaitReason::Approval),
199 "sub_agent_join" => snapshot.wait_children.as_ref().map(|children| {
200 crate::scheduler::tcb::WaitReason::SubAgentJoin(
201 children.iter().map(|id| id.clone().into()).collect()
202 )
203 }),
204 "tool" => Some(crate::scheduler::tcb::WaitReason::Tool),
205 "milestone" => Some(crate::scheduler::tcb::WaitReason::Milestone),
206 "signal" => Some(crate::scheduler::tcb::WaitReason::Signal),
207 "external" => Some(crate::scheduler::tcb::WaitReason::External),
208 _ => None,
209 });
210
211 let proc = snapshot.proc.as_ref().and_then(|p| {
213 let result = p.result.as_ref().and_then(|r| {
214 match r.termination.as_str() {
216 "\"Completed\"" | "Completed" => Some(crate::types::result::SubAgentResult {
217 agent_id: snapshot.id.clone(),
218 result: crate::types::result::LoopResult {
219 termination: crate::types::result::TerminationReason::Completed,
220 final_message: None,
221 turns_used: 0,
222 total_tokens_used: 0,
223 loop_continue: None,
224 classify_branch: None,
225 tournament_winner: None,
226 },
227 }),
228 _ => None,
229 }
230 });
231
232 Some(crate::scheduler::tcb::ProcInfo {
233 parent_session_id: p.parent_session_id.clone(),
234 role: p.role,
235 isolation: p.isolation,
236 context_inheritance: p.context_inheritance,
237 result,
238 })
239 });
240
241 Some(Tcb {
242 id: snapshot.id.clone(),
243 parent: snapshot.parent.clone(),
244 state: snapshot.state,
245 budget: crate::scheduler::tcb::BudgetLedger {
246 limits,
247 turns: snapshot.turns,
248 total_tokens: snapshot.total_tokens,
249 started_at_ms: snapshot.started_at_ms,
250 },
251 wait,
252 caps: snapshot.caps.clone(),
253 proc,
254 deferred_until: snapshot.deferred_until,
255 })
256 }
257}
258
259impl From<&Tcb> for TcbSnapshot {
260 fn from(tcb: &Tcb) -> Self {
261 Self {
262 id: tcb.id.clone(),
263 parent: tcb.parent.clone(),
264 state: tcb.state.clone(),
265 turns: tcb.budget.turns,
266 total_tokens: tcb.budget.total_tokens,
267 started_at_ms: tcb.budget.started_at_ms,
268 max_tokens: tcb.budget.limits.max_tokens,
269 max_turns: tcb.budget.limits.max_turns,
270 max_total_tokens: tcb.budget.limits.max_total_tokens,
271 max_wall_ms: tcb.budget.limits.max_wall_ms,
272 wait_reason: tcb.wait.as_ref().map(|w| w.label().to_string()),
273 wait_children: match &tcb.wait {
274 Some(crate::scheduler::tcb::WaitReason::SubAgentJoin(children)) => {
275 Some(children.clone())
276 }
277 _ => None,
278 },
279 deferred_until: tcb.deferred_until,
280 caps: tcb.caps.clone(),
281 proc: tcb.proc.as_ref().map(|p| ProcInfoSnapshot {
282 parent_session_id: p.parent_session_id.clone(),
283 role: p.role,
284 isolation: p.isolation,
285 context_inheritance: p.context_inheritance,
286 result: p.result.as_ref().map(|r| ResultSnapshot {
287 termination: format!("{:?}", r.result.termination),
288 }),
289 }),
290 }
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use crate::scheduler::policy::SchedulerBudget;
298
299 #[test]
300 fn tcb_snapshot_roundtrip() {
301 let mut tcb = Tcb::root("test-task", SchedulerBudget {
302 max_tokens: 128_000,
303 max_turns: 10,
304 max_total_tokens: 1000,
305 max_wall_ms: Some(60000),
306 });
307 tcb.budget.turns = 5;
308 tcb.budget.total_tokens = 500;
309 tcb.deferred_until = Some(1000);
310
311 let snapshot = TcbSnapshot::from(&tcb);
312 assert_eq!(snapshot.id.as_str(), "test-task");
313 assert_eq!(snapshot.turns, 5);
314 assert_eq!(snapshot.total_tokens, 500);
315 assert_eq!(snapshot.deferred_until, Some(1000));
316 }
317
318 #[test]
319 fn kernel_snapshot_serializes() {
320 let snap = KernelSnapshot {
321 turn: 1,
322 total_tokens: 100,
323 tasks: vec![],
324 context: ContextSnapshot::default(),
325 run_spec: None,
326 };
327
328 let json = serde_json::to_string(&snap).expect("serialize");
329 let restored: KernelSnapshot = serde_json::from_str(&json).expect("deserialize");
330
331 assert_eq!(restored.turn, 1);
332 assert_eq!(restored.total_tokens, 100);
333 }
334
335 #[test]
336 fn context_snapshot_captures_fields() {
337 let ctx = ContextSnapshot {
338 system_messages: vec![Message::system("You are helpful")],
339 task_goal: Some("Build something".to_string()),
340 ..Default::default()
341 };
342
343 assert_eq!(ctx.system_messages.len(), 1);
344 assert_eq!(ctx.task_goal.as_deref(), Some("Build something"));
345 }
346
347 #[test]
348 fn snapshot_from_state_captures_tasks() {
349 let mut table = TaskTable::new();
350 table.insert(Tcb::root("root", SchedulerBudget::default()));
351 table.insert(Tcb::root("child", SchedulerBudget::default()));
352
353 let ctx = ContextSnapshot::default();
354 let snap = KernelSnapshot::from_state(5, 1000, &table, &ctx, None);
355
356 assert_eq!(snap.turn, 5);
357 assert_eq!(snap.total_tokens, 1000);
358 assert_eq!(snap.tasks.len(), 2);
359 }
360
361 #[test]
362 fn context_snapshot_from_manager() {
363 use crate::context::manager::ContextManager;
364 use crate::types::message::Message;
365
366 let mut ctx = ContextManager::new(1000);
367 ctx.partitions.system.push(Message::system("You are helpful"), 10);
368 ctx.partitions.task_state.goal = "Test goal".to_string();
369
370 let snap = ContextSnapshot::from_context(&ctx);
371 assert_eq!(snap.system_messages.len(), 1);
372 assert_eq!(snap.task_goal.as_deref(), Some("Test goal"));
373 assert_eq!(snap.max_tokens, 1000);
374 assert!(snap.task_plan.is_none());
376 }
377
378 #[test]
379 fn tcb_restore_roundtrip() {
380 let original = Tcb::root("test-task", SchedulerBudget {
381 max_tokens: 128_000,
382 max_turns: 10,
383 max_total_tokens: 1000,
384 max_wall_ms: Some(60000),
385 });
386
387 let snap = TcbSnapshot::from(&original);
388 let kernel_snap = KernelSnapshot {
389 turn: 1,
390 total_tokens: 100,
391 tasks: vec![snap.clone()],
392 context: ContextSnapshot::default(),
393 run_spec: None,
394 };
395
396 let restored = kernel_snap.restore_tcb(&snap);
397 assert!(restored.is_some());
398
399 let tcb = restored.unwrap();
400 assert_eq!(tcb.id.as_str(), "test-task");
401 assert_eq!(tcb.state, original.state);
402 assert_eq!(tcb.budget.turns, original.budget.turns);
403 }
404
405 #[test]
406 fn tcb_restore_with_wait_reason() {
407 let mut tcb = Tcb::root("waiting-task", SchedulerBudget::default());
408 tcb.wait = Some(crate::scheduler::tcb::WaitReason::SubAgentJoin(
409 vec!["child-1".into(), "child-2".into()]
410 ));
411
412 let snap = TcbSnapshot::from(&tcb);
413 let kernel_snap = KernelSnapshot {
414 turn: 1,
415 total_tokens: 100,
416 tasks: vec![snap.clone()],
417 context: ContextSnapshot::default(),
418 run_spec: None,
419 };
420
421 let restored = kernel_snap.restore_tcb(&snap).expect("restore should succeed");
422 match restored.wait {
423 Some(crate::scheduler::tcb::WaitReason::SubAgentJoin(children)) => {
424 assert_eq!(children.len(), 2);
425 assert_eq!(children[0].as_str(), "child-1");
426 assert_eq!(children[1].as_str(), "child-2");
427 }
428 other => panic!("Expected SubAgentJoin, got {:?}", other),
429 }
430 }
431
432 #[test]
433 fn kernel_snapshot_to_os_process_records() {
434 let snap = KernelSnapshot {
436 turn: 5,
437 total_tokens: 1000,
438 tasks: vec![
439 TcbSnapshot {
441 id: "root".into(),
442 parent: None,
443 state: TaskState::Running,
444 turns: 5,
445 total_tokens: 500,
446 started_at_ms: Some(0),
447 max_tokens: 128_000,
448 max_turns: 100,
449 max_total_tokens: 1_000_000,
450 max_wall_ms: None,
451 wait_reason: None,
452 wait_children: None,
453 deferred_until: None,
454 caps: vec![],
455 proc: None,
456 },
457 TcbSnapshot {
459 id: "child-1".into(),
460 parent: Some("root".into()),
461 state: TaskState::Done(crate::types::result::TerminationReason::Completed),
462 turns: 3,
463 total_tokens: 300,
464 started_at_ms: Some(100),
465 max_tokens: 64_000,
466 max_turns: 50,
467 max_total_tokens: 500_000,
468 max_wall_ms: None,
469 wait_reason: None,
470 wait_children: None,
471 deferred_until: None,
472 caps: vec![],
473 proc: Some(ProcInfoSnapshot {
474 parent_session_id: "root".into(),
475 role: AgentRole::Implement,
476 isolation: AgentIsolation::Shared,
477 context_inheritance: ContextInheritance::None,
478 result: None,
479 }),
480 },
481 ],
482 context: ContextSnapshot::default(),
483 run_spec: None,
484 };
485
486 let records = snap.to_os_process_records();
487 assert_eq!(records.len(), 1); assert_eq!(records[0].agent_id, "child-1");
489 assert_eq!(records[0].parent_session_id, "root");
490 assert_eq!(records[0].turn, 3);
491 assert_eq!(records[0].state, "done"); }
493
494 #[test]
495 fn kernel_snapshot_to_os_records_matches_state_machine() {
496 use crate::scheduler::state_machine::LoopStateMachine;
497 use crate::scheduler::policy::LoopPolicy;
498 use crate::types::agent::{AgentIdentity, AgentRole, AgentRunSpec};
499
500 let mut sm = LoopStateMachine::new(LoopPolicy {
502 max_tokens: 128_000,
503 ..Default::default()
504 });
505 sm.start(crate::types::task::RuntimeTask::new("parent task"));
506
507 let _ = sm.spawn_sub_agent(
509 AgentRunSpec::new(
510 AgentIdentity::sub_agent("child", "child-session"),
511 AgentRole::Implement,
512 "child task",
513 ),
514 "parent-sess",
515 );
516
517 let snap = sm.snapshot();
519 let records = snap.to_os_process_records();
520
521 assert_eq!(records.len(), 1);
523 assert_eq!(records[0].agent_id, "child");
524 assert_eq!(records[0].parent_session_id, "parent-sess");
525 assert!(
527 records[0].state == "running" || records[0].state == "suspended",
528 "unexpected state: {}",
529 records[0].state
530 );
531 }
532
533 #[test]
534 fn pre_k1_snapshot_without_entries_meta_still_loads() {
535 let json = serde_json::json!({
538 "system_messages": [],
539 "knowledge_messages": [
540 { "role": "system", "content": "legacy knowledge" }
541 ],
542 "task_goal": "g",
543 "task_plan": null,
544 "task_progress": "p",
545 "task_open_steps": [],
546 "history_messages": [],
547 "signals": [],
548 "max_tokens": 1000,
549 "sprint": 0
550 });
551 let snap: ContextSnapshot = serde_json::from_value(json).expect("old snapshot loads");
552 assert!(snap.knowledge_entries_meta.is_empty());
553
554 let kernel_snap = KernelSnapshot {
555 turn: 0,
556 total_tokens: 0,
557 tasks: vec![],
558 context: snap,
559 run_spec: None,
560 };
561 let sm = crate::scheduler::state_machine::LoopStateMachine::restore(&kernel_snap);
562 assert_eq!(sm.ctx.partitions.knowledge.len(), 1);
563 let entry = &sm.ctx.partitions.knowledge.entries[0];
564 assert!(entry.key.is_none());
565 assert!(!entry.pinned);
566 }
567
568 #[test]
569 fn keyed_pinned_knowledge_round_trips_through_snapshot() {
570 let mut ctx = crate::context::manager::ContextManager::new(1000);
572 ctx.push_knowledge_entry(Some("ref".into()), Message::system("keyed doc"), 5, true);
573 ctx.push_knowledge(Message::system("legacy"), 3);
574
575 let context_snap = ContextSnapshot::from_context(&ctx);
576 assert_eq!(context_snap.knowledge_entries_meta.len(), 2);
577
578 let kernel_snap = KernelSnapshot {
579 turn: 0,
580 total_tokens: 0,
581 tasks: vec![],
582 context: context_snap,
583 run_spec: None,
584 };
585 let sm = crate::scheduler::state_machine::LoopStateMachine::restore(&kernel_snap);
586 let entries = &sm.ctx.partitions.knowledge.entries;
587 assert_eq!(entries.len(), 2);
588 assert_eq!(entries[0].key.as_deref(), Some("ref"));
589 assert!(entries[0].pinned);
590 assert!(entries[1].key.is_none());
591 assert!(!entries[1].pinned);
592 }
593}