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