Skip to main content

bamboo_engine/runtime/
counted_loop.rs

1//! Live, bounded repetition inside an existing execution owner (#898).
2//!
3//! This is an engine primitive, not a scheduler or a management API. Each
4//! iteration retains the same Session and provider context. Persisted live
5//! state is inspectable but is never automatically replayed by a new activation.
6
7use bamboo_agent_core::{AgentError, Message, Session};
8use serde::{Deserialize, Serialize};
9use tokio_util::sync::CancellationToken;
10
11use super::config::AgentLoopConfig;
12
13pub const LOOP_STATE_METADATA_KEY: &str = "runtime.counted_loop";
14pub const MAX_LOOP_ITERATIONS: u32 = 100;
15pub const MAX_LOOP_ROUNDS: u32 = 1_000;
16pub const MAX_LOOP_PROMPT_BYTES: usize = 16 * 1024;
17const MAX_STATE_BYTES: usize = MAX_LOOP_PROMPT_BYTES * 8;
18
19/// Host-supplied bounds for one live generation. There is no implicit default.
20/// Clones share the stop signal. Stop drains an already admitted iteration's
21/// current round/tool batch; it admits no subsequent round or iteration.
22#[derive(Clone)]
23pub struct CountedLoopConfig {
24    pub max_iterations: u32,
25    pub max_total_rounds: u32,
26    stop: CancellationToken,
27    owner: Option<CountedLoopOwner>,
28}
29
30#[derive(Clone)]
31struct CountedLoopOwner {
32    run: crate::session_activation::SessionRunOwnership,
33    session_birth: chrono::DateTime<chrono::Utc>,
34}
35
36impl std::fmt::Debug for CountedLoopConfig {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.debug_struct("CountedLoopConfig")
39            .field("max_iterations", &self.max_iterations)
40            .field("max_total_rounds", &self.max_total_rounds)
41            .field("stopped", &self.stop.is_cancelled())
42            .finish_non_exhaustive()
43    }
44}
45
46impl CountedLoopConfig {
47    pub fn new(max_iterations: u32, max_total_rounds: u32) -> Self {
48        Self {
49            max_iterations,
50            max_total_rounds,
51            stop: CancellationToken::new(),
52            owner: None,
53        }
54    }
55
56    /// Bind only the request value after consuming its real execution lease.
57    /// Earlier stop/config clones remain unbound and convey no ownership.
58    pub(crate) fn bind_owner(
59        &mut self,
60        run: crate::session_activation::SessionRunOwnership,
61        session: &Session,
62    ) {
63        self.owner = Some(CountedLoopOwner {
64            run,
65            session_birth: session.created_at,
66        });
67    }
68
69    pub(crate) async fn owns_session(
70        &self,
71        router: Option<&std::sync::Arc<crate::session_activation::SessionActivationRouter>>,
72        session: &Session,
73    ) -> bool {
74        let Some(owner) = self.owner.as_ref() else {
75            return false;
76        };
77        owner.session_birth == session.created_at
78            && router.is_some_and(|router| owner.run.matches_router(router))
79            && owner.run.is_current(&session.id).await
80    }
81
82    /// Idempotent, live-only stop. Ordinary Session cancellation remains
83    /// independent and uses the existing cancellation/error path.
84    pub fn stop(&self) {
85        self.stop.cancel();
86    }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum LoopStatus {
92    Running,
93    Finalizing,
94    Completed,
95    Stopped,
96    Cancelled,
97    BudgetExhausted,
98    Failed,
99    RecoveryUnsupported,
100}
101
102/// Persisted through the existing Session checkpoint, with no separate store.
103/// Objective revision is fixed at one in this slice. Future edits must use the
104/// generation and revision instead of treating a free-text followup as an edit.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct LoopState {
108    pub schema_version: u32,
109    pub generation: String,
110    pub revision: u64,
111    pub objective_revision: u64,
112    pub owner_execution_id: String,
113    pub objective: String,
114    pub max_iterations: u32,
115    pub max_total_rounds: u32,
116    pub iterations_started: u32,
117    pub iterations_completed: u32,
118    /// Conservative admissions, including a round whose preparation later fails.
119    /// Existing provider retries stay within one round and retain their limits.
120    pub rounds_started: u32,
121    pub status: LoopStatus,
122}
123
124fn invalid(reason: &str) -> AgentError {
125    AgentError::Budget(format!("counted loop: {reason}"))
126}
127
128fn validate_bounds(iterations: u32, rounds: u32, objective: &str) -> Result<(), AgentError> {
129    if !(1..=MAX_LOOP_ITERATIONS).contains(&iterations)
130        || !(iterations..=MAX_LOOP_ROUNDS).contains(&rounds)
131        || objective.trim().is_empty()
132        || objective.len() > MAX_LOOP_PROMPT_BYTES
133    {
134        return Err(invalid("invalid iteration, total-round, or prompt bound"));
135    }
136    Ok(())
137}
138
139impl LoopState {
140    fn validate(&self) -> Result<(), AgentError> {
141        validate_bounds(self.max_iterations, self.max_total_rounds, &self.objective)?;
142        if self.schema_version != 1
143            || self.revision == 0
144            || self.revision == u64::MAX
145            || self.objective_revision != 1
146            || uuid::Uuid::parse_str(&self.generation).is_err()
147            || self.owner_execution_id.is_empty()
148            || self.owner_execution_id.len() > 128
149            || self.iterations_started > self.max_iterations
150            || self.iterations_completed > self.iterations_started
151            || self.iterations_started - self.iterations_completed > 1
152            || self.rounds_started > self.max_total_rounds
153            || self.rounds_started < self.iterations_started
154            || (matches!(self.status, LoopStatus::Finalizing | LoopStatus::Completed)
155                && self.iterations_completed != self.max_iterations)
156            || (self.status == LoopStatus::Running
157                && self.iterations_completed == self.max_iterations)
158        {
159            return Err(invalid("malformed or unsupported persisted state"));
160        }
161        Ok(())
162    }
163
164    fn live(&self) -> bool {
165        matches!(self.status, LoopStatus::Running | LoopStatus::Finalizing)
166    }
167}
168
169/// Fallible inspection: malformed state must never look like an absent loop.
170pub fn read_loop_state(session: &Session) -> Result<Option<LoopState>, AgentError> {
171    let Some(raw) = session.metadata.get(LOOP_STATE_METADATA_KEY) else {
172        return Ok(None);
173    };
174    if raw.len() > MAX_STATE_BYTES {
175        return Err(invalid("persisted state exceeds its byte bound"));
176    }
177    let state: LoopState = serde_json::from_str(raw)
178        .map_err(|_| invalid("malformed or unsupported persisted state"))?;
179    state.validate()?;
180    Ok(Some(state))
181}
182
183async fn checkpoint(
184    session: &mut Session,
185    config: &AgentLoopConfig,
186    state: LoopState,
187) -> Result<(), AgentError> {
188    checkpoint_state(session, config.persistence.as_deref(), state).await
189}
190
191async fn checkpoint_state(
192    session: &mut Session,
193    persistence: Option<&dyn bamboo_domain::RuntimeSessionPersistence>,
194    state: LoopState,
195) -> Result<(), AgentError> {
196    stage_state(session, state)?;
197    persist_checkpoint(session, persistence).await
198}
199
200fn stage_state(session: &mut Session, mut state: LoopState) -> Result<String, AgentError> {
201    state.revision = state
202        .revision
203        .checked_add(1)
204        .ok_or_else(|| invalid("state revision exhausted"))?;
205    state.validate()?;
206    let encoded = serde_json::to_string(&state).map_err(|_| invalid("cannot serialize state"))?;
207    session
208        .metadata
209        .insert(LOOP_STATE_METADATA_KEY.into(), encoded.clone());
210    Ok(encoded)
211}
212
213async fn persist_checkpoint(
214    session: &mut Session,
215    persistence: Option<&dyn bamboo_domain::RuntimeSessionPersistence>,
216) -> Result<(), AgentError> {
217    if let Some(persistence) = persistence {
218        persistence
219            .checkpoint_runtime_session(session)
220            .await
221            .map_err(|_| invalid("Session checkpoint failed; execution not admitted"))?;
222    }
223    Ok(())
224}
225
226/// Read-only refusal, also used before outer runtime/adapter finalizers.
227/// A raw caller never gains a lease or write authority by presenting metadata.
228pub(crate) fn reject_recovery(session: &Session) -> Result<(), AgentError> {
229    if read_loop_state(session)?.is_some_and(|state| {
230        state.live()
231            || state.status == LoopStatus::RecoveryUnsupported
232            || has_pending_repeat(session, &state)
233    }) {
234        return Err(invalid(
235            "recovery unsupported; the prior admission is not replayed",
236        ));
237    }
238    Ok(())
239}
240
241// A generated continuation is not a new external admission. Match the exact
242// unfinished iteration; older completed repeats in this generation are history.
243fn has_pending_repeat(session: &Session, state: &LoopState) -> bool {
244    if state.iterations_started == state.iterations_completed {
245        return false;
246    }
247    let counted = |message: &Message| {
248        message
249            .metadata
250            .as_ref()
251            .and_then(|meta| meta.get("runtime_kind"))
252            .and_then(serde_json::Value::as_str)
253            == Some("counted_loop_iteration")
254    };
255    let pending = session.messages.iter().rposition(|message| {
256        message.role == bamboo_agent_core::Role::User
257            && counted(message)
258            && message.metadata.as_ref().is_some_and(|meta| {
259                meta.get("loop_generation")
260                    .and_then(serde_json::Value::as_str)
261                    == Some(state.generation.as_str())
262                    && meta
263                        .get("objective_revision")
264                        .and_then(serde_json::Value::as_u64)
265                        == Some(state.objective_revision)
266                    && meta.get("iteration").and_then(serde_json::Value::as_u64)
267                        == Some(u64::from(state.iterations_started))
268            })
269    });
270    pending.is_some_and(|position| {
271        !session.messages[position + 1..].iter().any(|message| {
272            message.role == bamboo_agent_core::Role::User
273                && !counted(message)
274                && !bamboo_domain::session::is_system_resume_message(message)
275        })
276    })
277}
278
279/// A failed error save can leave an unfinished terminal clone without the
280/// repeat already committed on disk. Public raw execution checks this before
281/// any hooks/finalizers. Reading never grants a lease or checkpoint authority.
282pub(crate) async fn reject_ambiguous_reentry(
283    session: &Session,
284    persistence: &dyn bamboo_domain::RuntimeSessionPersistence,
285) -> Result<(), AgentError> {
286    reject_recovery(session)?;
287    let Some(state) = read_loop_state(session)? else {
288        return Ok(());
289    };
290    if state.iterations_started == state.iterations_completed {
291        return Ok(());
292    }
293    let durable = persistence
294        .load_runtime_admission_snapshot(&session.id)
295        .await
296        .map_err(|_| invalid("cannot inspect durable state; recovery unsupported"))?;
297    let Some(durable) = durable else {
298        return Err(invalid(
299            "recovery unsupported; durable state missing; reload required",
300        ));
301    };
302    reject_recovery(&durable)?;
303    if durable.created_at != session.created_at
304        || read_loop_state(&durable)?.as_ref() != Some(&state)
305    {
306        return Err(invalid(
307            "recovery unsupported; durable generation or revision changed; reload required",
308        ));
309    }
310    Ok(())
311}
312
313fn interrupted_runtime_matches(
314    session_id: &str,
315    runtime: &bamboo_domain::AgentRuntimeState,
316    state: &LoopState,
317) -> bool {
318    match runtime.execution_id.as_deref() {
319        Some(id) => id == state.owner_execution_id,
320        // Only old snapshots without the typed identity may use the complete
321        // round namespace. A Session-id run_id or absent round proves nothing.
322        None => runtime.round.last_round_id.as_ref().is_some_and(|round| {
323            *round
324                == crate::runtime::runner::round_prelude::build_round_id(
325                    session_id,
326                    &state.owner_execution_id,
327                    runtime.round.current_round as usize,
328                )
329        }),
330    }
331}
332
333fn fail_interrupted_runtime(session: &mut Session, state: &LoopState) {
334    if let Some(runtime) = session.agent_runtime_state.as_mut() {
335        if interrupted_runtime_matches(&session.id, runtime, state)
336            && matches!(
337                runtime.status,
338                bamboo_domain::AgentStatusState::Initializing
339                    | bamboo_domain::AgentStatusState::Running
340                    | bamboo_domain::AgentStatusState::Finalizing
341                    | bamboo_domain::AgentStatusState::Completed
342            )
343        {
344            runtime.status = bamboo_domain::AgentStatusState::Failed;
345        }
346    }
347}
348
349/// Called only at an existing consumed/reserved lease boundary, before any
350/// preparation, tool replay, hooks or outer final save. No admission is minted.
351/// A real registration proves logical exclusion, never Actor write authority:
352/// callers must supply their already-bound execution persistence.
353pub(crate) async fn admit_registered(
354    session: &mut Session,
355    ownership: Option<&crate::session_activation::SessionRunOwnership>,
356    router: Option<&std::sync::Arc<crate::session_activation::SessionActivationRouter>>,
357    persistence: &dyn bamboo_domain::RuntimeSessionPersistence,
358) -> Result<(), AgentError> {
359    let presented = read_loop_state(session)?;
360    // Always read, even when the caller predates the first counted admission.
361    let durable = persistence
362        .load_runtime_admission_snapshot(&session.id)
363        .await
364        .map_err(|_| invalid("cannot inspect durable state; execution not admitted"))?;
365    let stored = durable.as_ref().map(read_loop_state).transpose()?.flatten();
366    if presented != stored
367        || (stored.is_some()
368            && durable
369                .as_ref()
370                .is_some_and(|fresh| fresh.created_at != session.created_at))
371    {
372        return Err(invalid(
373            "recovery unsupported; durable generation or revision changed; reload required",
374        ));
375    }
376    let Some(mut state) = stored else {
377        return Ok(());
378    };
379    if state.status == LoopStatus::RecoveryUnsupported {
380        return reject_recovery(session);
381    }
382    if !state.live() {
383        reject_recovery(session)?;
384        // A failed error-save can leave the live clone without the repeat
385        // that was already committed. Only canonical input can clear that
386        // ambiguity; a preappend SDK submission is not a recovery capability.
387        if let Some(fresh) = durable.as_ref() {
388            reject_recovery(fresh)?;
389        }
390        return Ok(());
391    }
392    let Some(ownership) = ownership else {
393        return reject_recovery(session);
394    };
395    if !router.is_some_and(|router| ownership.matches_router(router))
396        || !ownership.is_current(&session.id).await
397    {
398        return Err(invalid(
399            "recovery unsupported; exact activation owner required",
400        ));
401    }
402    if durable
403        .as_ref()
404        .is_some_and(|fresh| persistence.root_actor_execution_required(fresh))
405    {
406        return Err(invalid("recovery unsupported; bound Actor writer required"));
407    }
408    // Stage from canonical bytes, never from the rejected caller's clone. Its
409    // pending User, older waits or permission changes must not be checkpointed.
410    let mut staged = durable.expect("stored state requires durable Session");
411    fail_interrupted_runtime(&mut staged, &state);
412    state.status = LoopStatus::RecoveryUnsupported;
413    checkpoint_state(&mut staged, Some(persistence), state.clone()).await?;
414    // Reflect only this generation's outcome, preserving caller-owned input
415    // and unrelated runtime/wait state. Every adapter returns before saving it.
416    session.metadata.insert(
417        LOOP_STATE_METADATA_KEY.into(),
418        staged.metadata[LOOP_STATE_METADATA_KEY].clone(),
419    );
420    fail_interrupted_runtime(session, &state);
421    reject_recovery(session)
422}
423
424/// Runs before submission hooks or transcript mutation. A new activation cannot
425/// mistake an ambiguous persisted admission for permission to repeat it.
426pub(crate) async fn preflight(
427    session: &mut Session,
428    config: &AgentLoopConfig,
429    prompt: &str,
430) -> Result<(), AgentError> {
431    reject_recovery(session)?;
432    if let Some(counted) = config.counted_loop.as_ref() {
433        validate_bounds(counted.max_iterations, counted.max_total_rounds, prompt)?;
434        validate_bounds(
435            counted.max_iterations,
436            effective_round_cap(counted, config),
437            prompt,
438        )?;
439        // Combining the two objective/continuation contracts needs #897. Fail
440        // before either gate/provider runs instead of silently changing Gold.
441        if config.gold_config.as_ref().is_some_and(|gold| gold.enabled) {
442            return Err(invalid(
443                "enabled Gold and counted repetition cannot be combined",
444            ));
445        }
446        if counted.stop.is_cancelled() {
447            return Err(invalid("the supplied generation was already stopped"));
448        }
449        if config.skip_initial_user_message
450            && !session.messages.last().is_some_and(|message| {
451                message.role == bamboo_agent_core::Role::User
452                    && message.content == prompt
453                    && !bamboo_domain::session::is_system_resume_message(message)
454            })
455        {
456            return Err(invalid(
457                "the initial prompt must be the current admitted User",
458            ));
459        }
460    }
461    Ok(())
462}
463
464fn effective_round_cap(counted: &CountedLoopConfig, config: &AgentLoopConfig) -> u32 {
465    config
466        .run_budget
467        .max_rounds
468        .map_or(counted.max_total_rounds, |cap| {
469            cap.min(counted.max_total_rounds)
470        })
471}
472
473pub(crate) async fn begin(
474    session: &mut Session,
475    config: &AgentLoopConfig,
476    execution_id: &str,
477    prompt: &str,
478) -> Result<Option<String>, AgentError> {
479    let Some(counted) = config.counted_loop.as_ref() else {
480        return Ok(None);
481    };
482    validate_bounds(
483        counted.max_iterations,
484        effective_round_cap(counted, config),
485        prompt,
486    )?;
487    let generation = uuid::Uuid::new_v4().to_string();
488    let state = LoopState {
489        schema_version: 1,
490        generation: generation.clone(),
491        revision: 1,
492        objective_revision: 1,
493        owner_execution_id: execution_id.into(),
494        objective: prompt.into(),
495        max_iterations: counted.max_iterations,
496        max_total_rounds: effective_round_cap(counted, config),
497        iterations_started: 0,
498        iterations_completed: 0,
499        rounds_started: 0,
500        status: LoopStatus::Running,
501    };
502    if let Err(error) = checkpoint(session, config, state).await {
503        // The generation exists in memory before its first save returns. Keep
504        // the original failure, but fence and terminate that exact generation
505        // so the host's later error save cannot persist a phantom live loop.
506        let failure = Err(error);
507        if let Err(checkpoint_error) = finish(session, config, Some(&generation), &failure).await {
508            tracing::warn!(%checkpoint_error, "counted-loop initial failure checkpoint failed");
509        }
510        return failure.map(|()| Some(generation));
511    }
512    Ok(Some(generation))
513}
514
515fn active_state(
516    session: &Session,
517    config: &AgentLoopConfig,
518) -> Result<Option<LoopState>, AgentError> {
519    if config.counted_loop.is_none() {
520        return Ok(None);
521    }
522    Ok(Some(
523        read_loop_state(session)?.ok_or_else(|| invalid("missing live state"))?,
524    ))
525}
526
527/// The single serial admission boundary. Stop precedes prompt injection; a
528/// repeated User is never left queued when stop wins this boundary.
529pub(crate) async fn before_round(
530    session: &mut Session,
531    config: &AgentLoopConfig,
532    execution_id: &str,
533) -> Result<bool, AgentError> {
534    let Some(mut state) = active_state(session, config)? else {
535        return Ok(true);
536    };
537    if state.owner_execution_id != execution_id {
538        return Err(invalid("execution owner changed"));
539    }
540    if let Some(owner) = config
541        .counted_loop
542        .as_ref()
543        .and_then(|counted| counted.owner.as_ref())
544    {
545        if owner.session_birth != session.created_at || !owner.run.is_current(&session.id).await {
546            return Err(invalid("execution ownership is no longer current"));
547        }
548    }
549    if !state.live() {
550        return Ok(false);
551    }
552    if config
553        .counted_loop
554        .as_ref()
555        .expect("counted config")
556        .stop
557        .is_cancelled()
558    {
559        state.status = LoopStatus::Stopped;
560        checkpoint(session, config, state).await?;
561        return Ok(false);
562    }
563    if state.rounds_started == state.max_total_rounds {
564        state.status = LoopStatus::BudgetExhausted;
565        session.metadata.insert(
566            "runtime.completion_reason".into(),
567            "counted_loop_round_limit".into(),
568        );
569        checkpoint(session, config, state).await?;
570        return Ok(false);
571    }
572    let mut staged_repeat = None;
573    if state.status == LoopStatus::Running && state.iterations_started == state.iterations_completed
574    {
575        if state.iterations_started > 0 {
576            let mut message = Message::user(state.objective.clone());
577            message.metadata = Some(serde_json::json!({
578                "runtime_kind": "counted_loop_iteration",
579                "loop_generation": state.generation,
580                "objective_revision": state.objective_revision,
581                "iteration": state.iterations_started + 1,
582            }));
583            let mut staged = session.clone();
584            staged.add_message(message);
585            staged_repeat = Some(staged);
586        }
587        state.iterations_started += 1;
588    }
589    state.rounds_started += 1;
590    if let Some(mut staged) = staged_repeat {
591        // Do not expose a queued User before its checkpoint succeeds. Retain
592        // conservative counters on an indeterminate write, without publishing
593        // uncommitted input into the live execution or its later error save.
594        let admitted_state = stage_state(&mut staged, state)?;
595        if let Err(error) = persist_checkpoint(&mut staged, config.persistence.as_deref()).await {
596            session
597                .metadata
598                .insert(LOOP_STATE_METADATA_KEY.into(), admitted_state);
599            return Err(error);
600        }
601        *session = staged;
602    } else {
603        checkpoint(session, config, state).await?;
604    }
605    Ok(true)
606}
607
608pub(crate) enum TerminalDecision {
609    Continue,
610    Stop,
611    Finalize,
612}
613
614pub(crate) async fn exhaust_budget(
615    session: &mut Session,
616    config: &AgentLoopConfig,
617) -> Result<(), AgentError> {
618    let Some(mut state) = active_state(session, config)? else {
619        return Ok(());
620    };
621    state.status = LoopStatus::BudgetExhausted;
622    session.metadata.insert(
623        "runtime.completion_reason".into(),
624        "counted_loop_budget_exhausted".into(),
625    );
626    checkpoint(session, config, state).await
627}
628
629pub(crate) async fn terminal(
630    session: &mut Session,
631    config: &AgentLoopConfig,
632) -> Result<TerminalDecision, AgentError> {
633    let Some(mut state) = active_state(session, config)? else {
634        return Ok(TerminalDecision::Finalize);
635    };
636    if state.status == LoopStatus::Finalizing {
637        return Ok(TerminalDecision::Finalize);
638    }
639    if state.status != LoopStatus::Running
640        || state.iterations_started != state.iterations_completed + 1
641    {
642        return Err(invalid("terminal without one admitted iteration"));
643    }
644    state.iterations_completed += 1;
645    let decision = if config
646        .counted_loop
647        .as_ref()
648        .expect("counted config")
649        .stop
650        .is_cancelled()
651    {
652        state.status = LoopStatus::Stopped;
653        TerminalDecision::Stop
654    } else if state.iterations_completed == state.max_iterations {
655        state.status = LoopStatus::Finalizing;
656        TerminalDecision::Finalize
657    } else {
658        TerminalDecision::Continue
659    };
660    checkpoint(session, config, state).await?;
661    Ok(decision)
662}
663
664/// Covers setup hooks, provider/tool errors, cancellation, and suspension as
665/// well as ordinary completion. No terminal state grants another admission.
666pub(crate) async fn finish(
667    session: &mut Session,
668    config: &AgentLoopConfig,
669    generation: Option<&str>,
670    result: &Result<(), AgentError>,
671) -> Result<(), AgentError> {
672    let Some(generation) = generation else {
673        return Ok(());
674    };
675    let Some(mut state) = read_loop_state(session)? else {
676        return Err(invalid("missing terminal state"));
677    };
678    if state.generation != generation {
679        return Err(invalid("generation changed during execution"));
680    }
681    // These are the original execution's final outcomes, not new admissions.
682    // A bounded exit can have been checkpointed before fallible AfterRound or
683    // finalization work; the Result still governs its unpublished outcome.
684    state.status = match result {
685        Err(error) if error.is_cancelled() => LoopStatus::Cancelled,
686        Err(_) => match state.status {
687            LoopStatus::Failed | LoopStatus::Cancelled | LoopStatus::RecoveryUnsupported => {
688                state.status
689            }
690            _ => LoopStatus::Failed,
691        },
692        Ok(()) if !state.live() => state.status,
693        Ok(())
694            if session.metadata.contains_key("runtime.suspend_reason")
695                || session.agent_runtime_state.as_ref().is_some_and(|runtime| {
696                    matches!(runtime.status, bamboo_domain::AgentStatusState::Suspended)
697                }) =>
698        {
699            LoopStatus::RecoveryUnsupported
700        }
701        Ok(()) => {
702            if let Some(reason) = session.metadata.get("runtime.completion_reason") {
703                match reason.as_str() {
704                    "counted_loop_round_limit"
705                    | "counted_loop_budget_exhausted"
706                    | "budget_exceeded"
707                    | "max_rounds_reached"
708                    | "stop_hook_continuation_cap" => LoopStatus::BudgetExhausted,
709                    // Question/wait diagnostics do not imply target completion.
710                    _ => LoopStatus::RecoveryUnsupported,
711                }
712            } else if state.iterations_completed == state.max_iterations {
713                LoopStatus::Completed
714            } else {
715                LoopStatus::Failed
716            }
717        }
718    };
719    // Always persist the final Runtime mirror as well, even when a prior
720    // boundary already wrote the same Stopped/Budget/deny LoopStatus. Complete
721    // is published only after this full final snapshot succeeds.
722    checkpoint(session, config, state).await
723}