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    reject_recovery(session)?;
325    let Some(state) = read_loop_state(session)? else {
326        return Ok(());
327    };
328    if state.iterations_started == state.iterations_completed {
329        return Ok(());
330    }
331    let durable = persistence
332        .load_runtime_admission_snapshot(&session.id)
333        .await
334        .map_err(|_| invalid("cannot inspect durable state; recovery unsupported"))?;
335    let Some(durable) = durable else {
336        return Err(invalid(
337            "recovery unsupported; durable state missing; reload required",
338        ));
339    };
340    reject_recovery(&durable)?;
341    if durable.created_at != session.created_at
342        || read_loop_state(&durable)?.as_ref() != Some(&state)
343    {
344        return Err(invalid(
345            "recovery unsupported; durable generation or revision changed; reload required",
346        ));
347    }
348    Ok(())
349}
350
351fn interrupted_runtime_matches(
352    session_id: &str,
353    runtime: &bamboo_domain::AgentRuntimeState,
354    state: &LoopState,
355) -> bool {
356    match runtime.execution_id.as_deref() {
357        Some(id) => id == state.owner_execution_id,
358        // Only old snapshots without the typed identity may use the complete
359        // round namespace. A Session-id run_id or absent round proves nothing.
360        None => runtime.round.last_round_id.as_ref().is_some_and(|round| {
361            *round
362                == crate::runtime::runner::round_prelude::build_round_id(
363                    session_id,
364                    &state.owner_execution_id,
365                    runtime.round.current_round as usize,
366                )
367        }),
368    }
369}
370
371fn fail_interrupted_runtime(session: &mut Session, state: &LoopState) {
372    if let Some(runtime) = session.agent_runtime_state.as_mut() {
373        if interrupted_runtime_matches(&session.id, runtime, state)
374            && matches!(
375                runtime.status,
376                bamboo_domain::AgentStatusState::Initializing
377                    | bamboo_domain::AgentStatusState::Running
378                    | bamboo_domain::AgentStatusState::Finalizing
379                    | bamboo_domain::AgentStatusState::Completed
380            )
381        {
382            runtime.status = bamboo_domain::AgentStatusState::Failed;
383        }
384    }
385}
386
387/// Called only at an existing consumed/reserved lease boundary, before any
388/// preparation, tool replay, hooks or outer final save. No admission is minted.
389/// A real registration proves logical exclusion, never Actor write authority:
390/// callers must supply their already-bound execution persistence.
391pub(crate) async fn admit_registered(
392    session: &mut Session,
393    ownership: Option<&crate::session_activation::SessionRunOwnership>,
394    router: Option<&std::sync::Arc<crate::session_activation::SessionActivationRouter>>,
395    persistence: &dyn bamboo_domain::RuntimeSessionPersistence,
396) -> Result<(), AgentError> {
397    let presented = read_loop_state(session)?;
398    // Always read, even when the caller predates the first counted admission.
399    let durable = persistence
400        .load_runtime_admission_snapshot(&session.id)
401        .await
402        .map_err(|_| invalid("cannot inspect durable state; execution not admitted"))?;
403    let stored = durable.as_ref().map(read_loop_state).transpose()?.flatten();
404    if presented != stored
405        || (stored.is_some()
406            && durable
407                .as_ref()
408                .is_some_and(|fresh| fresh.created_at != session.created_at))
409    {
410        return Err(invalid(
411            "recovery unsupported; durable generation or revision changed; reload required",
412        ));
413    }
414    let Some(mut state) = stored else {
415        return Ok(());
416    };
417    if state.status == LoopStatus::RecoveryUnsupported {
418        return reject_recovery(session);
419    }
420    if !state.live() {
421        reject_recovery(session)?;
422        // A failed error-save can leave the live clone without the repeat
423        // that was already committed. Only canonical input can clear that
424        // ambiguity; a preappend SDK submission is not a recovery capability.
425        if let Some(fresh) = durable.as_ref() {
426            reject_recovery(fresh)?;
427        }
428        return Ok(());
429    }
430    let Some(ownership) = ownership else {
431        return reject_recovery(session);
432    };
433    if !router.is_some_and(|router| ownership.matches_router(router))
434        || !ownership.is_current(&session.id).await
435    {
436        return Err(invalid(
437            "recovery unsupported; exact activation owner required",
438        ));
439    }
440    if durable
441        .as_ref()
442        .is_some_and(|fresh| persistence.root_actor_execution_required(fresh))
443    {
444        return Err(invalid("recovery unsupported; bound Actor writer required"));
445    }
446    // Stage from canonical bytes, never from the rejected caller's clone. Its
447    // pending User, older waits or permission changes must not be checkpointed.
448    let mut staged = durable.expect("stored state requires durable Session");
449    fail_interrupted_runtime(&mut staged, &state);
450    state.status = LoopStatus::RecoveryUnsupported;
451    checkpoint_state(&mut staged, Some(persistence), state.clone()).await?;
452    // Reflect only this generation's outcome, preserving caller-owned input
453    // and unrelated runtime/wait state. Every adapter returns before saving it.
454    session.metadata.insert(
455        LOOP_STATE_METADATA_KEY.into(),
456        staged.metadata[LOOP_STATE_METADATA_KEY].clone(),
457    );
458    fail_interrupted_runtime(session, &state);
459    reject_recovery(session)
460}
461
462/// Runs before submission hooks or transcript mutation. A new activation cannot
463/// mistake an ambiguous persisted admission for permission to repeat it.
464pub(crate) async fn preflight(
465    session: &mut Session,
466    config: &AgentLoopConfig,
467    prompt: &str,
468) -> Result<(), AgentError> {
469    reject_recovery(session)?;
470    if let Some(control) = config.loop_control.as_ref() {
471        if config.counted_loop.is_some()
472            || config.ticket_worker_plan.is_some()
473            || config.gold_config.as_ref().is_some_and(|gold| gold.enabled)
474            || config.persistence.is_none()
475        {
476            return Err(invalid(
477                "self control requires an exclusive durable non-Gold execution",
478            ));
479        }
480        control.validate_current_owner(session).await?;
481        if !config
482            .run_budget
483            .max_rounds
484            .is_some_and(|cap| cap <= control.max_total_rounds)
485        {
486            return Err(invalid(
487                "self control host round ceiling must already be enforced",
488            ));
489        }
490    }
491    if let Some(counted) = config.effective_counted_loop() {
492        validate_bounds(counted.max_iterations, counted.max_total_rounds, prompt)?;
493        validate_bounds(
494            counted.max_iterations,
495            effective_round_cap(counted, config),
496            prompt,
497        )?;
498        // Combining the two objective/continuation contracts needs #897. Fail
499        // before either gate/provider runs instead of silently changing Gold.
500        if config.gold_config.as_ref().is_some_and(|gold| gold.enabled) {
501            return Err(invalid(
502                "enabled Gold and counted repetition cannot be combined",
503            ));
504        }
505        if counted.stop.is_cancelled() {
506            return Err(invalid("the supplied generation was already stopped"));
507        }
508        if config.skip_initial_user_message
509            && !session.messages.last().is_some_and(|message| {
510                message.role == bamboo_agent_core::Role::User
511                    && message.content == prompt
512                    && !bamboo_domain::session::is_system_resume_message(message)
513            })
514        {
515            return Err(invalid(
516                "the initial prompt must be the current admitted User",
517            ));
518        }
519    }
520    Ok(())
521}
522
523fn effective_round_cap(counted: &CountedLoopConfig, config: &AgentLoopConfig) -> u32 {
524    config
525        .run_budget
526        .max_rounds
527        .map_or(counted.max_total_rounds, |cap| {
528            cap.min(counted.max_total_rounds)
529        })
530}
531
532pub(crate) async fn begin(
533    session: &mut Session,
534    config: &AgentLoopConfig,
535    execution_id: &str,
536    prompt: &str,
537) -> Result<Option<String>, AgentError> {
538    let Some(counted) = config.effective_counted_loop() else {
539        return Ok(None);
540    };
541    validate_bounds(
542        counted.max_iterations,
543        effective_round_cap(counted, config),
544        prompt,
545    )?;
546    let generation = uuid::Uuid::new_v4().to_string();
547    let state = LoopState {
548        schema_version: 1,
549        generation: generation.clone(),
550        revision: 1,
551        objective_revision: 1,
552        owner_execution_id: execution_id.into(),
553        objective: prompt.into(),
554        max_iterations: counted.max_iterations,
555        max_total_rounds: effective_round_cap(counted, config),
556        iterations_started: 0,
557        iterations_completed: 0,
558        rounds_started: 0,
559        status: LoopStatus::Running,
560        control: None,
561    };
562    if let Err(error) = checkpoint(session, config, state).await {
563        // The generation exists in memory before its first save returns. Keep
564        // the original failure, but fence and terminate that exact generation
565        // so the host's later error save cannot persist a phantom live loop.
566        let failure = Err(error);
567        if let Err(checkpoint_error) = finish(session, config, Some(&generation), &failure).await {
568            tracing::warn!(%checkpoint_error, "counted-loop initial failure checkpoint failed");
569        }
570        return failure.map(|()| Some(generation));
571    }
572    Ok(Some(generation))
573}
574
575fn active_state(
576    session: &Session,
577    config: &AgentLoopConfig,
578) -> Result<Option<LoopState>, AgentError> {
579    if config.effective_counted_loop().is_none() {
580        return Ok(None);
581    }
582    Ok(Some(
583        read_loop_state(session)?.ok_or_else(|| invalid("missing live state"))?,
584    ))
585}
586
587/// The single serial admission boundary. Stop precedes prompt injection; a
588/// repeated User is never left queued when stop wins this boundary.
589pub(crate) async fn before_round(
590    session: &mut Session,
591    config: &AgentLoopConfig,
592    execution_id: &str,
593) -> Result<bool, AgentError> {
594    let Some(mut state) = active_state(session, config)? else {
595        return Ok(true);
596    };
597    if state.owner_execution_id != execution_id {
598        return Err(invalid("execution owner changed"));
599    }
600    if let Some(owner) = config
601        .effective_counted_loop()
602        .and_then(|counted| counted.owner.as_ref())
603    {
604        if owner.session_birth != session.created_at || !owner.run.is_current(&session.id).await {
605            return Err(invalid("execution ownership is no longer current"));
606        }
607    }
608    if !state.live() {
609        return Ok(false);
610    }
611    if config
612        .effective_counted_loop()
613        .expect("counted config")
614        .stop
615        .is_cancelled()
616    {
617        state.status = LoopStatus::Stopped;
618        checkpoint(session, config, state).await?;
619        return Ok(false);
620    }
621    if state.rounds_started == state.max_total_rounds {
622        state.status = LoopStatus::BudgetExhausted;
623        session.metadata.insert(
624            "runtime.completion_reason".into(),
625            "counted_loop_round_limit".into(),
626        );
627        checkpoint(session, config, state).await?;
628        return Ok(false);
629    }
630    let mut staged_repeat = None;
631    if state.status == LoopStatus::Running && state.iterations_started == state.iterations_completed
632    {
633        if let Some(control) = state.control.as_mut() {
634            if let Some(pending) = control.pending_objective.take() {
635                state.objective = pending.objective;
636                state.objective_revision = pending.objective_revision;
637                control.objective_origin = pending.origin;
638            }
639            control.applied_revision = control.control_revision;
640            control.iteration_objective_revision = state.objective_revision;
641        }
642        if state.iterations_started > 0 || state.control.is_some() {
643            let mut message = Message::user(state.objective.clone());
644            message.metadata = Some(serde_json::json!({
645                "runtime_kind": "counted_loop_iteration",
646                "loop_generation": state.generation,
647                "objective_revision": state.objective_revision,
648                "iteration": state.iterations_started + 1,
649            }));
650            let mut staged = session.clone();
651            staged.add_message(message);
652            staged_repeat = Some(staged);
653        }
654        state.iterations_started += 1;
655    }
656    state.rounds_started += 1;
657    if let Some(mut staged) = staged_repeat {
658        // Do not expose a queued User before its checkpoint succeeds. Retain
659        // conservative counters on an indeterminate write, without publishing
660        // uncommitted input into the live execution or its later error save.
661        let admitted_state = stage_state(&mut staged, state)?;
662        if let Err(error) =
663            super::loop_control::scoped::persist_current_checkpoint(&mut staged, config).await
664        {
665            if bamboo_domain::scoped_loop_objective_origins(&staged)
666                .is_ok_and(|origins| !origins.is_empty())
667                || staged
668                    .metadata
669                    .contains_key(bamboo_domain::SCOPED_LOOP_UNCERTAIN_KEY)
670            {
671                // The remote checkpoint helper preserved a strict canonical
672                // snapshot or a no-save fence. Do not reinstall the rejected
673                // objective/counters through a later generic error save.
674                *session = staged;
675            } else {
676                session
677                    .metadata
678                    .insert(LOOP_STATE_METADATA_KEY.into(), admitted_state);
679            }
680            return Err(error);
681        }
682        *session = staged;
683    } else {
684        checkpoint(session, config, state).await?;
685    }
686    Ok(true)
687}
688
689pub(crate) enum TerminalDecision {
690    Continue,
691    Stop,
692    Finalize,
693}
694
695pub(crate) async fn exhaust_budget(
696    session: &mut Session,
697    config: &AgentLoopConfig,
698) -> Result<(), AgentError> {
699    let Some(mut state) = active_state(session, config)? else {
700        if config.loop_control.is_some() {
701            session
702                .metadata
703                .insert("runtime.completion_reason".into(), "budget_exceeded".into());
704        }
705        return Ok(());
706    };
707    state.status = LoopStatus::BudgetExhausted;
708    session.metadata.insert(
709        "runtime.completion_reason".into(),
710        "counted_loop_budget_exhausted".into(),
711    );
712    checkpoint(session, config, state).await
713}
714
715pub(crate) async fn terminal(
716    session: &mut Session,
717    config: &AgentLoopConfig,
718) -> Result<TerminalDecision, AgentError> {
719    let Some(mut state) = active_state(session, config)? else {
720        return Ok(TerminalDecision::Finalize);
721    };
722    if state.status == LoopStatus::Finalizing {
723        return Ok(TerminalDecision::Finalize);
724    }
725    if state.status != LoopStatus::Running
726        || state.iterations_started != state.iterations_completed + 1
727    {
728        return Err(invalid("terminal without one admitted iteration"));
729    }
730    if state
731        .control
732        .as_ref()
733        .is_some_and(|control| control.iteration_objective_revision != state.objective_revision)
734    {
735        return Err(invalid("admitted objective revision changed"));
736    }
737    state.iterations_completed += 1;
738    let decision = if config
739        .effective_counted_loop()
740        .expect("counted config")
741        .stop
742        .is_cancelled()
743    {
744        state.status = LoopStatus::Stopped;
745        TerminalDecision::Stop
746    } else if state.iterations_completed == state.max_iterations
747        && state
748            .control
749            .as_ref()
750            .is_some_and(|c| c.pending_objective.is_some())
751    {
752        state.status = LoopStatus::BudgetExhausted;
753        session.metadata.insert(
754            "runtime.completion_reason".into(),
755            "loop_objective_unapplied_budget".into(),
756        );
757        TerminalDecision::Stop
758    } else if state.iterations_completed == state.max_iterations {
759        state.status = LoopStatus::Finalizing;
760        TerminalDecision::Finalize
761    } else {
762        TerminalDecision::Continue
763    };
764    checkpoint(session, config, state).await?;
765    Ok(decision)
766}
767
768/// Covers setup hooks, provider/tool errors, cancellation, and suspension as
769/// well as ordinary completion. No terminal state grants another admission.
770pub(crate) async fn finish(
771    session: &mut Session,
772    config: &AgentLoopConfig,
773    generation: Option<&str>,
774    result: &Result<(), AgentError>,
775) -> Result<(), AgentError> {
776    let Some(generation) = generation else {
777        return Ok(());
778    };
779    let Some(mut state) = read_loop_state(session)? else {
780        return Err(invalid("missing terminal state"));
781    };
782    if state.generation != generation {
783        return Err(invalid("generation changed during execution"));
784    }
785    // These are the original execution's final outcomes, not new admissions.
786    // A bounded exit can have been checkpointed before fallible AfterRound or
787    // finalization work; the Result still governs its unpublished outcome.
788    state.status = match result {
789        Err(error) if error.is_cancelled() => LoopStatus::Cancelled,
790        Err(_) => match state.status {
791            LoopStatus::Failed | LoopStatus::Cancelled | LoopStatus::RecoveryUnsupported => {
792                state.status
793            }
794            _ => LoopStatus::Failed,
795        },
796        Ok(()) if !state.live() => state.status,
797        Ok(())
798            if session.metadata.contains_key("runtime.suspend_reason")
799                || session.agent_runtime_state.as_ref().is_some_and(|runtime| {
800                    matches!(runtime.status, bamboo_domain::AgentStatusState::Suspended)
801                }) =>
802        {
803            LoopStatus::RecoveryUnsupported
804        }
805        Ok(()) => {
806            if let Some(reason) = session.metadata.get("runtime.completion_reason") {
807                match reason.as_str() {
808                    "counted_loop_round_limit"
809                    | "counted_loop_budget_exhausted"
810                    | "budget_exceeded"
811                    | "max_rounds_reached"
812                    | "stop_hook_continuation_cap" => LoopStatus::BudgetExhausted,
813                    // Question/wait diagnostics do not imply target completion.
814                    _ => LoopStatus::RecoveryUnsupported,
815                }
816            } else if state.iterations_completed == state.max_iterations {
817                LoopStatus::Completed
818            } else {
819                LoopStatus::Failed
820            }
821        }
822    };
823    // Always persist the final Runtime mirror as well, even when a prior
824    // boundary already wrote the same Stopped/Budget/deny LoopStatus. Complete
825    // is published only after this full final snapshot succeeds.
826    checkpoint(session, config, state).await
827}