Skip to main content

orchestral_runtime/
agent_control.rs

1//! Host-side control plane for one provider binding.
2//!
3//! This is the first production vertical slice over Agent Protocol v1. It owns
4//! normalized sequencing and the reducer projection for process-lifetime Runs.
5//! Durable storage is deliberately a later store implementation; callers must
6//! not mistake this in-memory controller for crash recovery.
7
8use std::collections::BTreeMap;
9use std::sync::Arc;
10use std::time::Duration;
11
12use futures_util::StreamExt;
13use orchestral_core::agent_protocol::{
14    reference::{
15        AgentContinuityState, AgentRunReducer, AgentRunStatus, ApplyOutcome,
16        ReconciliationProofVerifier, RecoveryReplayPolicy, SequencedApply,
17    },
18    spi::{
19        AgentJournalStore, AgentJournalStoreError, AgentProvider, AgentProviderStream,
20        AgentRecoveryRequest, AgentRunCatalogEntry, AgentRunRegistration, AgentStartError,
21        InMemoryAgentJournalStore, StoredAgentRun,
22    },
23    wire::{
24        AgentCommand, AgentCommandEnvelope, AgentEvent, AgentEventAuthority, AgentEventDraft,
25        AgentEventId, AgentExecutionRef, AgentJournalRecord, AgentProtocolError,
26        AgentProtocolErrorCode, AgentProviderStreamItem, AgentRunEnvelope, AgentRunState,
27        AgentRunView, AgentStartRequest, AgentTelemetryEnvelope, CommandAck, CommandId, Content,
28        Digest, ProviderBindingRef, ReconciliationProof, ReconciliationProofRef, RunId,
29    },
30};
31use thiserror::Error;
32use tokio::sync::{broadcast, Mutex, Notify, RwLock};
33use tokio::time::timeout;
34
35const RECOVERY_PREFIX_ITEM_TIMEOUT: Duration = Duration::from_secs(2);
36
37/// Ordered durable facts and best-effort live telemetry emitted by a Run.
38#[derive(Debug, Clone)]
39#[non_exhaustive]
40pub enum AgentControlEvent {
41    Durable(Arc<AgentJournalRecord>),
42    Telemetry(AgentTelemetryEnvelope),
43}
44
45/// Host control-plane failure. Run failures remain durable Agent events and
46/// are not represented by this infrastructure error.
47#[derive(Debug, Error)]
48#[non_exhaustive]
49pub enum AgentControlError {
50    #[error(transparent)]
51    Protocol(#[from] AgentProtocolError),
52    #[error(transparent)]
53    Start(#[from] AgentStartError),
54    #[error(transparent)]
55    Journal(#[from] AgentJournalStoreError),
56    #[error("Agent Run not found: {0}")]
57    RunNotFound(RunId),
58    #[error("Agent Run continuity is unknown: {0}")]
59    ContinuityUnknown(RunId),
60    #[error("Agent recovery evidence did not match the committed Run prefix: {0}")]
61    RecoveryMismatch(RunId),
62}
63
64struct RunEntry {
65    request: AgentStartRequest,
66    execution: AgentExecutionRef,
67    reducer: AgentRunReducer,
68    journal: Vec<AgentJournalRecord>,
69}
70
71struct RunSlot {
72    entry: Mutex<RunEntry>,
73    recovery_gate: Mutex<()>,
74    events: broadcast::Sender<AgentControlEvent>,
75    changed: Notify,
76}
77
78impl RunSlot {
79    fn publish(&self, event: AgentControlEvent) {
80        let _ = self.events.send(event);
81        self.changed.notify_waiters();
82    }
83}
84
85/// Process-lifetime Agent controller for one immutable Provider binding.
86///
87/// The controller, not the Provider, owns normalized `run_seq`, journal
88/// records, public inspection, command acknowledgement, and EOF-to-Unknown
89/// handling. One controller may host many isolated Runs.
90pub struct AgentController {
91    provider: Arc<dyn AgentProvider>,
92    binding_ref: ProviderBindingRef,
93    descriptor: orchestral_core::agent_protocol::wire::AgentDescriptorEnvelope,
94    journal_store: Arc<dyn AgentJournalStore>,
95    runs: RwLock<BTreeMap<RunId, Arc<RunSlot>>>,
96    start_gate: Mutex<()>,
97    event_buffer: usize,
98}
99
100impl AgentController {
101    pub fn new(
102        provider: Arc<dyn AgentProvider>,
103        binding_ref: ProviderBindingRef,
104    ) -> Result<Self, AgentProtocolError> {
105        Self::with_event_buffer(provider, binding_ref, 256)
106    }
107
108    pub fn with_event_buffer(
109        provider: Arc<dyn AgentProvider>,
110        binding_ref: ProviderBindingRef,
111        event_buffer: usize,
112    ) -> Result<Self, AgentProtocolError> {
113        Self::with_journal_store_and_event_buffer(
114            provider,
115            binding_ref,
116            Arc::new(InMemoryAgentJournalStore::default()),
117            event_buffer,
118        )
119    }
120
121    pub fn with_journal_store(
122        provider: Arc<dyn AgentProvider>,
123        binding_ref: ProviderBindingRef,
124        journal_store: Arc<dyn AgentJournalStore>,
125    ) -> Result<Self, AgentProtocolError> {
126        Self::with_journal_store_and_event_buffer(provider, binding_ref, journal_store, 256)
127    }
128
129    pub fn with_journal_store_and_event_buffer(
130        provider: Arc<dyn AgentProvider>,
131        binding_ref: ProviderBindingRef,
132        journal_store: Arc<dyn AgentJournalStore>,
133        event_buffer: usize,
134    ) -> Result<Self, AgentProtocolError> {
135        if binding_ref.is_empty() || event_buffer == 0 {
136            return Err(AgentProtocolError::new(
137                AgentProtocolErrorCode::InvalidSpec,
138                "Agent controller requires a Provider binding and a non-zero event buffer",
139            ));
140        }
141        let descriptor = provider.describe();
142        descriptor.validate_integrity()?;
143        Ok(Self {
144            provider,
145            binding_ref,
146            descriptor,
147            journal_store,
148            runs: RwLock::new(BTreeMap::new()),
149            start_gate: Mutex::new(()),
150            event_buffer,
151        })
152    }
153
154    pub fn descriptor(&self) -> &orchestral_core::agent_protocol::wire::AgentDescriptorEnvelope {
155        &self.descriptor
156    }
157
158    /// Starts or idempotently reopens one immutable Run and begins consuming
159    /// its atomic Provider stream in the background.
160    pub async fn start(
161        self: &Arc<Self>,
162        run: AgentRunEnvelope,
163    ) -> Result<AgentExecutionRef, AgentControlError> {
164        let request = AgentStartRequest::new(run, self.binding_ref.clone(), &self.descriptor)?;
165        let run_id = request.run.spec.run_id.clone();
166        let _start_guard = self.start_gate.lock().await;
167
168        if let Some(slot) = self.runs.read().await.get(&run_id).cloned() {
169            let entry = slot.entry.lock().await;
170            if entry.request != request {
171                return Err(AgentProtocolError::new(
172                    AgentProtocolErrorCode::RunIdConflict,
173                    "run_id already belongs to another immutable start request",
174                )
175                .into());
176            }
177            return Ok(entry.execution.clone());
178        }
179
180        if let Some(stored) = self.journal_store.load_run(&run_id).await? {
181            if stored.registration.request != request {
182                return Err(AgentProtocolError::new(
183                    AgentProtocolErrorCode::RunIdConflict,
184                    "run_id already belongs to another durable start request",
185                )
186                .into());
187            }
188            let slot = self.slot_from_stored(stored)?;
189            let execution = slot.entry.lock().await.execution.clone();
190            self.runs.write().await.insert(run_id.clone(), slot.clone());
191            let view = slot.entry.lock().await.reducer.view();
192            if !view.state.is_terminal() && view.state.status() != AgentRunStatus::Unknown {
193                self.mark_continuity_lost(
194                    &run_id,
195                    &slot,
196                    "Host controller restarted without a continuously attached Provider stream"
197                        .to_owned(),
198                )
199                .await?;
200            }
201            return Ok(execution);
202        }
203
204        let observed_descriptor = self.provider.describe();
205        if observed_descriptor != self.descriptor {
206            return Err(AgentProtocolError::new(
207                AgentProtocolErrorCode::RunIdConflict,
208                "Provider descriptor changed after controller binding",
209            )
210            .into());
211        }
212        let start = self.provider.start(request.clone()).await?;
213        start.execution.validate_for(&request, &self.descriptor)?;
214        let compatibility = self
215            .descriptor
216            .descriptor
217            .check_run_compatibility(&request.run)
218            .map_err(|rejection| {
219                AgentProtocolError::new(AgentProtocolErrorCode::Unsupported, rejection.to_string())
220                    .with_details(rejection.details)
221            })?;
222        start
223            .admission
224            .validate_against(&request.run, &compatibility)?;
225
226        let mut reducer = AgentRunReducer::new(
227            start.execution.clone(),
228            &request,
229            &self.descriptor,
230            start.admission.clone(),
231        )?;
232        let mut journal = Vec::with_capacity(start.admission.skipped_optional_bindings.len() + 4);
233        push_sequenced(
234            &mut journal,
235            reducer.apply_host_draft(AgentEventDraft {
236                event_id: AgentEventId::new(format!("host-run-accepted-{}", run_id.as_str())),
237                run_id: run_id.clone(),
238                causation_id: None,
239                source_fingerprint: None,
240                payload: AgentEvent::RunAccepted {
241                    session_id: request.run.spec.session_id.clone(),
242                    spec_digest: request.run.spec_digest.clone(),
243                },
244            })?,
245        );
246        for skip in &start.admission.skipped_optional_bindings {
247            push_sequenced(
248                &mut journal,
249                reducer.apply_host_draft(AgentEventDraft {
250                    event_id: AgentEventId::new(format!(
251                        "host-resource-skip-{}-{}",
252                        run_id.as_str(),
253                        skip.binding_id.as_str()
254                    )),
255                    run_id: run_id.clone(),
256                    causation_id: None,
257                    source_fingerprint: None,
258                    payload: AgentEvent::ResourceBindingSkipped { skip: skip.clone() },
259                })?,
260            );
261        }
262
263        let (events, _) = broadcast::channel(self.event_buffer);
264        self.journal_store
265            .create_run(StoredAgentRun {
266                registration: AgentRunRegistration {
267                    request: request.clone(),
268                    execution: start.execution.clone(),
269                    admission: start.admission.clone(),
270                },
271                records: journal.clone(),
272            })
273            .await?;
274        let slot = Arc::new(RunSlot {
275            entry: Mutex::new(RunEntry {
276                request,
277                execution: start.execution.clone(),
278                reducer,
279                journal: journal.clone(),
280            }),
281            recovery_gate: Mutex::new(()),
282            events,
283            changed: Notify::new(),
284        });
285        self.runs.write().await.insert(run_id.clone(), slot.clone());
286        for record in journal {
287            slot.publish(AgentControlEvent::Durable(Arc::new(record)));
288        }
289
290        let controller = Arc::clone(self);
291        tokio::spawn(async move {
292            controller.drive_stream(run_id, slot, start.stream).await;
293        });
294        Ok(start.execution)
295    }
296
297    /// Returns the bounded Host projection for a Run.
298    pub async fn inspect(&self, run_id: &RunId) -> Result<AgentRunView, AgentControlError> {
299        let slot = self.run_slot(run_id).await?;
300        let view = slot.entry.lock().await.reducer.view();
301        Ok(view)
302    }
303
304    /// Returns the immutable initial input from the registered Run spec.
305    ///
306    /// This is separate from the bounded public Run projection because it is
307    /// conversation content, not reducible execution state. Authenticated Host
308    /// surfaces can use it to reconstruct a transcript without duplicating the
309    /// input in a transport-specific registry.
310    pub async fn initial_input(&self, run_id: &RunId) -> Result<Vec<Content>, AgentControlError> {
311        let slot = self.run_slot(run_id).await?;
312        let input = slot.entry.lock().await.request.run.spec.input.clone();
313        Ok(input)
314    }
315
316    /// Returns digest-bound metadata from the immutable Run specification.
317    pub async fn run_extensions(
318        &self,
319        run_id: &RunId,
320    ) -> Result<orchestral_core::agent_protocol::wire::Extensions, AgentControlError> {
321        let slot = self.run_slot(run_id).await?;
322        let extensions = slot.entry.lock().await.request.run.spec.extensions.clone();
323        Ok(extensions)
324    }
325
326    /// Lists durable Runs directly from the Host journal. Transport-specific
327    /// session registries must not maintain a second Run ownership index.
328    pub async fn catalog_runs(&self) -> Result<Vec<AgentRunCatalogEntry>, AgentControlError> {
329        Ok(self.journal_store.catalog_runs().await?)
330    }
331
332    /// Reports whether a durable Run was registered against this controller's
333    /// current immutable Provider contract.
334    ///
335    /// Discovery surfaces use this before enriching a native session with a
336    /// Host-controlled Run. A Provider upgrade may legitimately change its
337    /// descriptor digest; those older journals remain durable history, but
338    /// they cannot be rehydrated or controlled by the new binding.
339    pub async fn can_control_run(&self, run_id: &RunId) -> Result<bool, AgentControlError> {
340        if let Some(slot) = self.runs.read().await.get(run_id).cloned() {
341            let entry = slot.entry.lock().await;
342            return Ok(self.registration_matches_current(&entry.request, &entry.execution));
343        }
344        let Some(stored) = self.journal_store.load_run(run_id).await? else {
345            return Ok(false);
346        };
347        stored.validate_shape()?;
348        Ok(self.registration_matches_current(
349            &stored.registration.request,
350            &stored.registration.execution,
351        ))
352    }
353
354    pub async fn has_run(&self, run_id: &RunId) -> Result<bool, AgentControlError> {
355        if self.runs.read().await.contains_key(run_id) {
356            return Ok(true);
357        }
358        Ok(self.journal_store.load_run(run_id).await?.is_some())
359    }
360
361    /// Replays normalized durable facts after the supplied Run sequence.
362    pub async fn events(
363        &self,
364        run_id: &RunId,
365        after_run_seq: u64,
366    ) -> Result<Vec<AgentJournalRecord>, AgentControlError> {
367        self.run_slot(run_id).await?;
368        Ok(self.journal_store.records(run_id, after_run_seq).await?)
369    }
370
371    /// Subscribes to events published after subscription. Call `events` first
372    /// (and again after broadcast lag) for lossless durable replay.
373    pub async fn subscribe(
374        &self,
375        run_id: &RunId,
376    ) -> Result<broadcast::Receiver<AgentControlEvent>, AgentControlError> {
377        Ok(self.run_slot(run_id).await?.events.subscribe())
378    }
379
380    /// Records, forwards, and durably projects one idempotent Provider command.
381    /// Replays return the existing Host acknowledgement without forwarding.
382    pub async fn command(
383        &self,
384        command: AgentCommandEnvelope,
385    ) -> Result<CommandAck, AgentControlError> {
386        command.verify_digest()?;
387        if orchestral_core::agent_protocol::wire::QueuedInputOperation::from_command(&command)?
388            .is_some()
389            && !orchestral_core::agent_protocol::wire::QueuedInputOperation::is_supported(
390                &self.descriptor.descriptor,
391            )
392        {
393            return Err(AgentProtocolError::new(
394                AgentProtocolErrorCode::Unsupported,
395                "this Agent does not support queued input; use immediate Steer explicitly",
396            )
397            .into());
398        }
399        let slot = self.run_slot(&command.run_id).await?;
400        let mut entry = slot.entry.lock().await;
401        if entry
402            .reducer
403            .recorded_command(&command.command_id)
404            .is_some_and(|recorded| recorded != &command)
405        {
406            return Err(AgentProtocolError::new(
407                AgentProtocolErrorCode::DuplicateConflict,
408                "command_id was already used with a different immutable command",
409            )
410            .into());
411        }
412        if let Ok(ack) = entry.reducer.command_ack(&command.command_id, true) {
413            return Ok(ack);
414        }
415
416        let mut next_reducer = entry.reducer.clone();
417        let received = next_reducer.apply_host_draft(AgentEventDraft {
418            event_id: AgentEventId::new(format!(
419                "host-command-received-{}-{}",
420                command.run_id.as_str(),
421                command.command_id.as_str()
422            )),
423            run_id: command.run_id.clone(),
424            causation_id: Some(command.command_id.clone()),
425            source_fingerprint: None,
426            payload: AgentEvent::CommandReceived {
427                command: command.clone(),
428            },
429        })?;
430        let duplicate = matches!(received.outcome, ApplyOutcome::ExactDuplicate);
431        self.commit_sequenced(&slot, &mut entry, next_reducer, received)
432            .await?;
433
434        let disposition = self
435            .provider
436            .command(&entry.execution, command.clone())
437            .await?;
438        disposition.validate_for(&command)?;
439        let mut next_reducer = entry.reducer.clone();
440        let disposition_event = next_reducer.apply_provider_draft(disposition.to_event_draft()?)?;
441        self.commit_sequenced(&slot, &mut entry, next_reducer, disposition_event)
442            .await?;
443        Ok(entry.reducer.command_ack(&command.command_id, duplicate)?)
444    }
445
446    /// Requests cancellation with a fresh idempotency identity. Callers that
447    /// need retry-stable command IDs should construct an `AgentCommandEnvelope`
448    /// once and use [`Self::command`] directly.
449    pub async fn cancel(
450        &self,
451        run_id: &RunId,
452        reason: impl Into<String>,
453    ) -> Result<CommandAck, AgentControlError> {
454        let command = AgentCommandEnvelope::new(
455            CommandId::new(format!("host-cancel-{}", uuid::Uuid::new_v4())),
456            run_id.clone(),
457            None,
458            AgentCommand::Cancel {
459                reason: reason.into(),
460            },
461        )?;
462        self.command(command).await
463    }
464
465    /// Reads immutable command identity without recovering or attaching native
466    /// work. Session-level retries use this even after the target Run ends.
467    pub async fn recorded_command(
468        &self,
469        run_id: &RunId,
470        command_id: &CommandId,
471    ) -> Result<Option<AgentCommandEnvelope>, AgentControlError> {
472        if let Some(slot) = self.runs.read().await.get(run_id).cloned() {
473            return Ok(slot
474                .entry
475                .lock()
476                .await
477                .reducer
478                .recorded_command(command_id)
479                .cloned());
480        }
481        let Some(stored) = self.journal_store.load_run(run_id).await? else {
482            return Ok(None);
483        };
484        stored.validate_shape()?;
485        Ok(stored
486            .records
487            .into_iter()
488            .find_map(|record| match record.event.payload {
489                AgentEvent::CommandReceived { command } if &command.command_id == command_id => {
490                    Some(command)
491                }
492                _ => None,
493            }))
494    }
495
496    pub async fn command_ack(
497        &self,
498        run_id: &RunId,
499        command_id: &CommandId,
500    ) -> Result<CommandAck, AgentControlError> {
501        let slot = self.run_slot(run_id).await?;
502        let entry = slot.entry.lock().await;
503        let ack = entry.reducer.command_ack(command_id, true)?;
504        Ok(ack)
505    }
506
507    /// Conservatively reattaches a Provider stream after a Host-recorded EOF.
508    ///
509    /// The reference controller requires the recovered stream to replay the
510    /// complete committed Provider prefix with stable event IDs and draft
511    /// digests. It restores continuity only after that prefix matches; opaque
512    /// adapters that cannot provide such evidence remain `Unknown` rather
513    /// than being guessed healthy.
514    pub async fn recover(
515        self: &Arc<Self>,
516        run_id: &RunId,
517    ) -> Result<AgentRunView, AgentControlError> {
518        let slot = self.run_slot(run_id).await?;
519        let _recovery_guard = slot.recovery_gate.lock().await;
520        let (
521            start_request,
522            execution,
523            expected_provider_prefix,
524            committed_provider_prefix,
525            last_confirmed_seq,
526            loss_event_digest,
527        ) = {
528            let entry = slot.entry.lock().await;
529            let AgentRunState::Unknown {
530                last_confirmed_seq, ..
531            } = entry.reducer.state()
532            else {
533                return Err(AgentProtocolError::new(
534                    AgentProtocolErrorCode::InvalidTransition,
535                    "recover requires a Run whose continuity is Unknown",
536                )
537                .into());
538            };
539            let loss_record = entry.journal.last().ok_or_else(|| {
540                AgentProtocolError::new(
541                    AgentProtocolErrorCode::InvalidTransition,
542                    "Unknown Run has no continuity-loss journal record",
543                )
544            })?;
545            if !matches!(
546                (&loss_record.authority, &loss_record.event.payload),
547                (
548                    AgentEventAuthority::Host { .. },
549                    AgentEvent::ContinuityLost { .. }
550                )
551            ) {
552                return Err(AgentProtocolError::new(
553                    AgentProtocolErrorCode::InvalidTransition,
554                    "automatic recovery is allowed only after a Host-recorded stream loss",
555                )
556                .into());
557            }
558            let pending_request_ids = entry
559                .reducer
560                .view()
561                .pending_requests
562                .iter()
563                .map(|request| request.request_id.clone())
564                .collect();
565            let provider_records = entry
566                .journal
567                .iter()
568                .filter(|record| {
569                    record.event.run_seq <= last_confirmed_seq
570                        && matches!(record.authority, AgentEventAuthority::Provider)
571                })
572                .collect::<Vec<_>>();
573            let prefix = provider_records
574                .iter()
575                .map(|record| {
576                    (
577                        record.event.event_id.clone(),
578                        record.draft_digest.clone(),
579                        record
580                            .event
581                            .payload
582                            .recovery_replay_policy(&pending_request_ids)
583                            == RecoveryReplayPolicy::ProviderEvidenceRequired,
584                    )
585                })
586                .collect::<Vec<_>>();
587            let committed_provider_prefix = provider_records
588                .iter()
589                .map(|record| AgentEventDraft {
590                    event_id: record.event.event_id.clone(),
591                    run_id: record.event.run_id.clone(),
592                    causation_id: record.event.causation_id.clone(),
593                    source_fingerprint: record.event.source_fingerprint.clone(),
594                    payload: record.event.payload.clone(),
595                })
596                .collect::<Vec<_>>();
597            (
598                entry.request.clone(),
599                entry.execution.clone(),
600                prefix,
601                committed_provider_prefix,
602                last_confirmed_seq,
603                loss_record.event.event_digest.clone(),
604            )
605        };
606
607        let recovery =
608            AgentRecoveryRequest::new(start_request, execution.clone(), &self.descriptor)?
609                .with_committed_provider_prefix(committed_provider_prefix)?;
610        let recovered = self.provider.recover(recovery).await?;
611        let (mut stream, confirmation) = recovered.into_parts();
612        let mut matched_digests = Vec::with_capacity(expected_provider_prefix.len());
613        let mut optional_stream_replays = Vec::new();
614        for (expected_event_id, expected_draft_digest, requires_stream_replay) in
615            &expected_provider_prefix
616        {
617            if !requires_stream_replay {
618                matched_digests.push(expected_draft_digest.clone());
619                optional_stream_replays
620                    .push((expected_event_id.clone(), expected_draft_digest.clone()));
621                continue;
622            }
623            loop {
624                let recovered_item = timeout(RECOVERY_PREFIX_ITEM_TIMEOUT, stream.next())
625                    .await
626                    .map_err(|_| AgentControlError::RecoveryMismatch(run_id.clone()))?;
627                match recovered_item {
628                    Some(Ok(AgentProviderStreamItem::Telemetry(telemetry))) => {
629                        telemetry.validate_integrity()?;
630                        if telemetry.run_id != *run_id {
631                            return Err(AgentControlError::RecoveryMismatch(run_id.clone()));
632                        }
633                        slot.publish(AgentControlEvent::Telemetry(telemetry));
634                    }
635                    Some(Ok(AgentProviderStreamItem::Event(draft))) => {
636                        let draft_digest = draft.computed_digest()?;
637                        if draft.event_id == *expected_event_id
638                            && draft_digest == *expected_draft_digest
639                        {
640                            // Any earlier optional command dispositions that
641                            // were not emitted are already proven by the Host
642                            // command journal. They cannot validly appear
643                            // after this later native observation.
644                            optional_stream_replays.clear();
645                            matched_digests.push(expected_draft_digest.clone());
646                            break;
647                        }
648                        if let Some(position) = optional_stream_replays.iter().position(
649                            |(optional_event_id, optional_digest)| {
650                                draft.event_id == *optional_event_id
651                                    && draft_digest == *optional_digest
652                            },
653                        ) {
654                            optional_stream_replays.drain(..=position);
655                            continue;
656                        }
657                        return Err(AgentControlError::RecoveryMismatch(run_id.clone()));
658                    }
659                    Some(Ok(_)) | Some(Err(_)) | None => {
660                        return Err(AgentControlError::RecoveryMismatch(run_id.clone()));
661                    }
662                }
663            }
664        }
665
666        let mut recovered_fingerprint = format!("{run_id}:{last_confirmed_seq}");
667        for digest in &matched_digests {
668            recovered_fingerprint.push(':');
669            recovered_fingerprint.push_str(digest.as_str());
670        }
671        let proof = ReconciliationProof::new(
672            ReconciliationProofRef::new(format!(
673                "host-recovery/{}/{}",
674                run_id.as_str(),
675                last_confirmed_seq
676            )),
677            last_confirmed_seq,
678            loss_event_digest,
679            Digest::sha256(recovered_fingerprint),
680        )?;
681        let verifier = ExactReconciliationVerifier {
682            execution: execution.clone(),
683            proof: proof.clone(),
684        };
685        {
686            let mut entry = slot.entry.lock().await;
687            let mut next_reducer = entry.reducer.clone();
688            let restored = next_reducer.apply_verified_reconciliation(
689                AgentEventDraft {
690                    event_id: AgentEventId::new(format!(
691                        "host-continuity-restored-{}-{}",
692                        run_id.as_str(),
693                        last_confirmed_seq
694                    )),
695                    run_id: run_id.clone(),
696                    causation_id: None,
697                    source_fingerprint: None,
698                    payload: AgentEvent::ContinuityRestored {
699                        proof,
700                        reason: "Provider recovery prefix matched the Host journal".to_owned(),
701                    },
702                },
703                &verifier,
704            )?;
705            self.commit_sequenced(&slot, &mut entry, next_reducer, restored)
706                .await?;
707        }
708
709        if let Err(error) = confirmation.await {
710            self.mark_continuity_lost(
711                run_id,
712                &slot,
713                format!("Provider could not resume after reconciliation: {error}"),
714            )
715            .await?;
716            return Err(error.into());
717        }
718
719        let controller = Arc::clone(self);
720        let recovered_run_id = run_id.clone();
721        let recovered_slot = slot.clone();
722        tokio::spawn(async move {
723            controller
724                .drive_stream(recovered_run_id, recovered_slot, stream)
725                .await;
726        });
727        self.inspect(run_id).await
728    }
729
730    /// Waits until the Run reaches an authoritative terminal. Unknown is
731    /// returned explicitly rather than guessed as success or cancellation.
732    pub async fn wait_for_terminal(
733        &self,
734        run_id: &RunId,
735    ) -> Result<AgentRunView, AgentControlError> {
736        let slot = self.run_slot(run_id).await?;
737        loop {
738            let changed = slot.changed.notified();
739            let view = slot.entry.lock().await.reducer.view();
740            if view.state.is_terminal() {
741                return Ok(view);
742            }
743            if view.state.status() == AgentRunStatus::Unknown {
744                return Err(AgentControlError::ContinuityUnknown(run_id.clone()));
745            }
746            changed.await;
747        }
748    }
749
750    async fn run_slot(&self, run_id: &RunId) -> Result<Arc<RunSlot>, AgentControlError> {
751        if let Some(slot) = self.runs.read().await.get(run_id).cloned() {
752            return Ok(slot);
753        }
754        let _start_guard = self.start_gate.lock().await;
755        if let Some(slot) = self.runs.read().await.get(run_id).cloned() {
756            return Ok(slot);
757        }
758        let stored = self
759            .journal_store
760            .load_run(run_id)
761            .await?
762            .ok_or_else(|| AgentControlError::RunNotFound(run_id.clone()))?;
763        let slot = self.slot_from_stored(stored)?;
764        self.runs.write().await.insert(run_id.clone(), slot.clone());
765        let view = slot.entry.lock().await.reducer.view();
766        if !view.state.is_terminal() && view.state.status() != AgentRunStatus::Unknown {
767            self.mark_continuity_lost(
768                run_id,
769                &slot,
770                "Host controller restarted without a continuously attached Provider stream"
771                    .to_owned(),
772            )
773            .await?;
774        }
775        Ok(slot)
776    }
777
778    fn slot_from_stored(&self, stored: StoredAgentRun) -> Result<Arc<RunSlot>, AgentControlError> {
779        stored.validate_shape()?;
780        let registration = &stored.registration;
781        registration
782            .execution
783            .validate_for(&registration.request, &self.descriptor)?;
784        let compatibility = self
785            .descriptor
786            .descriptor
787            .check_run_compatibility(&registration.request.run)
788            .map_err(|rejection| {
789                AgentProtocolError::new(AgentProtocolErrorCode::Unsupported, rejection.to_string())
790                    .with_details(rejection.details)
791            })?;
792        registration
793            .admission
794            .validate_against(&registration.request.run, &compatibility)?;
795        let mut reducer = AgentRunReducer::new(
796            registration.execution.clone(),
797            &registration.request,
798            &self.descriptor,
799            registration.admission.clone(),
800        )?;
801        for record in &stored.records {
802            reducer.replay_journal_record(record.clone())?;
803        }
804        let (events, _) = broadcast::channel(self.event_buffer);
805        Ok(Arc::new(RunSlot {
806            entry: Mutex::new(RunEntry {
807                request: registration.request.clone(),
808                execution: registration.execution.clone(),
809                reducer,
810                journal: stored.records,
811            }),
812            recovery_gate: Mutex::new(()),
813            events,
814            changed: Notify::new(),
815        }))
816    }
817
818    fn registration_matches_current(
819        &self,
820        request: &AgentStartRequest,
821        execution: &AgentExecutionRef,
822    ) -> bool {
823        request.provider_binding == self.binding_ref
824            && request.expected_descriptor_digest == self.descriptor.descriptor_digest
825            && execution.binding_ref == self.binding_ref
826            && execution.descriptor_digest == self.descriptor.descriptor_digest
827    }
828
829    async fn drive_stream(
830        self: Arc<Self>,
831        run_id: RunId,
832        slot: Arc<RunSlot>,
833        mut stream: AgentProviderStream,
834    ) {
835        let mut end_reason = "Provider stream ended before an authoritative terminal".to_owned();
836        while let Some(item) = stream.next().await {
837            match item {
838                Ok(AgentProviderStreamItem::Event(draft)) => {
839                    let mut entry = slot.entry.lock().await;
840                    let mut next_reducer = entry.reducer.clone();
841                    match next_reducer.apply_provider_draft(*draft) {
842                        Ok(sequenced) => {
843                            if let Err(error) = self
844                                .commit_sequenced(&slot, &mut entry, next_reducer, sequenced)
845                                .await
846                            {
847                                end_reason =
848                                    format!("Provider event could not be journaled: {error}");
849                                break;
850                            }
851                            if entry.reducer.state().is_terminal()
852                                || entry.reducer.state().status() == AgentRunStatus::Unknown
853                            {
854                                return;
855                            }
856                        }
857                        Err(error) => {
858                            end_reason = format!("Provider event was rejected: {error}");
859                            break;
860                        }
861                    }
862                }
863                Ok(AgentProviderStreamItem::Telemetry(telemetry)) => {
864                    if let Err(error) = telemetry.validate_integrity() {
865                        end_reason = format!("Provider telemetry was invalid: {error}");
866                        break;
867                    }
868                    if telemetry.run_id != run_id {
869                        end_reason = "Provider telemetry crossed a Run boundary".to_owned();
870                        break;
871                    }
872                    slot.publish(AgentControlEvent::Telemetry(telemetry));
873                }
874                Ok(_) => {
875                    end_reason = "Provider emitted an unsupported stream item".to_owned();
876                    break;
877                }
878                Err(error) => {
879                    end_reason = format!("Provider stream failed: {error}");
880                    break;
881                }
882            }
883        }
884        if let Err(error) = self.mark_continuity_lost(&run_id, &slot, end_reason).await {
885            tracing::error!(run_id = %run_id, error = %error, "failed to journal continuity loss");
886        }
887    }
888
889    async fn commit_sequenced(
890        &self,
891        slot: &RunSlot,
892        entry: &mut RunEntry,
893        next_reducer: AgentRunReducer,
894        sequenced: SequencedApply,
895    ) -> Result<(), AgentControlError> {
896        if matches!(sequenced.outcome, ApplyOutcome::ExactDuplicate) {
897            return Ok(());
898        }
899        let expected_previous = entry.reducer.view().last_run_seq.unwrap_or(0);
900        self.journal_store
901            .append_record(
902                &entry.execution.run_id,
903                expected_previous,
904                sequenced.record.clone(),
905            )
906            .await?;
907        entry.reducer = next_reducer;
908        entry.journal.push(sequenced.record.clone());
909        slot.publish(AgentControlEvent::Durable(Arc::new(sequenced.record)));
910        Ok(())
911    }
912
913    async fn mark_continuity_lost(
914        &self,
915        run_id: &RunId,
916        slot: &Arc<RunSlot>,
917        reason: String,
918    ) -> Result<(), AgentControlError> {
919        let mut entry = slot.entry.lock().await;
920        let view = entry.reducer.view();
921        if view.state.is_terminal() || view.state.status() == AgentRunStatus::Unknown {
922            return Ok(());
923        }
924        let last_confirmed_seq = view.last_run_seq.unwrap_or(0);
925        let draft = AgentEventDraft {
926            event_id: AgentEventId::new(format!(
927                "host-continuity-lost-{}-{}",
928                run_id.as_str(),
929                last_confirmed_seq + 1
930            )),
931            run_id: run_id.clone(),
932            causation_id: None,
933            source_fingerprint: None,
934            payload: AgentEvent::ContinuityLost {
935                last_confirmed_seq,
936                reason,
937            },
938        };
939        let mut next_reducer = entry.reducer.clone();
940        let sequenced = next_reducer.apply_host_draft(draft)?;
941        self.commit_sequenced(slot, &mut entry, next_reducer, sequenced)
942            .await?;
943        Ok(())
944    }
945}
946
947fn push_sequenced(journal: &mut Vec<AgentJournalRecord>, sequenced: SequencedApply) {
948    if !matches!(sequenced.outcome, ApplyOutcome::ExactDuplicate) {
949        journal.push(sequenced.record);
950    }
951}
952
953struct ExactReconciliationVerifier {
954    execution: AgentExecutionRef,
955    proof: ReconciliationProof,
956}
957
958impl ReconciliationProofVerifier for ExactReconciliationVerifier {
959    fn verify(
960        &self,
961        execution: &AgentExecutionRef,
962        continuity: &AgentContinuityState,
963        proof: &ReconciliationProof,
964    ) -> Result<(), AgentProtocolError> {
965        if execution != &self.execution || proof != &self.proof {
966            return Err(AgentProtocolError::new(
967                AgentProtocolErrorCode::InvalidDigest,
968                "reconciliation proof is not the controller-verified evidence",
969            ));
970        }
971        let AgentContinuityState::Unknown {
972            last_confirmed_seq,
973            loss_event_digest,
974            ..
975        } = continuity
976        else {
977            return Err(AgentProtocolError::new(
978                AgentProtocolErrorCode::InvalidTransition,
979                "reconciliation requires Unknown continuity",
980            ));
981        };
982        if *last_confirmed_seq != proof.last_confirmed_seq
983            || *loss_event_digest != proof.loss_event_digest
984        {
985            return Err(AgentProtocolError::new(
986                AgentProtocolErrorCode::InvalidDigest,
987                "reconciliation proof is not bound to the current continuity loss",
988            ));
989        }
990        proof.verify_integrity()
991    }
992}