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