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