Skip to main content

bamboo_engine/runtime/loop_control/
scoped.rs

1//! Target-owner integration. No public cross-Session state mutation API.
2use std::{collections::BTreeSet, sync::Arc};
3
4use bamboo_agent_core::{storage::Storage, AgentError, Session};
5use bamboo_domain::{
6    canonical_json_bytes, has_scoped_loop_consumption, scoped_loop_consumptions,
7    scoped_loop_control_input, scoped_loop_objective_origins, scoped_loop_operation_fingerprint,
8    LoopControlAction, RuntimeSessionPersistence, ScopedLoopControlBinding, ScopedLoopControlClaim,
9    ScopedLoopControlConsumption, ScopedLoopControlInput, SessionActivationPort, SessionInboxPort,
10    SupervisorReference, MAX_SCOPED_LOOP_CONSUMPTIONS, SCOPED_LOOP_CONSUMPTION_KEY,
11    SCOPED_LOOP_UNCERTAIN_KEY,
12};
13use sha2::{Digest, Sha256};
14
15use super::{
16    counted_loop, AgentLoopConfig, LoopControlConfig, LoopControlRequest, LoopState, LoopStatus,
17};
18
19/// Host-only, separate opt-in. The live runtime binds this template to the
20/// actual target lifetime and both execution identities after taking its lease.
21/// No model argument creates this value, installs it, or renews it.
22#[derive(Debug, Clone)]
23pub struct SupervisorLoopControlGrant {
24    id: uuid::Uuid,
25    supervisor: SupervisorReference,
26    supervisor_tool_authority_revision: u64,
27    link_revision: u64,
28    allowed_actions: BTreeSet<LoopControlAction>,
29    max_iterations: u32,
30    max_total_rounds: u32,
31}
32
33impl SupervisorLoopControlGrant {
34    pub fn new(
35        supervisor: SupervisorReference,
36        supervisor_tool_authority_revision: u64,
37        link_revision: u64,
38        allowed_actions: impl IntoIterator<Item = LoopControlAction>,
39        max_iterations: u32,
40        max_total_rounds: u32,
41    ) -> Result<Self, AgentError> {
42        let grant = Self {
43            id: uuid::Uuid::new_v4(),
44            supervisor,
45            supervisor_tool_authority_revision,
46            link_revision,
47            allowed_actions: allowed_actions.into_iter().collect(),
48            max_iterations,
49            max_total_rounds,
50        };
51        grant.validate(
52            counted_loop::MAX_LOOP_ITERATIONS,
53            counted_loop::MAX_LOOP_ROUNDS,
54        )?;
55        Ok(grant)
56    }
57
58    pub fn grant_id(&self) -> uuid::Uuid {
59        self.id
60    }
61
62    pub(super) fn validate(&self, iterations: u32, rounds: u32) -> Result<(), AgentError> {
63        if self.supervisor.session_id != bamboo_domain::DEFAULT_SUPERVISOR_SESSION_ID
64            || self.supervisor.incarnation_id.is_nil()
65            || self.link_revision == 0
66            || self.allowed_actions.is_empty()
67            || !(1..=iterations.min(counted_loop::MAX_LOOP_ITERATIONS))
68                .contains(&self.max_iterations)
69            || !(self.max_iterations..=rounds.min(counted_loop::MAX_LOOP_ROUNDS))
70                .contains(&self.max_total_rounds)
71        {
72            return Err(super::fatal("invalid separate Supervisor Loop grant"));
73        }
74        Ok(())
75    }
76}
77
78impl LoopControlConfig {
79    pub(crate) async fn register_supervisor_control(
80        &self,
81        session: &Session,
82        storage: &Arc<dyn Storage>,
83    ) -> Result<(), AgentError> {
84        let Some(grant) = &self.supervisor_grant else {
85            return Ok(());
86        };
87        if !storage.supports_scoped_loop_transport_retirement() {
88            return Err(super::fatal(
89                "Supervisor Loop transport retirement unsupported",
90            ));
91        }
92        self.validate_current_owner(session).await?;
93        grant.validate(self.max_iterations, self.max_total_rounds)?;
94        let ownership = self
95            .owner
96            .ownership()
97            .ok_or_else(|| super::fatal("target owner missing"))?;
98        let execution = self
99            .execution
100            .get()
101            .ok_or_else(|| super::fatal("pipeline owner missing"))?;
102        if execution.cancel.is_cancelled() {
103            return Err(AgentError::Cancelled);
104        }
105        let binding = ScopedLoopControlBinding {
106            grant_id: grant.id,
107            supervisor: grant.supervisor.clone(),
108            supervisor_tool_authority_revision: grant.supervisor_tool_authority_revision,
109            target_session_id: session.id.clone(),
110            target_created_at: session.created_at,
111            target_metadata_version: session.metadata_version,
112            target_project_id: session
113                .project_id_meta()
114                .ok_or_else(|| super::fatal("target Project missing"))?
115                .parse()
116                .map_err(|_| super::fatal("target Project invalid"))?,
117            link_revision: grant.link_revision,
118            owner_run_id: ownership.run_id().into(),
119            execution_id: execution.id.clone(),
120            allowed_actions: grant.allowed_actions.clone(),
121            max_iterations: grant.max_iterations,
122            max_total_rounds: grant.max_total_rounds,
123        };
124        binding
125            .validate()
126            .map_err(|_| super::fatal("invalid live Supervisor binding"))?;
127        ownership
128            .router()
129            .bind_supervisor_loop_control(
130                &ownership,
131                binding.clone(),
132                execution.cancel.clone(),
133                self.stop.clone(),
134            )
135            .await
136            .map_err(|_| super::fatal("Supervisor Loop owner binding failed"))?;
137        self.live_supervisor_binding
138            .set(binding)
139            .map_err(|_| super::fatal("Supervisor Loop binding already consumed"))?;
140        Ok(())
141    }
142}
143
144fn runtime_authority(
145    policy: &LoopControlConfig,
146) -> Result<Arc<dyn SessionActivationPort>, AgentError> {
147    let owner = policy
148        .owner
149        .ownership()
150        .ok_or_else(|| super::fatal("target owner missing"))?;
151    Ok(owner.router().clone())
152}
153
154fn cancellation_wins(policy: &LoopControlConfig, error: AgentError) -> AgentError {
155    if policy
156        .execution_cancel()
157        .is_ok_and(|token| token.is_cancelled())
158    {
159        AgentError::Cancelled
160    } else {
161        error
162    }
163}
164
165/// On any failed/uncertain physical checkpoint, abort provider admission. A
166/// strict canonical reload may recover observable bytes, never a successful ACK.
167/// If even that read fails, the local snapshot is fenced from generic saves.
168async fn checkpoint_scoped(
169    session: &mut Session,
170    config: &AgentLoopConfig,
171    bindings: &[ScopedLoopControlBinding],
172    input: Option<(Arc<dyn SessionInboxPort>, ScopedLoopControlClaim)>,
173) -> Result<(), AgentError> {
174    let policy = config
175        .loop_control
176        .as_ref()
177        .ok_or_else(|| super::fatal("remote checkpoint owner absent"))?;
178    let persistence = config
179        .persistence
180        .as_ref()
181        .ok_or_else(|| super::fatal("durable checkpoint required"))?;
182    if session.metadata.contains_key(SCOPED_LOOP_UNCERTAIN_KEY) {
183        return Err(super::fatal(
184            "prior Supervisor Loop checkpoint remains unknown",
185        ));
186    }
187    let id = session.id.clone();
188    let birth = session.created_at;
189    let result = persistence
190        .checkpoint_scoped_loop_control(session, bindings, runtime_authority(policy)?, input)
191        .await;
192    if let Err(error) = result {
193        match persistence.load_loop_control_checkpoint(&id).await {
194            Ok(Some(canonical)) if canonical.id == id && canonical.created_at == birth => {
195                *session = canonical;
196                session.metadata.remove(SCOPED_LOOP_UNCERTAIN_KEY);
197            }
198            _ => {
199                session.metadata.insert(
200                    SCOPED_LOOP_UNCERTAIN_KEY.into(),
201                    "unconfirmed checkpoint; strict reload failed".into(),
202                );
203            }
204        }
205        if policy.execution_cancel()?.is_cancelled() {
206            return Err(AgentError::Cancelled);
207        }
208        return Err(AgentError::Budget(format!(
209            "scoped Loop checkpoint failed/unknown; no further admission: {error}"
210        )));
211    }
212    Ok(())
213}
214
215/// Every subsequent running checkpoint retains both active and pending remote
216/// objective authority. Terminal error/cancellation records retire admitted
217/// work using the original target owner and never authorize another provider.
218pub(crate) async fn persist_current_checkpoint(
219    session: &mut Session,
220    config: &AgentLoopConfig,
221) -> Result<(), AgentError> {
222    if session.metadata.contains_key(SCOPED_LOOP_UNCERTAIN_KEY) {
223        return Err(super::fatal(
224            "unknown scoped checkpoint forbids snapshot publication",
225        ));
226    }
227    let bindings = scoped_loop_objective_origins(session)
228        .map_err(|_| super::fatal("invalid remote objective origins"))?;
229    let running = counted_loop::read_loop_state(session)?
230        .is_some_and(|state| matches!(state.status, LoopStatus::Running | LoopStatus::Finalizing));
231    if bindings.is_empty() || !running {
232        counted_loop::persist_checkpoint(session, config.persistence.as_deref()).await
233    } else {
234        checkpoint_scoped(session, config, &bindings, None).await?;
235        if config
236            .loop_control
237            .as_ref()
238            .ok_or_else(|| super::fatal("target owner absent"))?
239            .execution_cancel()?
240            .is_cancelled()
241        {
242            return Err(AgentError::Cancelled);
243        }
244        Ok(())
245    }
246}
247
248pub(crate) async fn drain(
249    session: &mut Session,
250    config: &AgentLoopConfig,
251    rounds_spent: u32,
252) -> Result<(), AgentError> {
253    let Some(policy) = config.loop_control.as_ref() else {
254        return Ok(());
255    };
256    let Some(binding) = policy.live_supervisor_binding.get() else {
257        return Ok(());
258    };
259    let inbox = config
260        .session_inbox
261        .as_ref()
262        .ok_or_else(|| super::fatal("Supervisor Loop Inbox absent"))?;
263    let persistence = config
264        .persistence
265        .as_ref()
266        .ok_or_else(|| super::fatal("Supervisor Loop persistence absent"))?;
267    for _ in 0..MAX_SCOPED_LOOP_CONSUMPTIONS {
268        if policy.execution_cancel()?.is_cancelled() {
269            return Err(AgentError::Cancelled);
270        }
271        // Host stop revokes the grant; model Stop cancels only its generation.
272        // A configured remote Stop retires its later unconsumed carriers inside
273        // the guarded Storage checkpoint. This drain never applies more work
274        // after Stop; host/local terminal recovery is not granted here.
275        let generation_stopped = counted_loop::read_loop_state(session)?.is_some_and(|state| {
276            state.owner_execution_id == binding.execution_id
277                && state
278                    .control
279                    .as_ref()
280                    .is_some_and(|control| control.stop_requested)
281        });
282        if policy.stop.is_cancelled() || generation_stopped {
283            return Ok(());
284        }
285        policy
286            .validate_current_owner(session)
287            .await
288            .map_err(|error| cancellation_wins(policy, error))?;
289        let claims = persistence
290            .claim_scoped_loop_controls(session, inbox.clone(), &binding.owner_run_id, 1)
291            .await
292            .map_err(|_| {
293                cancellation_wins(policy, super::fatal("typed Supervisor Loop claim failed"))
294            })?;
295        if policy.execution_cancel()?.is_cancelled() {
296            return Err(AgentError::Cancelled);
297        }
298        if policy.stop.is_cancelled() {
299            return Ok(());
300        }
301        let Some(claim) = claims.into_iter().next() else {
302            return Ok(());
303        };
304        apply_input(
305            session,
306            config,
307            policy,
308            binding,
309            inbox.clone(),
310            claim,
311            rounds_spent,
312        )
313        .await
314        .map_err(|error| cancellation_wins(policy, error))?;
315    }
316    // Finite receipt capacity is also the maximum work at one safe boundary.
317    Ok(())
318}
319
320#[allow(clippy::too_many_arguments)]
321async fn apply_input(
322    session: &mut Session,
323    config: &AgentLoopConfig,
324    policy: &LoopControlConfig,
325    binding: &ScopedLoopControlBinding,
326    inbox: Arc<dyn SessionInboxPort>,
327    claim: ScopedLoopControlClaim,
328    rounds_spent: u32,
329) -> Result<(), AgentError> {
330    let envelope = claim.envelope().clone();
331    let input = scoped_loop_control_input(&envelope)
332        .map_err(|_| super::fatal("invalid typed Loop input"))?
333        .ok_or_else(|| super::fatal("Loop claim did not contain typed control"))?;
334    if &input.binding != binding {
335        return Err(super::fatal("remote grant/owner changed"));
336    }
337    let storage = config
338        .storage
339        .as_ref()
340        .ok_or_else(|| super::fatal("canonical Loop authority absent"))?;
341    let authority = storage
342        .load_scoped_loop_session(binding, runtime_authority(policy)?)
343        .await;
344    if policy.execution_cancel()?.is_cancelled() {
345        return Err(AgentError::Cancelled);
346    }
347    // No configuration proposal or checkpoint has started. A host stop that
348    // invalidated this read is an ordinary stop boundary, not a failed task.
349    if policy.stop.is_cancelled() {
350        return Ok(());
351    }
352    authority
353        .map_err(|_| super::fatal("Supervisor Loop authority revoked before application"))?
354        .ok_or_else(|| super::fatal("Supervisor Loop target disappeared"))?;
355    policy
356        .validate_current_owner(session)
357        .await
358        .map_err(|error| cancellation_wins(policy, error))?;
359    let mut records = scoped_loop_consumptions(session)
360        .map_err(|_| super::fatal("malformed Loop consumption proof"))?;
361    records.retain(|record| {
362        record.binding.owner_run_id == binding.owner_run_id
363            && record.binding.execution_id == binding.execution_id
364    });
365    let matching = has_scoped_loop_consumption(session, &envelope);
366    let mut staged = session.clone();
367    let mut accepted = None;
368    if !matching {
369        if records.len() >= MAX_SCOPED_LOOP_CONSUMPTIONS {
370            return Err(super::fatal("Supervisor Loop receipt capacity exhausted"));
371        }
372        let proposal = propose(session, config, policy, &input, rounds_spent).await?;
373        let (receipt, rejection) = match proposal {
374            Ok(state) => {
375                let receipt = state
376                    .control
377                    .as_ref()
378                    .and_then(|control| control.operations.last())
379                    .ok_or_else(|| super::fatal("shared Loop proposal lost receipt"))?
380                    .receipt
381                    .clone();
382                counted_loop::stage_state(&mut staged, state.clone())?;
383                accepted = Some(state);
384                (
385                    Some(
386                        serde_json::to_value(receipt)
387                            .map_err(|_| super::fatal("receipt encoding failed"))?,
388                    ),
389                    None,
390                )
391            }
392            Err(code) => (None, Some(code.to_string())),
393        };
394        records.push(ScopedLoopControlConsumption {
395            input_id: envelope.id.clone(),
396            input_sha256: hex::encode(Sha256::digest(canonical_json_bytes(
397                &envelope.idempotency_semantics(),
398            ))),
399            binding: binding.clone(),
400            operation_id: input.operation_id().into(),
401            configured: receipt.is_some(),
402            receipt,
403            rejection,
404        });
405        let encoded = serde_json::to_string(&records)
406            .map_err(|_| super::fatal("consumption encoding failed"))?;
407        if encoded.len() > 64 * 1024 {
408            return Err(super::fatal("Loop consumption checkpoint exceeds bound"));
409        }
410        staged
411            .metadata
412            .insert(SCOPED_LOOP_CONSUMPTION_KEY.into(), encoded);
413    }
414    staged
415        .session_inbox_admission_mut()
416        .record(envelope.id.clone(), claim.generation());
417    let mut bindings = scoped_loop_objective_origins(&staged)
418        .map_err(|_| super::fatal("invalid objective provenance"))?;
419    if !bindings.contains(binding) {
420        bindings.push(binding.clone());
421    }
422    if policy.execution_cancel()?.is_cancelled() {
423        return Err(AgentError::Cancelled);
424    }
425    if policy.stop.is_cancelled() {
426        return Ok(());
427    }
428    // From this point onward every physical I/O/unknown result is preserved;
429    // a concurrent host stop cannot turn a checkpoint error into success.
430    let checkpoint = checkpoint_scoped(&mut staged, config, &bindings, Some((inbox, claim))).await;
431    // The helper has either a confirmed checkpoint, a strict canonical reload,
432    // or an explicit volatile no-save fence. Never replace it with the proposal.
433    *session = staged;
434    if checkpoint.is_err() {
435        // A strict reload can reveal a partially/fully written generation even
436        // though ACK was not confirmed. Bind it only so existing error/cancel
437        // finalization can retire that observed generation; never continue it.
438        if !session.metadata.contains_key(SCOPED_LOOP_UNCERTAIN_KEY)
439            && has_scoped_loop_consumption(session, &envelope)
440        {
441            if let Some(state) = accepted.as_ref().filter(|state| {
442                counted_loop::read_loop_state(session)
443                    .ok()
444                    .flatten()
445                    .is_some_and(|actual| actual.generation == state.generation)
446            }) {
447                let _ = super::install_command_effects(policy, state, &input.command);
448            }
449        }
450        return checkpoint;
451    }
452    if let Some(state) = accepted {
453        super::install_command_effects(policy, &state, &input.command).map_err(super::fatal)?;
454    }
455    if policy.execution_cancel()?.is_cancelled() {
456        return Err(AgentError::Cancelled);
457    }
458    Ok(())
459}
460
461async fn propose(
462    session: &Session,
463    config: &AgentLoopConfig,
464    policy: &LoopControlConfig,
465    input: &ScopedLoopControlInput,
466    rounds_spent: u32,
467) -> Result<Result<LoopState, &'static str>, AgentError> {
468    input
469        .validate()
470        .map_err(|_| super::fatal("remote command outside host grant"))?;
471    let execution = policy
472        .execution
473        .get()
474        .ok_or_else(|| super::fatal("pipeline execution absent"))?;
475    let current = counted_loop::read_loop_state(session)?;
476    let hash = scoped_loop_operation_fingerprint(&input.binding, &input.command);
477    let prior = current
478        .as_ref()
479        .and_then(|state| state.control.as_ref())
480        .and_then(|control| {
481            control
482                .operations
483                .iter()
484                .find(|operation| operation.receipt.operation_id == input.operation_id())
485        });
486    if let Some(prior) = prior {
487        if prior.origin.as_ref() != Some(&input.binding) || prior.payload_sha256 != hash {
488            return Ok(Err("operation_payload_or_origin_conflict"));
489        }
490        if policy.generation() != Some(prior.receipt.generation.as_str())
491            || current
492                .as_ref()
493                .is_none_or(|state| state.owner_execution_id != execution.id)
494        {
495            return Ok(Err("generation_not_current_activation"));
496        }
497        // A prior successful input is recovered through its consumption proof,
498        // not by generating another state revision or selecting a newer receipt.
499        return Ok(Err("committed_operation_requires_existing_consumption"));
500    }
501    if policy.stop.is_cancelled() && !matches!(input.command, LoopControlRequest::Stop { .. }) {
502        return Ok(Err("host_stop_requested"));
503    }
504    if matches!(input.command, LoopControlRequest::Start { .. }) {
505        if let Some(reason) = super::suspension::start_refusal(session, config).await {
506            return Ok(Err(reason));
507        }
508        policy.validate_current_owner(session).await?;
509        if execution.cancel.is_cancelled() {
510            return Err(AgentError::Cancelled);
511        }
512    }
513    Ok(super::accept_command_at_boundary(
514        policy,
515        config,
516        current,
517        &input.command,
518        &execution.id,
519        rounds_spent,
520        hash,
521        Some(&input.binding),
522    ))
523}
524
525impl LoopControlConfig {
526    /// Installed before polling the runtime. The clone shares the actual live
527    /// binding and successful-cleanup bit, never recreating authority from disk.
528    pub(crate) fn bind_transport_cleanup(
529        &self,
530        registration: &mut crate::session_activation::SessionRunRegistration,
531        storage: Arc<dyn Storage>,
532        inbox: Option<Arc<dyn SessionInboxPort>>,
533        owner: Option<bamboo_domain::storage::RootActorRuntimeWrite>,
534    ) {
535        if self.supervisor_grant.is_none() {
536            return;
537        }
538        let policy = self.clone();
539        registration.set_scoped_transport_cleanup(move || {
540            let policy = policy.clone();
541            let storage = storage.clone();
542            let inbox = inbox.clone();
543            let owner = owner.clone();
544            async move {
545                policy
546                    .retire_transport(&storage, inbox, owner.as_ref())
547                    .await
548            }
549        });
550    }
551
552    async fn retire_transport(
553        &self,
554        storage: &Arc<dyn Storage>,
555        inbox: Option<Arc<dyn SessionInboxPort>>,
556        owner: Option<&bamboo_domain::storage::RootActorRuntimeWrite>,
557    ) -> std::io::Result<()> {
558        if self
559            .transport_retired
560            .load(std::sync::atomic::Ordering::Acquire)
561        {
562            return Ok(());
563        }
564        let Some(binding) = self.live_supervisor_binding.get() else {
565            return Ok(());
566        };
567        let ownership = self
568            .owner
569            .ownership()
570            .ok_or_else(|| std::io::Error::other("target retirement owner missing"))?;
571        ownership.close_scoped_transport(binding).await?;
572        let inbox =
573            inbox.ok_or_else(|| std::io::Error::other("target retirement Inbox missing"))?;
574        let retained_binding = binding.clone();
575        storage
576            .retire_scoped_loop_transport(bamboo_domain::storage::ScopedLoopTransportRetirement {
577                binding,
578                inbox,
579                owner,
580                retain: Arc::new(move || {
581                    ownership.retain_closed_scoped_transport(&retained_binding)
582                }),
583            })
584            .await?;
585        // Only the complete physical sweep, including ACK/cancel jobs, proves
586        // success. Subsequent registration finish adds no fallible post-Complete work.
587        self.transport_retired
588            .store(true, std::sync::atomic::Ordering::Release);
589        Ok(())
590    }
591}
592
593pub(crate) async fn retire_terminal_transport(config: &AgentLoopConfig) -> Result<(), AgentError> {
594    let Some(policy) = config
595        .loop_control
596        .as_ref()
597        .filter(|policy| policy.supervisor_grant_id().is_some())
598    else {
599        return Ok(());
600    };
601    let storage = config
602        .storage
603        .as_ref()
604        .ok_or_else(|| super::fatal("target retirement storage missing"))?;
605    policy
606        .retire_transport(
607            storage,
608            config.session_inbox.clone(),
609            config
610                .persistence
611                .as_ref()
612                .and_then(|persistence| persistence.root_actor_writer())
613                .as_ref(),
614        )
615        .await
616        .map_err(|error| super::fatal(&format!("target transport retirement failed: {error}")))
617}