1use 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#[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 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 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#[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 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
195pub 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
227pub(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
264pub(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
279fn 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
317pub(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 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 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
398pub(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 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 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 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 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
481pub(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 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 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
606pub(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 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 *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
787pub(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 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 _ => LoopStatus::RecoveryUnsupported,
834 }
835 } else if state.iterations_completed == state.max_iterations {
836 LoopStatus::Completed
837 } else {
838 LoopStatus::Failed
839 }
840 }
841 };
842 checkpoint(session, config, state).await
846}