Skip to main content

meerkat_core/lifecycle/
core_executor.rs

1//! CoreExecutor trait — the interface core exposes to the runtime layer.
2//!
3//! The runtime layer implements this trait (as `AgentCoreExecutor`) to bridge
4//! RunPrimitive into Agent session mutations. The trait lives in core so both
5//! layers can reference it without circular dependencies.
6
7use super::RunId;
8use super::run_primitive::RunPrimitive;
9use super::run_receipt::RunBoundaryReceiptDraft;
10use crate::error::AgentError;
11use crate::service::SessionError;
12use crate::session::PendingSystemContextAppend;
13use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};
14use crate::types::RunResult;
15use crate::{TurnErrorMetadata, event::AgentEvent, interaction::InteractionId};
16use serde_json::Value;
17use sha2::{Digest, Sha256};
18use std::sync::Arc;
19
20/// Closed classifier for failures observed while applying a run primitive.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum CoreApplyFailureCauseKind {
24    PrimitiveRejected,
25    RuntimeContextApply,
26    RuntimeTurn,
27    HookDenied,
28    HookRuntimeFailure,
29    ExecutorStopped,
30    ExecutorControlFailed,
31    ExecutorInternal,
32    Unknown,
33}
34
35impl CoreApplyFailureCauseKind {
36    pub fn as_str(self) -> &'static str {
37        match self {
38            Self::PrimitiveRejected => "PrimitiveRejected",
39            Self::RuntimeContextApply => "RuntimeContextApply",
40            Self::RuntimeTurn => "RuntimeTurn",
41            Self::HookDenied => "HookDenied",
42            Self::HookRuntimeFailure => "HookRuntimeFailure",
43            Self::ExecutorStopped => "ExecutorStopped",
44            Self::ExecutorControlFailed => "ExecutorControlFailed",
45            Self::ExecutorInternal => "ExecutorInternal",
46            Self::Unknown => "Unknown",
47        }
48    }
49
50    pub fn from_wire_str(value: &str) -> Option<Self> {
51        match value {
52            "PrimitiveRejected" => Some(Self::PrimitiveRejected),
53            "RuntimeContextApply" => Some(Self::RuntimeContextApply),
54            "RuntimeTurn" => Some(Self::RuntimeTurn),
55            "HookDenied" => Some(Self::HookDenied),
56            "HookRuntimeFailure" => Some(Self::HookRuntimeFailure),
57            "ExecutorStopped" => Some(Self::ExecutorStopped),
58            "ExecutorControlFailed" => Some(Self::ExecutorControlFailed),
59            "ExecutorInternal" => Some(Self::ExecutorInternal),
60            "Unknown" => Some(Self::Unknown),
61            _ => None,
62        }
63    }
64}
65
66/// Typed apply-failure cause plus its human-readable display projection.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct CoreApplyFailureCause {
69    pub kind: CoreApplyFailureCauseKind,
70    pub message: String,
71}
72
73impl CoreApplyFailureCause {
74    pub fn new(kind: CoreApplyFailureCauseKind, message: impl Into<String>) -> Self {
75        Self {
76            kind,
77            message: message.into(),
78        }
79    }
80
81    pub fn primitive_rejected(message: impl Into<String>) -> Self {
82        Self::new(CoreApplyFailureCauseKind::PrimitiveRejected, message)
83    }
84
85    pub fn runtime_context_apply(message: impl Into<String>) -> Self {
86        Self::new(CoreApplyFailureCauseKind::RuntimeContextApply, message)
87    }
88
89    pub fn runtime_turn(message: impl Into<String>) -> Self {
90        Self::new(CoreApplyFailureCauseKind::RuntimeTurn, message)
91    }
92
93    pub fn hook_denied(message: impl Into<String>) -> Self {
94        Self::new(CoreApplyFailureCauseKind::HookDenied, message)
95    }
96
97    pub fn hook_runtime_failure(message: impl Into<String>) -> Self {
98        Self::new(CoreApplyFailureCauseKind::HookRuntimeFailure, message)
99    }
100
101    pub fn executor_stopped() -> Self {
102        Self::new(
103            CoreApplyFailureCauseKind::ExecutorStopped,
104            "executor is stopped",
105        )
106    }
107
108    pub fn executor_control_failed(message: impl Into<String>) -> Self {
109        Self::new(CoreApplyFailureCauseKind::ExecutorControlFailed, message)
110    }
111
112    pub fn executor_internal(message: impl Into<String>) -> Self {
113        Self::new(CoreApplyFailureCauseKind::ExecutorInternal, message)
114    }
115
116    pub fn unknown(message: impl Into<String>) -> Self {
117        Self::new(CoreApplyFailureCauseKind::Unknown, message)
118    }
119
120    pub fn from_agent_error(error: &AgentError) -> Self {
121        match error {
122            AgentError::HookDenied { .. } => Self::hook_denied(error.to_string()),
123            AgentError::HookTimeout { .. }
124            | AgentError::HookExecutionFailed { .. }
125            | AgentError::HookConfigInvalid { .. } => Self::hook_runtime_failure(error.to_string()),
126            _ => Self::runtime_turn(error.to_string()),
127        }
128    }
129
130    pub fn from_session_error(error: &SessionError) -> Self {
131        match error {
132            SessionError::Agent(agent_error) => Self::from_agent_error(agent_error),
133            _ => Self::runtime_turn(error.to_string()),
134        }
135    }
136
137    pub fn message(&self) -> &str {
138        &self.message
139    }
140}
141
142impl std::fmt::Display for CoreApplyFailureCause {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        f.write_str(&self.message)
145    }
146}
147
148/// Closed classifier for failures observed while applying control commands.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150#[non_exhaustive]
151pub enum CoreControlFailureCauseKind {
152    RuntimeControl,
153    ExecutorInternal,
154    Unknown,
155}
156
157/// Typed control-failure cause plus its human-readable display projection.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct CoreControlFailureCause {
160    pub kind: CoreControlFailureCauseKind,
161    pub message: String,
162}
163
164/// Machine-independent reason an executor can no longer own its live session.
165///
166/// This is a handoff request, not an ordinary apply failure: the runtime loop
167/// must close the staged run, publish the exact executor, and let the
168/// machine-owned unregister saga perform external cleanup. Executors must not
169/// call unregister (or discard their session) from inside `apply`.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171#[non_exhaustive]
172pub enum CoreExecutorTeardownReason {
173    ArchivedSession,
174    SessionUnavailable,
175}
176
177impl CoreExecutorTeardownReason {
178    pub fn as_str(self) -> &'static str {
179        match self {
180            Self::ArchivedSession => "ArchivedSession",
181            Self::SessionUnavailable => "SessionUnavailable",
182        }
183    }
184
185    pub fn from_wire_str(value: &str) -> Option<Self> {
186        match value {
187            "ArchivedSession" => Some(Self::ArchivedSession),
188            "SessionUnavailable" => Some(Self::SessionUnavailable),
189            _ => None,
190        }
191    }
192}
193
194impl CoreControlFailureCause {
195    pub fn new(kind: CoreControlFailureCauseKind, message: impl Into<String>) -> Self {
196        Self {
197            kind,
198            message: message.into(),
199        }
200    }
201
202    pub fn runtime_control(message: impl Into<String>) -> Self {
203        Self::new(CoreControlFailureCauseKind::RuntimeControl, message)
204    }
205
206    pub fn executor_internal(message: impl Into<String>) -> Self {
207        Self::new(CoreControlFailureCauseKind::ExecutorInternal, message)
208    }
209
210    pub fn unknown(message: impl Into<String>) -> Self {
211        Self::new(CoreControlFailureCauseKind::Unknown, message)
212    }
213}
214
215impl std::fmt::Display for CoreControlFailureCause {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        f.write_str(&self.message)
218    }
219}
220
221/// Errors from CoreExecutor operations.
222#[derive(Debug, Clone, thiserror::Error)]
223#[non_exhaustive]
224pub enum CoreExecutorError {
225    /// The primitive could not be applied (conversation mutation failed).
226    #[error("Apply failed: {cause}")]
227    ApplyFailed { cause: CoreApplyFailureCause },
228
229    /// The core executor observed a machine-owned terminal turn failure while
230    /// applying a runtime turn. The runtime loop must preserve this typed
231    /// terminal cause instead of reclassifying it as a runtime apply failure.
232    #[error("Terminal failure: {outcome:?} ({cause_kind:?}): {message}")]
233    TerminalFailure {
234        outcome: TurnTerminalOutcome,
235        cause_kind: TurnTerminalCauseKind,
236        message: String,
237    },
238
239    /// The executor's owned session reached a terminal/unavailable condition
240    /// that requires canonical teardown after the runtime loop hands off the
241    /// exact executor. This variant must never enter failed-batch backlog
242    /// retry, and must never be realized by unregistering inside `apply`.
243    #[error("Executor requires teardown ({reason:?}): {message}")]
244    TeardownRequired {
245        reason: CoreExecutorTeardownReason,
246        message: String,
247    },
248
249    /// The control command could not be executed.
250    #[error("Control failed: {cause}")]
251    ControlFailed { cause: CoreControlFailureCause },
252
253    /// The executor is in a terminal state and cannot accept more work.
254    #[error("Executor is stopped")]
255    Stopped,
256
257    /// The applied turn reached the canonical cancellation terminal.
258    #[error("Run was cancelled")]
259    Cancelled,
260
261    /// Internal error.
262    #[error("Internal error: {0}")]
263    Internal(String),
264}
265
266impl CoreExecutorError {
267    pub fn apply_failed(cause: CoreApplyFailureCause) -> Self {
268        Self::ApplyFailed { cause }
269    }
270
271    pub fn apply_failed_primitive_rejected(message: impl Into<String>) -> Self {
272        Self::apply_failed(CoreApplyFailureCause::primitive_rejected(message))
273    }
274
275    pub fn apply_failed_runtime_context(message: impl Into<String>) -> Self {
276        Self::apply_failed(CoreApplyFailureCause::runtime_context_apply(message))
277    }
278
279    pub fn apply_failed_runtime_turn(message: impl Into<String>) -> Self {
280        Self::apply_failed(CoreApplyFailureCause::runtime_turn(message))
281    }
282
283    pub fn terminal_failure(
284        outcome: TurnTerminalOutcome,
285        cause_kind: TurnTerminalCauseKind,
286        message: impl Into<String>,
287    ) -> Self {
288        Self::TerminalFailure {
289            outcome,
290            cause_kind,
291            message: message.into(),
292        }
293    }
294
295    pub fn teardown_required(
296        reason: CoreExecutorTeardownReason,
297        message: impl Into<String>,
298    ) -> Self {
299        Self::TeardownRequired {
300            reason,
301            message: message.into(),
302        }
303    }
304
305    pub fn archived_session_requires_teardown(message: impl Into<String>) -> Self {
306        Self::teardown_required(CoreExecutorTeardownReason::ArchivedSession, message)
307    }
308
309    pub fn session_unavailable_requires_teardown(message: impl Into<String>) -> Self {
310        Self::teardown_required(CoreExecutorTeardownReason::SessionUnavailable, message)
311    }
312
313    pub fn apply_failed_from_session_error(error: SessionError) -> Self {
314        if error.requests_runtime_executor_stop() {
315            return Self::Stopped;
316        }
317        match error {
318            SessionError::Agent(AgentError::Cancelled) => Self::Cancelled,
319            SessionError::Agent(AgentError::StickyModelFallbackAuthorityUnknown { message }) => {
320                Self::session_unavailable_requires_teardown(message)
321            }
322            SessionError::Agent(AgentError::TerminalFailure {
323                outcome,
324                cause_kind,
325                message,
326            }) if cause_kind.is_specific_failure_cause() => {
327                Self::terminal_failure(outcome, cause_kind, message)
328            }
329            SessionError::Agent(AgentError::TerminalFailure { cause_kind, .. }) => Self::Internal(
330                format!("runtime turn returned unknown machine terminal cause: {cause_kind:?}"),
331            ),
332            error => Self::apply_failed(CoreApplyFailureCause::from_session_error(&error)),
333        }
334    }
335
336    pub fn apply_failed_unknown(message: impl Into<String>) -> Self {
337        Self::apply_failed(CoreApplyFailureCause::unknown(message))
338    }
339
340    pub fn cancelled() -> Self {
341        Self::Cancelled
342    }
343
344    pub fn is_cancelled(&self) -> bool {
345        matches!(self, Self::Cancelled)
346    }
347
348    pub fn requires_runtime_teardown(&self) -> bool {
349        matches!(self, Self::TeardownRequired { .. })
350    }
351
352    pub fn control_failed(cause: CoreControlFailureCause) -> Self {
353        Self::ControlFailed { cause }
354    }
355
356    pub fn control_failed_runtime(message: impl Into<String>) -> Self {
357        Self::control_failed(CoreControlFailureCause::runtime_control(message))
358    }
359
360    pub fn apply_failure_cause(&self) -> CoreApplyFailureCause {
361        match self {
362            Self::ApplyFailed { cause } => cause.clone(),
363            Self::TerminalFailure { cause_kind, .. } => {
364                CoreApplyFailureCause::executor_internal(format!(
365                    "typed machine terminal failure escaped runtime-loop handling: {cause_kind:?}"
366                ))
367            }
368            Self::TeardownRequired { reason, message } => CoreApplyFailureCause::new(
369                CoreApplyFailureCauseKind::ExecutorStopped,
370                format!("executor requested {} teardown: {message}", reason.as_str()),
371            ),
372            Self::ControlFailed { cause } => {
373                CoreApplyFailureCause::executor_control_failed(cause.message.clone())
374            }
375            Self::Stopped => CoreApplyFailureCause::executor_stopped(),
376            Self::Cancelled => CoreApplyFailureCause::runtime_turn("cancelled"),
377            Self::Internal(message) => CoreApplyFailureCause::executor_internal(message.clone()),
378        }
379    }
380}
381
382/// Successful result of applying a run primitive.
383#[derive(Debug, Clone)]
384pub enum CoreApplyTerminal {
385    /// The run completed and produced a result.
386    RunResult(Box<RunResult>),
387    /// A resume-pending request reached the session with no pending boundary.
388    NoPendingBoundary,
389    /// The exact admitted runtime turn reached a generated hard-failure
390    /// terminal after mutating the session. The runtime must atomically commit
391    /// the accompanying receipt/session snapshot with failed-run lifecycle;
392    /// this is a completed application, not an executor-mechanism error.
393    MachineTerminalFailure { error: TurnErrorMetadata },
394    /// The run committed a continuation boundary and is waiting for external
395    /// tool results before it can continue.
396    CallbackPending {
397        tool_use_id: String,
398        tool_name: String,
399        args: Value,
400    },
401    /// The run committed one assistant batch containing multiple external
402    /// callback calls. All results must be supplied as one exact set.
403    CallbackBatchPending {
404        pending_tool_calls: Vec<crate::error::PendingCallbackToolCall>,
405    },
406}
407
408#[derive(Debug, Clone)]
409pub struct CoreApplyOutput {
410    /// Unsequenced receipt proving boundary application. The runtime driver
411    /// mints the final sequenced [`super::run_receipt::RunBoundaryReceipt`]
412    /// from the generated machine's per-run boundary counter at commit time
413    /// (dogma K10 — executors cannot produce the boundary sequence).
414    pub receipt: RunBoundaryReceiptDraft,
415    /// Optional serialized session snapshot to durably commit atomically with
416    /// the receipt and input-state updates.
417    pub session_snapshot: Option<Vec<u8>>,
418    /// Terminal payload observation produced by runtime-backed execution.
419    ///
420    /// `None` means the primitive committed successfully but did not produce
421    /// a result payload (for example immediate context appends). Runtime
422    /// surfaces must route this payload shape through generated machine
423    /// authority before choosing a public completion result class.
424    pub terminal: Option<CoreApplyTerminal>,
425}
426
427/// Durable receipt for one exact interaction-terminal publication.
428#[derive(Debug, Clone, PartialEq, Eq)]
429pub struct CoreInteractionTerminalPublicationReceipt {
430    interaction_id: InteractionId,
431    terminal_seq: u64,
432    payload_digest: String,
433}
434
435impl CoreInteractionTerminalPublicationReceipt {
436    pub fn try_new(event: &AgentEvent, terminal_seq: u64) -> Result<Self, CoreExecutorError> {
437        if terminal_seq == 0 {
438            return Err(CoreExecutorError::Internal(
439                "interaction terminal durable sequence must be non-zero".to_string(),
440            ));
441        }
442        let interaction_id = match event {
443            AgentEvent::InteractionComplete { interaction_id, .. }
444            | AgentEvent::InteractionCallbackPending { interaction_id, .. }
445            | AgentEvent::InteractionFailed { interaction_id, .. } => *interaction_id,
446            _ => {
447                return Err(CoreExecutorError::Internal(
448                    "interaction terminal publication receipt requires an Interaction terminal event"
449                        .to_string(),
450                ));
451            }
452        };
453        let encoded = serde_json::to_vec(event).map_err(|error| {
454            CoreExecutorError::Internal(format!(
455                "failed to encode interaction terminal publication receipt: {error}"
456            ))
457        })?;
458        Ok(Self {
459            interaction_id,
460            terminal_seq,
461            payload_digest: format!("{:x}", Sha256::digest(encoded)),
462        })
463    }
464
465    pub fn interaction_id(&self) -> InteractionId {
466        self.interaction_id
467    }
468
469    pub fn terminal_seq(&self) -> u64 {
470        self.terminal_seq
471    }
472
473    pub fn payload_digest(&self) -> &str {
474        &self.payload_digest
475    }
476}
477
478/// Typed failure while preparing or resolving an exact live turn boundary.
479///
480/// Only [`CoreBoundaryStageError::Unavailable`] permits a caller to fall back
481/// to queued delivery. `Stale` means an exact actor/run/generation witness was
482/// invalidated, while `Fault` means the preparation mechanism itself failed;
483/// neither may be laundered into ordinary unavailability.
484#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
485pub enum CoreBoundaryStageError {
486    #[error("active turn boundary is unavailable: {reason}")]
487    Unavailable { reason: String },
488    #[error("active turn boundary authority is stale: {reason}")]
489    Stale { reason: String },
490    #[error("active turn boundary preparation failed: {reason}")]
491    Fault { reason: String },
492}
493
494impl CoreBoundaryStageError {
495    pub fn unavailable(reason: impl Into<String>) -> Self {
496        Self::Unavailable {
497            reason: reason.into(),
498        }
499    }
500
501    pub fn stale(reason: impl Into<String>) -> Self {
502        Self::Stale {
503            reason: reason.into(),
504        }
505    }
506
507    pub fn fault(reason: impl Into<String>) -> Self {
508        Self::Fault {
509            reason: reason.into(),
510        }
511    }
512
513    #[must_use]
514    pub fn is_unavailable(&self) -> bool {
515        matches!(self, Self::Unavailable { .. })
516    }
517}
518
519pub(crate) trait CoreBoundaryStageCommitAuthority: Send {
520    fn commit(&mut self) -> Result<(), CoreBoundaryStageError>;
521    fn abort(&mut self) -> Result<(), CoreBoundaryStageError>;
522}
523
524/// Successful prepare result for one exact parked model boundary.
525///
526/// The value is deliberately non-`Clone` and `#[must_use]`: it owns the only
527/// commit/abort authority for the parked `{actor, run, generation}`. Dropping
528/// it synchronously aborts the preparation and wakes the runner.
529///
530/// `commit` is the publication linearization point, not a claim that the LLM
531/// consumed the context. A hard cancel that linearizes after publication but
532/// before the runner's final synchronous consume still cancels that
533/// active-turn-only context; the runner-owned consumption witness distinguishes
534/// those outcomes.
535#[must_use = "a prepared boundary must be committed or aborted; dropping it aborts"]
536pub struct CoreBoundaryStageOutput {
537    /// Optional serialized session snapshot to commit atomically with the
538    /// generated receipt and input-state updates.
539    session_snapshot: Option<Vec<u8>>,
540    authority: Option<Box<dyn CoreBoundaryStageCommitAuthority>>,
541}
542
543impl CoreBoundaryStageOutput {
544    pub(crate) fn prepared(
545        session_snapshot: Option<Vec<u8>>,
546        authority: Box<dyn CoreBoundaryStageCommitAuthority>,
547    ) -> Self {
548        Self {
549            session_snapshot,
550            authority: Some(authority),
551        }
552    }
553
554    #[must_use]
555    pub fn session_snapshot(&self) -> Option<&[u8]> {
556        self.session_snapshot.as_deref()
557    }
558
559    /// Publish the prepared candidate exactly once and unblock its runner.
560    ///
561    /// Success means the exact parked actor accepted publication. Delivery to
562    /// the model remains cancellable until the runner consumes its separate
563    /// model-boundary witness at the final call seam.
564    pub fn commit(mut self) -> Result<(), CoreBoundaryStageError> {
565        let Some(mut authority) = self.authority.take() else {
566            return Err(CoreBoundaryStageError::stale(
567                "prepared boundary authority was already resolved",
568            ));
569        };
570        authority.commit()
571    }
572
573    pub fn abort(mut self) -> Result<(), CoreBoundaryStageError> {
574        let Some(mut authority) = self.authority.take() else {
575            return Err(CoreBoundaryStageError::stale(
576                "prepared boundary authority was already resolved",
577            ));
578        };
579        authority.abort()
580    }
581}
582
583impl std::fmt::Debug for CoreBoundaryStageOutput {
584    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
585        formatter
586            .debug_struct("CoreBoundaryStageOutput")
587            .field(
588                "session_snapshot_len",
589                &self.session_snapshot.as_ref().map(Vec::len),
590            )
591            .field("authority", &self.authority.as_ref().map(|_| "prepared"))
592            .finish()
593    }
594}
595
596impl CoreApplyOutput {
597    pub fn with_run_result(
598        receipt: RunBoundaryReceiptDraft,
599        session_snapshot: Option<Vec<u8>>,
600        run_result: RunResult,
601    ) -> Self {
602        Self {
603            receipt,
604            session_snapshot,
605            terminal: Some(CoreApplyTerminal::RunResult(Box::new(run_result))),
606        }
607    }
608
609    pub fn with_callback_pending(
610        receipt: RunBoundaryReceiptDraft,
611        session_snapshot: Option<Vec<u8>>,
612        tool_use_id: impl Into<String>,
613        tool_name: impl Into<String>,
614        args: Value,
615    ) -> Self {
616        Self {
617            receipt,
618            session_snapshot,
619            terminal: Some(CoreApplyTerminal::CallbackPending {
620                tool_use_id: tool_use_id.into(),
621                tool_name: tool_name.into(),
622                args,
623            }),
624        }
625    }
626
627    pub fn with_callback_batch_pending(
628        receipt: RunBoundaryReceiptDraft,
629        session_snapshot: Option<Vec<u8>>,
630        pending_tool_calls: Vec<crate::error::PendingCallbackToolCall>,
631    ) -> Self {
632        Self {
633            receipt,
634            session_snapshot,
635            terminal: Some(CoreApplyTerminal::CallbackBatchPending { pending_tool_calls }),
636        }
637    }
638
639    pub fn without_terminal(
640        receipt: RunBoundaryReceiptDraft,
641        session_snapshot: Option<Vec<u8>>,
642    ) -> Self {
643        Self {
644            receipt,
645            session_snapshot,
646            terminal: None,
647        }
648    }
649}
650
651/// Cloneable live endpoint for cooperative in-flight turn boundaries.
652///
653/// ```compile_fail
654/// use meerkat_core::lifecycle::CoreExecutorBoundaryHandle;
655///
656/// async fn boundary_handles_cannot_hard_cancel(handle: &dyn CoreExecutorBoundaryHandle) {
657///     handle
658///         .hard_cancel_current_run("wrong authority".to_string())
659///         .await
660///         .unwrap();
661/// }
662/// ```
663#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
664#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
665pub trait CoreExecutorBoundaryHandle: Send + Sync {
666    /// Request cooperative cancellation for one exact active run.
667    async fn cancel_after_boundary(
668        &self,
669        expected_run_id: &RunId,
670        reason: String,
671    ) -> Result<(), CoreExecutorError>;
672
673    /// Prepare runtime-owned system context for one exact cooperative LLM
674    /// boundary and return only after the actor is parked immediately before
675    /// consumption. The non-clone result owns explicit commit/abort authority.
676    ///
677    /// Implementations that can serialize the staged session snapshot return
678    /// it so the runtime control plane can commit the snapshot atomically with
679    /// the consumed input state. Implementations without durable session
680    /// authority may return `None`.
681    async fn prepare_system_context_at_boundary(
682        &self,
683        _expected_run_id: &RunId,
684        _appends: Vec<PendingSystemContextAppend>,
685    ) -> Result<CoreBoundaryStageOutput, CoreBoundaryStageError> {
686        Err(CoreBoundaryStageError::unavailable(
687            "live boundary system-context preparation is unsupported by this executor",
688        ))
689    }
690}
691
692/// Cloneable live endpoint for hard-cancelling the active run immediately.
693#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
694#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
695pub trait CoreExecutorInterruptHandle: Send + Sync {
696    async fn hard_cancel_current_run(&self, reason: String) -> Result<(), CoreExecutorError>;
697}
698
699/// Cloneable capability for exact durable interaction-terminal publication.
700///
701/// Runtime control paths may need to terminalize queued or staged directed
702/// inputs while the owning executor is in flight (destroy/unregister) or after
703/// its loop channels have been detached. Keeping this authority on a separate
704/// handle prevents those paths from borrowing or duplicating the executor
705/// while still routing publication through the executor's owning session
706/// surface.
707#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
708#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
709pub trait CoreExecutorPublicationHandle: Send + Sync {
710    async fn publish_interaction_terminals(
711        &self,
712        events: &[AgentEvent],
713    ) -> Result<Vec<CoreInteractionTerminalPublicationReceipt>, CoreExecutorError>;
714}
715
716/// Cloneable service/surface cleanup authority retained by the runtime entry.
717///
718/// Unlike adapter unregister, this handle owns only the executor's live actor
719/// and surface-local state. `MeerkatMachine` invokes it inside the exact
720/// attachment's generated unregister window, and can retry it after a failed
721/// or externally initiated drain without resurrecting the executor object.
722/// Implementations must therefore be idempotent, including when a prior
723/// attempt completed only part of its sidecar cleanup before returning an
724/// error.
725#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
726#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
727pub trait CoreExecutorPostStopCleanupHandle: Send + Sync {
728    async fn cleanup_after_runtime_stop_terminalized(&self) -> Result<(), CoreExecutorError>;
729
730    /// Cleanup when the runtime loop already owns this session's stable outer
731    /// turn-finalization boundary. Implementations backed by that boundary must
732    /// not reacquire it.
733    async fn cleanup_after_runtime_stop_terminalized_under_turn_finalization_boundary(
734        &self,
735    ) -> Result<(), CoreExecutorError> {
736        self.cleanup_after_runtime_stop_terminalized().await
737    }
738}
739
740/// Opaque RAII witness that one session actor's turn-finalization interval is
741/// exclusively owned. The runtime holds this from before queue/effect staging
742/// through machine commit, compatibility checkpoint, exact terminal receipt
743/// persistence, and waiter resolution.
744pub trait CoreExecutorTurnFinalizationGuard: Send {}
745
746impl<T: Send> CoreExecutorTurnFinalizationGuard for T {}
747
748/// Cloneable endpoint for the stable per-session turn-finalization boundary.
749#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
750#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
751pub trait CoreExecutorTurnFinalizationBoundaryHandle: Send + Sync {
752    async fn acquire(
753        &self,
754    ) -> Result<Box<dyn CoreExecutorTurnFinalizationGuard>, CoreExecutorError>;
755}
756
757/// The interface core exposes for the runtime layer to apply run primitives.
758///
759/// The runtime layer creates an implementation that wraps an `Agent` and
760/// translates `RunPrimitive` into session mutations. This trait is defined
761/// in core so both layers can depend on it without circular deps.
762///
763/// # Object Safety
764/// This trait is object-safe to allow `Box<dyn CoreExecutor>` usage.
765#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
766#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
767pub trait CoreExecutor: Send + Sync {
768    /// Optional live cooperative-boundary endpoint.
769    ///
770    /// Implementations return this only when the underlying live turn can be
771    /// signaled while `apply()` is in flight and will also wake any yielding
772    /// turn so the boundary request can be observed.
773    fn boundary_handle(&self) -> Option<Arc<dyn CoreExecutorBoundaryHandle>> {
774        None
775    }
776
777    /// Optional live hard-interrupt endpoint.
778    ///
779    /// Hard cancel is intentionally live-handle-only. It is not available on
780    /// the queued in-loop executor channel because user/session interrupt
781    /// semantics require prompt delivery during a long in-flight turn.
782    fn interrupt_handle(&self) -> Option<Arc<dyn CoreExecutorInterruptHandle>> {
783        None
784    }
785
786    /// Optional cloneable authority for exact durable terminal publication.
787    fn publication_handle(&self) -> Option<Arc<dyn CoreExecutorPublicationHandle>> {
788        None
789    }
790
791    /// Whether `MeerkatMachine` should retain and fence this attachment's exact
792    /// post-stop service cleanup authority.
793    ///
794    /// Opted-in executors expose a cloneable attachment-local cleanup handle.
795    /// The machine fences it by the attachment incarnation it created, so a
796    /// stale cleanup cannot remove replacement state. Ordinary runtime stop
797    /// cleans the service incarnation while preserving the registered
798    /// `Stopped` machine state; explicit unregister owns the later `Draining`
799    /// transition and registration removal.
800    fn machine_managed_post_stop_unregister(&self) -> bool {
801        false
802    }
803
804    /// Cloneable service/surface cleanup authority for machine-managed
805    /// post-stop unregister.
806    fn post_stop_cleanup_handle(&self) -> Option<Arc<dyn CoreExecutorPostStopCleanupHandle>> {
807        None
808    }
809
810    /// Stable boundary shared with direct and non-turn session mutations.
811    fn turn_finalization_boundary_handle(
812        &self,
813    ) -> Option<Arc<dyn CoreExecutorTurnFinalizationBoundaryHandle>> {
814        None
815    }
816
817    /// Apply a run primitive to the conversation.
818    ///
819    /// Returns a receipt proving the application, including a digest of the
820    /// conversation state after mutation.
821    async fn apply(
822        &mut self,
823        run_id: RunId,
824        primitive: RunPrimitive,
825    ) -> Result<CoreApplyOutput, CoreExecutorError>;
826
827    /// Persist or project the committed session snapshot after the runtime
828    /// control plane has durably committed the machine boundary.
829    ///
830    /// RuntimeStore remains the authority for runtime-backed turns; this hook
831    /// is for compatibility projections such as `SessionStore` snapshots that
832    /// must not be written before the machine commit succeeds. Recovery may
833    /// invoke this with the authoritative RuntimeStore snapshot after outbox
834    /// finalization so a stale compatibility snapshot cannot resurrect an
835    /// already-finalized compaction intent.
836    async fn checkpoint_committed_session_snapshot(
837        &mut self,
838        _session_snapshot: &[u8],
839    ) -> Result<(), CoreExecutorError> {
840        Ok(())
841    }
842
843    /// Reconcile and finalize semantic-memory compaction stages named by the
844    /// exact RuntimeStore atomic outbox. The empty slice is authoritative: a
845    /// durable implementation must use it to abort any invisible stage left by
846    /// a crash before the runtime boundary committed.
847    async fn reconcile_committed_compaction_projections(
848        &mut self,
849        intents: &[crate::memory::CompactionProjectionIntent],
850    ) -> Result<(), CoreExecutorError> {
851        if intents.is_empty() {
852            Ok(())
853        } else {
854            Err(CoreExecutorError::Internal(
855                "executor cannot reconcile committed compaction projections".to_string(),
856            ))
857        }
858    }
859
860    /// Roll back and abort any invisible compaction stage after the runtime
861    /// boundary commit was rejected and the authoritative outbox was observed
862    /// empty. This is deliberately separate from committed reconciliation so
863    /// an empty post-error observation can never be mistaken for commit
864    /// authority.
865    async fn abort_uncommitted_compaction_projections(&mut self) -> Result<(), CoreExecutorError> {
866        Ok(())
867    }
868
869    /// Abort every executor-owned projection staged by a run whose atomic
870    /// runtime boundary was rejected.
871    ///
872    /// The default preserves compatibility with executors that can stage only
873    /// compaction. Runtime-backed session executors override this to also
874    /// remove any uncommitted live transcript and context-event projections.
875    /// Implementations must be cancellation-safe and retry-idempotent: once an
876    /// attempt observes one sub-projection aborted, cancellation before the
877    /// whole cleanup returns must leave enough mechanical progress to continue
878    /// without requiring an already-discarded live carrier.
879    async fn abort_rejected_run_projections(&mut self) -> Result<(), CoreExecutorError> {
880        self.abort_uncommitted_compaction_projections().await
881    }
882
883    /// Durably publish exact per-input Interaction terminal events after
884    /// generated runtime completion authority has observed finalization.
885    /// Implementations must make replay idempotent by interaction ID and
886    /// reject a mismatching existing payload.
887    async fn publish_interaction_terminals(
888        &mut self,
889        events: &[AgentEvent],
890    ) -> Result<Vec<CoreInteractionTerminalPublicationReceipt>, CoreExecutorError> {
891        if events.is_empty() {
892            return Ok(Vec::new());
893        }
894        Err(CoreExecutorError::Internal(
895            "exact interaction terminal publication is unsupported by this executor".to_string(),
896        ))
897    }
898
899    /// Request cancellation at the next cooperative boundary.
900    async fn cancel_after_boundary(&mut self, reason: String) -> Result<(), CoreExecutorError>;
901
902    /// Ask this runtime executor to stop accepting work.
903    async fn stop_runtime_executor(&mut self, reason: String) -> Result<(), CoreExecutorError>;
904
905    /// Cleanup of executor-owned external/session material that is safe only
906    /// after the runtime control plane has durably terminalized the stop.
907    ///
908    /// This hook must not unregister the runtime session. The machine-owned
909    /// runtime-loop cleanup coordinator invokes it; ordinary stop preserves
910    /// the registered `Stopped` session, while explicit or executor-required
911    /// unregister separately owns registration removal. Recursive unregister
912    /// from this hook is rejected fail-closed.
913    async fn cleanup_after_runtime_stop_terminalized(&mut self) -> Result<(), CoreExecutorError> {
914        Ok(())
915    }
916}
917
918#[cfg(test)]
919#[allow(clippy::panic)]
920mod tests {
921    use super::*;
922
923    // Verify CoreExecutor is object-safe
924    fn _assert_object_safe(_: &dyn CoreExecutor) {}
925
926    #[test]
927    fn core_executor_error_display() {
928        let err = CoreExecutorError::ApplyFailed {
929            cause: CoreApplyFailureCause::runtime_turn("bad input"),
930        };
931        assert_eq!(err.to_string(), "Apply failed: bad input");
932
933        let err = CoreExecutorError::ControlFailed {
934            cause: CoreControlFailureCause::runtime_control("not running"),
935        };
936        assert_eq!(err.to_string(), "Control failed: not running");
937
938        let err = CoreExecutorError::Stopped;
939        assert_eq!(err.to_string(), "Executor is stopped");
940
941        let err = CoreExecutorError::Cancelled;
942        assert_eq!(err.to_string(), "Run was cancelled");
943
944        let err = CoreExecutorError::Internal("oops".into());
945        assert_eq!(err.to_string(), "Internal error: oops");
946    }
947
948    #[test]
949    fn apply_failed_carries_typed_cause() {
950        let err = CoreExecutorError::ApplyFailed {
951            cause: CoreApplyFailureCause::runtime_context_apply("context write failed"),
952        };
953
954        match err {
955            CoreExecutorError::ApplyFailed { cause } => {
956                assert_eq!(cause.kind, CoreApplyFailureCauseKind::RuntimeContextApply);
957                assert_eq!(cause.message(), "context write failed");
958            }
959            other => panic!("expected typed apply failure, got {other:?}"),
960        }
961    }
962
963    #[test]
964    fn cancelled_session_error_remains_typed_at_runtime_executor_boundary() {
965        let err = CoreExecutorError::apply_failed_from_session_error(SessionError::Agent(
966            AgentError::Cancelled,
967        ));
968
969        assert!(err.is_cancelled());
970        assert_eq!(
971            err.apply_failure_cause().kind,
972            CoreApplyFailureCauseKind::RuntimeTurn
973        );
974    }
975
976    #[test]
977    fn corrupted_live_session_signal_stops_instead_of_retrying_apply() {
978        let err = CoreExecutorError::apply_failed_from_session_error(
979            SessionError::runtime_executor_stopped("terminal witness mismatch"),
980        );
981
982        assert!(matches!(err, CoreExecutorError::Stopped));
983    }
984
985    #[test]
986    fn hook_denial_agent_error_maps_to_typed_apply_failure_cause() {
987        let error = AgentError::HookDenied {
988            hook_id: crate::hooks::HookId::new("guard"),
989            point: crate::hooks::HookPoint::PreToolExecution,
990            reason_code: crate::hooks::HookReasonCode::PolicyViolation,
991            message: "blocked by hook".to_string(),
992            payload: None,
993        };
994
995        let cause = CoreApplyFailureCause::from_agent_error(&error);
996        assert_eq!(cause.kind, CoreApplyFailureCauseKind::HookDenied);
997        assert!(cause.message().contains("blocked by hook"));
998    }
999
1000    #[test]
1001    fn hook_runtime_agent_error_maps_to_typed_apply_failure_cause() {
1002        let error = AgentError::HookExecutionFailed {
1003            hook_id: crate::hooks::HookId::new("guard"),
1004            reason: "missing runtime".to_string(),
1005        };
1006
1007        let cause = CoreApplyFailureCause::from_agent_error(&error);
1008        assert_eq!(cause.kind, CoreApplyFailureCauseKind::HookRuntimeFailure);
1009        assert!(cause.message().contains("missing runtime"));
1010    }
1011}