1use async_trait::async_trait;
9use bamboo_agent_core::{Message, Role, Session, SessionKind};
10use bamboo_domain::{ChildContextBinding, MessagePhase};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::collections::HashSet;
15
16const TERMINAL_SOURCE_KEY: &str = "runtime.child_completion_source_v1";
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct ChildCompletionSource {
23 pub activation_run_id: String,
24 pub child_session_id: String,
25 pub child_created_at: DateTime<Utc>,
26 pub parent_session_id: String,
27 pub assignment_sha256: Option<String>,
28 pub project_id: Option<String>,
29 pub runtime_run_id: Option<String>,
30 pub input_sha256: String,
31 pub status: String,
32 pub error_sha256: Option<String>,
33 pub last_assistant: Option<ChildCompletionMessageSource>,
34 pub result_message_id: Option<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct ChildCompletionMessageSource {
40 pub message_id: String,
41 pub message_sha256: String,
42}
43
44#[derive(Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49struct PendingDeliverySource {
50 pending_confirmation: ChildCompletionSource,
51}
52
53fn digest(value: impl Serialize) -> Option<String> {
54 Some(hex::encode(Sha256::digest(
55 serde_json::to_vec(&value).ok()?,
56 )))
57}
58
59fn source_project(session: &Session) -> Option<Option<String>> {
60 use crate::project_context::{ProjectContextResolver, SessionProjectIdentity};
61 match ProjectContextResolver::session_project_identity(session) {
62 SessionProjectIdentity::Unassigned => Some(None),
63 SessionProjectIdentity::Assigned(id) => Some(Some(id.to_string())),
64 SessionProjectIdentity::Invalid { .. } => None,
65 }
66}
67
68fn last_assistant(session: &Session) -> Option<(usize, &Message)> {
69 session
70 .messages
71 .iter()
72 .enumerate()
73 .rev()
74 .find(|(_, m)| m.role == Role::Assistant)
75}
76
77fn message_source(message: &Message) -> Option<ChildCompletionMessageSource> {
78 Some(ChildCompletionMessageSource {
79 message_id: message.id.clone(),
80 message_sha256: digest(message)?,
81 })
82}
83
84fn current_final(session: &Session, index: usize, message: &Message) -> bool {
85 !message.id.is_empty()
86 && !message.content.trim().is_empty()
87 && message.phase != Some(MessagePhase::Commentary)
88 && message.tool_calls.as_ref().is_none_or(Vec::is_empty)
89 && !message.compressed
90 && message.compressed_by_event_id.is_none()
91 && message.compression_level == 0
92 && !session.messages[index + 1..]
93 .iter()
94 .any(|m| m.role == Role::User)
95}
96
97impl ChildCompletionSource {
98 pub(crate) fn prepare(
102 session: &mut Session,
103 activation_run_id: &str,
104 prior_message_ids: &HashSet<String>,
105 ) {
106 session.metadata.remove(TERMINAL_SOURCE_KEY);
107 let source = (|| {
108 let status = session.last_run_status()?;
109 if session.kind != SessionKind::Child
110 || activation_run_id.is_empty()
111 || !matches!(
112 status.as_str(),
113 "completed" | "error" | "cancelled" | "timeout" | "skipped"
114 )
115 {
116 return None;
117 }
118 let binding = ChildContextBinding::from_session(session).ok()?;
120 let assistant = last_assistant(session);
121 let result_message_id = assistant
122 .filter(|(index, message)| {
123 !prior_message_ids.contains(&message.id)
124 && current_final(session, *index, message)
125 })
126 .map(|(_, message)| message.id.clone());
127 Some(Self {
128 activation_run_id: activation_run_id.to_string(),
129 child_session_id: session.id.clone(),
130 child_created_at: session.created_at,
131 parent_session_id: session.parent_session_id.clone()?,
132 assignment_sha256: binding.map(|b| b.assignment_sha256),
133 project_id: source_project(session)?,
134 runtime_run_id: crate::runtime::runner::state_bridge::read_runtime_state(session)
135 .map(|state| state.run_id),
136 input_sha256: digest(
137 session
138 .messages
139 .iter()
140 .filter(|m| m.role == Role::User)
141 .collect::<Vec<_>>(),
142 )?,
143 status,
144 error_sha256: session.last_run_error().map(digest).transpose_option()?,
145 last_assistant: assistant
146 .map(|(_, m)| message_source(m))
147 .transpose_option()?,
148 result_message_id,
149 })
150 })();
151 if let Some(source) = source
152 .and_then(|s| serde_json::to_string(&s).ok())
153 .filter(|s| s.len() <= 4096)
154 {
155 session
156 .metadata
157 .insert(TERMINAL_SOURCE_KEY.to_string(), source);
158 }
159 }
160
161 pub(crate) fn from_committed_session(session: &Session) -> Option<Self> {
164 let raw = session.metadata.get(TERMINAL_SOURCE_KEY)?;
165 if raw.len() > 4096 {
166 return None;
167 }
168 let source: Self = serde_json::from_str(raw).ok()?;
169 source.matches_snapshot(session).then_some(source)
170 }
171
172 pub(crate) fn defer_for_durable_delivery(
175 session: &mut Session,
176 activation_run_id: &str,
177 ) -> Result<bool, String> {
178 if !Self::has_source_record(session) {
179 return Ok(false);
180 }
181 let source = Self::from_committed_session(session)
182 .filter(|source| source.activation_run_id == activation_run_id)
183 .ok_or_else(|| "Child delivery source does not match the final snapshot".to_string())?;
184 let pending = serde_json::to_string(&PendingDeliverySource {
185 pending_confirmation: source,
186 })
187 .map_err(|error| error.to_string())?;
188 session.metadata.insert(TERMINAL_SOURCE_KEY.into(), pending);
189 Ok(true)
190 }
191
192 pub(crate) fn publish_after_durable_delivery(
196 session: &mut Session,
197 activation_run_id: &str,
198 ) -> Result<bool, String> {
199 let Some(raw) = session.metadata.get(TERMINAL_SOURCE_KEY) else {
200 return Ok(false);
201 };
202 if raw.len() > 8192 {
203 return Err("Pending Child delivery source exceeds its size bound".into());
204 }
205 let pending = match serde_json::from_str::<PendingDeliverySource>(raw) {
206 Ok(pending) => pending,
207 Err(_) => {
210 return serde_json::from_str::<Self>(raw)
211 .map(|_| false)
212 .map_err(|_| "Pending Child delivery source is malformed".to_string());
213 }
214 };
215 let source = pending.pending_confirmation;
216 if source.activation_run_id != activation_run_id || !source.matches_snapshot(session) {
217 return Err("Pending Child delivery source does not match this activation".into());
218 }
219 let raw = serde_json::to_string(&source).map_err(|error| error.to_string())?;
220 if raw.len() > 4096 {
221 return Err("Child delivery source exceeds its size bound".into());
222 }
223 session.metadata.insert(TERMINAL_SOURCE_KEY.into(), raw);
224 Ok(true)
225 }
226
227 pub(crate) fn has_source_record(session: &Session) -> bool {
228 session.metadata.contains_key(TERMINAL_SOURCE_KEY)
229 }
230
231 pub(crate) fn retire_pending_delivery_for_successor(
234 session: &mut Session,
235 activation_run_id: &str,
236 ) -> bool {
237 let retire = session
238 .metadata
239 .get(TERMINAL_SOURCE_KEY)
240 .filter(|raw| raw.len() <= 8192)
241 .and_then(|raw| serde_json::from_str::<PendingDeliverySource>(raw).ok())
242 .is_some_and(|pending| {
243 pending.pending_confirmation.activation_run_id != activation_run_id
244 });
245 if retire {
246 session.metadata.remove(TERMINAL_SOURCE_KEY);
247 }
248 retire
249 }
250
251 pub(crate) fn after_final_save(session: &Session, saved: bool) -> Option<Self> {
252 saved
253 .then(|| Self::from_committed_session(session))
254 .flatten()
255 }
256
257 fn matches_snapshot(&self, session: &Session) -> bool {
258 let Some(binding) = ChildContextBinding::from_session(session).ok() else {
259 return false;
260 };
261 session.kind == SessionKind::Child
262 && session.host_run_matches_launch()
263 && session
264 .host_run_id()
265 .is_none_or(|run| run == self.activation_run_id)
266 && session.id == self.child_session_id
267 && session.created_at == self.child_created_at
268 && session.parent_session_id.as_deref() == Some(self.parent_session_id.as_str())
269 && session.last_run_status().as_deref() == Some(self.status.as_str())
270 && session.last_run_error().map(digest).transpose_option()
271 == Some(self.error_sha256.clone())
272 && binding.map(|b| b.assignment_sha256) == self.assignment_sha256
273 && source_project(session) == Some(self.project_id.clone())
274 && crate::runtime::runner::state_bridge::read_runtime_state(session).map(|s| s.run_id)
275 == self.runtime_run_id
276 && digest(
277 session
278 .messages
279 .iter()
280 .filter(|m| m.role == Role::User)
281 .collect::<Vec<_>>(),
282 )
283 .as_deref()
284 == Some(self.input_sha256.as_str())
285 && last_assistant(session)
286 .map(|(_, m)| message_source(m))
287 .transpose_option()
288 == Some(self.last_assistant.clone())
289 && self.result_message_id.as_ref().is_none_or(|id| {
290 last_assistant(session).is_some_and(|(index, m)| {
291 &m.id == id
292 && current_final(session, index, m)
293 && session.messages.iter().filter(|m| &m.id == id).count() == 1
294 })
295 })
296 }
297
298 pub(crate) fn matches_completion(&self, completion: &ChildCompletion) -> bool {
299 self.child_session_id == completion.child_session_id
300 && self.parent_session_id == completion.parent_session_id
301 && self.status == completion.status
302 && completion.error.clone().map(digest).transpose_option()
303 == Some(self.error_sha256.clone())
304 }
305
306 pub(crate) fn matches_parent(&self, child: &Session, parent: &Session) -> bool {
307 if child.parent_session_id.as_deref() != Some(parent.id.as_str()) {
308 return false;
309 }
310 match ChildContextBinding::from_session(child) {
311 Ok(None) => true,
312 Ok(Some(binding)) => {
313 binding.validate_parent_sources(parent).is_ok()
314 && source_project(child) == source_project(parent)
315 && child.root_session_id == parent.root_session_id
316 && parent.spawn_depth.checked_add(1) == Some(child.spawn_depth)
317 }
318 Err(_) => false,
319 }
320 }
321
322 pub(crate) fn result(&self, child: &Session, parent: &Session) -> Option<String> {
323 if Self::from_committed_session(child).as_ref() != Some(self)
324 || !self.matches_parent(child, parent)
325 {
326 return None;
327 }
328 let id = self.result_message_id.as_ref()?;
329 child
330 .messages
331 .iter()
332 .find(|m| &m.id == id)
333 .map(|m| m.content.clone())
334 }
335}
336
337trait TransposeOption<T> {
340 fn transpose_option(self) -> Option<Option<T>>;
341}
342impl<T> TransposeOption<T> for Option<Option<T>> {
343 fn transpose_option(self) -> Option<Option<T>> {
344 match self {
345 Some(value) => value.map(Some),
346 None => Some(None),
347 }
348 }
349}
350
351#[derive(Debug, Clone)]
353pub struct ChildCompletion {
354 pub parent_session_id: String,
355 pub child_session_id: String,
356 pub status: String,
358 pub error: Option<String>,
359 pub completed_at: DateTime<Utc>,
360 pub source: Option<ChildCompletionSource>,
362}
363
364#[async_trait]
368pub trait ChildCompletionHandler: Send + Sync {
369 async fn on_child_completed(&self, completion: ChildCompletion);
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 fn final_child() -> Session {
377 let mut child = Session::new_child("child", "parent", "model", "Child");
378 child.add_message(Message::user("assignment A"));
379 child.add_message(Message::assistant("answer A", None));
380 child.set_last_run_status("completed");
381 child
382 }
383
384 #[test]
385 fn seal_rejects_different_host_uuid_and_newer_cancelled_launch_with_same_outcome() {
386 let mut child = final_child();
387 child.set_host_run_id("host-A");
388 child.set_last_run_status("cancelled");
389 child.synchronize_terminal_runtime_status();
390 ChildCompletionSource::prepare(&mut child, "host-A", &HashSet::new());
391 assert!(ChildCompletionSource::from_committed_session(&child).is_some());
392 let mut successor = child.clone();
393 successor.set_host_run_id("host-B");
394 successor.set_last_run_status("cancelled");
395 successor.synchronize_terminal_runtime_status();
396 assert!(ChildCompletionSource::from_committed_session(&successor).is_none());
397 child.advance_child_launch_generation().unwrap();
398 child.cancel_child_launch_generation();
399 assert!(ChildCompletionSource::from_committed_session(&child).is_none());
400 }
401
402 #[test]
403 fn seal_rejects_failed_save_and_overwrites_worker_supplied_provenance() {
404 let mut child = final_child();
405 child.metadata.insert(
406 TERMINAL_SOURCE_KEY.into(),
407 "{\"activation_run_id\":\"forged\"}".into(),
408 );
409 ChildCompletionSource::prepare(&mut child, "host-run-A", &HashSet::new());
410 assert!(ChildCompletionSource::after_final_save(&child, false).is_none());
411 let source = ChildCompletionSource::after_final_save(&child, true).unwrap();
412 assert_eq!(source.activation_run_id, "host-run-A");
413 assert_eq!(
414 source
415 .result(&child, &Session::new("parent", "model"))
416 .as_deref(),
417 Some("answer A")
418 );
419 }
420
421 #[test]
422 fn seal_never_imports_an_older_answer_when_run_produced_none() {
423 let mut child = final_child();
424 let prior = child.messages.iter().map(|m| m.id.clone()).collect();
425 child.set_last_run_status("error");
426 child.set_last_run_error("provider failed before output");
427 ChildCompletionSource::prepare(&mut child, "host-run-B", &prior);
428 let source = ChildCompletionSource::after_final_save(&child, true).unwrap();
429 assert!(source.result_message_id.is_none());
430 assert!(source
431 .result(&child, &Session::new("parent", "model"))
432 .is_none());
433 }
434
435 #[test]
436 fn pending_delivery_source_survives_restart_without_publishing_success() {
437 let mut child = final_child();
438 ChildCompletionSource::prepare(&mut child, "host-run-A", &HashSet::new());
439 assert!(
440 ChildCompletionSource::defer_for_durable_delivery(&mut child, "host-run-A").unwrap()
441 );
442 child.set_last_run_status("running");
443 let mut cold: Session =
444 serde_json::from_slice(&serde_json::to_vec(&child).unwrap()).unwrap();
445 assert!(ChildCompletionSource::has_source_record(&cold));
446 assert!(ChildCompletionSource::from_committed_session(&cold).is_none());
447 assert!(
448 ChildCompletionSource::publish_after_durable_delivery(&mut cold, "host-run-A").is_err()
449 );
450
451 cold.set_last_run_status("completed");
452 assert!(
453 ChildCompletionSource::publish_after_durable_delivery(&mut cold, "other-run").is_err()
454 );
455 assert!(ChildCompletionSource::from_committed_session(&cold).is_none());
456 assert!(
457 ChildCompletionSource::publish_after_durable_delivery(&mut cold, "host-run-A").unwrap()
458 );
459 assert_eq!(
460 ChildCompletionSource::from_committed_session(&cold)
461 .unwrap()
462 .activation_run_id,
463 "host-run-A"
464 );
465 }
466
467 #[test]
468 fn pending_delivery_source_rejects_changed_snapshot_and_prepare_replaces_it() {
469 let mut child = final_child();
470 ChildCompletionSource::prepare(&mut child, "host-run-A", &HashSet::new());
471 ChildCompletionSource::defer_for_durable_delivery(&mut child, "host-run-A").unwrap();
472 child.messages[1].content.push_str("changed");
473 assert!(
474 ChildCompletionSource::publish_after_durable_delivery(&mut child, "host-run-A")
475 .is_err()
476 );
477 assert!(ChildCompletionSource::from_committed_session(&child).is_none());
478
479 ChildCompletionSource::prepare(&mut child, "host-run-B", &HashSet::new());
480 assert_eq!(
481 ChildCompletionSource::from_committed_session(&child)
482 .unwrap()
483 .activation_run_id,
484 "host-run-B"
485 );
486 }
487
488 #[test]
489 fn published_historical_source_is_untouched_during_successor_recovery() {
490 let mut child = final_child();
491 ChildCompletionSource::prepare(&mut child, "host-run-A", &HashSet::new());
492 let source = child.metadata.get(TERMINAL_SOURCE_KEY).cloned();
493 child.set_last_run_status("running");
494 child.add_message(Message::user("successor task"));
495 child.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState::new("host-run-B"));
496 assert!(
497 !ChildCompletionSource::publish_after_durable_delivery(&mut child, "host-run-A")
498 .unwrap()
499 );
500 assert_eq!(child.metadata.get(TERMINAL_SOURCE_KEY), source.as_ref());
501 assert_eq!(child.last_run_status().as_deref(), Some("running"));
502 }
503
504 #[test]
505 fn successor_retires_only_another_well_formed_pending_delivery() {
506 let mut child = final_child();
507 ChildCompletionSource::prepare(&mut child, "host-run-A", &HashSet::new());
508 ChildCompletionSource::defer_for_durable_delivery(&mut child, "host-run-A").unwrap();
509 assert!(
510 !ChildCompletionSource::retire_pending_delivery_for_successor(&mut child, "host-run-A")
511 );
512 assert!(ChildCompletionSource::has_source_record(&child));
513 assert!(
514 ChildCompletionSource::retire_pending_delivery_for_successor(&mut child, "host-run-B")
515 );
516 assert!(!ChildCompletionSource::has_source_record(&child));
517 child.metadata.insert(
518 TERMINAL_SOURCE_KEY.into(),
519 "{\"pending_confirmation\":{}}".into(),
520 );
521 assert!(
522 !ChildCompletionSource::retire_pending_delivery_for_successor(&mut child, "host-run-B")
523 );
524 assert!(ChildCompletionSource::has_source_record(&child));
525 assert!(ChildCompletionSource::from_committed_session(&child).is_none());
526 }
527
528 #[test]
529 fn seal_rejects_birth_input_result_and_binding_mutations() {
530 let mut child = final_child();
531 ChildCompletionSource::prepare(&mut child, "host-run-A", &HashSet::new());
532 for mutation in 0..6 {
533 let mut changed = child.clone();
534 match mutation {
535 0 => changed.created_at += chrono::Duration::seconds(1),
536 1 => changed.messages[0].content.push_str("changed"),
537 2 => changed.messages[1].content.push_str("changed"),
538 3 => {
539 changed.messages.pop();
540 }
541 4 => changed.messages.push(changed.messages[1].clone()),
542 _ => {
543 changed
544 .metadata
545 .insert(bamboo_domain::CHILD_PACKET_REQUIRED_KEY.into(), "v1".into());
546 }
547 }
548 assert!(
549 ChildCompletionSource::from_committed_session(&changed).is_none(),
550 "mutation {mutation}"
551 );
552 }
553 child
554 .metadata
555 .insert(bamboo_domain::CHILD_PACKET_REQUIRED_KEY.into(), "v1".into());
556 ChildCompletionSource::prepare(&mut child, "host-run-B", &HashSet::new());
557 assert!(
558 !child.metadata.contains_key(TERMINAL_SOURCE_KEY),
559 "invalid binding cannot retain an old seal"
560 );
561 }
562}