Skip to main content

meerkat_runtime/handles/
turn_state.rs

1//! Runtime impl of [`meerkat_core::handles::TurnStateHandle`].
2
3use std::collections::BTreeSet;
4use std::sync::Arc;
5
6use meerkat_core::handles::{DslTransitionError, TurnStateHandle, TurnStateSnapshot};
7use meerkat_core::lifecycle::RunId;
8use meerkat_core::ops::{AsyncOpRef, OperationId, WaitPolicy};
9use meerkat_core::retry::LlmRetrySchedule;
10#[cfg(test)]
11use meerkat_core::turn_execution_authority::TurnFailureSourceKind;
12use meerkat_core::turn_execution_authority::{
13    CallTimeoutSource as TurnCallTimeoutSource, CallTimeoutVerdict as TurnCallTimeoutVerdict,
14    ContentShape, LlmFailureRecoveryKind, TurnExecutionEffect, TurnExecutionInput,
15    TurnFailureReason, TurnFailureSource, TurnPhase, TurnPrimitiveKind, TurnTerminalCauseKind,
16    TurnTerminalOutcome, terminal_outcome_for_budget_exceeded,
17};
18
19use super::HandleDslAuthority;
20use crate::meerkat_machine::dsl as mm_dsl;
21
22/// Runtime-backed [`TurnStateHandle`] impl.
23#[derive(Debug)]
24pub struct RuntimeTurnStateHandle {
25    dsl: Arc<HandleDslAuthority>,
26    standalone_session_id: Option<meerkat_core::SessionId>,
27}
28
29impl RuntimeTurnStateHandle {
30    /// Construct a handle backed by the session's shared DSL authority.
31    pub fn new(dsl: Arc<HandleDslAuthority>) -> Self {
32        Self {
33            dsl,
34            standalone_session_id: None,
35        }
36    }
37
38    /// Construct the standalone facade handle. With no runtime loop to stage
39    /// `Prepare`, the turn handle performs that generated input immediately
40    /// before each run-start input on the same shared authority.
41    pub(crate) fn standalone(
42        dsl: Arc<HandleDslAuthority>,
43        session_id: meerkat_core::SessionId,
44    ) -> Self {
45        Self {
46            dsl,
47            standalone_session_id: Some(session_id),
48        }
49    }
50
51    /// Construct a handle backed by an ephemeral DSL authority.
52    pub fn ephemeral() -> Self {
53        Self::new(Arc::new(HandleDslAuthority::ephemeral()))
54    }
55
56    fn prepare_standalone_run(&self, run_id: &RunId) -> Result<(), DslTransitionError> {
57        let Some(session_id) = self.standalone_session_id.as_ref() else {
58            return Ok(());
59        };
60        // Intra-machine generated input: no routed effect crosses a machine
61        // boundary, so the CompositionDispatcher is not applicable here.
62        self.dsl.apply_input(
63            mm_dsl::MeerkatMachineInput::Prepare {
64                session_id: mm_dsl::SessionId::from_domain(session_id),
65                run_id: mm_dsl::RunId::from_domain(run_id),
66            },
67            "TurnStateHandle::standalone_prepare",
68        )
69    }
70}
71
72fn parse_effect_run_id(
73    run_id: &mm_dsl::RunId,
74    context: &'static str,
75) -> Result<RunId, DslTransitionError> {
76    uuid::Uuid::parse_str(&run_id.0)
77        .map(RunId::from_uuid)
78        .map_err(|err| {
79            DslTransitionError::guard_rejected(
80                context,
81                format!(
82                    "generated MeerkatMachine turn effect carried malformed run_id `{}`: {err}",
83                    run_id.0
84                ),
85            )
86        })
87}
88
89fn map_generated_turn_effect(
90    effect: mm_dsl::MeerkatMachineEffect,
91    context: &'static str,
92) -> Result<Option<TurnExecutionEffect>, DslTransitionError> {
93    Ok(Some(match effect {
94        mm_dsl::MeerkatMachineEffect::TurnRunStarted { run_id } => {
95            TurnExecutionEffect::RunStarted {
96                run_id: parse_effect_run_id(&run_id, context)?,
97            }
98        }
99        mm_dsl::MeerkatMachineEffect::TurnBoundaryApplied {
100            run_id,
101            boundary_sequence,
102        } => TurnExecutionEffect::BoundaryApplied {
103            run_id: parse_effect_run_id(&run_id, context)?,
104            boundary_sequence,
105        },
106        mm_dsl::MeerkatMachineEffect::TurnRunCompleted { run_id, .. } => {
107            TurnExecutionEffect::RunCompleted {
108                run_id: parse_effect_run_id(&run_id, context)?,
109            }
110        }
111        mm_dsl::MeerkatMachineEffect::TurnRunFailed {
112            run_id,
113            terminal_cause_kind,
114            error,
115        } => {
116            let cause_kind: TurnTerminalCauseKind = terminal_cause_kind.into();
117            if !cause_kind.is_specific_failure_cause() {
118                return Err(DslTransitionError::guard_rejected(
119                    context,
120                    "generated MeerkatMachine TurnRunFailed effect carried unknown terminal_cause_kind",
121                ));
122            }
123            TurnExecutionEffect::RunFailed {
124                run_id: parse_effect_run_id(&run_id, context)?,
125                reason: TurnFailureReason::with_cause(
126                    cause_kind,
127                    cause_kind.agent_error_class(),
128                    error,
129                ),
130            }
131        }
132        mm_dsl::MeerkatMachineEffect::TurnRunCancelled { run_id, .. } => {
133            TurnExecutionEffect::RunCancelled {
134                run_id: parse_effect_run_id(&run_id, context)?,
135            }
136        }
137        mm_dsl::MeerkatMachineEffect::TurnCheckCompaction => TurnExecutionEffect::CheckCompaction,
138        mm_dsl::MeerkatMachineEffect::LlmFailureRecoveryClassified { recovery } => {
139            TurnExecutionEffect::LlmFailureRecoveryClassified {
140                recovery: match recovery {
141                    mm_dsl::LlmFailureRecoveryKind::Recover => LlmFailureRecoveryKind::Recover,
142                    mm_dsl::LlmFailureRecoveryKind::Exhausted => LlmFailureRecoveryKind::Exhausted,
143                    mm_dsl::LlmFailureRecoveryKind::Fatal => LlmFailureRecoveryKind::Fatal,
144                },
145            }
146        }
147        mm_dsl::MeerkatMachineEffect::AssistantOutputClassified {
148            empty_response_terminal,
149        } => TurnExecutionEffect::AssistantOutputClassified {
150            empty_response_terminal,
151        },
152        mm_dsl::MeerkatMachineEffect::CallTimeoutClassified {
153            verdict,
154            timeout_ms,
155        } => TurnExecutionEffect::CallTimeoutClassified {
156            verdict: match verdict {
157                mm_dsl::CallTimeoutVerdict::RetryableCallTimeout => {
158                    TurnCallTimeoutVerdict::RetryableCallTimeout
159                }
160                mm_dsl::CallTimeoutVerdict::TerminalTurnBudget => {
161                    TurnCallTimeoutVerdict::TerminalTurnBudget
162                }
163            },
164            timeout_ms,
165        },
166        _ => return Ok(None),
167    }))
168}
169
170impl TurnStateHandle for RuntimeTurnStateHandle {
171    fn apply_turn_input(
172        &self,
173        input: TurnExecutionInput,
174    ) -> Result<Vec<TurnExecutionEffect>, DslTransitionError> {
175        let context = "TurnStateHandle::apply_turn_input";
176        let standalone_run_id = match &input {
177            TurnExecutionInput::StartConversationRun { run_id, .. }
178            | TurnExecutionInput::StartImmediateAppend { run_id } => Some(run_id),
179            _ => None,
180        };
181        if let Some(run_id) = standalone_run_id {
182            self.prepare_standalone_run(run_id)?;
183        }
184        let dsl_input = match input {
185            TurnExecutionInput::StartConversationRun {
186                run_id,
187                primitive_kind,
188                admitted_content_shape,
189                vision_enabled,
190                image_tool_results_enabled,
191                max_extraction_retries,
192            } => mm_dsl::MeerkatMachineInput::StartConversationRun {
193                run_id: mm_dsl::RunId::from_domain(&run_id),
194                primitive_kind: mm_dsl::TurnPrimitiveKind::from(primitive_kind),
195                admitted_content_shape: mm_dsl::ContentShape::from(admitted_content_shape),
196                vision_enabled,
197                image_tool_results_enabled,
198                max_extraction_retries,
199            },
200            TurnExecutionInput::StartImmediateAppend { run_id } => {
201                mm_dsl::MeerkatMachineInput::StartImmediateAppend {
202                    run_id: mm_dsl::RunId::from_domain(&run_id),
203                }
204            }
205            TurnExecutionInput::PrimitiveApplied { run_id } => {
206                mm_dsl::MeerkatMachineInput::PrimitiveApplied {
207                    run_id: mm_dsl::RunId::from_domain(&run_id),
208                }
209            }
210            TurnExecutionInput::LlmReturnedToolCalls { run_id, tool_count } => {
211                mm_dsl::MeerkatMachineInput::LlmReturnedToolCalls {
212                    run_id: mm_dsl::RunId::from_domain(&run_id),
213                    tool_count: u64::from(tool_count),
214                }
215            }
216            TurnExecutionInput::CallbackPending { run_id } => {
217                mm_dsl::MeerkatMachineInput::CallbackPending {
218                    run_id: mm_dsl::RunId::from_domain(&run_id),
219                }
220            }
221            TurnExecutionInput::LlmReturnedTerminal { run_id } => {
222                mm_dsl::MeerkatMachineInput::LlmReturnedTerminal {
223                    run_id: mm_dsl::RunId::from_domain(&run_id),
224                }
225            }
226            TurnExecutionInput::RegisterPendingOps {
227                run_id,
228                op_refs,
229                barrier_operation_ids,
230                ..
231            } => mm_dsl::MeerkatMachineInput::RegisterPendingOps {
232                run_id: mm_dsl::RunId::from_domain(&run_id),
233                op_refs: op_refs
234                    .iter()
235                    .map(|op_ref| op_ref.operation_id.to_string())
236                    .collect(),
237                // #354: barrier ids are now a typed `Set<OperationId>` in the
238                // DSL. The token repr stays the plain-UUID Display string that
239                // `parse_operation_id` round-trips (NOT the JSON `from_domain`
240                // form), so the projection back to domain `OperationId` is
241                // lossless.
242                barrier_operation_ids: barrier_operation_ids
243                    .iter()
244                    .map(|id| mm_dsl::OperationId::from(id.to_string()))
245                    .collect(),
246            },
247            TurnExecutionInput::ToolCallsResolved { run_id } => {
248                mm_dsl::MeerkatMachineInput::ToolCallsResolved {
249                    run_id: mm_dsl::RunId::from_domain(&run_id),
250                }
251            }
252            TurnExecutionInput::OpsBarrierSatisfied {
253                run_id,
254                operation_ids,
255            } => mm_dsl::MeerkatMachineInput::OpsBarrierSatisfied {
256                run_id: mm_dsl::RunId::from_domain(&run_id),
257                // #354: typed `Set<OperationId>`; same plain-UUID token repr.
258                operation_ids: operation_ids
259                    .iter()
260                    .map(|id| mm_dsl::OperationId::from(id.to_string()))
261                    .collect(),
262            },
263            TurnExecutionInput::BoundaryContinue { run_id } => {
264                mm_dsl::MeerkatMachineInput::BoundaryContinue {
265                    run_id: mm_dsl::RunId::from_domain(&run_id),
266                }
267            }
268            TurnExecutionInput::BoundaryComplete { run_id } => {
269                mm_dsl::MeerkatMachineInput::BoundaryComplete {
270                    run_id: mm_dsl::RunId::from_domain(&run_id),
271                }
272            }
273            TurnExecutionInput::RecoverableFailure { run_id, retry } => {
274                mm_dsl::MeerkatMachineInput::RecoverableFailure {
275                    run_id: mm_dsl::RunId::from_domain(&run_id),
276                    failure_kind: retry.failure.kind.into(),
277                    retry_attempt: u64::from(retry.plan.attempt),
278                    max_retries: u64::from(retry.plan.max_retries),
279                    selected_delay_ms: retry.plan.selected_delay_ms,
280                    error: retry.failure.message,
281                }
282            }
283            TurnExecutionInput::FatalFailure { run_id, failure } => {
284                mm_dsl::MeerkatMachineInput::FatalFailure {
285                    run_id: mm_dsl::RunId::from_domain(&run_id),
286                    terminal_failure_source: mm_dsl::RunFailureSourceKind::from(
287                        failure.source_kind,
288                    ),
289                    error: failure.message,
290                }
291            }
292            TurnExecutionInput::RetryRequested {
293                run_id,
294                retry_attempt,
295            } => mm_dsl::MeerkatMachineInput::RetryRequested {
296                run_id: mm_dsl::RunId::from_domain(&run_id),
297                retry_attempt: u64::from(retry_attempt),
298            },
299            TurnExecutionInput::ClassifyLlmFailureRecovery {
300                failure_kind,
301                retry_attempt,
302                max_retries,
303            } => mm_dsl::MeerkatMachineInput::ClassifyLlmFailureRecovery {
304                failure_kind: failure_kind.map(Into::into),
305                retry_attempt: u64::from(retry_attempt),
306                max_retries: u64::from(max_retries),
307            },
308            TurnExecutionInput::ClassifyAssistantOutput {
309                has_visible_or_actionable,
310            } => mm_dsl::MeerkatMachineInput::ClassifyAssistantOutput {
311                has_visible_or_actionable,
312            },
313            TurnExecutionInput::ClassifyCallTimeout { source, timeout_ms } => {
314                mm_dsl::MeerkatMachineInput::ClassifyCallTimeout {
315                    source: match source {
316                        TurnCallTimeoutSource::CallBudget => mm_dsl::CallTimeoutSource::CallBudget,
317                        TurnCallTimeoutSource::TurnBudget => mm_dsl::CallTimeoutSource::TurnBudget,
318                    },
319                    timeout_ms,
320                }
321            }
322            TurnExecutionInput::CancelNow { run_id } => mm_dsl::MeerkatMachineInput::CancelNow {
323                run_id: mm_dsl::RunId::from_domain(&run_id),
324            },
325            TurnExecutionInput::CancelAfterBoundary { run_id } => {
326                mm_dsl::MeerkatMachineInput::RequestCancelAfterBoundary {
327                    run_id: mm_dsl::RunId::from_domain(&run_id),
328                }
329            }
330            TurnExecutionInput::CancellationObserved { run_id } => {
331                mm_dsl::MeerkatMachineInput::CancellationObserved {
332                    run_id: mm_dsl::RunId::from_domain(&run_id),
333                }
334            }
335            TurnExecutionInput::AcknowledgeTerminal { run_id } => {
336                let outcome = self.snapshot().terminal_outcome.ok_or_else(|| {
337                    DslTransitionError::guard_rejected(
338                        context,
339                        "generated MeerkatMachine terminal outcome missing for AcknowledgeTerminal",
340                    )
341                })?;
342                mm_dsl::MeerkatMachineInput::AcknowledgeTerminal {
343                    run_id: mm_dsl::RunId::from_domain(&run_id),
344                    outcome: mm_dsl::TurnTerminalOutcome::from(outcome),
345                }
346            }
347            TurnExecutionInput::TurnLimitReached {
348                run_id,
349                turn_count,
350                max_turns,
351            } => mm_dsl::MeerkatMachineInput::TurnLimitReached {
352                run_id: mm_dsl::RunId::from_domain(&run_id),
353                turn_count,
354                max_turns,
355            },
356            TurnExecutionInput::BudgetExhausted { run_id } => {
357                mm_dsl::MeerkatMachineInput::BudgetExhausted {
358                    run_id: mm_dsl::RunId::from_domain(&run_id),
359                }
360            }
361            TurnExecutionInput::TimeBudgetExceeded { run_id } => {
362                mm_dsl::MeerkatMachineInput::TimeBudgetExceeded {
363                    run_id: mm_dsl::RunId::from_domain(&run_id),
364                }
365            }
366            TurnExecutionInput::BudgetLimitExceeded { run_id, exceeded } => {
367                match terminal_outcome_for_budget_exceeded(exceeded) {
368                    TurnTerminalOutcome::TimeBudgetExceeded => {
369                        mm_dsl::MeerkatMachineInput::TimeBudgetExceeded {
370                            run_id: mm_dsl::RunId::from_domain(&run_id),
371                        }
372                    }
373                    TurnTerminalOutcome::BudgetExhausted => {
374                        mm_dsl::MeerkatMachineInput::BudgetExhausted {
375                            run_id: mm_dsl::RunId::from_domain(&run_id),
376                        }
377                    }
378                    _ => unreachable!("budget exceeded maps only to budget terminal outcomes"),
379                }
380            }
381            TurnExecutionInput::EnterExtraction {
382                run_id,
383                max_retries,
384            } => mm_dsl::MeerkatMachineInput::EnterExtraction {
385                run_id: mm_dsl::RunId::from_domain(&run_id),
386                max_extraction_retries: u64::from(max_retries),
387            },
388            TurnExecutionInput::ExtractionValidationPassed { run_id } => {
389                mm_dsl::MeerkatMachineInput::ExtractionValidationPassed {
390                    run_id: mm_dsl::RunId::from_domain(&run_id),
391                }
392            }
393            TurnExecutionInput::ExtractionValidationFailed { run_id, error } => {
394                mm_dsl::MeerkatMachineInput::ExtractionValidationFailed {
395                    run_id: mm_dsl::RunId::from_domain(&run_id),
396                    error,
397                }
398            }
399            TurnExecutionInput::ExtractionFailed { run_id, error } => {
400                mm_dsl::MeerkatMachineInput::ExtractionFailed {
401                    run_id: mm_dsl::RunId::from_domain(&run_id),
402                    error,
403                }
404            }
405            TurnExecutionInput::ExtractionStart { run_id } => {
406                mm_dsl::MeerkatMachineInput::ExtractionStart {
407                    run_id: mm_dsl::RunId::from_domain(&run_id),
408                }
409            }
410            TurnExecutionInput::ForceCancelNoRun => mm_dsl::MeerkatMachineInput::ForceCancelNoRun,
411        };
412        self.dsl
413            .apply_input_with_effects(dsl_input, context)?
414            .into_iter()
415            .map(|effect| map_generated_turn_effect(effect, context))
416            .filter_map(Result::transpose)
417            .collect()
418    }
419
420    fn start_conversation_run(
421        &self,
422        run_id: RunId,
423        primitive_kind: TurnPrimitiveKind,
424        admitted_content_shape: ContentShape,
425        vision_enabled: bool,
426        image_tool_results_enabled: bool,
427        max_extraction_retries: u64,
428    ) -> Result<(), DslTransitionError> {
429        self.prepare_standalone_run(&run_id)?;
430        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
431        self.dsl.apply_input(
432            mm_dsl::MeerkatMachineInput::StartConversationRun {
433                run_id: mm_dsl::RunId::from_domain(&run_id),
434                primitive_kind: mm_dsl::TurnPrimitiveKind::from(primitive_kind),
435                admitted_content_shape: mm_dsl::ContentShape::from(admitted_content_shape),
436                vision_enabled,
437                image_tool_results_enabled,
438                max_extraction_retries,
439            },
440            "TurnStateHandle::start_conversation_run",
441        )
442    }
443
444    fn start_immediate_append(&self, run_id: RunId) -> Result<(), DslTransitionError> {
445        self.prepare_standalone_run(&run_id)?;
446        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
447        self.dsl.apply_input(
448            mm_dsl::MeerkatMachineInput::StartImmediateAppend {
449                run_id: mm_dsl::RunId::from_domain(&run_id),
450            },
451            "TurnStateHandle::start_immediate_append",
452        )
453    }
454
455    fn primitive_applied(&self, run_id: RunId) -> Result<(), DslTransitionError> {
456        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
457        self.dsl.apply_input(
458            mm_dsl::MeerkatMachineInput::PrimitiveApplied {
459                run_id: mm_dsl::RunId::from_domain(&run_id),
460            },
461            "TurnStateHandle::primitive_applied",
462        )
463    }
464
465    fn llm_returned_tool_calls(
466        &self,
467        run_id: RunId,
468        tool_count: u64,
469    ) -> Result<(), DslTransitionError> {
470        self.apply_turn_input(TurnExecutionInput::LlmReturnedToolCalls {
471            run_id,
472            tool_count: u32::try_from(tool_count).map_err(|_| {
473                DslTransitionError::guard_rejected(
474                    "TurnStateHandle::llm_returned_tool_calls",
475                    "tool_count exceeds u32 turn input range",
476                )
477            })?,
478        })
479        .map(|_| ())
480    }
481
482    fn llm_returned_terminal(&self, run_id: RunId) -> Result<(), DslTransitionError> {
483        self.apply_turn_input(TurnExecutionInput::LlmReturnedTerminal { run_id })
484            .map(|_| ())
485    }
486
487    fn register_pending_ops(
488        &self,
489        run_id: RunId,
490        op_refs: BTreeSet<AsyncOpRef>,
491        barrier_operation_ids: BTreeSet<OperationId>,
492    ) -> Result<(), DslTransitionError> {
493        let has_barrier_ops = !barrier_operation_ids.is_empty();
494        self.apply_turn_input(TurnExecutionInput::RegisterPendingOps {
495            run_id,
496            op_refs: op_refs.into_iter().collect(),
497            barrier_operation_ids: barrier_operation_ids.into_iter().collect(),
498            has_barrier_ops,
499        })
500        .map(|_| ())
501    }
502
503    fn tool_calls_resolved(&self, run_id: RunId) -> Result<(), DslTransitionError> {
504        self.apply_turn_input(TurnExecutionInput::ToolCallsResolved { run_id })
505            .map(|_| ())
506    }
507
508    fn ops_barrier_satisfied(
509        &self,
510        run_id: RunId,
511        operation_ids: BTreeSet<OperationId>,
512    ) -> Result<(), DslTransitionError> {
513        self.apply_turn_input(TurnExecutionInput::OpsBarrierSatisfied {
514            run_id,
515            operation_ids: operation_ids.into_iter().collect(),
516        })
517        .map(|_| ())
518    }
519
520    fn boundary_continue(&self, run_id: RunId) -> Result<(), DslTransitionError> {
521        self.apply_turn_input(TurnExecutionInput::BoundaryContinue { run_id })
522            .map(|_| ())
523    }
524
525    fn boundary_complete(&self, run_id: RunId) -> Result<(), DslTransitionError> {
526        self.apply_turn_input(TurnExecutionInput::BoundaryComplete { run_id })
527            .map(|_| ())
528    }
529
530    fn enter_extraction(&self, run_id: RunId, max_retries: u32) -> Result<(), DslTransitionError> {
531        self.apply_turn_input(TurnExecutionInput::EnterExtraction {
532            run_id,
533            max_retries,
534        })
535        .map(|_| ())
536    }
537
538    fn extraction_start(&self, run_id: RunId) -> Result<(), DslTransitionError> {
539        self.apply_turn_input(TurnExecutionInput::ExtractionStart { run_id })
540            .map(|_| ())
541    }
542
543    fn extraction_validation_passed(&self, run_id: RunId) -> Result<(), DslTransitionError> {
544        self.apply_turn_input(TurnExecutionInput::ExtractionValidationPassed { run_id })
545            .map(|_| ())
546    }
547
548    fn extraction_validation_failed(
549        &self,
550        run_id: RunId,
551        error: String,
552    ) -> Result<(), DslTransitionError> {
553        self.apply_turn_input(TurnExecutionInput::ExtractionValidationFailed { run_id, error })
554            .map(|_| ())
555    }
556
557    fn extraction_failed(&self, run_id: RunId, error: String) -> Result<(), DslTransitionError> {
558        self.apply_turn_input(TurnExecutionInput::ExtractionFailed { run_id, error })
559            .map(|_| ())
560    }
561
562    fn recoverable_failure(
563        &self,
564        run_id: RunId,
565        retry: LlmRetrySchedule,
566    ) -> Result<(), DslTransitionError> {
567        self.apply_turn_input(TurnExecutionInput::RecoverableFailure { run_id, retry })
568            .map(|_| ())
569    }
570
571    fn fatal_failure(
572        &self,
573        run_id: RunId,
574        failure: TurnFailureSource,
575    ) -> Result<(), DslTransitionError> {
576        self.apply_turn_input(TurnExecutionInput::FatalFailure { run_id, failure })
577            .map(|_| ())
578    }
579
580    fn retry_requested(&self, run_id: RunId, retry_attempt: u32) -> Result<(), DslTransitionError> {
581        self.apply_turn_input(TurnExecutionInput::RetryRequested {
582            run_id,
583            retry_attempt,
584        })
585        .map(|_| ())
586    }
587
588    fn cancel_now(&self, run_id: RunId) -> Result<(), DslTransitionError> {
589        self.apply_turn_input(TurnExecutionInput::CancelNow { run_id })
590            .map(|_| ())
591    }
592
593    fn request_cancel_after_boundary(&self, run_id: RunId) -> Result<(), DslTransitionError> {
594        self.apply_turn_input(TurnExecutionInput::CancelAfterBoundary { run_id })
595            .map(|_| ())
596    }
597
598    fn cancellation_observed(&self, run_id: RunId) -> Result<(), DslTransitionError> {
599        self.apply_turn_input(TurnExecutionInput::CancellationObserved { run_id })
600            .map(|_| ())
601    }
602
603    fn acknowledge_terminal(&self, run_id: RunId) -> Result<(), DslTransitionError> {
604        self.apply_turn_input(TurnExecutionInput::AcknowledgeTerminal { run_id })
605            .map(|_| ())
606    }
607
608    fn turn_limit_reached(
609        &self,
610        run_id: RunId,
611        turn_count: u64,
612        max_turns: u64,
613    ) -> Result<(), DslTransitionError> {
614        self.apply_turn_input(TurnExecutionInput::TurnLimitReached {
615            run_id,
616            turn_count,
617            max_turns,
618        })
619        .map(|_| ())
620    }
621
622    fn budget_exhausted(&self, run_id: RunId) -> Result<(), DslTransitionError> {
623        self.apply_turn_input(TurnExecutionInput::BudgetExhausted { run_id })
624            .map(|_| ())
625    }
626
627    fn time_budget_exceeded(&self, run_id: RunId) -> Result<(), DslTransitionError> {
628        self.apply_turn_input(TurnExecutionInput::TimeBudgetExceeded { run_id })
629            .map(|_| ())
630    }
631
632    fn force_cancel_no_run(&self) -> Result<(), DslTransitionError> {
633        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
634        self.dsl.apply_input(
635            mm_dsl::MeerkatMachineInput::ForceCancelNoRun,
636            "TurnStateHandle::force_cancel_no_run",
637        )
638    }
639
640    fn run_completed(&self, _run_id: RunId) -> Result<(), DslTransitionError> {
641        // Runtime-backed run terminalization is owned by
642        // MeerkatMachine::Commit after the durable boundary receipt is ready.
643        // Core still emits this effect for standalone/test handles, but this
644        // runtime handle must not provide a second terminal writer.
645        Ok(())
646    }
647
648    fn run_failed(
649        &self,
650        _run_id: RunId,
651        _reason: TurnFailureReason,
652    ) -> Result<(), DslTransitionError> {
653        // Runtime-backed failure terminalization is owned by
654        // MeerkatMachine::Fail/Commit and its durable terminal receipt path.
655        Ok(())
656    }
657
658    fn run_cancelled(&self, _run_id: RunId) -> Result<(), DslTransitionError> {
659        // Runtime-backed cancellation terminalization is owned by machine
660        // commands that can keep lifecycle and durable state aligned.
661        Ok(())
662    }
663
664    #[allow(clippy::expect_used)]
665    fn snapshot(&self) -> TurnStateSnapshot {
666        let state = self.dsl.snapshot_state();
667        let turn_phase = map_turn_phase(state.turn_phase);
668        let barrier_operation_ids: BTreeSet<_> = state
669            .barrier_operation_ids
670            .iter()
671            .map(|id| parse_operation_id(id.0.as_str()))
672            .collect();
673        let pending_op_refs = state
674            .pending_op_refs
675            .iter()
676            .map(|id| {
677                let operation_id = parse_operation_id(id);
678                AsyncOpRef {
679                    wait_policy: if barrier_operation_ids.contains(&operation_id) {
680                        WaitPolicy::Barrier
681                    } else {
682                        WaitPolicy::Detached
683                    },
684                    operation_id,
685                }
686            })
687            .collect();
688        let turn_terminal = classify_turn_terminal(&state);
689        let active_run_id = if turn_terminal {
690            None
691        } else {
692            state.current_run_id.as_ref().map(parse_snapshot_run_id)
693        };
694        TurnStateSnapshot {
695            active_run_id,
696            terminal_run_id: state
697                .turn_terminal_run_id
698                .as_ref()
699                .map(parse_snapshot_run_id),
700            loop_state: map_loop_state(state.turn_phase),
701            turn_phase,
702            turn_terminal,
703            primitive_kind: state.primitive_kind.map(TurnPrimitiveKind::from),
704            admitted_content_shape: state.admitted_content_shape.map(Into::into),
705            vision_enabled: state.vision_enabled,
706            image_tool_results_enabled: state.image_tool_results_enabled,
707            tool_calls_pending: state.tool_calls_pending,
708            pending_op_refs,
709            barrier_operation_ids,
710            has_barrier_ops: state.has_barrier_ops,
711            barrier_satisfied: state.barrier_satisfied,
712            boundary_count: state.boundary_count,
713            cancel_after_boundary: state.cancel_after_boundary,
714            terminal_outcome: state.terminal_outcome.map(TurnTerminalOutcome::from),
715            terminal_cause_kind: state.terminal_cause_kind.map(Into::into),
716            extraction_attempts: state.extraction_attempts,
717            max_extraction_retries: state.max_extraction_retries,
718            extraction_active: state.extraction_active,
719            llm_retry_attempt: u32::try_from(state.llm_retry_attempt)
720                .expect("generated MeerkatMachine llm_retry_attempt must fit u32"),
721            llm_retry_max_retries: u32::try_from(state.llm_retry_max_retries)
722                .expect("generated MeerkatMachine llm_retry_max_retries must fit u32"),
723            llm_retry_selected_delay_ms: state.llm_retry_selected_delay_ms,
724        }
725    }
726}
727
728#[allow(clippy::expect_used)]
729fn parse_operation_id(value: &str) -> OperationId {
730    uuid::Uuid::parse_str(value)
731        .map(OperationId)
732        .expect("generated MeerkatMachine operation id projection must be well formed")
733}
734
735#[allow(clippy::expect_used)]
736fn parse_snapshot_run_id(run_id: &mm_dsl::RunId) -> RunId {
737    uuid::Uuid::parse_str(&run_id.0)
738        .map(RunId::from_uuid)
739        .expect("generated MeerkatMachine current_run_id projection must be well formed")
740}
741
742/// Mirror the canonical MeerkatMachine turn-terminality verdict over the
743/// recovered turn state.
744///
745/// The terminality verdict (which turn phases are terminal) is a machine fact:
746/// this owner extracts no fact — it recovers a read-only authority from the DSL
747/// state, drives the `ClassifyTurnTerminality` input, and mirrors the emitted
748/// `TurnTerminalityClassified.terminal`. It decides nothing. Fails closed:
749/// an unclassifiable state is treated as terminal so a live run is never assumed
750/// to still be active off an unreadable snapshot.
751fn classify_turn_terminal(state: &mm_dsl::MeerkatMachineState) -> bool {
752    let Ok(mut authority) = mm_dsl::MeerkatMachineAuthority::recover_from_state(state.clone())
753    else {
754        return true;
755    };
756    let Ok(transition) = mm_dsl::MeerkatMachineMutator::apply(
757        &mut authority,
758        mm_dsl::MeerkatMachineInput::ClassifyTurnTerminality {},
759    ) else {
760        return true;
761    };
762    let mut classified = None;
763    for effect in transition.effects() {
764        if let mm_dsl::MeerkatMachineEffect::TurnTerminalityClassified { terminal } = effect
765            && classified.replace(*terminal).is_some()
766        {
767            return true;
768        }
769    }
770    classified.unwrap_or(true)
771}
772
773/// Exhaustive 1-to-1 projection of the DSL's typed turn phase into the
774/// cross-crate [`TurnPhase`] contract. The compiler enforces that every
775/// DSL variant has a core-facing twin; any new variant in either enum
776/// must be reflected here.
777fn map_turn_phase(phase: mm_dsl::TurnPhase) -> TurnPhase {
778    match phase {
779        mm_dsl::TurnPhase::Ready => TurnPhase::Ready,
780        mm_dsl::TurnPhase::ApplyingPrimitive => TurnPhase::ApplyingPrimitive,
781        mm_dsl::TurnPhase::CallingLlm => TurnPhase::CallingLlm,
782        mm_dsl::TurnPhase::WaitingForOps => TurnPhase::WaitingForOps,
783        mm_dsl::TurnPhase::DrainingBoundary => TurnPhase::DrainingBoundary,
784        mm_dsl::TurnPhase::Extracting => TurnPhase::Extracting,
785        mm_dsl::TurnPhase::ErrorRecovery => TurnPhase::ErrorRecovery,
786        mm_dsl::TurnPhase::Cancelling => TurnPhase::Cancelling,
787        mm_dsl::TurnPhase::Completed => TurnPhase::Completed,
788        mm_dsl::TurnPhase::Failed => TurnPhase::Failed,
789        mm_dsl::TurnPhase::Cancelled => TurnPhase::Cancelled,
790    }
791}
792
793/// Owner-side projection from DSL turn phase to the legacy observable loop
794/// state. Keep this beside `map_turn_phase` so the agent runner receives one
795/// coherent snapshot from the DSL authority instead of reclassifying phases.
796fn map_loop_state(phase: mm_dsl::TurnPhase) -> meerkat_core::LoopState {
797    match phase {
798        mm_dsl::TurnPhase::Ready
799        | mm_dsl::TurnPhase::ApplyingPrimitive
800        | mm_dsl::TurnPhase::CallingLlm => meerkat_core::LoopState::CallingLlm,
801        mm_dsl::TurnPhase::WaitingForOps => meerkat_core::LoopState::WaitingForOps,
802        mm_dsl::TurnPhase::DrainingBoundary | mm_dsl::TurnPhase::Extracting => {
803            meerkat_core::LoopState::DrainingEvents
804        }
805        mm_dsl::TurnPhase::ErrorRecovery => meerkat_core::LoopState::ErrorRecovery,
806        mm_dsl::TurnPhase::Cancelling => meerkat_core::LoopState::Cancelling,
807        mm_dsl::TurnPhase::Completed | mm_dsl::TurnPhase::Failed | mm_dsl::TurnPhase::Cancelled => {
808            meerkat_core::LoopState::Completed
809        }
810    }
811}
812
813#[cfg(test)]
814#[allow(clippy::unwrap_used)]
815mod tests {
816    use super::*;
817    use meerkat_core::retry::{
818        LlmRetryFailure, LlmRetryFailureKind, LlmRetryPlan, LlmRetrySchedule,
819    };
820    use uuid::Uuid;
821
822    fn retry_schedule(attempt: u32) -> LlmRetrySchedule {
823        retry_schedule_with_kind(attempt, 3, LlmRetryFailureKind::RateLimited)
824    }
825
826    fn retry_schedule_with_kind(
827        attempt: u32,
828        max_retries: u32,
829        kind: LlmRetryFailureKind,
830    ) -> LlmRetrySchedule {
831        LlmRetrySchedule {
832            failure: LlmRetryFailure {
833                provider: "test".to_string(),
834                kind,
835                retry_after_ms: Some(1_000),
836                duration_ms: None,
837                message: "rate limited".to_string(),
838            },
839            plan: LlmRetryPlan {
840                attempt,
841                max_retries,
842                computed_delay_ms: 500,
843                selected_delay_ms: 1_000,
844                retry_after_hint_ms: Some(1_000),
845                rate_limit_floor_applied: false,
846                budget_capped: false,
847            },
848        }
849    }
850
851    fn start_running_conversation_turn(handle: &RuntimeTurnStateHandle, run_id: &RunId) {
852        handle
853            .start_conversation_run(
854                run_id.clone(),
855                TurnPrimitiveKind::ConversationTurn,
856                meerkat_core::turn_execution_authority::ContentShape::Conversation,
857                false,
858                false,
859                0,
860            )
861            .unwrap();
862        handle.primitive_applied(run_id.clone()).unwrap();
863    }
864
865    fn unknown_failure_source(message: &'static str) -> TurnFailureSource {
866        TurnFailureSource::new(TurnFailureSourceKind::Unknown, message)
867    }
868
869    fn failure_source(
870        source_kind: TurnFailureSourceKind,
871        message: &'static str,
872    ) -> TurnFailureSource {
873        TurnFailureSource::new(source_kind, message)
874    }
875
876    #[test]
877    fn snapshot_carries_active_run_id_for_runtime_backed_turns() {
878        let handle = RuntimeTurnStateHandle::ephemeral();
879        let run_id = RunId(Uuid::from_u128(7));
880
881        handle
882            .start_conversation_run(
883                run_id.clone(),
884                TurnPrimitiveKind::ConversationTurn,
885                meerkat_core::turn_execution_authority::ContentShape::Conversation,
886                true,
887                false,
888                2,
889            )
890            .unwrap();
891
892        let snapshot = handle.snapshot();
893        assert_eq!(snapshot.active_run_id, Some(run_id.clone()));
894        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
895        assert_eq!(
896            snapshot.primitive_kind,
897            Some(TurnPrimitiveKind::ConversationTurn)
898        );
899    }
900
901    #[test]
902    fn primitive_applied_rejects_mismatched_run_id() {
903        let handle = RuntimeTurnStateHandle::ephemeral();
904        let run_id = RunId(Uuid::from_u128(21));
905        let stale_run_id = RunId(Uuid::from_u128(22));
906
907        handle
908            .start_conversation_run(
909                run_id.clone(),
910                TurnPrimitiveKind::ConversationTurn,
911                meerkat_core::turn_execution_authority::ContentShape::Conversation,
912                false,
913                false,
914                0,
915            )
916            .unwrap();
917
918        assert!(handle.primitive_applied(stale_run_id).is_err());
919        let snapshot = handle.snapshot();
920        assert_eq!(snapshot.active_run_id, Some(run_id));
921        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
922    }
923
924    #[test]
925    fn post_primitive_observation_rejects_mismatched_run_id() {
926        let handle = RuntimeTurnStateHandle::ephemeral();
927        let run_id = RunId(Uuid::from_u128(23));
928        let stale_run_id = RunId(Uuid::from_u128(24));
929
930        handle
931            .start_conversation_run(
932                run_id.clone(),
933                TurnPrimitiveKind::ConversationTurn,
934                meerkat_core::turn_execution_authority::ContentShape::Conversation,
935                false,
936                false,
937                0,
938            )
939            .unwrap();
940        handle.primitive_applied(run_id.clone()).unwrap();
941
942        assert!(handle.llm_returned_terminal(stale_run_id).is_err());
943        let snapshot = handle.snapshot();
944        assert_eq!(snapshot.active_run_id, Some(run_id));
945        assert_eq!(snapshot.turn_phase, TurnPhase::CallingLlm);
946    }
947
948    #[test]
949    fn turn_limit_handle_rejects_unreached_limit() {
950        let handle = RuntimeTurnStateHandle::ephemeral();
951        let run_id = RunId(Uuid::from_u128(31));
952        start_running_conversation_turn(&handle, &run_id);
953
954        handle
955            .turn_limit_reached(run_id.clone(), 1, 2)
956            .expect_err("machine guard must reject turn_count below max_turns");
957        let snapshot = handle.snapshot();
958        assert_eq!(snapshot.turn_phase, TurnPhase::CallingLlm);
959        assert_eq!(snapshot.terminal_cause_kind, None);
960
961        handle
962            .turn_limit_reached(run_id, 2, 2)
963            .expect("turn limit reached at the boundary");
964        let snapshot = handle.snapshot();
965        assert_eq!(snapshot.turn_phase, TurnPhase::Failed);
966        assert_eq!(
967            snapshot.terminal_cause_kind,
968            Some(TurnTerminalCauseKind::TurnLimitReached)
969        );
970    }
971
972    #[test]
973    fn fatal_failure_rejects_max_turns_source() {
974        let handle = RuntimeTurnStateHandle::ephemeral();
975        let run_id = RunId(Uuid::from_u128(32));
976        start_running_conversation_turn(&handle, &run_id);
977
978        handle
979            .fatal_failure(
980                run_id.clone(),
981                failure_source(TurnFailureSourceKind::MaxTurnsReached, "max turns"),
982            )
983            .expect_err("turn-limit terminality must use counted TurnLimitReached input");
984        let snapshot = handle.snapshot();
985        assert_eq!(snapshot.turn_phase, TurnPhase::CallingLlm);
986        assert_eq!(snapshot.terminal_cause_kind, None);
987    }
988
989    #[test]
990    fn snapshot_clears_active_run_id_after_terminal_turn() {
991        let handle = RuntimeTurnStateHandle::ephemeral();
992        let run_id = RunId(Uuid::from_u128(8));
993
994        handle
995            .start_conversation_run(
996                run_id.clone(),
997                TurnPrimitiveKind::ConversationTurn,
998                meerkat_core::turn_execution_authority::ContentShape::Conversation,
999                false,
1000                false,
1001                0,
1002            )
1003            .unwrap();
1004        handle.primitive_applied(run_id.clone()).unwrap();
1005        handle.llm_returned_terminal(run_id.clone()).unwrap();
1006        handle.boundary_complete(run_id).unwrap();
1007
1008        let snapshot = handle.snapshot();
1009        assert_eq!(snapshot.turn_phase, TurnPhase::Completed);
1010        assert_eq!(snapshot.active_run_id, None);
1011    }
1012
1013    #[test]
1014    fn cancel_after_boundary_cancels_continuation_boundary() {
1015        let handle = RuntimeTurnStateHandle::ephemeral();
1016        let run_id = RunId(Uuid::from_u128(18));
1017
1018        handle
1019            .start_conversation_run(
1020                run_id.clone(),
1021                TurnPrimitiveKind::ConversationTurn,
1022                meerkat_core::turn_execution_authority::ContentShape::Conversation,
1023                false,
1024                false,
1025                0,
1026            )
1027            .unwrap();
1028        handle.primitive_applied(run_id.clone()).unwrap();
1029        handle.llm_returned_tool_calls(run_id.clone(), 1).unwrap();
1030        handle
1031            .register_pending_ops(run_id.clone(), BTreeSet::new(), BTreeSet::new())
1032            .unwrap();
1033        handle.tool_calls_resolved(run_id.clone()).unwrap();
1034        handle
1035            .request_cancel_after_boundary(run_id.clone())
1036            .unwrap();
1037        handle.boundary_continue(run_id).unwrap();
1038
1039        let snapshot = handle.snapshot();
1040        assert_eq!(snapshot.turn_phase, TurnPhase::Cancelled);
1041        assert_eq!(
1042            snapshot.terminal_outcome,
1043            Some(TurnTerminalOutcome::Cancelled)
1044        );
1045        assert!(!snapshot.cancel_after_boundary);
1046        assert_eq!(snapshot.active_run_id, None);
1047    }
1048
1049    #[test]
1050    fn cancel_after_boundary_cancels_terminal_boundary() {
1051        let handle = RuntimeTurnStateHandle::ephemeral();
1052        let run_id = RunId(Uuid::from_u128(19));
1053
1054        handle
1055            .start_conversation_run(
1056                run_id.clone(),
1057                TurnPrimitiveKind::ConversationTurn,
1058                meerkat_core::turn_execution_authority::ContentShape::Conversation,
1059                false,
1060                false,
1061                0,
1062            )
1063            .unwrap();
1064        handle.primitive_applied(run_id.clone()).unwrap();
1065        handle.llm_returned_terminal(run_id.clone()).unwrap();
1066        handle
1067            .request_cancel_after_boundary(run_id.clone())
1068            .unwrap();
1069        handle.boundary_complete(run_id).unwrap();
1070
1071        let snapshot = handle.snapshot();
1072        assert_eq!(snapshot.turn_phase, TurnPhase::Cancelled);
1073        assert_eq!(
1074            snapshot.terminal_outcome,
1075            Some(TurnTerminalOutcome::Cancelled)
1076        );
1077        assert!(!snapshot.cancel_after_boundary);
1078        assert_eq!(snapshot.active_run_id, None);
1079    }
1080
1081    #[test]
1082    fn immediate_append_derives_content_shape() {
1083        let handle = RuntimeTurnStateHandle::ephemeral();
1084        let run_id = RunId(Uuid::from_u128(10));
1085
1086        handle.start_immediate_append(run_id).unwrap();
1087
1088        assert_eq!(
1089            handle.snapshot().admitted_content_shape,
1090            Some(meerkat_core::turn_execution_authority::ContentShape::ImmediateAppend)
1091        );
1092    }
1093
1094    #[test]
1095    fn cancel_after_boundary_cancels_immediate_boundary() {
1096        let handle = RuntimeTurnStateHandle::ephemeral();
1097        let run_id = RunId(Uuid::from_u128(20));
1098
1099        handle.start_immediate_append(run_id.clone()).unwrap();
1100        handle
1101            .request_cancel_after_boundary(run_id.clone())
1102            .unwrap();
1103        handle.primitive_applied(run_id).unwrap();
1104
1105        let snapshot = handle.snapshot();
1106        assert_eq!(snapshot.turn_phase, TurnPhase::Cancelled);
1107        assert_eq!(
1108            snapshot.terminal_outcome,
1109            Some(TurnTerminalOutcome::Cancelled)
1110        );
1111        assert!(!snapshot.cancel_after_boundary);
1112        assert_eq!(snapshot.active_run_id, None);
1113    }
1114
1115    #[test]
1116    fn retry_schedule_is_recorded_and_attempt_guarded() {
1117        let handle = RuntimeTurnStateHandle::ephemeral();
1118        let run_id = RunId(Uuid::from_u128(9));
1119
1120        handle
1121            .start_conversation_run(
1122                run_id.clone(),
1123                TurnPrimitiveKind::ConversationTurn,
1124                meerkat_core::turn_execution_authority::ContentShape::Conversation,
1125                false,
1126                false,
1127                0,
1128            )
1129            .unwrap();
1130        handle.primitive_applied(run_id.clone()).unwrap();
1131
1132        handle
1133            .recoverable_failure(run_id.clone(), retry_schedule(2))
1134            .unwrap();
1135
1136        let snapshot = handle.snapshot();
1137        assert_eq!(snapshot.turn_phase, TurnPhase::ErrorRecovery);
1138        assert_eq!(snapshot.llm_retry_attempt, 2);
1139        assert_eq!(snapshot.llm_retry_max_retries, 3);
1140        assert_eq!(snapshot.llm_retry_selected_delay_ms, 1_000);
1141
1142        assert!(handle.retry_requested(run_id.clone(), 1).is_err());
1143        handle.retry_requested(run_id, 2).unwrap();
1144        assert_eq!(handle.snapshot().turn_phase, TurnPhase::CallingLlm);
1145    }
1146
1147    /// P0 Dogma Invariant 1: the machine — not the shell `RetryPolicy` — is the
1148    /// authority on retry exhaustion. A `RecoverableFailure` whose one-based
1149    /// `retry_attempt` exceeds `max_retries` must be machine-rejected so the
1150    /// turn cannot enter `ErrorRecovery` past exhaustion.
1151    #[test]
1152    fn recoverable_failure_past_exhaustion_is_machine_rejected() {
1153        let handle = RuntimeTurnStateHandle::ephemeral();
1154        let run_id = RunId(Uuid::from_u128(31));
1155        start_running_conversation_turn(&handle, &run_id);
1156
1157        // attempt 4 with max_retries 3 is past exhaustion.
1158        let exhausted = retry_schedule_with_kind(4, 3, LlmRetryFailureKind::RateLimited);
1159        let err = handle
1160            .recoverable_failure(run_id.clone(), exhausted)
1161            .expect_err("exhausted retry must be rejected by the machine");
1162        assert!(err.is_guard_rejected(), "expected guard rejection: {err:?}");
1163
1164        // The turn never entered recovery; it remains in CallingLlm.
1165        let snapshot = handle.snapshot();
1166        assert_eq!(snapshot.turn_phase, TurnPhase::CallingLlm);
1167        assert_eq!(snapshot.llm_retry_attempt, 0);
1168
1169        // A retry at the exhaustion boundary (attempt == max_retries) is still
1170        // legitimate and the machine accepts it.
1171        let last = retry_schedule_with_kind(3, 3, LlmRetryFailureKind::NetworkTimeout);
1172        handle.recoverable_failure(run_id, last).unwrap();
1173        let snapshot = handle.snapshot();
1174        assert_eq!(snapshot.turn_phase, TurnPhase::ErrorRecovery);
1175        assert_eq!(snapshot.llm_retry_attempt, 3);
1176        assert_eq!(snapshot.llm_retry_max_retries, 3);
1177    }
1178
1179    #[test]
1180    fn fatal_failure_unknown_source_rejects_before_machine_apply() {
1181        let handle = RuntimeTurnStateHandle::ephemeral();
1182        let run_id = RunId(Uuid::from_u128(11));
1183
1184        handle
1185            .start_conversation_run(
1186                run_id.clone(),
1187                TurnPrimitiveKind::ConversationTurn,
1188                meerkat_core::turn_execution_authority::ContentShape::Conversation,
1189                false,
1190                false,
1191                0,
1192            )
1193            .unwrap();
1194
1195        let err = handle
1196            .fatal_failure(
1197                run_id.clone(),
1198                unknown_failure_source("display text must not classify fatal failure"),
1199            )
1200            .expect_err("unknown fatal source should reject before state mutation");
1201
1202        assert!(err.is_guard_rejected(), "expected guard rejection: {err:?}");
1203        let snapshot = handle.snapshot();
1204        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
1205        assert_eq!(snapshot.terminal_cause_kind, None);
1206
1207        handle
1208            .fatal_failure(
1209                run_id,
1210                failure_source(TurnFailureSourceKind::InternalError, "fatal failure"),
1211            )
1212            .expect("specific fatal source should remain accepted");
1213        assert_eq!(
1214            handle.snapshot().terminal_cause_kind,
1215            Some(meerkat_core::TurnTerminalCauseKind::FatalFailure)
1216        );
1217    }
1218
1219    #[test]
1220    fn run_failed_effect_does_not_terminalize_runtime_state() {
1221        let handle = RuntimeTurnStateHandle::ephemeral();
1222        let run_id = RunId(Uuid::from_u128(12));
1223
1224        handle
1225            .start_conversation_run(
1226                run_id.clone(),
1227                TurnPrimitiveKind::ConversationTurn,
1228                meerkat_core::turn_execution_authority::ContentShape::Conversation,
1229                false,
1230                false,
1231                0,
1232            )
1233            .unwrap();
1234
1235        handle
1236            .run_failed(
1237                run_id.clone(),
1238                TurnFailureReason::with_cause(
1239                    meerkat_core::TurnTerminalCauseKind::Unknown,
1240                    meerkat_core::event::AgentErrorClass::Internal,
1241                    "display text must not classify run failure",
1242                ),
1243            )
1244            .expect("runtime-backed run_failed effect is observation-only");
1245
1246        let snapshot = handle.snapshot();
1247        assert_eq!(snapshot.active_run_id, Some(run_id.clone()));
1248        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
1249        assert_eq!(snapshot.terminal_cause_kind, None);
1250
1251        handle
1252            .run_completed(run_id.clone())
1253            .expect("runtime-backed run_completed effect is observation-only");
1254        handle
1255            .run_cancelled(run_id)
1256            .expect("runtime-backed run_cancelled effect is observation-only");
1257        let snapshot = handle.snapshot();
1258        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
1259        assert_eq!(snapshot.terminal_cause_kind, None);
1260    }
1261}