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 { tool_name: String, args: Value },
397}
398
399#[derive(Debug, Clone)]
400pub struct CoreApplyOutput {
401    /// Unsequenced receipt proving boundary application. The runtime driver
402    /// mints the final sequenced [`super::run_receipt::RunBoundaryReceipt`]
403    /// from the generated machine's per-run boundary counter at commit time
404    /// (dogma K10 — executors cannot produce the boundary sequence).
405    pub receipt: RunBoundaryReceiptDraft,
406    /// Optional serialized session snapshot to durably commit atomically with
407    /// the receipt and input-state updates.
408    pub session_snapshot: Option<Vec<u8>>,
409    /// Terminal payload observation produced by runtime-backed execution.
410    ///
411    /// `None` means the primitive committed successfully but did not produce
412    /// a result payload (for example immediate context appends). Runtime
413    /// surfaces must route this payload shape through generated machine
414    /// authority before choosing a public completion result class.
415    pub terminal: Option<CoreApplyTerminal>,
416}
417
418/// Durable receipt for one exact interaction-terminal publication.
419#[derive(Debug, Clone, PartialEq, Eq)]
420pub struct CoreInteractionTerminalPublicationReceipt {
421    interaction_id: InteractionId,
422    terminal_seq: u64,
423    payload_digest: String,
424}
425
426impl CoreInteractionTerminalPublicationReceipt {
427    pub fn try_new(event: &AgentEvent, terminal_seq: u64) -> Result<Self, CoreExecutorError> {
428        if terminal_seq == 0 {
429            return Err(CoreExecutorError::Internal(
430                "interaction terminal durable sequence must be non-zero".to_string(),
431            ));
432        }
433        let interaction_id = match event {
434            AgentEvent::InteractionComplete { interaction_id, .. }
435            | AgentEvent::InteractionCallbackPending { interaction_id, .. }
436            | AgentEvent::InteractionFailed { interaction_id, .. } => *interaction_id,
437            _ => {
438                return Err(CoreExecutorError::Internal(
439                    "interaction terminal publication receipt requires an Interaction terminal event"
440                        .to_string(),
441                ));
442            }
443        };
444        let encoded = serde_json::to_vec(event).map_err(|error| {
445            CoreExecutorError::Internal(format!(
446                "failed to encode interaction terminal publication receipt: {error}"
447            ))
448        })?;
449        Ok(Self {
450            interaction_id,
451            terminal_seq,
452            payload_digest: format!("{:x}", Sha256::digest(encoded)),
453        })
454    }
455
456    pub fn interaction_id(&self) -> InteractionId {
457        self.interaction_id
458    }
459
460    pub fn terminal_seq(&self) -> u64 {
461        self.terminal_seq
462    }
463
464    pub fn payload_digest(&self) -> &str {
465        &self.payload_digest
466    }
467}
468
469/// Typed failure while preparing or resolving an exact live turn boundary.
470///
471/// Only [`CoreBoundaryStageError::Unavailable`] permits a caller to fall back
472/// to queued delivery. `Stale` means an exact actor/run/generation witness was
473/// invalidated, while `Fault` means the preparation mechanism itself failed;
474/// neither may be laundered into ordinary unavailability.
475#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
476pub enum CoreBoundaryStageError {
477    #[error("active turn boundary is unavailable: {reason}")]
478    Unavailable { reason: String },
479    #[error("active turn boundary authority is stale: {reason}")]
480    Stale { reason: String },
481    #[error("active turn boundary preparation failed: {reason}")]
482    Fault { reason: String },
483}
484
485impl CoreBoundaryStageError {
486    pub fn unavailable(reason: impl Into<String>) -> Self {
487        Self::Unavailable {
488            reason: reason.into(),
489        }
490    }
491
492    pub fn stale(reason: impl Into<String>) -> Self {
493        Self::Stale {
494            reason: reason.into(),
495        }
496    }
497
498    pub fn fault(reason: impl Into<String>) -> Self {
499        Self::Fault {
500            reason: reason.into(),
501        }
502    }
503
504    #[must_use]
505    pub fn is_unavailable(&self) -> bool {
506        matches!(self, Self::Unavailable { .. })
507    }
508}
509
510pub(crate) trait CoreBoundaryStageCommitAuthority: Send {
511    fn commit(&mut self) -> Result<(), CoreBoundaryStageError>;
512    fn abort(&mut self) -> Result<(), CoreBoundaryStageError>;
513}
514
515/// Successful prepare result for one exact parked model boundary.
516///
517/// The value is deliberately non-`Clone` and `#[must_use]`: it owns the only
518/// commit/abort authority for the parked `{actor, run, generation}`. Dropping
519/// it synchronously aborts the preparation and wakes the runner.
520///
521/// `commit` is the publication linearization point, not a claim that the LLM
522/// consumed the context. A hard cancel that linearizes after publication but
523/// before the runner's final synchronous consume still cancels that
524/// active-turn-only context; the runner-owned consumption witness distinguishes
525/// those outcomes.
526#[must_use = "a prepared boundary must be committed or aborted; dropping it aborts"]
527pub struct CoreBoundaryStageOutput {
528    /// Optional serialized session snapshot to commit atomically with the
529    /// generated receipt and input-state updates.
530    session_snapshot: Option<Vec<u8>>,
531    authority: Option<Box<dyn CoreBoundaryStageCommitAuthority>>,
532}
533
534impl CoreBoundaryStageOutput {
535    pub(crate) fn prepared(
536        session_snapshot: Option<Vec<u8>>,
537        authority: Box<dyn CoreBoundaryStageCommitAuthority>,
538    ) -> Self {
539        Self {
540            session_snapshot,
541            authority: Some(authority),
542        }
543    }
544
545    #[must_use]
546    pub fn session_snapshot(&self) -> Option<&[u8]> {
547        self.session_snapshot.as_deref()
548    }
549
550    /// Publish the prepared candidate exactly once and unblock its runner.
551    ///
552    /// Success means the exact parked actor accepted publication. Delivery to
553    /// the model remains cancellable until the runner consumes its separate
554    /// model-boundary witness at the final call seam.
555    pub fn commit(mut self) -> Result<(), CoreBoundaryStageError> {
556        let Some(mut authority) = self.authority.take() else {
557            return Err(CoreBoundaryStageError::stale(
558                "prepared boundary authority was already resolved",
559            ));
560        };
561        authority.commit()
562    }
563
564    pub fn abort(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.abort()
571    }
572}
573
574impl std::fmt::Debug for CoreBoundaryStageOutput {
575    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
576        formatter
577            .debug_struct("CoreBoundaryStageOutput")
578            .field(
579                "session_snapshot_len",
580                &self.session_snapshot.as_ref().map(Vec::len),
581            )
582            .field("authority", &self.authority.as_ref().map(|_| "prepared"))
583            .finish()
584    }
585}
586
587impl CoreApplyOutput {
588    pub fn with_run_result(
589        receipt: RunBoundaryReceiptDraft,
590        session_snapshot: Option<Vec<u8>>,
591        run_result: RunResult,
592    ) -> Self {
593        Self {
594            receipt,
595            session_snapshot,
596            terminal: Some(CoreApplyTerminal::RunResult(Box::new(run_result))),
597        }
598    }
599
600    pub fn with_callback_pending(
601        receipt: RunBoundaryReceiptDraft,
602        session_snapshot: Option<Vec<u8>>,
603        tool_name: impl Into<String>,
604        args: Value,
605    ) -> Self {
606        Self {
607            receipt,
608            session_snapshot,
609            terminal: Some(CoreApplyTerminal::CallbackPending {
610                tool_name: tool_name.into(),
611                args,
612            }),
613        }
614    }
615
616    pub fn without_terminal(
617        receipt: RunBoundaryReceiptDraft,
618        session_snapshot: Option<Vec<u8>>,
619    ) -> Self {
620        Self {
621            receipt,
622            session_snapshot,
623            terminal: None,
624        }
625    }
626}
627
628/// Cloneable live endpoint for cooperative in-flight turn boundaries.
629///
630/// ```compile_fail
631/// use meerkat_core::lifecycle::CoreExecutorBoundaryHandle;
632///
633/// async fn boundary_handles_cannot_hard_cancel(handle: &dyn CoreExecutorBoundaryHandle) {
634///     handle
635///         .hard_cancel_current_run("wrong authority".to_string())
636///         .await
637///         .unwrap();
638/// }
639/// ```
640#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
641#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
642pub trait CoreExecutorBoundaryHandle: Send + Sync {
643    /// Request cooperative cancellation for one exact active run.
644    async fn cancel_after_boundary(
645        &self,
646        expected_run_id: &RunId,
647        reason: String,
648    ) -> Result<(), CoreExecutorError>;
649
650    /// Prepare runtime-owned system context for one exact cooperative LLM
651    /// boundary and return only after the actor is parked immediately before
652    /// consumption. The non-clone result owns explicit commit/abort authority.
653    ///
654    /// Implementations that can serialize the staged session snapshot return
655    /// it so the runtime control plane can commit the snapshot atomically with
656    /// the consumed input state. Implementations without durable session
657    /// authority may return `None`.
658    async fn prepare_system_context_at_boundary(
659        &self,
660        _expected_run_id: &RunId,
661        _appends: Vec<PendingSystemContextAppend>,
662    ) -> Result<CoreBoundaryStageOutput, CoreBoundaryStageError> {
663        Err(CoreBoundaryStageError::unavailable(
664            "live boundary system-context preparation is unsupported by this executor",
665        ))
666    }
667}
668
669/// Cloneable live endpoint for hard-cancelling the active run immediately.
670#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
671#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
672pub trait CoreExecutorInterruptHandle: Send + Sync {
673    async fn hard_cancel_current_run(&self, reason: String) -> Result<(), CoreExecutorError>;
674}
675
676/// Cloneable capability for exact durable interaction-terminal publication.
677///
678/// Runtime control paths may need to terminalize queued or staged directed
679/// inputs while the owning executor is in flight (destroy/unregister) or after
680/// its loop channels have been detached. Keeping this authority on a separate
681/// handle prevents those paths from borrowing or duplicating the executor
682/// while still routing publication through the executor's owning session
683/// surface.
684#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
685#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
686pub trait CoreExecutorPublicationHandle: Send + Sync {
687    async fn publish_interaction_terminals(
688        &self,
689        events: &[AgentEvent],
690    ) -> Result<Vec<CoreInteractionTerminalPublicationReceipt>, CoreExecutorError>;
691}
692
693/// Cloneable service/surface cleanup authority retained by the runtime entry.
694///
695/// Unlike adapter unregister, this handle owns only the executor's live actor
696/// and surface-local state. `MeerkatMachine` invokes it inside the exact
697/// attachment's generated unregister window, and can retry it after a failed
698/// or externally initiated drain without resurrecting the executor object.
699/// Implementations must therefore be idempotent, including when a prior
700/// attempt completed only part of its sidecar cleanup before returning an
701/// error.
702#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
703#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
704pub trait CoreExecutorPostStopCleanupHandle: Send + Sync {
705    async fn cleanup_after_runtime_stop_terminalized(&self) -> Result<(), CoreExecutorError>;
706
707    /// Cleanup when the runtime loop already owns this session's stable outer
708    /// turn-finalization boundary. Implementations backed by that boundary must
709    /// not reacquire it.
710    async fn cleanup_after_runtime_stop_terminalized_under_turn_finalization_boundary(
711        &self,
712    ) -> Result<(), CoreExecutorError> {
713        self.cleanup_after_runtime_stop_terminalized().await
714    }
715}
716
717/// Opaque RAII witness that one session actor's turn-finalization interval is
718/// exclusively owned. The runtime holds this from before queue/effect staging
719/// through machine commit, compatibility checkpoint, exact terminal receipt
720/// persistence, and waiter resolution.
721pub trait CoreExecutorTurnFinalizationGuard: Send {}
722
723impl<T: Send> CoreExecutorTurnFinalizationGuard for T {}
724
725/// Cloneable endpoint for the stable per-session turn-finalization boundary.
726#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
727#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
728pub trait CoreExecutorTurnFinalizationBoundaryHandle: Send + Sync {
729    async fn acquire(
730        &self,
731    ) -> Result<Box<dyn CoreExecutorTurnFinalizationGuard>, CoreExecutorError>;
732}
733
734/// The interface core exposes for the runtime layer to apply run primitives.
735///
736/// The runtime layer creates an implementation that wraps an `Agent` and
737/// translates `RunPrimitive` into session mutations. This trait is defined
738/// in core so both layers can depend on it without circular deps.
739///
740/// # Object Safety
741/// This trait is object-safe to allow `Box<dyn CoreExecutor>` usage.
742#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
743#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
744pub trait CoreExecutor: Send + Sync {
745    /// Optional live cooperative-boundary endpoint.
746    ///
747    /// Implementations return this only when the underlying live turn can be
748    /// signaled while `apply()` is in flight and will also wake any yielding
749    /// turn so the boundary request can be observed.
750    fn boundary_handle(&self) -> Option<Arc<dyn CoreExecutorBoundaryHandle>> {
751        None
752    }
753
754    /// Optional live hard-interrupt endpoint.
755    ///
756    /// Hard cancel is intentionally live-handle-only. It is not available on
757    /// the queued in-loop executor channel because user/session interrupt
758    /// semantics require prompt delivery during a long in-flight turn.
759    fn interrupt_handle(&self) -> Option<Arc<dyn CoreExecutorInterruptHandle>> {
760        None
761    }
762
763    /// Optional cloneable authority for exact durable terminal publication.
764    fn publication_handle(&self) -> Option<Arc<dyn CoreExecutorPublicationHandle>> {
765        None
766    }
767
768    /// Whether `MeerkatMachine` should retain and fence this attachment's exact
769    /// post-stop service cleanup authority.
770    ///
771    /// Opted-in executors expose a cloneable attachment-local cleanup handle.
772    /// The machine fences it by the attachment incarnation it created, so a
773    /// stale cleanup cannot remove replacement state. Ordinary runtime stop
774    /// cleans the service incarnation while preserving the registered
775    /// `Stopped` machine state; explicit unregister owns the later `Draining`
776    /// transition and registration removal.
777    fn machine_managed_post_stop_unregister(&self) -> bool {
778        false
779    }
780
781    /// Cloneable service/surface cleanup authority for machine-managed
782    /// post-stop unregister.
783    fn post_stop_cleanup_handle(&self) -> Option<Arc<dyn CoreExecutorPostStopCleanupHandle>> {
784        None
785    }
786
787    /// Stable boundary shared with direct and non-turn session mutations.
788    fn turn_finalization_boundary_handle(
789        &self,
790    ) -> Option<Arc<dyn CoreExecutorTurnFinalizationBoundaryHandle>> {
791        None
792    }
793
794    /// Apply a run primitive to the conversation.
795    ///
796    /// Returns a receipt proving the application, including a digest of the
797    /// conversation state after mutation.
798    async fn apply(
799        &mut self,
800        run_id: RunId,
801        primitive: RunPrimitive,
802    ) -> Result<CoreApplyOutput, CoreExecutorError>;
803
804    /// Persist or project the committed session snapshot after the runtime
805    /// control plane has durably committed the machine boundary.
806    ///
807    /// RuntimeStore remains the authority for runtime-backed turns; this hook
808    /// is for compatibility projections such as `SessionStore` snapshots that
809    /// must not be written before the machine commit succeeds. Recovery may
810    /// invoke this with the authoritative RuntimeStore snapshot after outbox
811    /// finalization so a stale compatibility snapshot cannot resurrect an
812    /// already-finalized compaction intent.
813    async fn checkpoint_committed_session_snapshot(
814        &mut self,
815        _session_snapshot: &[u8],
816    ) -> Result<(), CoreExecutorError> {
817        Ok(())
818    }
819
820    /// Reconcile and finalize semantic-memory compaction stages named by the
821    /// exact RuntimeStore atomic outbox. The empty slice is authoritative: a
822    /// durable implementation must use it to abort any invisible stage left by
823    /// a crash before the runtime boundary committed.
824    async fn reconcile_committed_compaction_projections(
825        &mut self,
826        intents: &[crate::memory::CompactionProjectionIntent],
827    ) -> Result<(), CoreExecutorError> {
828        if intents.is_empty() {
829            Ok(())
830        } else {
831            Err(CoreExecutorError::Internal(
832                "executor cannot reconcile committed compaction projections".to_string(),
833            ))
834        }
835    }
836
837    /// Roll back and abort any invisible compaction stage after the runtime
838    /// boundary commit was rejected and the authoritative outbox was observed
839    /// empty. This is deliberately separate from committed reconciliation so
840    /// an empty post-error observation can never be mistaken for commit
841    /// authority.
842    async fn abort_uncommitted_compaction_projections(&mut self) -> Result<(), CoreExecutorError> {
843        Ok(())
844    }
845
846    /// Abort every executor-owned projection staged by a run whose atomic
847    /// runtime boundary was rejected.
848    ///
849    /// The default preserves compatibility with executors that can stage only
850    /// compaction. Runtime-backed session executors override this to also
851    /// remove any uncommitted live transcript and context-event projections.
852    /// Implementations must be cancellation-safe and retry-idempotent: once an
853    /// attempt observes one sub-projection aborted, cancellation before the
854    /// whole cleanup returns must leave enough mechanical progress to continue
855    /// without requiring an already-discarded live carrier.
856    async fn abort_rejected_run_projections(&mut self) -> Result<(), CoreExecutorError> {
857        self.abort_uncommitted_compaction_projections().await
858    }
859
860    /// Durably publish exact per-input Interaction terminal events after
861    /// generated runtime completion authority has observed finalization.
862    /// Implementations must make replay idempotent by interaction ID and
863    /// reject a mismatching existing payload.
864    async fn publish_interaction_terminals(
865        &mut self,
866        events: &[AgentEvent],
867    ) -> Result<Vec<CoreInteractionTerminalPublicationReceipt>, CoreExecutorError> {
868        if events.is_empty() {
869            return Ok(Vec::new());
870        }
871        Err(CoreExecutorError::Internal(
872            "exact interaction terminal publication is unsupported by this executor".to_string(),
873        ))
874    }
875
876    /// Request cancellation at the next cooperative boundary.
877    async fn cancel_after_boundary(&mut self, reason: String) -> Result<(), CoreExecutorError>;
878
879    /// Ask this runtime executor to stop accepting work.
880    async fn stop_runtime_executor(&mut self, reason: String) -> Result<(), CoreExecutorError>;
881
882    /// Cleanup of executor-owned external/session material that is safe only
883    /// after the runtime control plane has durably terminalized the stop.
884    ///
885    /// This hook must not unregister the runtime session. The machine-owned
886    /// runtime-loop cleanup coordinator invokes it; ordinary stop preserves
887    /// the registered `Stopped` session, while explicit or executor-required
888    /// unregister separately owns registration removal. Recursive unregister
889    /// from this hook is rejected fail-closed.
890    async fn cleanup_after_runtime_stop_terminalized(&mut self) -> Result<(), CoreExecutorError> {
891        Ok(())
892    }
893}
894
895#[cfg(test)]
896#[allow(clippy::panic)]
897mod tests {
898    use super::*;
899
900    // Verify CoreExecutor is object-safe
901    fn _assert_object_safe(_: &dyn CoreExecutor) {}
902
903    #[test]
904    fn core_executor_error_display() {
905        let err = CoreExecutorError::ApplyFailed {
906            cause: CoreApplyFailureCause::runtime_turn("bad input"),
907        };
908        assert_eq!(err.to_string(), "Apply failed: bad input");
909
910        let err = CoreExecutorError::ControlFailed {
911            cause: CoreControlFailureCause::runtime_control("not running"),
912        };
913        assert_eq!(err.to_string(), "Control failed: not running");
914
915        let err = CoreExecutorError::Stopped;
916        assert_eq!(err.to_string(), "Executor is stopped");
917
918        let err = CoreExecutorError::Cancelled;
919        assert_eq!(err.to_string(), "Run was cancelled");
920
921        let err = CoreExecutorError::Internal("oops".into());
922        assert_eq!(err.to_string(), "Internal error: oops");
923    }
924
925    #[test]
926    fn apply_failed_carries_typed_cause() {
927        let err = CoreExecutorError::ApplyFailed {
928            cause: CoreApplyFailureCause::runtime_context_apply("context write failed"),
929        };
930
931        match err {
932            CoreExecutorError::ApplyFailed { cause } => {
933                assert_eq!(cause.kind, CoreApplyFailureCauseKind::RuntimeContextApply);
934                assert_eq!(cause.message(), "context write failed");
935            }
936            other => panic!("expected typed apply failure, got {other:?}"),
937        }
938    }
939
940    #[test]
941    fn cancelled_session_error_remains_typed_at_runtime_executor_boundary() {
942        let err = CoreExecutorError::apply_failed_from_session_error(SessionError::Agent(
943            AgentError::Cancelled,
944        ));
945
946        assert!(err.is_cancelled());
947        assert_eq!(
948            err.apply_failure_cause().kind,
949            CoreApplyFailureCauseKind::RuntimeTurn
950        );
951    }
952
953    #[test]
954    fn corrupted_live_session_signal_stops_instead_of_retrying_apply() {
955        let err = CoreExecutorError::apply_failed_from_session_error(
956            SessionError::runtime_executor_stopped("terminal witness mismatch"),
957        );
958
959        assert!(matches!(err, CoreExecutorError::Stopped));
960    }
961
962    #[test]
963    fn hook_denial_agent_error_maps_to_typed_apply_failure_cause() {
964        let error = AgentError::HookDenied {
965            hook_id: crate::hooks::HookId::new("guard"),
966            point: crate::hooks::HookPoint::PreToolExecution,
967            reason_code: crate::hooks::HookReasonCode::PolicyViolation,
968            message: "blocked by hook".to_string(),
969            payload: None,
970        };
971
972        let cause = CoreApplyFailureCause::from_agent_error(&error);
973        assert_eq!(cause.kind, CoreApplyFailureCauseKind::HookDenied);
974        assert!(cause.message().contains("blocked by hook"));
975    }
976
977    #[test]
978    fn hook_runtime_agent_error_maps_to_typed_apply_failure_cause() {
979        let error = AgentError::HookExecutionFailed {
980            hook_id: crate::hooks::HookId::new("guard"),
981            reason: "missing runtime".to_string(),
982        };
983
984        let cause = CoreApplyFailureCause::from_agent_error(&error);
985        assert_eq!(cause.kind, CoreApplyFailureCauseKind::HookRuntimeFailure);
986        assert!(cause.message().contains("missing runtime"));
987    }
988}