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.id == self.child_session_id
263 && session.created_at == self.child_created_at
264 && session.parent_session_id.as_deref() == Some(self.parent_session_id.as_str())
265 && session.last_run_status().as_deref() == Some(self.status.as_str())
266 && session.last_run_error().map(digest).transpose_option()
267 == Some(self.error_sha256.clone())
268 && binding.map(|b| b.assignment_sha256) == self.assignment_sha256
269 && source_project(session) == Some(self.project_id.clone())
270 && crate::runtime::runner::state_bridge::read_runtime_state(session).map(|s| s.run_id)
271 == self.runtime_run_id
272 && digest(
273 session
274 .messages
275 .iter()
276 .filter(|m| m.role == Role::User)
277 .collect::<Vec<_>>(),
278 )
279 .as_deref()
280 == Some(self.input_sha256.as_str())
281 && last_assistant(session)
282 .map(|(_, m)| message_source(m))
283 .transpose_option()
284 == Some(self.last_assistant.clone())
285 && self.result_message_id.as_ref().is_none_or(|id| {
286 last_assistant(session).is_some_and(|(index, m)| {
287 &m.id == id
288 && current_final(session, index, m)
289 && session.messages.iter().filter(|m| &m.id == id).count() == 1
290 })
291 })
292 }
293
294 pub(crate) fn matches_completion(&self, completion: &ChildCompletion) -> bool {
295 self.child_session_id == completion.child_session_id
296 && self.parent_session_id == completion.parent_session_id
297 && self.status == completion.status
298 && completion.error.clone().map(digest).transpose_option()
299 == Some(self.error_sha256.clone())
300 }
301
302 pub(crate) fn matches_parent(&self, child: &Session, parent: &Session) -> bool {
303 if child.parent_session_id.as_deref() != Some(parent.id.as_str()) {
304 return false;
305 }
306 match ChildContextBinding::from_session(child) {
307 Ok(None) => true,
308 Ok(Some(binding)) => {
309 binding.validate_parent_sources(parent).is_ok()
310 && source_project(child) == source_project(parent)
311 && child.root_session_id == parent.root_session_id
312 && parent.spawn_depth.checked_add(1) == Some(child.spawn_depth)
313 }
314 Err(_) => false,
315 }
316 }
317
318 pub(crate) fn result(&self, child: &Session, parent: &Session) -> Option<String> {
319 if Self::from_committed_session(child).as_ref() != Some(self)
320 || !self.matches_parent(child, parent)
321 {
322 return None;
323 }
324 let id = self.result_message_id.as_ref()?;
325 child
326 .messages
327 .iter()
328 .find(|m| &m.id == id)
329 .map(|m| m.content.clone())
330 }
331}
332
333trait TransposeOption<T> {
336 fn transpose_option(self) -> Option<Option<T>>;
337}
338impl<T> TransposeOption<T> for Option<Option<T>> {
339 fn transpose_option(self) -> Option<Option<T>> {
340 match self {
341 Some(value) => value.map(Some),
342 None => Some(None),
343 }
344 }
345}
346
347#[derive(Debug, Clone)]
349pub struct ChildCompletion {
350 pub parent_session_id: String,
351 pub child_session_id: String,
352 pub status: String,
354 pub error: Option<String>,
355 pub completed_at: DateTime<Utc>,
356 pub source: Option<ChildCompletionSource>,
358}
359
360#[async_trait]
364pub trait ChildCompletionHandler: Send + Sync {
365 async fn on_child_completed(&self, completion: ChildCompletion);
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 fn final_child() -> Session {
373 let mut child = Session::new_child("child", "parent", "model", "Child");
374 child.add_message(Message::user("assignment A"));
375 child.add_message(Message::assistant("answer A", None));
376 child.set_last_run_status("completed");
377 child
378 }
379
380 #[test]
381 fn seal_rejects_failed_save_and_overwrites_worker_supplied_provenance() {
382 let mut child = final_child();
383 child.metadata.insert(
384 TERMINAL_SOURCE_KEY.into(),
385 "{\"activation_run_id\":\"forged\"}".into(),
386 );
387 ChildCompletionSource::prepare(&mut child, "host-run-A", &HashSet::new());
388 assert!(ChildCompletionSource::after_final_save(&child, false).is_none());
389 let source = ChildCompletionSource::after_final_save(&child, true).unwrap();
390 assert_eq!(source.activation_run_id, "host-run-A");
391 assert_eq!(
392 source
393 .result(&child, &Session::new("parent", "model"))
394 .as_deref(),
395 Some("answer A")
396 );
397 }
398
399 #[test]
400 fn seal_never_imports_an_older_answer_when_run_produced_none() {
401 let mut child = final_child();
402 let prior = child.messages.iter().map(|m| m.id.clone()).collect();
403 child.set_last_run_status("error");
404 child.set_last_run_error("provider failed before output");
405 ChildCompletionSource::prepare(&mut child, "host-run-B", &prior);
406 let source = ChildCompletionSource::after_final_save(&child, true).unwrap();
407 assert!(source.result_message_id.is_none());
408 assert!(source
409 .result(&child, &Session::new("parent", "model"))
410 .is_none());
411 }
412
413 #[test]
414 fn pending_delivery_source_survives_restart_without_publishing_success() {
415 let mut child = final_child();
416 ChildCompletionSource::prepare(&mut child, "host-run-A", &HashSet::new());
417 assert!(
418 ChildCompletionSource::defer_for_durable_delivery(&mut child, "host-run-A").unwrap()
419 );
420 child.set_last_run_status("running");
421 let mut cold: Session =
422 serde_json::from_slice(&serde_json::to_vec(&child).unwrap()).unwrap();
423 assert!(ChildCompletionSource::has_source_record(&cold));
424 assert!(ChildCompletionSource::from_committed_session(&cold).is_none());
425 assert!(
426 ChildCompletionSource::publish_after_durable_delivery(&mut cold, "host-run-A").is_err()
427 );
428
429 cold.set_last_run_status("completed");
430 assert!(
431 ChildCompletionSource::publish_after_durable_delivery(&mut cold, "other-run").is_err()
432 );
433 assert!(ChildCompletionSource::from_committed_session(&cold).is_none());
434 assert!(
435 ChildCompletionSource::publish_after_durable_delivery(&mut cold, "host-run-A").unwrap()
436 );
437 assert_eq!(
438 ChildCompletionSource::from_committed_session(&cold)
439 .unwrap()
440 .activation_run_id,
441 "host-run-A"
442 );
443 }
444
445 #[test]
446 fn pending_delivery_source_rejects_changed_snapshot_and_prepare_replaces_it() {
447 let mut child = final_child();
448 ChildCompletionSource::prepare(&mut child, "host-run-A", &HashSet::new());
449 ChildCompletionSource::defer_for_durable_delivery(&mut child, "host-run-A").unwrap();
450 child.messages[1].content.push_str("changed");
451 assert!(
452 ChildCompletionSource::publish_after_durable_delivery(&mut child, "host-run-A")
453 .is_err()
454 );
455 assert!(ChildCompletionSource::from_committed_session(&child).is_none());
456
457 ChildCompletionSource::prepare(&mut child, "host-run-B", &HashSet::new());
458 assert_eq!(
459 ChildCompletionSource::from_committed_session(&child)
460 .unwrap()
461 .activation_run_id,
462 "host-run-B"
463 );
464 }
465
466 #[test]
467 fn published_historical_source_is_untouched_during_successor_recovery() {
468 let mut child = final_child();
469 ChildCompletionSource::prepare(&mut child, "host-run-A", &HashSet::new());
470 let source = child.metadata.get(TERMINAL_SOURCE_KEY).cloned();
471 child.set_last_run_status("running");
472 child.add_message(Message::user("successor task"));
473 child.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState::new("host-run-B"));
474 assert!(
475 !ChildCompletionSource::publish_after_durable_delivery(&mut child, "host-run-A")
476 .unwrap()
477 );
478 assert_eq!(child.metadata.get(TERMINAL_SOURCE_KEY), source.as_ref());
479 assert_eq!(child.last_run_status().as_deref(), Some("running"));
480 }
481
482 #[test]
483 fn successor_retires_only_another_well_formed_pending_delivery() {
484 let mut child = final_child();
485 ChildCompletionSource::prepare(&mut child, "host-run-A", &HashSet::new());
486 ChildCompletionSource::defer_for_durable_delivery(&mut child, "host-run-A").unwrap();
487 assert!(
488 !ChildCompletionSource::retire_pending_delivery_for_successor(&mut child, "host-run-A")
489 );
490 assert!(ChildCompletionSource::has_source_record(&child));
491 assert!(
492 ChildCompletionSource::retire_pending_delivery_for_successor(&mut child, "host-run-B")
493 );
494 assert!(!ChildCompletionSource::has_source_record(&child));
495 child.metadata.insert(
496 TERMINAL_SOURCE_KEY.into(),
497 "{\"pending_confirmation\":{}}".into(),
498 );
499 assert!(
500 !ChildCompletionSource::retire_pending_delivery_for_successor(&mut child, "host-run-B")
501 );
502 assert!(ChildCompletionSource::has_source_record(&child));
503 assert!(ChildCompletionSource::from_committed_session(&child).is_none());
504 }
505
506 #[test]
507 fn seal_rejects_birth_input_result_and_binding_mutations() {
508 let mut child = final_child();
509 ChildCompletionSource::prepare(&mut child, "host-run-A", &HashSet::new());
510 for mutation in 0..6 {
511 let mut changed = child.clone();
512 match mutation {
513 0 => changed.created_at += chrono::Duration::seconds(1),
514 1 => changed.messages[0].content.push_str("changed"),
515 2 => changed.messages[1].content.push_str("changed"),
516 3 => {
517 changed.messages.pop();
518 }
519 4 => changed.messages.push(changed.messages[1].clone()),
520 _ => {
521 changed
522 .metadata
523 .insert(bamboo_domain::CHILD_PACKET_REQUIRED_KEY.into(), "v1".into());
524 }
525 }
526 assert!(
527 ChildCompletionSource::from_committed_session(&changed).is_none(),
528 "mutation {mutation}"
529 );
530 }
531 child
532 .metadata
533 .insert(bamboo_domain::CHILD_PACKET_REQUIRED_KEY.into(), "v1".into());
534 ChildCompletionSource::prepare(&mut child, "host-run-B", &HashSet::new());
535 assert!(
536 !child.metadata.contains_key(TERMINAL_SOURCE_KEY),
537 "invalid binding cannot retain an old seal"
538 );
539 }
540}