Skip to main content

bamboo_engine/runtime/execution/
child_completion.rs

1//! Child-session completion notification primitives.
2//!
3//! The engine owns child runner lifecycle, but parent resume policy lives in the
4//! server/application layer.  This module defines the small callback boundary
5//! between them so child completion is event-driven without making the engine
6//! depend on `AppState`.
7
8use 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/// A seal stored by the final runtime writer in the existing Session commit.
19/// It is not a second outcome store: later input/output makes it unavailable.
20#[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/// Keep the existing source key (and its merge protection) while withholding
45/// the seal until the separate Host receipt proof has been committed. A plain
46/// source reader rejects this envelope, including after a process restart.
47#[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    /// Stage the source in the same snapshot as the terminal save. Callers may
99    /// publish it ONLY after save success and `from_committed_session` validates
100    /// the lock-reconciled snapshot. Never stamp a reloaded successor snapshot.
101    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            // Malformed binding is not the same as a valid legacy absence.
119            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    /// This only observes a successful caller-owned canonical save, or a later
162    /// durable snapshot. Reading a seal does not certify a separate activation.
163    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    /// Withhold a prepared terminal seal during the transcript/proof gap.
173    /// Non-terminal handoffs have no source and need no deferred publication.
174    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    /// Unwrap only the exact staged activation after its Host proof is known
193    /// committed. The caller must checkpoint this mutation before publishing
194    /// completion or ACKing delivery. Already-published legacy sources are inert.
195    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            // A historical published source can legitimately stop matching
208            // after a successor starts. It requires no publication mutation.
209            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    /// A successor must retire an older pending publication before dispatch.
232    /// Unknown/malformed records stay fail-closed rather than being erased.
233    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
333// Option<Option<T>> has no standard transpose; keep serialization failures
334// distinct from the legitimate absence of a source field.
335trait 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/// Terminal child-session completion recorded by the child runner lifecycle.
348#[derive(Debug, Clone)]
349pub struct ChildCompletion {
350    pub parent_session_id: String,
351    pub child_session_id: String,
352    /// One of: completed | cancelled | error | skipped | timeout.
353    pub status: String,
354    pub error: Option<String>,
355    pub completed_at: DateTime<Utc>,
356    /// None for synthetic/early failures or an unsuccessful terminal save.
357    pub source: Option<ChildCompletionSource>,
358}
359
360/// Application-layer callback invoked when a child session reaches a terminal
361/// state. Implementations must be idempotent: duplicate completion events for
362/// the same child can occur when watchdog timeout races normal runner teardown.
363#[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}