Skip to main content

awaken_server/
mailbox.rs

1//! Mailbox service: persistent run queue, dispatch execution, leasing, and lifecycle management.
2
3use std::collections::{HashMap, VecDeque};
4use std::sync::Arc;
5use std::sync::Mutex as StdMutex;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::time::Duration;
8
9use async_trait::async_trait;
10use parking_lot::Mutex as SyncMutex;
11use thiserror::Error;
12use tokio::sync::{Mutex, RwLock};
13use tokio::task::JoinHandle;
14
15use awaken_runtime::{ResolveError, RunActivation};
16use awaken_server_contract::contract::commit_coordinator::CommitCoordinator;
17use awaken_server_contract::contract::event::AgentEvent;
18use awaken_server_contract::contract::event_sink::EventSink;
19use awaken_server_contract::contract::mailbox::{MailboxStore, RunDispatchStatus};
20use awaken_server_contract::contract::message::Message;
21use awaken_server_contract::contract::run::{
22    RunActivationSnapshot, RunInputSnapshot, RunIntent, RunKind, RunOptions, RunTraceContext,
23};
24use awaken_server_contract::contract::staged_commit::{
25    OutboxServerEventPublisher, StagedCommitCoordinator,
26};
27use awaken_server_contract::contract::storage::{RunRecord, StorageError, ThreadRunStore};
28use awaken_server_contract::contract::suspension::{ToolCallOutcome, ToolCallResume};
29use awaken_server_contract::contract::tool_intercept::{AdapterKind, RunMode};
30
31use crate::transport::channel_sink::ReconnectableEventSink;
32
33/// Guard window for inline-claimed dispatches: if the process crashes between
34/// enqueue and claim, the sweep will reclaim the dispatch after this period.
35const INLINE_CLAIM_GUARD_MS: u64 = 60_000;
36#[cfg(not(test))]
37const REMOTE_CANCEL_WAIT_MS: u64 = 5_000;
38#[cfg(test)]
39const REMOTE_CANCEL_WAIT_MS: u64 = 250;
40const REMOTE_CANCEL_POLL_MS: u64 = 25;
41const DISPATCH_SIGNAL_BATCH_DEFAULT: usize = 32;
42const DISPATCH_SIGNAL_EXPIRES_DEFAULT: Duration = Duration::from_millis(500);
43const DISPATCH_SIGNAL_ERROR_DELAY: Duration = Duration::from_millis(250);
44const DISPATCH_SIGNAL_BLOCKED_NACK_BASE_DELAY_DEFAULT: Duration = Duration::from_millis(500);
45const DISPATCH_SIGNAL_BLOCKED_NACK_MAX_DELAY_DEFAULT: Duration = Duration::from_secs(30);
46const DISPATCH_SIGNAL_BATCH_ENV: &str = "AWAKEN_DISPATCH_SIGNAL_BATCH_SIZE";
47const DISPATCH_SIGNAL_EXPIRES_ENV: &str = "AWAKEN_DISPATCH_SIGNAL_FETCH_EXPIRES_MS";
48const DISPATCH_SIGNAL_NACK_BASE_DELAY_ENV: &str = "AWAKEN_DISPATCH_SIGNAL_NACK_BASE_DELAY_MS";
49const DISPATCH_SIGNAL_NACK_MAX_DELAY_ENV: &str = "AWAKEN_DISPATCH_SIGNAL_NACK_MAX_DELAY_MS";
50const DISPATCH_SIGNAL_MAX_CONCURRENT_HANDLERS_DEFAULT: usize = 32;
51const DISPATCH_SIGNAL_MAX_CONCURRENT_HANDLERS_ENV: &str =
52    "AWAKEN_DISPATCH_SIGNAL_MAX_CONCURRENT_HANDLERS";
53const TERMINAL_RECONCILE_BATCH: usize = 100;
54const MAILBOX_DEPTH_STATUSES: [RunDispatchStatus; 6] = [
55    RunDispatchStatus::Queued,
56    RunDispatchStatus::Claimed,
57    RunDispatchStatus::Acked,
58    RunDispatchStatus::Cancelled,
59    RunDispatchStatus::Superseded,
60    RunDispatchStatus::DeadLetter,
61];
62
63/// Validation message returned when an inline submit loses the active-run race.
64pub(crate) const ACTIVE_RUN_CONFLICT_MESSAGE: &str =
65    "thread has an active run; cannot claim inline";
66
67pub(super) fn run_activation_snapshot(
68    request: &RunActivation,
69    persisted_input: RunInputSnapshot,
70    resolution_id: Option<String>,
71) -> RunActivationSnapshot {
72    RunActivationSnapshot {
73        intent: request.intent.clone(),
74        input: persisted_input,
75        options: request.options.clone(),
76        trace: request.trace.clone(),
77        seeded_decisions: request.control.seeded_decisions.clone(),
78        resolution_id,
79    }
80}
81
82// ── Legacy run snapshot conversion ───────────────────────────────
83
84/// Typed envelope for RunActivation fields that Mailbox stores opaquely.
85/// Centralizes the legacy run snapshot round-trip round-trip.
86#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
87pub(super) struct LegacyRunSnapshotExtras {
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    overrides: Option<awaken_server_contract::contract::inference::InferenceOverride>,
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    decisions: Vec<(
92        String,
93        awaken_server_contract::contract::suspension::ToolCallResume,
94    )>,
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    frontend_tools: Vec<awaken_server_contract::contract::tool::ToolDescriptor>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    continue_run_id: Option<String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    run_id_hint: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    dispatch_id_hint: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    parent_thread_id: Option<String>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    transport_request_id: Option<String>,
107    #[serde(default)]
108    run_mode: RunMode,
109    #[serde(default)]
110    adapter: AdapterKind,
111}
112
113impl LegacyRunSnapshotExtras {
114    #[cfg(test)]
115    fn from_request(request: &awaken_runtime::RunActivation) -> Self {
116        Self {
117            overrides: request.options.overrides.clone(),
118            decisions: request.control.seeded_decisions.clone(),
119            frontend_tools: request.options.frontend_tools.clone(),
120            continue_run_id: request.resume_run_id().map(str::to_owned),
121            run_id_hint: request.persistence.run_id_hint.clone(),
122            dispatch_id_hint: request.persistence.dispatch_id_hint.clone(),
123            parent_thread_id: request.trace.parent_thread_id.clone(),
124            transport_request_id: request.trace.transport_request_id.clone(),
125            run_mode: request.trace.run_mode,
126            adapter: request.trace.adapter,
127        }
128    }
129
130    #[cfg(test)]
131    fn to_value(&self) -> Result<Option<serde_json::Value>, serde_json::Error> {
132        if self.overrides.is_none()
133            && self.decisions.is_empty()
134            && self.frontend_tools.is_empty()
135            && self.continue_run_id.is_none()
136            && self.run_id_hint.is_none()
137            && self.dispatch_id_hint.is_none()
138            && self.parent_thread_id.is_none()
139            && self.transport_request_id.is_none()
140            && self.run_mode == RunMode::Foreground
141            && self.adapter == AdapterKind::Internal
142        {
143            Ok(None)
144        } else {
145            serde_json::to_value(self).map(Some)
146        }
147    }
148
149    fn from_value(value: &serde_json::Value) -> Result<Self, serde_json::Error> {
150        serde_json::from_value(value.clone())
151    }
152
153    #[cfg(test)]
154    fn apply_to(self, mut request: awaken_runtime::RunActivation) -> awaken_runtime::RunActivation {
155        if let Some(ov) = self.overrides {
156            request = request.with_overrides(ov);
157        }
158        if !self.decisions.is_empty() {
159            request = request.with_decisions(self.decisions);
160        }
161        if !self.frontend_tools.is_empty() {
162            request = request.with_frontend_tools(self.frontend_tools);
163        }
164        if let Some(crid) = self.continue_run_id {
165            request = request.with_continue_run_id(crid);
166        }
167        if let Some(run_id_hint) = self.run_id_hint {
168            request = request.with_run_id_hint(run_id_hint);
169        }
170        if let Some(dispatch_id_hint) = self.dispatch_id_hint {
171            request = request.with_dispatch_id_hint(dispatch_id_hint);
172        }
173        if let Some(parent_thread_id) = self.parent_thread_id {
174            request = request.with_parent_thread_id(parent_thread_id);
175        }
176        if let Some(transport_request_id) = self.transport_request_id {
177            request = request.with_transport_request_id(transport_request_id);
178        }
179        request
180            .with_run_mode(self.run_mode)
181            .with_adapter(self.adapter)
182    }
183}
184
185pub(super) struct LegacyRunRequestSnapshotAdapter {
186    pub snapshot: awaken_server_contract::contract::storage::RunRequestSnapshot,
187    pub input: RunInputSnapshot,
188    pub resolution_id: Option<String>,
189    pub thread_id: String,
190    pub agent_id: Option<String>,
191    pub parent_run_id: Option<String>,
192    pub extras: Option<LegacyRunSnapshotExtras>,
193}
194
195impl TryFrom<LegacyRunRequestSnapshotAdapter> for RunActivationSnapshot {
196    type Error = String;
197
198    fn try_from(value: LegacyRunRequestSnapshotAdapter) -> Result<Self, Self::Error> {
199        let mut options = RunOptions {
200            overrides: None,
201            frontend_tools: value.snapshot.frontend_tools,
202        };
203        let mut trace = RunTraceContext {
204            parent_run_id: value.parent_run_id,
205            parent_thread_id: value.snapshot.parent_thread_id,
206            origin: value.snapshot.origin.into(),
207            adapter: AdapterKind::Internal,
208            run_mode: RunMode::Resume,
209            dispatch_id: None,
210            session_id: None,
211            transport_request_id: value.snapshot.transport_request_id,
212            correlation_id: None,
213        };
214        let mut kind = RunKind::NewIntent;
215        let mut seeded_decisions = value
216            .snapshot
217            .decisions
218            .into_iter()
219            .map(|decision| (decision.call_id, decision.resume))
220            .collect::<Vec<_>>();
221
222        if let Some(extras) = value.extras {
223            if let Some(overrides) = extras.overrides {
224                options.overrides = Some(overrides);
225            }
226            if !extras.frontend_tools.is_empty() {
227                options.frontend_tools = extras.frontend_tools;
228            }
229            if !extras.decisions.is_empty() {
230                seeded_decisions = extras.decisions;
231            }
232            if let Some(run_id) = extras.continue_run_id {
233                kind = RunKind::HitlResume { run_id };
234            }
235            if let Some(parent_thread_id) = extras.parent_thread_id {
236                trace.parent_thread_id = Some(parent_thread_id);
237            }
238            if let Some(transport_request_id) = extras.transport_request_id {
239                trace.transport_request_id = Some(transport_request_id);
240            }
241            trace.run_mode = extras.run_mode;
242            trace.adapter = extras.adapter;
243        }
244
245        Ok(RunActivationSnapshot {
246            intent: RunIntent {
247                agent_id: value.agent_id,
248                thread_id: value.thread_id,
249                kind,
250            },
251            input: value.input,
252            options,
253            trace,
254            seeded_decisions,
255            resolution_id: value.resolution_id,
256        })
257    }
258}
259
260pub(super) fn legacy_input_snapshot(
261    run: &RunRecord,
262    snapshot: &awaken_server_contract::contract::storage::RunRequestSnapshot,
263) -> RunInputSnapshot {
264    if let Some(input) = run.input.as_ref() {
265        return RunInputSnapshot {
266            thread_id: input.thread_id.clone(),
267            range: input.range,
268            trigger_message_ids: input.trigger_message_ids.clone(),
269            selected_message_ids: input.selected_message_ids.clone(),
270            context_policy: input.context_policy.clone(),
271            compacted_snapshot_id: input.compacted_snapshot_id.clone(),
272        };
273    }
274    RunInputSnapshot {
275        thread_id: run.thread_id.clone(),
276        range: None,
277        trigger_message_ids: snapshot.input_message_ids.clone(),
278        selected_message_ids: Vec::new(),
279        context_policy: None,
280        compacted_snapshot_id: None,
281    }
282}
283
284// ── TaskDoneMailboxNotify ────────────────────────────────────────────
285
286/// Fallback for inbox delivery when the agent's run has ended.
287///
288/// Implements [`OnInboxClosed`](awaken_runtime::inbox::OnInboxClosed) — when an `InboxSender::send()` fails
289/// because the receiver was dropped (agent run returned with AwaitingTasks),
290/// this enqueues a mailbox wake dispatch so the thread gets a continuation run.
291pub struct TaskDoneMailboxNotify {
292    mailbox: Arc<Mailbox>,
293    thread_id: String,
294    continue_run_id: Option<String>,
295}
296
297impl TaskDoneMailboxNotify {
298    pub fn new(mailbox: Arc<Mailbox>, thread_id: String, continue_run_id: Option<String>) -> Self {
299        Self {
300            mailbox,
301            thread_id,
302            continue_run_id,
303        }
304    }
305}
306
307impl awaken_runtime::inbox::OnInboxClosed for TaskDoneMailboxNotify {
308    fn closed(&self, message: &serde_json::Value) {
309        let mailbox = self.mailbox.clone();
310        let thread_id = self.thread_id.clone();
311        let continue_run_id = self.continue_run_id.clone();
312        let wake_message = awaken_runtime::inbox::inbox_event_message(message);
313
314        // Spawn because OnInboxClosed::closed is sync but enqueue+dispatch is async
315        tokio::spawn(async move {
316            let mut request = RunActivation::new(thread_id.clone(), vec![wake_message])
317                .with_origin(awaken_server_contract::contract::storage::RunRequestOrigin::Internal)
318                .with_run_mode(RunMode::InternalWake)
319                .with_adapter(AdapterKind::Internal);
320            if let Some(run_id) = continue_run_id {
321                request = request.with_continue_run_id(run_id);
322            }
323            if let Err(e) = mailbox.submit_background(request).await {
324                tracing::warn!(thread_id, error = %e, "failed to enqueue background task wake dispatch");
325            }
326        });
327    }
328}
329
330// ── Public types ─────────────────────────────────────────────────────
331
332/// Result returned by submit/submit_background.
333#[derive(Debug, Clone)]
334pub struct MailboxSubmitResult {
335    pub dispatch_id: String,
336    pub run_id: String,
337    pub thread_id: String,
338    pub status: MailboxDispatchStatus,
339}
340
341/// Dispatch status for a submitted run activation.
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum MailboxDispatchStatus {
344    /// Job was claimed and is executing now.
345    Running,
346    /// Job is queued, waiting for the current run to finish.
347    Queued,
348}
349
350/// Mailbox service errors.
351#[derive(Debug, Error)]
352pub enum MailboxError {
353    #[error("validation error: {0}")]
354    Validation(String),
355    #[error("store error: {0}")]
356    Store(#[from] StorageError),
357    #[error("resolution error while {context}: {source}")]
358    Resolution {
359        context: &'static str,
360        source: ResolveError,
361    },
362    #[error("internal error: {0}")]
363    Internal(String),
364    /// A foreground submit cannot be consumed because a barrier pending entry
365    /// ahead of it must be consumed first. Returned by the submit preflight
366    /// *before* any interrupt/cancel side effect, so a blocked submit never
367    /// cancels the active run (ADR-0042 D6: barriers are never skipped).
368    #[error("delivery blocked by barrier: pending '{blocking_pending_id}' must be consumed first")]
369    DeliveryBlockedByBarrier { blocking_pending_id: String },
370}
371
372/// Outcome classification for runtime run results.
373#[derive(Debug)]
374pub enum MailboxRunOutcome {
375    /// Run completed successfully.
376    Completed,
377    /// Transient infrastructure failure -- retry.
378    TransientError(String),
379    /// Permanent failure -- do not retry.
380    PermanentError(String),
381}
382
383impl MailboxRunOutcome {
384    fn metric_label(&self) -> &'static str {
385        match self {
386            Self::Completed => "completed",
387            Self::TransientError(_) => "transient_error",
388            Self::PermanentError(_) => "permanent_error",
389        }
390    }
391}
392
393/// Configuration for the Mailbox service.
394#[derive(Debug, Clone)]
395pub struct MailboxConfig {
396    /// Lease duration in milliseconds (default 30_000).
397    pub lease_ms: u64,
398    /// Lease duration in milliseconds when the run is suspended/waiting
399    /// for human input (default 600_000 = 10 minutes).
400    pub suspended_lease_ms: u64,
401    /// How often to renew leases (default 10s).
402    pub lease_renewal_interval: Duration,
403    /// How often to sweep for expired leases (default 30s).
404    pub sweep_interval: Duration,
405    /// How often to run GC for terminal dispatches (default 60s).
406    pub gc_interval: Duration,
407    /// How long to keep terminal dispatches before purging (default 24h).
408    pub gc_ttl: Duration,
409    /// Default max attempts before dead-lettering (default 5).
410    pub default_max_attempts: u32,
411    /// Default retry delay in milliseconds (default 250).
412    pub default_retry_delay_ms: u64,
413    /// Maximum retry delay in milliseconds for exponential backoff (default 30_000).
414    pub max_retry_delay_ms: u64,
415}
416
417impl Default for MailboxConfig {
418    fn default() -> Self {
419        Self {
420            lease_ms: 30_000,
421            suspended_lease_ms: 600_000,
422            lease_renewal_interval: Duration::from_secs(10),
423            sweep_interval: Duration::from_secs(30),
424            gc_interval: Duration::from_secs(60),
425            gc_ttl: Duration::from_secs(24 * 60 * 60),
426            default_max_attempts: 5,
427            default_retry_delay_ms: 250,
428            max_retry_delay_ms: 30_000,
429        }
430    }
431}
432
433/// Callback invoked during mailbox maintenance GC ticks.
434pub type MailboxMaintenanceCallback = Arc<dyn Fn() + Send + Sync + 'static>;
435
436/// Startup recovery retry settings used by lifecycle startup.
437#[derive(Clone)]
438pub struct MailboxStartupRecoveryConfig {
439    /// Maximum recovery attempts before giving up. Values below 1 are treated
440    /// as one attempt.
441    pub max_attempts: u32,
442    /// Delay between failed recovery attempts.
443    pub retry_delay: Duration,
444}
445
446impl Default for MailboxStartupRecoveryConfig {
447    fn default() -> Self {
448        Self {
449            max_attempts: 1,
450            retry_delay: Duration::from_millis(250),
451        }
452    }
453}
454
455/// Configuration for framework-managed mailbox lifecycle tasks.
456#[derive(Clone)]
457pub struct MailboxLifecycleConfig {
458    /// Delay before startup recovery and maintenance begin.
459    pub startup_delay: Duration,
460    /// Retry policy for startup recovery.
461    pub startup_recovery: MailboxStartupRecoveryConfig,
462    /// Optional cleanup hook for application-owned resources.
463    pub maintenance_callback: Option<MailboxMaintenanceCallback>,
464}
465
466impl Default for MailboxLifecycleConfig {
467    fn default() -> Self {
468        Self {
469            startup_delay: Duration::ZERO,
470            startup_recovery: MailboxStartupRecoveryConfig::default(),
471            maintenance_callback: None,
472        }
473    }
474}
475
476/// Handle for framework-managed mailbox lifecycle tasks.
477///
478/// Dropping the handle does not stop lifecycle tasks. Call [`shutdown`](Self::shutdown)
479/// for quiescent shutdown or [`abort`](Self::abort) for fire-and-forget stop.
480#[derive(Clone)]
481pub struct MailboxLifecycleHandle {
482    tasks: Arc<StdMutex<Option<MailboxLifecycleTasks>>>,
483    transition_lock: Arc<Mutex<()>>,
484}
485
486impl MailboxLifecycleHandle {
487    /// Abort lifecycle tasks. This is idempotent.
488    pub fn abort(&self) {
489        if let Some(tasks) = self.tasks.lock().expect("lifecycle lock poisoned").take() {
490            tasks.abort();
491        }
492    }
493
494    /// Abort lifecycle tasks and wait until they have fully exited.
495    ///
496    /// This is the quiescent shutdown path. Use it when a caller needs a hard
497    /// guarantee that a subsequent lifecycle start cannot overlap old recovery
498    /// or maintenance tasks.
499    pub async fn shutdown(&self) -> Result<(), MailboxError> {
500        let _transition_guard = self.transition_lock.lock().await;
501        let tasks = self.tasks.lock().expect("lifecycle lock poisoned").take();
502        if let Some(tasks) = tasks {
503            tasks.shutdown().await?;
504        }
505        Ok(())
506    }
507
508    /// Returns true while lifecycle tasks are registered for this mailbox.
509    pub fn is_running(&self) -> bool {
510        self.tasks
511            .lock()
512            .expect("lifecycle lock poisoned")
513            .is_some()
514    }
515}
516
517struct MailboxLifecycleTasks {
518    recover_task: Option<JoinHandle<()>>,
519    dispatch_signal_task: Option<JoinHandle<()>>,
520    maintenance_task: JoinHandle<()>,
521}
522
523impl MailboxLifecycleTasks {
524    fn abort(self) {
525        if let Some(task) = self.recover_task {
526            task.abort();
527        }
528        if let Some(task) = self.dispatch_signal_task {
529            task.abort();
530        }
531        self.maintenance_task.abort();
532    }
533
534    async fn shutdown(self) -> Result<(), MailboxError> {
535        if let Some(task) = self.recover_task {
536            task.abort();
537            await_lifecycle_task("mailbox startup recovery", task).await?;
538        }
539        if let Some(task) = self.dispatch_signal_task {
540            task.abort();
541            await_lifecycle_task("mailbox dispatch signal loop", task).await?;
542        }
543        self.maintenance_task.abort();
544        await_lifecycle_task("mailbox maintenance", self.maintenance_task).await
545    }
546}
547
548async fn await_lifecycle_task(name: &str, task: JoinHandle<()>) -> Result<(), MailboxError> {
549    match task.await {
550        Ok(()) => Ok(()),
551        Err(error) if error.is_cancelled() => Ok(()),
552        Err(error) if error.is_panic() => Err(MailboxError::Internal(format!("{name} panicked"))),
553        Err(error) => Err(MailboxError::Internal(format!("{name} failed: {error}"))),
554    }
555}
556
557// ── Internal types ───────────────────────────────────────────────────
558
559/// Per-thread worker status.
560enum MailboxWorkerStatus {
561    Idle,
562    /// Transitional: claim in progress. Prevents TOCTOU race where two
563    /// concurrent dispatches both see Idle and both try to claim.
564    Claiming,
565    Running {
566        dispatch_id: String,
567        run_id: String,
568        lease_handle: JoinHandle<()>,
569        sink: Arc<ReconnectableEventSink>,
570    },
571}
572
573#[derive(Debug, Clone, Copy, PartialEq, Eq)]
574enum DispatchAttempt {
575    Claimed,
576    Busy,
577    NoEligible,
578    TransientError,
579}
580
581impl DispatchAttempt {
582    fn started_execution(self) -> bool {
583        matches!(self, DispatchAttempt::Claimed)
584    }
585}
586
587/// Cached thread state, valid for the duration of a lease.
588struct ThreadContext {
589    messages: Vec<Message>,
590    latest_run: Option<RunRecord>,
591    run_cache: HashMap<String, RunRecord>,
592}
593
594impl ThreadContext {
595    async fn load(run_store: &dyn ThreadRunStore, thread_id: &str) -> Result<Self, MailboxError> {
596        let messages = run_store
597            .load_messages(thread_id)
598            .await?
599            .unwrap_or_default();
600        let latest_run = run_store.latest_run(thread_id).await?;
601        let mut run_cache = HashMap::new();
602        if let Some(ref run) = latest_run {
603            run_cache.insert(run.run_id.clone(), run.clone());
604        }
605        Ok(Self {
606            messages,
607            latest_run,
608            run_cache,
609        })
610    }
611
612    fn get_run(&self, run_id: &str) -> Option<&RunRecord> {
613        self.run_cache.get(run_id)
614    }
615
616    fn apply_checkpoint(&mut self, messages: &[Message], run: &RunRecord) {
617        self.messages = messages.to_vec();
618        self.latest_run = Some(run.clone());
619        self.run_cache.insert(run.run_id.clone(), run.clone());
620    }
621}
622
623/// Per-thread worker. Store is the sole queue authority.
624struct MailboxWorker {
625    status: MailboxWorkerStatus,
626    thread_ctx: Option<ThreadContext>,
627}
628
629impl Default for MailboxWorker {
630    fn default() -> Self {
631        Self {
632            status: MailboxWorkerStatus::Idle,
633            thread_ctx: None,
634        }
635    }
636}
637
638// ── Suspension-aware event sink ──────────────────────────────────────
639
640/// Wraps an inner `EventSink` and sets a shared flag when the run
641/// enters a suspended (waiting) state, detected by a `ToolCallDone`
642/// event with `ToolCallOutcome::Suspended`.
643struct SuspensionAwareSink {
644    inner: Arc<dyn EventSink>,
645    suspended: Arc<AtomicBool>,
646}
647
648#[async_trait]
649impl EventSink for SuspensionAwareSink {
650    async fn emit(&self, event: AgentEvent) {
651        if matches!(
652            &event,
653            AgentEvent::ToolCallDone {
654                outcome: ToolCallOutcome::Suspended,
655                ..
656            }
657        ) {
658            self.suspended.store(true, Ordering::Release);
659        }
660        // Reset the flag when the run resumes from suspension.
661        if matches!(&event, AgentEvent::ToolCallResumed { .. }) {
662            self.suspended.store(false, Ordering::Release);
663        }
664        self.inner.emit(event).await;
665    }
666
667    async fn close(&self) {
668        self.inner.close().await;
669    }
670}
671
672/// RAII guard that decrements the active-runs gauge on drop.
673struct ActiveRunGuard;
674
675impl Drop for ActiveRunGuard {
676    fn drop(&mut self) {
677        crate::metrics::dec_active_runs();
678    }
679}
680
681// ── Mailbox service ──────────────────────────────────────────────────
682
683/// Unified persistent run queue.
684///
685/// Orchestrates `MailboxStore` (dispatch persistence) + `ThreadRunStore`
686/// (run/message truth) + `RunDispatchExecutor` (execution)
687/// with lease-based distributed claim, per-thread serialization, sweep,
688/// and garbage collection.
689pub struct Mailbox {
690    executor: Arc<dyn RunDispatchExecutor>,
691    store: Arc<dyn MailboxStore>,
692    /// Single durable-write boundary from `executor.commit_coordinator()` (see `try_new`).
693    coordinator: Arc<dyn CommitCoordinator>,
694    /// Staged variant of `coordinator`, when the executor exposes one. Present
695    /// only when the executor can commit canonical-event/outbox writes
696    /// atomically with the checkpoint; required for runtime event capture.
697    staged_coordinator: Option<Arc<dyn StagedCommitCoordinator>>,
698    /// Full thread/run store for server-side reads and queries. Supplied by
699    /// the caller, which builds the coordinator from this same store.
700    run_store: Arc<dyn ThreadRunStore>,
701    pending_thread_run_store: Option<Arc<dyn awaken_stores::PendingThreadRunStore>>,
702    consumer_id: String,
703    workers: RwLock<HashMap<String, Arc<SyncMutex<MailboxWorker>>>>,
704    config: MailboxConfig,
705    runtime_event_capture: Option<RuntimeEventCaptureConfig>,
706    server_event_publisher: Option<Arc<dyn OutboxServerEventPublisher>>,
707    server_event_origin: String,
708    lifecycle_tasks: Arc<StdMutex<Option<MailboxLifecycleTasks>>>,
709    lifecycle_start_lock: Arc<Mutex<()>>,
710    /// Striped per-thread locks serializing the message-append
711    /// read-modify-write in `prepare_run_for_dispatch` (see `lock_thread_append`).
712    thread_append_locks: Box<[Mutex<()>]>,
713    /// Retry queue for checkpoint events that failed to publish (see `checkpoint_repair`).
714    checkpoint_repair_queue: Arc<StdMutex<VecDeque<checkpoint_repair::CheckpointRepairTask>>>,
715    /// Optional source for materializing a run's pinned `RegistrySet` from its
716    /// persisted resolution_id (publication snapshot_version) at execution
717    /// time. Lets a durable run resolve against a pinned publication — e.g. an
718    /// unsaved admin-sandbox draft agent — by handing the runtime a resolved
719    /// `RegistrySet` via `with_pinned_registry_set`. Pin/publication logic stays
720    /// server-side; the runtime never learns what a "pin" is. `None` keeps the
721    /// live-registry resolution used by all other deployments.
722    pinned_registry: Option<PinnedRegistrySource>,
723}
724
725/// Server-side handle for re-materializing a run's pinned publication.
726#[derive(Clone)]
727struct PinnedRegistrySource {
728    store: Arc<dyn awaken_server_contract::VersionedRegistryStore>,
729    scope: awaken_server_contract::ScopeId,
730}
731
732impl Mailbox {
733    /// Create a new Mailbox service.
734    pub fn new(
735        executor: impl IntoDispatchExecutor,
736        store: Arc<dyn MailboxStore>,
737        run_store: Arc<dyn ThreadRunStore>,
738        consumer_id: String,
739        config: MailboxConfig,
740    ) -> Self {
741        Self::try_new(executor, store, run_store, consumer_id, config)
742            .expect("Mailbox requires a CommitCoordinator outside unit-test fallback")
743    }
744
745    /// Fallible constructor for production wiring that must fail closed.
746    ///
747    /// ADR-0038 D7: the mailbox adopts `executor.commit_coordinator()` and
748    /// derives its `ThreadRunStore` from that coordinator. Unit tests retain
749    /// the legacy implicit run-store coordinator for small harnesses; every
750    /// non-test build returns an error instead of silently taking a partial
751    /// durable path with no canonical events/outbox writes.
752    pub fn try_new(
753        executor: impl IntoDispatchExecutor,
754        store: Arc<dyn MailboxStore>,
755        run_store: Arc<dyn ThreadRunStore>,
756        consumer_id: String,
757        config: MailboxConfig,
758    ) -> Result<Self, MailboxError> {
759        let executor = executor.into_dispatch_executor();
760        let staged_coordinator = executor.staged_commit_coordinator();
761        let coordinator = if let Some(coordinator) = executor.commit_coordinator() {
762            coordinator
763        } else if cfg!(test) {
764            tracing::warn!(
765                "using unit-test MailboxRunStoreCoordinator fallback; non-test executors must \
766                 provide a CommitCoordinator"
767            );
768            Arc::new(MailboxRunStoreCoordinator::new(Arc::clone(&run_store)))
769                as Arc<dyn CommitCoordinator>
770        } else {
771            return Err(MailboxError::Internal(
772                "Mailbox requires a CommitCoordinator; wire a durable \
773                 Memory/File/Pg coordinator through the runtime"
774                    .to_string(),
775            ));
776        };
777        if let (Some(coordinator_identity), Some(run_store_identity)) = (
778            coordinator.thread_run_storage_identity(),
779            run_store.thread_run_storage_identity(),
780        ) && coordinator_identity != run_store_identity
781        {
782            return Err(MailboxError::Validation(format!(
783                "mailbox run_store must match executor CommitCoordinator thread/run store \
784                 (coordinator={coordinator_identity}, run_store={run_store_identity})"
785            )));
786        }
787        Ok(Self {
788            executor,
789            store,
790            coordinator,
791            staged_coordinator,
792            run_store,
793            pending_thread_run_store: None,
794            consumer_id,
795            workers: RwLock::new(HashMap::new()),
796            config,
797            runtime_event_capture: None,
798            server_event_publisher: None,
799            server_event_origin: "mailbox".to_string(),
800            lifecycle_tasks: Arc::new(StdMutex::new(None)),
801            lifecycle_start_lock: Arc::new(Mutex::new(())),
802            thread_append_locks: (0..Self::THREAD_APPEND_STRIPES)
803                .map(|_| Mutex::new(()))
804                .collect(),
805            checkpoint_repair_queue: Arc::new(StdMutex::new(VecDeque::new())),
806            pinned_registry: None,
807        })
808    }
809
810    /// Wire a versioned-registry source so durable runs can resolve against a
811    /// pinned publication (by their persisted resolution_id) at execution
812    /// time. Used to run unsaved draft agents (admin sandbox) durably. The
813    /// runtime stays pin-agnostic — it only receives the materialized
814    /// `RegistrySet` via `with_pinned_registry_set`.
815    #[must_use]
816    pub fn with_pinned_registry(
817        self,
818        store: Arc<dyn awaken_server_contract::VersionedRegistryStore>,
819        scope: impl Into<String>,
820    ) -> Self {
821        self.try_with_pinned_registry(store, scope)
822            .expect("pinned registry scope_id must be valid")
823    }
824
825    pub fn try_with_pinned_registry(
826        mut self,
827        store: Arc<dyn awaken_server_contract::VersionedRegistryStore>,
828        scope: impl Into<String>,
829    ) -> Result<Self, awaken_server_contract::ScopeError> {
830        let scope = awaken_server_contract::ScopeId::new(scope.into())?;
831        self.pinned_registry = Some(PinnedRegistrySource { store, scope });
832        Ok(self)
833    }
834
835    /// Materialize the pinned `RegistrySet` for a run's resolution_id
836    /// (publication snapshot_version), overlaying live runtime objects. Returns
837    /// `Ok(None)` only when no pinned-registry source is wired.
838    async fn materialize_pinned_registry_set(
839        &self,
840        resolution_id: &str,
841    ) -> Result<Option<awaken_runtime::registry::RegistrySet>, MailboxError> {
842        let Some(source) = self.pinned_registry.as_ref() else {
843            return Ok(None);
844        };
845        let snapshot_version = resolution_id.parse::<u64>().map_err(|error| {
846            MailboxError::Internal(format!(
847                "invalid pinned registry resolution id '{resolution_id}': {error}"
848            ))
849        })?;
850        let live = self.executor.live_registry_set().ok_or_else(|| {
851            MailboxError::Internal(
852                "pinned registry materialization requires a live registry snapshot".to_string(),
853            )
854        })?;
855        let materializer = crate::services::frozen_registry::FrozenAgentRegistryMaterializer::new(
856            source.store.clone(),
857        );
858        let frozen = materializer
859            .materialize(awaken_server_contract::VersionSelector::Publication {
860                scope_id: source.scope.as_str().to_string(),
861                snapshot_version,
862            })
863            .await
864            .map_err(|error| {
865                MailboxError::Internal(format!(
866                    "failed to materialize pinned registry publication {snapshot_version}: {error}"
867                ))
868            })?;
869        Ok(Some(frozen.to_registry_set(&live)))
870    }
871
872    /// Number of stripes for `lock_thread_append` (defined in `submit`).
873    const THREAD_APPEND_STRIPES: usize = 256;
874
875    /// Default bounded channel capacity for the runtime->SSE relay.
876    const EVENT_CHANNEL_CAPACITY: usize = 256;
877
878    /// Single source of truth for the thread/run store. Always returns the
879    /// store wrapped by the mailbox's [`CommitCoordinator`] — callers that
880    /// previously held a parallel `Arc<dyn ThreadRunStore>` should reach
881    /// it through this accessor instead.
882    pub fn thread_run_store(&self) -> &Arc<dyn ThreadRunStore> {
883        &self.run_store
884    }
885
886    /// Borrow the durable-write coordinator.
887    pub fn coordinator(&self) -> &Arc<dyn CommitCoordinator> {
888        &self.coordinator
889    }
890
891    /// Forward a tool-call decision to an active run in this process only.
892    ///
893    /// Distributed callers should use [`Self::send_decision_live`] so remote
894    /// active runs can receive the decision through targeted live delivery.
895    pub fn send_decision(&self, id: &str, tool_call_id: String, resume: ToolCallResume) -> bool {
896        self.executor.send_decision(id, tool_call_id, resume)
897    }
898}
899
900mod cancel;
901mod checkpoint;
902mod checkpoint_repair;
903mod coordinator_facade;
904mod decision;
905mod dispatch_execution;
906mod helpers;
907mod lifecycle;
908mod live_delivery;
909mod pending_delivery;
910mod runtime_event_capture;
911mod server_event_capture;
912mod signal_loop;
913mod staging_coordinator;
914mod submit;
915
916use self::coordinator_facade::MailboxRunStoreCoordinator;
917use self::{helpers::*, runtime_event_capture::RuntimeEventCaptureConfig};
918pub use crate::run_dispatch::RunDispatchExecutor;
919pub use coordinator_facade::IntoDispatchExecutor;
920#[cfg(test)]
921mod tests;