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