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