Skip to main content

lash_core/runtime/
turn_loop.rs

1use super::*;
2
3fn trace_fields_from_outcome(
4    outcome: &TurnOutcome,
5) -> (
6    &'static str,
7    &'static str,
8    Option<lash_trace::TraceAgentFrameSwitch>,
9) {
10    match outcome {
11        TurnOutcome::Finished(TurnFinish::AssistantMessage { .. }) => {
12            ("completed", "assistant_message", None)
13        }
14        TurnOutcome::Finished(TurnFinish::FinalValue { .. }) => ("completed", "final_value", None),
15        TurnOutcome::Finished(TurnFinish::ToolValue { .. }) => ("completed", "tool_value", None),
16        TurnOutcome::AgentFrameSwitch { frame_id, .. } => (
17            "completed",
18            "agent_frame_switch",
19            Some(lash_trace::TraceAgentFrameSwitch {
20                frame_id: frame_id.clone(),
21            }),
22        ),
23        TurnOutcome::Stopped(stop) => ("failed", trace_stop_reason(stop), None),
24    }
25}
26
27fn trace_stop_reason(stop: &TurnStop) -> &'static str {
28    match stop {
29        TurnStop::Cancelled => "cancelled",
30        TurnStop::Incomplete => "incomplete",
31        TurnStop::InvalidInput => "invalid_input",
32        TurnStop::MaxTurns => "max_turns",
33        TurnStop::ToolFailure => "tool_failure",
34        TurnStop::ProviderError => "provider_error",
35        TurnStop::PluginAbort => "plugin_abort",
36        TurnStop::RuntimeError => "runtime_error",
37        TurnStop::SubmittedError { .. } => "submitted_error",
38        TurnStop::ToolError { .. } => "tool_error",
39    }
40}
41
42fn session_head_refresh_error(err: SessionError) -> RuntimeError {
43    RuntimeError::new(
44        RuntimeErrorCode::Other("session_head_refresh".to_string()),
45        err.to_string(),
46    )
47}
48
49#[derive(Clone, Copy)]
50enum SessionExecutionLeaseReleasePolicy {
51    FinalCommit,
52    KeepOnAgentFrameSwitch,
53}
54
55impl SessionExecutionLeaseReleasePolicy {
56    fn should_release(self, outcome: &TurnOutcome) -> bool {
57        match self {
58            Self::FinalCommit => true,
59            Self::KeepOnAgentFrameSwitch => {
60                !matches!(outcome, TurnOutcome::AgentFrameSwitch { .. })
61            }
62        }
63    }
64}
65
66fn queued_work_payload_type(payload: &crate::QueuedWorkPayload) -> &'static str {
67    match payload {
68        crate::QueuedWorkPayload::ProcessWake { .. } => "process_wake",
69        crate::QueuedWorkPayload::SessionCommand { command } => command.kind(),
70    }
71}
72
73fn queued_work_batch_ids(claim: &crate::QueuedWorkClaim) -> Vec<String> {
74    claim
75        .batches
76        .iter()
77        .map(|batch| batch.batch_id.clone())
78        .collect()
79}
80
81/// Measures the whole host-visible turn.
82///
83/// Opened before the runtime claims the turn (session-execution lease and
84/// queued-work/turn-input claims) and stamped onto the assembled turn after
85/// the final commit and post-persist hooks complete, so
86/// [`ExecutionSummary`](crate::ExecutionSummary) timing covers
87/// claim → final commit. Reads only the injected [`Clock`](crate::Clock):
88/// `started_at_ms` comes from the wall-clock source and the duration from the
89/// monotonic source, so deterministic clocks produce deterministic timing.
90#[derive(Clone, Copy)]
91struct TurnStopwatch {
92    started: std::time::Instant,
93    started_at_ms: u64,
94}
95
96impl TurnStopwatch {
97    fn start(clock: &dyn crate::Clock) -> Self {
98        Self {
99            started: clock.now(),
100            started_at_ms: clock.timestamp_ms(),
101        }
102    }
103
104    fn stamp(&self, turn: &mut AssembledTurn, clock: &dyn crate::Clock) {
105        turn.execution.started_at_ms = self.started_at_ms;
106        turn.execution.duration_ms = clock
107            .now()
108            .saturating_duration_since(self.started)
109            .as_millis() as u64;
110    }
111}
112
113fn turn_phase_id(parent_turn_id: &str, phase: &str) -> String {
114    format!("{parent_turn_id}:{phase}")
115}
116
117fn scoped_child_turn_controller<'run>(
118    scoped_effect_controller: &'run ScopedEffectController<'_>,
119    session_id: &str,
120    turn_id: &str,
121) -> Result<ScopedEffectController<'run>, RuntimeError> {
122    ScopedEffectController::borrowed(
123        scoped_effect_controller.controller(),
124        ExecutionScope::turn(session_id, turn_id),
125    )
126}
127
128pub(in crate::runtime) fn queued_work_trace_payload(
129    boundary: crate::QueuedWorkClaimBoundary,
130    claim: &crate::QueuedWorkClaim,
131    causes: &[crate::TurnCause],
132) -> serde_json::Value {
133    serde_json::json!({
134        "boundary": boundary,
135        "claim_id": claim.claim_id,
136        "owner_id": claim.owner.owner_id,
137        "incarnation_id": claim.owner.incarnation_id,
138        "batch_ids": queued_work_batch_ids(claim),
139        "payload_types": claim.batches.iter()
140            .flat_map(|batch| batch.items.iter())
141            .map(|item| queued_work_payload_type(&item.payload))
142            .collect::<Vec<_>>(),
143        "causes": causes,
144    })
145}
146
147pub(in crate::runtime) fn queued_work_completion_trace_payload(
148    completions: &[crate::QueuedWorkCompletion],
149) -> serde_json::Value {
150    serde_json::json!({
151        "claims": completions.iter().map(|completion| {
152            serde_json::json!({
153                "session_id": completion.session_id,
154                "claim_id": completion.claim_id,
155                "batch_ids": completion.batch_ids,
156            })
157        }).collect::<Vec<_>>(),
158    })
159}
160
161async fn emit_queued_work_started_to_sink(
162    events: &dyn TurnActivitySink,
163    boundary: crate::QueuedWorkClaimBoundary,
164    claim: &crate::QueuedWorkClaim,
165    causes: Vec<crate::TurnCause>,
166) {
167    emit_turn_activity_to_sink(
168        events,
169        TurnActivity::independent(TurnEvent::QueuedWorkStarted {
170            boundary,
171            batch_ids: queued_work_batch_ids(claim),
172            causes,
173        }),
174    )
175    .await;
176}
177
178pub(in crate::runtime) async fn send_queued_work_started_event(
179    event_tx: &mpsc::Sender<RuntimeStreamEvent>,
180    boundary: crate::QueuedWorkClaimBoundary,
181    claim: &crate::QueuedWorkClaim,
182    causes: Vec<crate::TurnCause>,
183) {
184    send_turn_activity(
185        event_tx,
186        TurnActivityId::fresh(),
187        TurnEvent::QueuedWorkStarted {
188            boundary,
189            batch_ids: queued_work_batch_ids(claim),
190            causes,
191        },
192    )
193    .await;
194}
195
196struct TurnFinishInput {
197    turn_pipeline: TurnBoundary,
198    assembler: TurnAssembler,
199    new_messages: crate::MessageSequence,
200    policy: RuntimeSessionPolicy,
201    turn_index: usize,
202    queued_work_completions: Vec<crate::QueuedWorkCompletion>,
203    turn_input_completions: Vec<crate::TurnInputCompletion>,
204    trace_turn_id: String,
205}
206
207impl LashRuntime {
208    fn max_context_tokens(&self) -> usize {
209        self.state.effective_policy().context_window_tokens()
210    }
211
212    async fn claim_session_execution_lease(
213        &self,
214        cancel: CancellationToken,
215        busy_is_error: bool,
216    ) -> Result<Option<SessionExecutionLeaseGuard>, RuntimeError> {
217        let Some(store) = self
218            .session
219            .as_ref()
220            .and_then(|session| session.history_store())
221        else {
222            return Ok(None);
223        };
224        match SessionExecutionLeaseGuard::try_acquire(
225            store,
226            &self.state.session_id,
227            &self.runtime_lease_owner,
228            self.host.core.control.lease_timings,
229            Arc::clone(&self.host.core.clock),
230            cancel,
231        )
232        .await
233        .map_err(|err| RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, err.to_string()))?
234        {
235            Some(lease) => Ok(Some(lease)),
236            None if busy_is_error => Err(RuntimeError::new(
237                RuntimeErrorCode::SessionExecutionBusy,
238                format!(
239                    "session `{}` is already executing on another runtime owner",
240                    self.state.session_id
241                ),
242            )),
243            None => Ok(None),
244        }
245    }
246
247    async fn settle_session_execution_lease<T>(
248        &self,
249        guard: Option<&SessionExecutionLeaseGuard>,
250        result: Result<T, RuntimeError>,
251    ) -> Result<T, RuntimeError> {
252        match result {
253            Ok(value) => {
254                if let Some(guard) = guard {
255                    guard.release_if_live().await.map_err(|err| {
256                        RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, err.to_string())
257                    })?;
258                }
259                Ok(value)
260            }
261            Err(err) => {
262                if err.code != RuntimeErrorCode::StoreCommitFailed
263                    && let Some(guard) = guard
264                    && let Err(release_err) = guard.release_if_live().await
265                {
266                    tracing::warn!(
267                        error = %release_err,
268                        "failed to release session execution lease after runtime error"
269                    );
270                }
271                Err(err)
272            }
273        }
274    }
275
276    async fn ensure_session_execution_lease_live(
277        &self,
278        guard: Option<&SessionExecutionLeaseGuard>,
279    ) -> Result<(), RuntimeError> {
280        let Some(guard) = guard else {
281            return Ok(());
282        };
283        guard.refresh_or_mark_lost().await.map_err(|err| {
284            RuntimeError::new(
285                RuntimeErrorCode::SessionExecutionLeaseLost,
286                format!(
287                    "session execution lease for session `{}` was lost before commit: {err}",
288                    self.state.session_id
289                ),
290            )
291        })
292    }
293
294    // Prompt handback on lease loss. This is no longer load-bearing for
295    // correctness: a claim is generation-fenced under the session lease, so once
296    // this owner has lost the lease its claims are already superseded and the
297    // next acquirer reclaims them by generation regardless (ADR 0029). Abandoning
298    // eagerly just lets a peer reclaim the rows without waiting to observe the
299    // generation bump.
300    async fn abandon_queued_work_claims_after_lease_loss(
301        &self,
302        err: &RuntimeError,
303        claims: &[crate::QueuedWorkClaim],
304    ) {
305        if err.code != RuntimeErrorCode::SessionExecutionLeaseLost || claims.is_empty() {
306            return;
307        }
308        let Some(store) = self
309            .session
310            .as_ref()
311            .and_then(|session| session.history_store())
312        else {
313            return;
314        };
315        for claim in claims {
316            if let Err(abandon_err) = store.abandon_queued_work_claim(claim).await {
317                tracing::warn!(
318                    error = %abandon_err,
319                    session_id = %claim.session_id,
320                    claim_id = %claim.claim_id,
321                    "failed to abandon queued work claim after session execution lease loss"
322                );
323            }
324        }
325    }
326
327    async fn abandon_turn_input_claims_after_lease_loss(
328        &self,
329        err: &RuntimeError,
330        claims: &[crate::TurnInputClaim],
331    ) {
332        if err.code != RuntimeErrorCode::SessionExecutionLeaseLost || claims.is_empty() {
333            return;
334        }
335        let Some(store) = self
336            .session
337            .as_ref()
338            .and_then(|session| session.history_store())
339        else {
340            return;
341        };
342        for claim in claims {
343            if let Err(abandon_err) = store.abandon_turn_input_claim(claim).await {
344                tracing::warn!(
345                    error = %abandon_err,
346                    session_id = %claim.session_id,
347                    claim_id = %claim.claim_id,
348                    "failed to abandon turn input claim after session execution lease loss"
349                );
350            }
351        }
352    }
353
354    #[doc(hidden)]
355    pub fn set_turn_phase_probe(&mut self, probe: Arc<dyn RuntimeTurnPhaseProbe>) {
356        self.turn_phase_probe = Some(probe);
357    }
358
359    fn mark_phase_begin(&self, phase: RuntimeTurnPhase) {
360        if let Some(probe) = self.turn_phase_probe.as_ref() {
361            probe.begin(phase);
362        }
363    }
364
365    fn mark_phase_end(&self, phase: RuntimeTurnPhase) {
366        if let Some(probe) = self.turn_phase_probe.as_ref() {
367            probe.end(phase);
368        }
369    }
370
371    async fn finish_turn(
372        &mut self,
373        finish: TurnFinishInput,
374        events: &dyn EventSink,
375        scoped_effect_controller: &ScopedEffectController<'_>,
376        cancel_state: &CancellationToken,
377        session_execution_lease: Option<&SessionExecutionLeaseGuard>,
378        session_execution_lease_release_policy: SessionExecutionLeaseReleasePolicy,
379    ) -> Result<AssembledTurn, RuntimeError> {
380        let TurnFinishInput {
381            mut turn_pipeline,
382            assembler,
383            new_messages,
384            policy,
385            turn_index,
386            queued_work_completions,
387            turn_input_completions,
388            trace_turn_id,
389        } = finish;
390        self.policy = self.state.effective_policy().clone();
391        turn_pipeline.state_mut().policy = self.policy.clone();
392        turn_pipeline.state_mut().turn_index = turn_index;
393
394        let mut turn_usage_delta = {
395            let mut ledger = self.shared_token_ledger.lock().expect("token ledger lock");
396            std::mem::take(&mut *ledger)
397        };
398        if assembler.token_usage.total() > 0 {
399            turn_usage_delta.push(TokenLedgerEntry {
400                source: "turn".to_string(),
401                model: policy.model.id.clone(),
402                usage: assembler.token_usage.clone(),
403            });
404        }
405        let turn_usage_delta = merge_usage_delta_entries(turn_usage_delta);
406
407        turn_pipeline.finalize_turn_read_state(new_messages, cancel_state.is_cancelled());
408        if assembler.token_usage.total() > 0 {
409            turn_pipeline.state_mut().token_usage = assembler.token_usage.clone();
410        }
411
412        let last_prompt_usage = assembler.last_llm_usage().and_then(normalize_prompt_usage);
413        turn_pipeline.state_mut().last_prompt_usage = last_prompt_usage;
414        let assembled_state = turn_pipeline.export_state_for_assembly();
415        let assembled = assembler.finish(
416            assembled_state,
417            cancel_state.is_cancelled(),
418            None,
419            &self.host.core.control.termination,
420        );
421
422        let Some(session) = self.session.as_ref() else {
423            self.state.apply_snapshot(&assembled.state);
424            self.emit_completed_turn_trace(&assembled.state, &assembled.outcome, &trace_turn_id);
425            return Ok(assembled);
426        };
427        self.ensure_session_execution_lease_live(session_execution_lease)
428            .await?;
429
430        let plugins = Arc::clone(session.plugins());
431        let manager = match self.runtime_session_services_for_turn(None) {
432            Ok(manager) => manager,
433            Err(err) => {
434                return Err(RuntimeError::new(
435                    RuntimeErrorCode::PluginSessionManager,
436                    err.to_string(),
437                ));
438            }
439        };
440
441        self.mark_phase_begin(RuntimeTurnPhase::FinalizeTurn);
442        let finalized = match plugins
443            .finalize_turn_with_phase_probe(
444                assembled,
445                manager.state_service(),
446                manager.lifecycle_service(),
447                manager.graph_service(),
448                self.turn_phase_probe.clone(),
449            )
450            .await
451        {
452            Ok(finalized) => finalized,
453            Err(err) => {
454                self.mark_phase_end(RuntimeTurnPhase::FinalizeTurn);
455                return Err(RuntimeError::new(
456                    RuntimeErrorCode::PluginFinalizeTurn,
457                    err.to_string(),
458                ));
459            }
460        };
461        self.mark_phase_end(RuntimeTurnPhase::FinalizeTurn);
462        self.ensure_session_execution_lease_live(session_execution_lease)
463            .await?;
464
465        let mut returned_turn = finalized.turn;
466        let release_session_execution_lease =
467            session_execution_lease_release_policy.should_release(&returned_turn.outcome);
468        self.mark_phase_begin(RuntimeTurnPhase::PersistTurn);
469        self.mark_phase_begin(RuntimeTurnPhase::FinalCommit);
470        let queued_work_completion_trace = queued_work_completions.clone();
471        let pending_attachment_ids = self
472            .host
473            .core
474            .durability
475            .attachment_store
476            .pending_manifest_commit_ids();
477        if let Err(err) = turn_pipeline
478            .final_commit(
479                &mut returned_turn,
480                self.session.as_mut(),
481                &turn_usage_delta,
482                Some(&trace_turn_id),
483                queued_work_completions,
484                turn_input_completions,
485                cancel_state.is_cancelled().then(|| trace_turn_id.clone()),
486                pending_attachment_ids.clone(),
487                release_session_execution_lease
488                    .then(|| session_execution_lease.map(SessionExecutionLeaseGuard::completion))
489                    .flatten(),
490            )
491            .await
492        {
493            self.mark_phase_end(RuntimeTurnPhase::FinalCommit);
494            self.mark_phase_end(RuntimeTurnPhase::PersistTurn);
495            return Err(err);
496        }
497        if release_session_execution_lease && let Some(lease) = session_execution_lease {
498            lease.mark_released();
499        }
500        self.host
501            .core
502            .durability
503            .attachment_store
504            .mark_manifest_committed(&pending_attachment_ids);
505        self.mark_phase_end(RuntimeTurnPhase::FinalCommit);
506
507        emit_session_events_to_sink(events, finalized.events).await;
508        self.state = turn_pipeline.into_final_state();
509        if matches!(returned_turn.outcome, TurnOutcome::AgentFrameSwitch { .. })
510            && let Some(session) = self.session.as_mut()
511        {
512            let protocol_session = Arc::clone(session.plugins().protocol_session());
513            let session_id = self.state.session_id.clone();
514            protocol_session
515                .restore_session(
516                    crate::plugin::ProtocolSessionContext::new(session, &session_id),
517                    &self.state,
518                )
519                .await
520                .map_err(|err| {
521                    RuntimeError::new(
522                        RuntimeErrorCode::Other("protocol_restore_session".to_string()),
523                        err.to_string(),
524                    )
525                })?;
526        }
527        if !queued_work_completion_trace.is_empty() {
528            crate::trace::emit_trace(
529                &self.host.core.tracing.trace_sink,
530                &self.host.core.tracing.trace_context,
531                lash_trace::TraceContext::default()
532                    .for_session(returned_turn.state.session_id.clone())
533                    .for_turn_index(returned_turn.state.turn_index)
534                    .for_turn(trace_turn_id.clone()),
535                lash_trace::TraceEvent::Custom {
536                    name: "queued_work.completed".to_string(),
537                    payload: queued_work_completion_trace_payload(&queued_work_completion_trace),
538                },
539                self.host.core.clock.as_ref(),
540            );
541        }
542        self.mark_phase_begin(RuntimeTurnPhase::PostPersistHooks);
543        self.emit_turn_persisted_event(&returned_turn, scoped_effect_controller, &trace_turn_id)
544            .await?;
545        self.mark_phase_end(RuntimeTurnPhase::PostPersistHooks);
546        self.mark_phase_end(RuntimeTurnPhase::PersistTurn);
547
548        self.emit_completed_turn_trace(
549            &returned_turn.state,
550            &returned_turn.outcome,
551            &trace_turn_id,
552        );
553        Ok(returned_turn)
554    }
555
556    fn emit_completed_turn_trace(
557        &self,
558        state: &SessionSnapshot,
559        outcome: &TurnOutcome,
560        trace_turn_id: &str,
561    ) {
562        if self.host.core.tracing.trace_sink.is_none() {
563            return;
564        }
565
566        let (status, done_reason, agent_frame_switch) = trace_fields_from_outcome(outcome);
567        crate::trace::emit_trace(
568            &self.host.core.tracing.trace_sink,
569            &self.host.core.tracing.trace_context,
570            lash_trace::TraceContext::default()
571                .for_session(state.session_id.clone())
572                .for_turn_index(state.turn_index)
573                .for_turn(trace_turn_id.to_string()),
574            lash_trace::TraceEvent::TurnCompleted {
575                status: status.to_string(),
576                done_reason: done_reason.to_string(),
577                agent_frame_switch,
578            },
579            self.host.core.clock.as_ref(),
580        );
581    }
582
583    async fn emit_turn_persisted_event(
584        &self,
585        returned_turn: &AssembledTurn,
586        scoped_effect_controller: &ScopedEffectController<'_>,
587        trace_turn_id: &str,
588    ) -> Result<(), RuntimeError> {
589        let Some(session) = self.session.as_ref() else {
590            return Ok(());
591        };
592        let Ok(manager) = self.runtime_session_services() else {
593            return Ok(());
594        };
595        let phase_turn_id = turn_phase_id(trace_turn_id, "turn-persisted");
596        let phase_controller = scoped_child_turn_controller(
597            scoped_effect_controller,
598            &self.state.session_id,
599            &phase_turn_id,
600        )?;
601        let direct_completions = manager.direct_completion_client(
602            RuntimeEffectControllerHandle::borrowed(phase_controller),
603            Some(phase_turn_id),
604        );
605
606        session
607            .plugins()
608            .emit_runtime_event_with_phase_probe(
609                crate::PluginLifecycleEvent::TurnPersisted(Box::new(
610                    crate::SessionStateChangedContext {
611                        session_id: self.state.session_id.clone(),
612                        state: crate::SessionReadView::from_snapshot(&returned_turn.state),
613                        sessions: manager.state_service(),
614                        session_graph: manager.graph_service(),
615                        direct_completions,
616                    },
617                )),
618                self.turn_phase_probe.clone(),
619            )
620            .await;
621        Ok(())
622    }
623
624    /// Run a single turn and stream events to the host sink.
625    pub async fn stream_turn(
626        &mut self,
627        input: TurnInput,
628        opts: TurnOptions<'_>,
629    ) -> Result<AssembledTurn, RuntimeError> {
630        let stopwatch = TurnStopwatch::start(self.host.core.clock.as_ref());
631        let cancel = opts.cancel.clone();
632        let session_execution_lease = self
633            .claim_session_execution_lease(cancel.clone(), true)
634            .await?;
635        let scoped_effect_controller = opts.scoped_effect_controller();
636        let result = self
637            .stream_turn_with_scoped_effect_controller_inner(
638                input,
639                opts.events_or_noop(),
640                opts.turn_events_or_noop(),
641                scoped_effect_controller,
642                cancel,
643                None,
644                None,
645                session_execution_lease.as_ref(),
646                SessionExecutionLeaseReleasePolicy::FinalCommit,
647            )
648            .await
649            .map(|mut turn| {
650                stopwatch.stamp(&mut turn, self.host.core.clock.as_ref());
651                turn
652            });
653        self.settle_session_execution_lease(session_execution_lease.as_ref(), result)
654            .await
655    }
656
657    pub async fn stream_next_queued_work(
658        &mut self,
659        opts: TurnOptions<'_>,
660    ) -> Result<Option<AssembledTurn>, RuntimeError> {
661        self.stream_queued_work(opts, None).await
662    }
663
664    pub async fn stream_selected_queued_work(
665        &mut self,
666        opts: TurnOptions<'_>,
667        batch_ids: &[String],
668    ) -> Result<Option<AssembledTurn>, RuntimeError> {
669        self.stream_queued_work(opts, Some(batch_ids)).await
670    }
671
672    async fn stream_queued_work(
673        &mut self,
674        opts: TurnOptions<'_>,
675        selected_batch_ids: Option<&[String]>,
676    ) -> Result<Option<AssembledTurn>, RuntimeError> {
677        let stopwatch = TurnStopwatch::start(self.host.core.clock.as_ref());
678        let cancel = opts.cancel.clone();
679        let Some(session_execution_lease) = self
680            .claim_session_execution_lease(cancel.clone(), false)
681            .await?
682        else {
683            return Ok(None);
684        };
685        let session_execution_fence = session_execution_lease.fence();
686        let Some(store) = self
687            .session
688            .as_ref()
689            .and_then(|session| session.history_store())
690        else {
691            session_execution_lease
692                .release_if_live()
693                .await
694                .map_err(|err| {
695                    RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, err.to_string())
696                })?;
697            return Ok(None);
698        };
699        let drain_commands_before_turn_input = if selected_batch_ids.is_some() {
700            true
701        } else {
702            self.session_commands_precede_pending_turn_input(store.as_ref())
703                .await?
704        };
705        if drain_commands_before_turn_input {
706            loop {
707                match self
708                    .drain_next_session_command(&session_execution_fence)
709                    .await
710                {
711                    Ok(Some(_)) => {}
712                    Ok(None) => break,
713                    Err(err) => {
714                        let _ = session_execution_lease.release_if_live().await;
715                        return Err(err);
716                    }
717                }
718            }
719        }
720        if selected_batch_ids.is_none() {
721            let input_claim = store
722                .claim_next_turn_inputs(
723                    &self.state.session_id,
724                    &session_execution_fence,
725                    &self.runtime_lease_owner,
726                    64,
727                )
728                .await
729                .map_err(super::runtime_error_from_store_commit)?;
730            if let Some(input_claim) = input_claim {
731                let mut input = input_claim.materialize_for_turn();
732                let turn_id = input
733                    .trace_turn_id
734                    .clone()
735                    .or_else(|| Some(opts.execution_scope_id().to_owned()))
736                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
737                input.trace_turn_id = Some(turn_id.clone());
738                crate::trace::emit_trace(
739                    &self.host.core.tracing.trace_sink,
740                    &self.host.core.tracing.trace_context,
741                    lash_trace::TraceContext::default()
742                        .for_session(self.state.session_id.clone())
743                        .for_turn_index(self.state.turn_index + 1)
744                        .for_turn(turn_id.clone()),
745                    lash_trace::TraceEvent::Custom {
746                        name: "turn_input.claimed".to_string(),
747                        payload: serde_json::json!({
748                            "claim_id": &input_claim.claim_id,
749                            "input_ids": input_claim.inputs.iter().map(|input| input.input_id.clone()).collect::<Vec<_>>(),
750                        }),
751                    },
752                    self.host.core.clock.as_ref(),
753                );
754                let claim_for_abandon = input_claim.clone();
755                let scoped_effect_controller = opts.scoped_effect_controller();
756                let result = self
757                    .stream_turn_with_scoped_effect_controller_inner(
758                        input,
759                        opts.events_or_noop(),
760                        opts.turn_events_or_noop(),
761                        scoped_effect_controller,
762                        cancel,
763                        None,
764                        Some(input_claim),
765                        Some(&session_execution_lease),
766                        SessionExecutionLeaseReleasePolicy::FinalCommit,
767                    )
768                    .await
769                    .map(|mut turn| {
770                        stopwatch.stamp(&mut turn, self.host.core.clock.as_ref());
771                        Some(turn)
772                    });
773                if let Err(err) = &result {
774                    self.abandon_turn_input_claims_after_lease_loss(
775                        err,
776                        std::slice::from_ref(&claim_for_abandon),
777                    )
778                    .await;
779                }
780                return self
781                    .settle_session_execution_lease(Some(&session_execution_lease), result)
782                    .await;
783            }
784        }
785        let claim = if let Some(batch_ids) = selected_batch_ids {
786            store
787                .claim_ready_queued_work_by_batch_ids(
788                    &self.state.session_id,
789                    &session_execution_fence,
790                    &self.runtime_lease_owner,
791                    crate::QueuedWorkClaimBoundary::Idle,
792                    batch_ids,
793                )
794                .await
795        } else {
796            store
797                .claim_ready_queued_work(
798                    &self.state.session_id,
799                    &session_execution_fence,
800                    &self.runtime_lease_owner,
801                    crate::QueuedWorkClaimBoundary::Idle,
802                    64,
803                )
804                .await
805        }
806        .map_err(super::runtime_error_from_store_commit)?;
807        let Some(claim) = claim else {
808            session_execution_lease
809                .release_if_live()
810                .await
811                .map_err(|err| {
812                    RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, err.to_string())
813                })?;
814            return Ok(None);
815        };
816        let mut work = claim.materialize_for_turn();
817        let turn_id = work
818            .input
819            .trace_turn_id
820            .clone()
821            .or_else(|| Some(opts.execution_scope_id().to_owned()))
822            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
823        work.input.trace_turn_id = Some(turn_id.clone());
824        let causes = work.turn_causes.clone();
825        emit_queued_work_started_to_sink(
826            opts.turn_events_or_noop(),
827            crate::QueuedWorkClaimBoundary::Idle,
828            &claim,
829            causes.clone(),
830        )
831        .await;
832        crate::trace::emit_trace(
833            &self.host.core.tracing.trace_sink,
834            &self.host.core.tracing.trace_context,
835            lash_trace::TraceContext::default()
836                .for_session(self.state.session_id.clone())
837                .for_turn_index(self.state.turn_index + 1)
838                .for_turn(turn_id.clone()),
839            lash_trace::TraceEvent::Custom {
840                name: "queued_work.claimed".to_string(),
841                payload: queued_work_trace_payload(
842                    crate::QueuedWorkClaimBoundary::Idle,
843                    &claim,
844                    &causes,
845                ),
846            },
847            self.host.core.clock.as_ref(),
848        );
849        let claim_for_abandon = claim.clone();
850        let scoped_effect_controller = opts.scoped_effect_controller();
851        let result = self
852            .stream_turn_with_scoped_effect_controller_inner(
853                work.input,
854                opts.events_or_noop(),
855                opts.turn_events_or_noop(),
856                scoped_effect_controller,
857                cancel,
858                Some(claim),
859                None,
860                Some(&session_execution_lease),
861                SessionExecutionLeaseReleasePolicy::FinalCommit,
862            )
863            .await
864            .map(|mut turn| {
865                stopwatch.stamp(&mut turn, self.host.core.clock.as_ref());
866                Some(turn)
867            });
868        if let Err(err) = &result {
869            self.abandon_queued_work_claims_after_lease_loss(
870                err,
871                std::slice::from_ref(&claim_for_abandon),
872            )
873            .await;
874        }
875        self.settle_session_execution_lease(Some(&session_execution_lease), result)
876            .await
877    }
878
879    async fn session_commands_precede_pending_turn_input(
880        &self,
881        store: &dyn crate::RuntimePersistence,
882    ) -> Result<bool, RuntimeError> {
883        let pending_inputs = store
884            .list_pending_turn_inputs(&self.state.session_id)
885            .await
886            .map_err(super::runtime_error_from_store_commit)?;
887        let earliest_input = pending_inputs
888            .iter()
889            .filter(|input| input.state.is_next_turn_pending())
890            .min_by_key(|input| (input.enqueued_at_ms, input.enqueue_seq));
891        let queued_work = store
892            .list_pending_queued_work(&self.state.session_id)
893            .await
894            .map_err(super::runtime_error_from_store_commit)?;
895        let earliest_command = queued_work
896            .iter()
897            .filter(|batch| batch.is_session_command_work())
898            .min_by_key(|batch| (batch.enqueued_at_ms, batch.enqueue_seq));
899        Ok(match (earliest_command, earliest_input) {
900            (Some(command), Some(input)) => command.enqueued_at_ms < input.enqueued_at_ms,
901            (Some(_), None) => true,
902            _ => false,
903        })
904    }
905
906    /// Enforce the durable-first wiring invariant at a turn-scope boundary: when
907    /// the host wired a durable effect host, every store reachable from this
908    /// scope must also be durable. A durable host running against any ephemeral
909    /// store fails loudly here rather than silently degrading.
910    ///
911    /// Inline controllers (the default tier) impose no requirement, so
912    /// inline/in-memory hosts pass unchanged.
913    fn ensure_durable_store_facets_for_scope(
914        &self,
915        scoped_effect_controller: &ScopedEffectController<'_>,
916    ) -> Result<(), RuntimeError> {
917        if scoped_effect_controller.controller().durability_tier() != crate::DurabilityTier::Durable
918        {
919            return Ok(());
920        }
921        if self
922            .host
923            .core
924            .durability
925            .attachment_store
926            .persistence()
927            .durability_tier()
928            != crate::DurabilityTier::Durable
929        {
930            return Err(RuntimeError::durable_store_required(
931                crate::DurableStoreFacet::AttachmentStore,
932            ));
933        }
934        if self
935            .host
936            .core
937            .durability
938            .process_env_store
939            .durability_tier()
940            != crate::DurabilityTier::Durable
941        {
942            return Err(RuntimeError::durable_store_required(
943                crate::DurableStoreFacet::ProcessEnvStore,
944            ));
945        }
946        if let Some(store) = self
947            .session
948            .as_ref()
949            .and_then(|session| session.history_store())
950            && store.durability_tier() != crate::DurabilityTier::Durable
951        {
952            return Err(RuntimeError::durable_store_required(
953                crate::DurableStoreFacet::SessionStore,
954            ));
955        }
956        if let Some(process_registry) = self.host.process_registry.as_ref()
957            && process_registry.durability_tier() != crate::DurabilityTier::Durable
958        {
959            return Err(RuntimeError::durable_store_required(
960                crate::DurableStoreFacet::ProcessRegistry,
961            ));
962        }
963        Ok(())
964    }
965
966    #[allow(clippy::too_many_arguments)]
967    async fn stream_turn_with_scoped_effect_controller_inner(
968        &mut self,
969        mut input: TurnInput,
970        events: &dyn EventSink,
971        turn_events: &dyn TurnActivitySink,
972        scoped_effect_controller: ScopedEffectController<'_>,
973        cancel: CancellationToken,
974        queued_claim: Option<crate::QueuedWorkClaim>,
975        turn_input_claim: Option<crate::TurnInputClaim>,
976        session_execution_lease: Option<&SessionExecutionLeaseGuard>,
977        session_execution_lease_release_policy: SessionExecutionLeaseReleasePolicy,
978    ) -> Result<AssembledTurn, RuntimeError> {
979        if queued_claim.is_none() && turn_input_claim.is_none() {
980            if let Some(lease) = session_execution_lease {
981                while self
982                    .drain_next_session_command(&lease.fence())
983                    .await?
984                    .is_some()
985                {}
986            } else if self
987                .session
988                .as_ref()
989                .and_then(|session| session.history_store())
990                .is_some()
991            {
992                return Err(RuntimeError::new(
993                    RuntimeErrorCode::StoreCommitFailed,
994                    "session command drain requires a session execution lease",
995                ));
996            }
997        }
998        if let Some(input_turn_id) = input.trace_turn_id.as_deref()
999            && scoped_effect_controller
1000                .execution_scope()
1001                .validates_turn_trace_id()
1002            && input_turn_id != scoped_effect_controller.scope_id()
1003        {
1004            return Err(RuntimeError::new(
1005                RuntimeErrorCode::ExecutionScopeTurnIdMismatch,
1006                format!(
1007                    "input trace_turn_id `{input_turn_id}` does not match execution scope id `{}`",
1008                    scoped_effect_controller.scope_id()
1009                ),
1010            ));
1011        }
1012        self.ensure_durable_store_facets_for_scope(&scoped_effect_controller)?;
1013        input
1014            .trace_turn_id
1015            .get_or_insert_with(|| scoped_effect_controller.scope_id().to_string());
1016        self.stream_turn_inner(
1017            input.clone(),
1018            events,
1019            turn_events,
1020            scoped_effect_controller,
1021            cancel.clone(),
1022            queued_claim,
1023            turn_input_claim,
1024            session_execution_lease,
1025            session_execution_lease_release_policy,
1026        )
1027        .await
1028    }
1029
1030    /// Stream one logical host turn, following foreground AgentFrame switches
1031    /// until a terminal outcome is reached.
1032    ///
1033    /// A protocol continuation creates a new frame in the same session. Hosts
1034    /// that only care about the benchmark/app answer should not need to
1035    /// special-case that intermediate outcome; this helper keeps driving the
1036    /// same session through each frame's task with the normal runtime turn
1037    /// guards.
1038    pub async fn stream_turn_with_agent_frames(
1039        &mut self,
1040        input: TurnInput,
1041        opts: TurnOptions<'_>,
1042    ) -> Result<AgentFrameRun, RuntimeError> {
1043        let stopwatch = TurnStopwatch::start(self.host.core.clock.as_ref());
1044        let cancel = opts.cancel.clone();
1045        let session_execution_lease = self
1046            .claim_session_execution_lease(cancel.clone(), true)
1047            .await?;
1048        let scoped_effect_controller = opts.scoped_effect_controller();
1049        let result = self
1050            .stream_turn_with_agent_frames_inner(
1051                input,
1052                opts.events_or_noop(),
1053                opts.turn_events_or_noop(),
1054                scoped_effect_controller,
1055                cancel,
1056                session_execution_lease.as_ref(),
1057                stopwatch,
1058            )
1059            .await;
1060        self.settle_session_execution_lease(session_execution_lease.as_ref(), result)
1061            .await
1062    }
1063
1064    #[allow(clippy::too_many_arguments)]
1065    async fn stream_turn_with_agent_frames_inner(
1066        &mut self,
1067        mut input: TurnInput,
1068        events: &dyn EventSink,
1069        turn_events: &dyn TurnActivitySink,
1070        scoped_effect_controller: ScopedEffectController<'_>,
1071        cancel: CancellationToken,
1072        session_execution_lease: Option<&SessionExecutionLeaseGuard>,
1073        stopwatch: TurnStopwatch,
1074    ) -> Result<AgentFrameRun, RuntimeError> {
1075        if let Some(input_turn_id) = input.trace_turn_id.as_deref()
1076            && scoped_effect_controller
1077                .execution_scope()
1078                .validates_turn_trace_id()
1079            && input_turn_id != scoped_effect_controller.scope_id()
1080        {
1081            return Err(RuntimeError::new(
1082                RuntimeErrorCode::ExecutionScopeTurnIdMismatch,
1083                format!(
1084                    "input trace_turn_id `{input_turn_id}` does not match execution scope id `{}`",
1085                    scoped_effect_controller.scope_id()
1086                ),
1087            ));
1088        }
1089        let follow_protocol_turn_options = input.protocol_turn_options.clone();
1090        let follow_turn_context = input.turn_context.clone();
1091        let follow_trace_turn_id = input
1092            .trace_turn_id
1093            .clone()
1094            .unwrap_or_else(|| scoped_effect_controller.scope_id().to_string());
1095        input
1096            .trace_turn_id
1097            .get_or_insert(follow_trace_turn_id.clone());
1098        let mut turns = Vec::new();
1099        loop {
1100            let turn_trace_turn_id = agent_frame_follow_turn_id(&follow_trace_turn_id, turns.len());
1101            input.trace_turn_id = Some(turn_trace_turn_id.clone());
1102            let turn_effect_controller = if turns.is_empty() {
1103                scoped_effect_controller.clone()
1104            } else {
1105                ScopedEffectController::borrowed(
1106                    scoped_effect_controller.controller(),
1107                    ExecutionScope::turn(&self.state.session_id, &turn_trace_turn_id),
1108                )?
1109            };
1110            // The first frame's window opened before the lease claim; each
1111            // follow frame is timed from its own start so per-frame durations
1112            // stay honest.
1113            let frame_stopwatch = if turns.is_empty() {
1114                stopwatch
1115            } else {
1116                TurnStopwatch::start(self.host.core.clock.as_ref())
1117            };
1118            let mut turn = self
1119                .stream_turn_with_scoped_effect_controller_inner(
1120                    input,
1121                    events,
1122                    turn_events,
1123                    turn_effect_controller,
1124                    cancel.clone(),
1125                    None,
1126                    None,
1127                    session_execution_lease,
1128                    SessionExecutionLeaseReleasePolicy::KeepOnAgentFrameSwitch,
1129                )
1130                .await?;
1131            frame_stopwatch.stamp(&mut turn, self.host.core.clock.as_ref());
1132            let switched_frame = match &turn.outcome {
1133                TurnOutcome::AgentFrameSwitch { frame_id, task } => {
1134                    Some((frame_id.clone(), task.clone()))
1135                }
1136                _ => None,
1137            };
1138            turns.push(turn);
1139
1140            let Some((_frame_id, task)) = switched_frame else {
1141                return Ok(AgentFrameRun { turns });
1142            };
1143            input = turn_input_from_text(task);
1144            input.protocol_turn_options = follow_protocol_turn_options.clone();
1145            input.turn_context = follow_turn_context.clone();
1146        }
1147    }
1148
1149    #[allow(clippy::too_many_arguments)]
1150    async fn stream_turn_inner(
1151        &mut self,
1152        mut input: TurnInput,
1153        events: &dyn EventSink,
1154        turn_events: &dyn TurnActivitySink,
1155        scoped_effect_controller: ScopedEffectController<'_>,
1156        cancel: CancellationToken,
1157        queued_claim: Option<crate::QueuedWorkClaim>,
1158        turn_input_claim: Option<crate::TurnInputClaim>,
1159        session_execution_lease: Option<&SessionExecutionLeaseGuard>,
1160        session_execution_lease_release_policy: SessionExecutionLeaseReleasePolicy,
1161    ) -> Result<AssembledTurn, RuntimeError> {
1162        self.refresh_session_graph_from_store()
1163            .await
1164            .map_err(session_head_refresh_error)?;
1165        let input_trace_turn_id = input.trace_turn_id.clone();
1166        let queued_turn_work = queued_claim
1167            .as_ref()
1168            .map(crate::QueuedWorkClaim::materialize_for_turn);
1169        let pending_turn_input = turn_input_claim
1170            .as_ref()
1171            .map(crate::TurnInputClaim::materialize_for_turn);
1172        if let Some(work) = pending_turn_input.as_ref()
1173            && input.items.is_empty()
1174            && input.image_blobs.is_empty()
1175        {
1176            input = work.clone();
1177            if input.trace_turn_id.is_none() {
1178                input.trace_turn_id = input_trace_turn_id.clone();
1179            }
1180        }
1181        if let Some(work) = queued_turn_work.as_ref()
1182            && input.items.is_empty()
1183            && input.image_blobs.is_empty()
1184        {
1185            input = work.input.clone();
1186            if input.trace_turn_id.is_none() {
1187                input.trace_turn_id = input_trace_turn_id;
1188            }
1189        }
1190        if self
1191            .session
1192            .as_ref()
1193            .and_then(|session| session.history_store())
1194            .is_some()
1195        {
1196            ensure_durable_effect_input(&input)?;
1197        }
1198        if let Some(extension) = &input.protocol_extension
1199            && let Some(session) = self.session.as_ref()
1200        {
1201            let protocol_session = std::sync::Arc::clone(session.plugins().protocol_session());
1202            protocol_session
1203                .validate_turn_extension(extension)
1204                .await
1205                .map_err(|err| {
1206                    RuntimeError::new(RuntimeErrorCode::ProtocolTurnExtension, err.to_string())
1207                })?;
1208        }
1209        let previous_prompt_usage = self.state.last_prompt_usage.clone();
1210        let normalized = match self
1211            .normalize_input_items(&input.items, &input.image_blobs)
1212            .await
1213        {
1214            Ok(items) => items,
1215            Err(e) => {
1216                self.state.last_prompt_usage = None;
1217                let mut assembler = TurnAssembler::default();
1218                let error_event = SessionEvent::Error {
1219                    message: e.clone(),
1220                    envelope: Some(crate::session_model::ErrorEnvelope {
1221                        kind: "input_validation".to_string(),
1222                        code: Some("invalid_turn_input".to_string()),
1223                        terminal_reason: None,
1224                        user_message: e.clone(),
1225                        raw: None,
1226                        retryable: Some(false),
1227                        provider_failure_kind: None,
1228                    }),
1229                };
1230                assembler.push(&error_event);
1231                emit_turn_activity_to_sink(
1232                    turn_events,
1233                    TurnActivity::independent(TurnEvent::Error { message: e }),
1234                )
1235                .await;
1236                emit_session_event_to_sink(events, error_event).await;
1237                let outcome_event = SessionEvent::TurnOutcome {
1238                    outcome: TurnOutcome::Stopped(TurnStop::InvalidInput),
1239                };
1240                assembler.push(&outcome_event);
1241                emit_session_event_to_sink(events, outcome_event).await;
1242                assembler.push(&SessionEvent::Done);
1243                emit_session_event_to_sink(events, SessionEvent::Done).await;
1244                return Ok(assembler.finish(
1245                    self.state.to_snapshot(),
1246                    false,
1247                    None,
1248                    &self.host.core.control.termination,
1249                ));
1250            }
1251        };
1252        let turn_index = self.state.turn_index + 1;
1253        let trace_turn_id = input
1254            .trace_turn_id
1255            .clone()
1256            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1257        if self.host.core.tracing.trace_sink.is_some() {
1258            let mut trace_metadata = std::collections::BTreeMap::new();
1259            trace_metadata.insert(
1260                "input_item_count".to_string(),
1261                serde_json::json!(normalized.len()),
1262            );
1263            crate::trace::emit_trace(
1264                &self.host.core.tracing.trace_sink,
1265                &self.host.core.tracing.trace_context,
1266                lash_trace::TraceContext::default()
1267                    .for_session(self.state.session_id.clone())
1268                    .for_turn_index(turn_index)
1269                    .for_turn(trace_turn_id.clone()),
1270                lash_trace::TraceEvent::TurnStarted {
1271                    metadata: trace_metadata,
1272                },
1273                self.host.core.clock.as_ref(),
1274            );
1275        }
1276
1277        let base_read_model = self.state.read_model();
1278        let base_messages = base_read_model.messages;
1279        let base_render_cache = base_read_model.prompt_render_cache;
1280        let mut turn_delta = Vec::new();
1281        let initial_turn_causes = queued_turn_work
1282            .as_ref()
1283            .map(|work| work.turn_causes.clone())
1284            .unwrap_or_default();
1285        turn_delta.extend(
1286            initial_turn_causes
1287                .iter()
1288                .map(crate::TurnCause::to_event_message),
1289        );
1290
1291        let user_id = fresh_message_id();
1292        let mut user_parts: Vec<Part> = Vec::new();
1293        for item in normalized {
1294            match item {
1295                NormalizedItem::Text(text) => {
1296                    if text.is_empty() {
1297                        continue;
1298                    }
1299                    user_parts.push(Part {
1300                        id: format!("{}.p{}", user_id, user_parts.len()),
1301                        kind: PartKind::Text,
1302                        content: text,
1303                        attachment: None,
1304                        tool_call_id: None,
1305                        tool_name: None,
1306                        tool_replay: None,
1307                        prune_state: PruneState::Intact,
1308                        reasoning_meta: None,
1309                        response_meta: None,
1310                    });
1311                }
1312                NormalizedItem::Image(reference) => {
1313                    user_parts.push(Part {
1314                        id: format!("{}.p{}", user_id, user_parts.len()),
1315                        kind: PartKind::Image,
1316                        content: String::new(),
1317                        attachment: Some(crate::session_model::message::PartAttachment {
1318                            reference,
1319                        }),
1320                        tool_call_id: None,
1321                        tool_name: None,
1322                        tool_replay: None,
1323                        prune_state: PruneState::Intact,
1324                        reasoning_meta: None,
1325                        response_meta: None,
1326                    });
1327                }
1328            }
1329        }
1330        if user_parts.is_empty() && initial_turn_causes.is_empty() {
1331            user_parts.push(Part {
1332                id: format!("{}.p0", user_id),
1333                kind: PartKind::Text,
1334                content: String::new(),
1335                attachment: None,
1336                tool_call_id: None,
1337                tool_name: None,
1338                tool_replay: None,
1339                prune_state: PruneState::Intact,
1340                reasoning_meta: None,
1341                response_meta: None,
1342            });
1343        }
1344        if !user_parts.is_empty() {
1345            reassign_part_ids(&user_id, &mut user_parts);
1346            turn_delta.push(Message {
1347                id: user_id.clone(),
1348                role: MessageRole::User,
1349                parts: shared_parts(user_parts),
1350                origin: None,
1351            });
1352        }
1353
1354        let manager = self
1355            .runtime_session_services_for_turn(None)
1356            .map_err(|err| {
1357                RuntimeError::new(RuntimeErrorCode::PluginSessionManager, err.to_string())
1358            })?;
1359        let plugin_session = self
1360            .session
1361            .as_ref()
1362            .map(|s| Arc::clone(s.plugins()))
1363            .ok_or_else(|| {
1364                RuntimeError::new(
1365                    RuntimeErrorCode::ContextPrepareTurn,
1366                    "runtime session not available",
1367                )
1368            })?;
1369        let prepare_phase_turn_id = turn_phase_id(&trace_turn_id, "prepare-turn");
1370        let prepare_phase_controller = scoped_child_turn_controller(
1371            &scoped_effect_controller,
1372            &self.state.session_id,
1373            &prepare_phase_turn_id,
1374        )?;
1375        let turn_ctx = crate::TurnTransformContext {
1376            session_id: self.state.session_id.clone(),
1377            state: self.read_view(),
1378            prompt_usage: previous_prompt_usage.clone(),
1379            max_context_tokens: Some(LashRuntime::max_context_tokens(self)),
1380            sessions: manager.state_service(),
1381            session_lifecycle: manager.lifecycle_service(),
1382            session_graph: manager.graph_service(),
1383            scoped_effect_controller: scoped_effect_controller.clone(),
1384            direct_completions: manager.direct_completion_client(
1385                RuntimeEffectControllerHandle::borrowed(prepare_phase_controller),
1386                Some(prepare_phase_turn_id),
1387            ),
1388        };
1389        self.mark_phase_begin(RuntimeTurnPhase::ContextTransform);
1390        let prepared_context = plugin_session
1391            .prepare_turn_context(
1392                &turn_ctx,
1393                crate::session_model::context::PreparedContext {
1394                    messages: crate::MessageSequence::from_base_and_delta(
1395                        base_messages,
1396                        turn_delta,
1397                    )
1398                    .with_base_render_cache(base_render_cache),
1399                    ..Default::default()
1400                },
1401                self.turn_phase_probe.clone(),
1402            )
1403            .await
1404            .map_err(|err| {
1405                RuntimeError::new(RuntimeErrorCode::ContextPrepareTurn, err.to_string())
1406            })?;
1407        self.mark_phase_end(RuntimeTurnPhase::ContextTransform);
1408        // Release the read-view's graph clone before the rest of the turn
1409        // runs. Keeping it alive into `stream_prepared_turn` forces the
1410        // post-turn `append_active_read_delta` to deep-clone the session
1411        // graph (Arc::make_mut with refcount > 1).
1412        drop(turn_ctx);
1413        let messages = prepared_context.messages;
1414        if let Some(session) = self.session.as_mut() {
1415            session
1416                .set_context_overlay(
1417                    prepared_context.tool_providers,
1418                    prepared_context.prompt_contributions,
1419                    prepared_context.include_base_tools,
1420                )
1421                .map_err(|err| {
1422                    RuntimeError::new(
1423                        RuntimeErrorCode::Other("session_tool_registry".to_string()),
1424                        err.to_string(),
1425                    )
1426                })?;
1427        }
1428
1429        self.state.last_prompt_usage = None;
1430        Box::pin(self.stream_prepared_turn_inner(
1431            messages,
1432            previous_prompt_usage,
1433            input.protocol_turn_options.clone(),
1434            input.protocol_extension.clone(),
1435            input.turn_context.clone(),
1436            initial_turn_causes,
1437            trace_turn_id,
1438            turn_index,
1439            events,
1440            turn_events,
1441            scoped_effect_controller,
1442            cancel,
1443            queued_claim,
1444            turn_input_claim,
1445            session_execution_lease,
1446            session_execution_lease_release_policy,
1447        ))
1448        .await
1449    }
1450
1451    /// Run a single turn and return only the assembled terminal result.
1452    pub async fn run_turn_assembled(
1453        &mut self,
1454        input: TurnInput,
1455        cancel: CancellationToken,
1456        scoped_effect_controller: ScopedEffectController<'_>,
1457    ) -> Result<AssembledTurn, RuntimeError> {
1458        self.stream_turn(input, TurnOptions::new(cancel, scoped_effect_controller))
1459            .await
1460    }
1461
1462    /// Run a turn using host-prepared message history.
1463    #[allow(clippy::too_many_arguments)]
1464    pub async fn stream_prepared_turn(
1465        &mut self,
1466        messages: crate::MessageSequence,
1467        previous_prompt_usage: Option<PromptUsage>,
1468        protocol_turn_options: Option<crate::ProtocolTurnOptions>,
1469        protocol_extension: Option<crate::ProtocolTurnExtensionHandle>,
1470        turn_context: crate::TurnContext,
1471        initial_turn_causes: Vec<crate::TurnCause>,
1472        trace_turn_id: String,
1473        turn_index: usize,
1474        events: &dyn EventSink,
1475        turn_events: &dyn TurnActivitySink,
1476        scoped_effect_controller: ScopedEffectController<'_>,
1477        cancel: CancellationToken,
1478        initial_queue_claim: Option<crate::QueuedWorkClaim>,
1479        initial_turn_input_claim: Option<crate::TurnInputClaim>,
1480    ) -> Result<AssembledTurn, RuntimeError> {
1481        let stopwatch = TurnStopwatch::start(self.host.core.clock.as_ref());
1482        let session_execution_lease = self
1483            .claim_session_execution_lease(cancel.clone(), true)
1484            .await?;
1485        let result = self
1486            .stream_prepared_turn_inner(
1487                messages,
1488                previous_prompt_usage,
1489                protocol_turn_options,
1490                protocol_extension,
1491                turn_context,
1492                initial_turn_causes,
1493                trace_turn_id,
1494                turn_index,
1495                events,
1496                turn_events,
1497                scoped_effect_controller,
1498                cancel,
1499                initial_queue_claim,
1500                initial_turn_input_claim,
1501                session_execution_lease.as_ref(),
1502                SessionExecutionLeaseReleasePolicy::FinalCommit,
1503            )
1504            .await
1505            .map(|mut turn| {
1506                stopwatch.stamp(&mut turn, self.host.core.clock.as_ref());
1507                turn
1508            });
1509        self.settle_session_execution_lease(session_execution_lease.as_ref(), result)
1510            .await
1511    }
1512
1513    #[allow(clippy::too_many_arguments)]
1514    async fn stream_prepared_turn_inner(
1515        &mut self,
1516        messages: crate::MessageSequence,
1517        _previous_prompt_usage: Option<PromptUsage>,
1518        protocol_turn_options: Option<crate::ProtocolTurnOptions>,
1519        protocol_extension: Option<crate::ProtocolTurnExtensionHandle>,
1520        turn_context: crate::TurnContext,
1521        initial_turn_causes: Vec<crate::TurnCause>,
1522        trace_turn_id: String,
1523        turn_index: usize,
1524        events: &dyn EventSink,
1525        turn_events: &dyn TurnActivitySink,
1526        scoped_effect_controller: ScopedEffectController<'_>,
1527        cancel: CancellationToken,
1528        initial_queue_claim: Option<crate::QueuedWorkClaim>,
1529        initial_turn_input_claim: Option<crate::TurnInputClaim>,
1530        session_execution_lease: Option<&SessionExecutionLeaseGuard>,
1531        session_execution_lease_release_policy: SessionExecutionLeaseReleasePolicy,
1532    ) -> Result<AssembledTurn, RuntimeError> {
1533        if session_execution_lease.is_none()
1534            && self
1535                .session
1536                .as_ref()
1537                .and_then(|session| session.history_store())
1538                .is_some()
1539        {
1540            return Err(RuntimeError::new(
1541                RuntimeErrorCode::StoreCommitFailed,
1542                "prepared turn requires a session execution lease",
1543            ));
1544        }
1545        let session_execution_fence =
1546            session_execution_lease.map(SessionExecutionLeaseGuard::fence);
1547        let (event_tx, mut event_rx) = mpsc::channel::<RuntimeStreamEvent>(100);
1548        let child_usage_event_relay = ChildUsageEventRelay::new(event_tx.clone());
1549        let mut turn_policy = self.state.effective_policy().clone();
1550        let turn_provider_override = turn_context.provider().cloned();
1551        if let Some(provider) = turn_provider_override.as_ref() {
1552            turn_policy.provider_id = provider.kind().to_string();
1553        }
1554        if let Some(model) = turn_context.model_spec() {
1555            turn_policy.model = model.clone();
1556        }
1557        let session_protocol_turn_options = self.state.effective_protocol_turn_options().clone();
1558        let effective_protocol_turn_options = protocol_turn_options
1559            .clone()
1560            .map(|options| session_protocol_turn_options.merged_with_override(&options))
1561            .unwrap_or(session_protocol_turn_options);
1562        let manager = self
1563            .runtime_session_services_for_turn(Some(child_usage_event_relay.clone()))
1564            .map_err(|err| {
1565                RuntimeError::new(RuntimeErrorCode::PluginSessionManager, err.to_string())
1566            })?;
1567        let plugins = {
1568            let session = self
1569                .session
1570                .as_ref()
1571                .expect("lash runtime session must be available");
1572            Arc::clone(session.plugins())
1573        };
1574        let mut assembler = TurnAssembler::new();
1575        self.mark_phase_begin(RuntimeTurnPhase::BeforeTurnHooks);
1576        // Block-scope the pinned future so it (and its captured
1577        // `SessionReadView` clone of the session graph) drops before the
1578        // post-turn `append_active_read_delta` mutation. Keeping it alive
1579        // across the turn forces `Arc::make_mut` to deep-clone
1580        // `SessionGraphData`.
1581        let prepared = {
1582            let prepare_turn = plugins.prepare_turn_with_phase_probe(
1583                PrepareTurnRequest {
1584                    session_id: self.state.session_id.clone(),
1585                    state: crate::SessionReadView::from_runtime_state(
1586                        &self.state,
1587                        turn_policy.clone(),
1588                        effective_protocol_turn_options.clone(),
1589                    ),
1590                    messages,
1591                    sessions: manager.state_service(),
1592                    session_lifecycle: manager.lifecycle_service(),
1593                    session_graph: manager.graph_service(),
1594                    turn_context: turn_context.clone(),
1595                },
1596                self.turn_phase_probe.clone(),
1597            );
1598            let mut prepare_turn = Box::pin(prepare_turn);
1599
1600            loop {
1601                tokio::select! {
1602                    prepared = prepare_turn.as_mut() => {
1603                        let prepared = prepared.map_err(|err| {
1604                            RuntimeError::new(RuntimeErrorCode::PluginPrepareTurn, err.to_string())
1605                        })?;
1606                        self.mark_phase_end(RuntimeTurnPhase::BeforeTurnHooks);
1607                        break prepared;
1608                    }
1609                    maybe_event = event_rx.recv() => {
1610                        if let Some(event) = maybe_event {
1611                            emit_runtime_stream_event_to_sinks(
1612                                events,
1613                                turn_events,
1614                                event,
1615                                &mut assembler,
1616                            )
1617                            .await;
1618                        }
1619                    }
1620                }
1621            }
1622        };
1623        for event in &prepared.events {
1624            assembler.push(event);
1625        }
1626        emit_session_events_to_sink(events, prepared.events).await;
1627        if let Some(abort) = prepared.abort {
1628            drop(event_tx);
1629
1630            let mut turn_pipeline = TurnBoundary::from_state_with_clock(
1631                self.state.clone(),
1632                Arc::clone(&self.host.core.clock),
1633            );
1634            turn_pipeline.apply_prepared_messages(&prepared.messages);
1635            let state = turn_pipeline.into_final_state();
1636            let issue = TurnIssue {
1637                kind: "plugin".to_string(),
1638                code: Some(abort.code),
1639                terminal_reason: None,
1640                message: abort.message.clone(),
1641                raw: None,
1642                retryable: None,
1643                provider_failure_kind: None,
1644            };
1645            let error_event = SessionEvent::Error {
1646                message: abort.message,
1647                envelope: Some(crate::session_model::ErrorEnvelope {
1648                    kind: "plugin".to_string(),
1649                    code: issue.code.clone(),
1650                    terminal_reason: None,
1651                    user_message: issue.message.clone(),
1652                    raw: None,
1653                    retryable: None,
1654                    provider_failure_kind: None,
1655                }),
1656            };
1657            assembler.push(&error_event);
1658            emit_turn_activity_to_sink(
1659                turn_events,
1660                TurnActivity::independent(TurnEvent::Error {
1661                    message: issue.message.clone(),
1662                }),
1663            )
1664            .await;
1665            emit_session_event_to_sink(events, error_event).await;
1666            let outcome_event = SessionEvent::TurnOutcome {
1667                outcome: TurnOutcome::Stopped(TurnStop::PluginAbort),
1668            };
1669            assembler.push(&outcome_event);
1670            emit_session_event_to_sink(events, outcome_event).await;
1671            assembler.push(&SessionEvent::Done);
1672            emit_session_event_to_sink(events, SessionEvent::Done).await;
1673            return Ok(assembler.finish(
1674                state.to_snapshot(),
1675                cancel.is_cancelled(),
1676                Some(issue),
1677                &self.host.core.control.termination,
1678            ));
1679        }
1680        let mut turn_pipeline = TurnBoundary::from_state_with_clock(
1681            self.state.clone(),
1682            Arc::clone(&self.host.core.clock),
1683        )
1684        .with_session_execution_lease(session_execution_fence.clone());
1685        let store = self
1686            .session
1687            .as_ref()
1688            .and_then(|session| session.history_store());
1689        // Durable controllers, like Restate, own in-flight replay. Writing
1690        // progress checkpoints directly to the shared store would make handler
1691        // replay observe a newer partial turn and change effect replay keys.
1692        let progress_store = if scoped_effect_controller.controller().durability_tier()
1693            == crate::DurabilityTier::Durable
1694        {
1695            None
1696        } else {
1697            store.as_ref().map(|store| store.as_ref())
1698        };
1699        turn_pipeline
1700            .prepared_checkpoint(
1701                progress_store,
1702                turn_policy.clone(),
1703                turn_index,
1704                &prepared.messages,
1705                self.session.as_mut(),
1706            )
1707            .await
1708            .map_err(super::runtime_error_from_store_commit)?;
1709        let resolved_turn_policy = if let Some(provider) = turn_provider_override {
1710            RuntimeSessionPolicy::from_provider(
1711                turn_policy.clone(),
1712                provider.with_clock(Arc::clone(&self.host.core.clock)),
1713            )
1714            .map_err(|err| RuntimeError::new("llm_provider", err.to_string()))?
1715        } else {
1716            self.host
1717                .resolve_session_policy(&self.state.session_id, turn_policy.clone())
1718                .map_err(|err| RuntimeError::new("llm_provider", err.to_string()))?
1719        };
1720        let manager = self
1721            .runtime_session_services_for_turn(Some(child_usage_event_relay.clone()))
1722            .map_err(|err| {
1723                RuntimeError::new(RuntimeErrorCode::PluginSessionManager, err.to_string())
1724            })?;
1725        let cancel_state = cancel.clone();
1726        let finish_scoped_effect_controller = scoped_effect_controller.clone();
1727        let session = self
1728            .session
1729            .take()
1730            .expect("lash runtime session must be available");
1731        let mut driver = RuntimeTurnDriver {
1732            session,
1733            policy: resolved_turn_policy,
1734            host: self.host.clone(),
1735            turn_id: scoped_effect_controller.scope_id().to_string(),
1736            scoped_effect_controller,
1737            session_id: self.state.session_id.clone(),
1738            turn_index,
1739            turn_pipeline,
1740            llm_stream_summaries: HashMap::new(),
1741            next_llm_ordinal: 0,
1742            session_services: manager,
1743            protocol_turn_options: effective_protocol_turn_options,
1744            protocol_extension,
1745            turn_context,
1746            turn_causes: initial_turn_causes,
1747            pending_queue_claims: initial_queue_claim.into_iter().collect(),
1748            pending_turn_input_claims: initial_turn_input_claim.into_iter().collect(),
1749            checkpoint_messages: crate::tool_dispatch::CheckpointMessageBuffer::default(),
1750            session_execution_lease: session_execution_fence,
1751            runtime_lease_owner: self.runtime_lease_owner.clone(),
1752            turn_phase_probe: self.turn_phase_probe.clone(),
1753        };
1754        let protocol_run_offset = 0;
1755        self.mark_phase_begin(RuntimeTurnPhase::EffectLoop);
1756        let run_result = drive_turn_to_completion(
1757            driver.run(prepared.messages, event_tx, cancel, protocol_run_offset),
1758            &mut event_rx,
1759            &mut assembler,
1760            &child_usage_event_relay,
1761            events,
1762            turn_events,
1763        )
1764        .await;
1765        let (new_messages, _new_protocol_iteration) = match run_result {
1766            Ok(result) => result,
1767            Err(err) => {
1768                self.mark_phase_end(RuntimeTurnPhase::EffectLoop);
1769                let RuntimeTurnDriver {
1770                    session,
1771                    pending_queue_claims,
1772                    pending_turn_input_claims,
1773                    ..
1774                } = driver;
1775                self.session = Some(session);
1776                self.abandon_queued_work_claims_after_lease_loss(&err, &pending_queue_claims)
1777                    .await;
1778                self.abandon_turn_input_claims_after_lease_loss(&err, &pending_turn_input_claims)
1779                    .await;
1780                return Err(err);
1781            }
1782        };
1783        self.mark_phase_end(RuntimeTurnPhase::EffectLoop);
1784        tracing::debug!(
1785            new_message_count = new_messages.len(),
1786            tool_call_count = assembler.tool_calls.len(),
1787            "runtime post-run_task"
1788        );
1789
1790        let RuntimeTurnDriver {
1791            session,
1792            policy,
1793            turn_pipeline,
1794            pending_queue_claims,
1795            pending_turn_input_claims,
1796            ..
1797        } = driver;
1798        self.session = Some(session);
1799        let pending_queue_claims_for_abandon = pending_queue_claims.clone();
1800        let pending_turn_input_claims_for_abandon = pending_turn_input_claims.clone();
1801        let finish_result = self
1802            .finish_turn(
1803                TurnFinishInput {
1804                    turn_pipeline,
1805                    assembler,
1806                    new_messages,
1807                    policy,
1808                    turn_index,
1809                    queued_work_completions: pending_queue_claims
1810                        .iter()
1811                        .map(crate::QueuedWorkClaim::completion)
1812                        .collect(),
1813                    turn_input_completions: pending_turn_input_claims
1814                        .iter()
1815                        .map(crate::TurnInputClaim::completion)
1816                        .collect(),
1817                    trace_turn_id,
1818                },
1819                events,
1820                &finish_scoped_effect_controller,
1821                &cancel_state,
1822                session_execution_lease,
1823                session_execution_lease_release_policy,
1824            )
1825            .await;
1826        if let Err(err) = &finish_result {
1827            self.abandon_queued_work_claims_after_lease_loss(
1828                err,
1829                &pending_queue_claims_for_abandon,
1830            )
1831            .await;
1832            self.abandon_turn_input_claims_after_lease_loss(
1833                err,
1834                &pending_turn_input_claims_for_abandon,
1835            )
1836            .await;
1837        }
1838        finish_result
1839    }
1840    async fn normalize_input_items(
1841        &self,
1842        items: &[InputItem],
1843        image_blobs: &HashMap<String, Vec<u8>>,
1844    ) -> Result<Vec<NormalizedItem>, String> {
1845        normalize_input_items(
1846            items,
1847            image_blobs,
1848            self.host.core.durability.attachment_store.as_ref(),
1849        )
1850        .await
1851    }
1852}
1853
1854fn turn_input_from_text(text: String) -> TurnInput {
1855    TurnInput {
1856        items: vec![InputItem::Text { text }],
1857        image_blobs: HashMap::new(),
1858        protocol_turn_options: None,
1859        trace_turn_id: None,
1860        protocol_extension: None,
1861        turn_context: crate::TurnContext::default(),
1862    }
1863}
1864
1865fn agent_frame_follow_turn_id(root_turn_id: &str, completed_turn_count: usize) -> String {
1866    if completed_turn_count == 0 {
1867        root_turn_id.to_string()
1868    } else {
1869        format!("{root_turn_id}:agent-frame:{completed_turn_count}")
1870    }
1871}
1872
1873pub fn ensure_durable_effect_input(input: &TurnInput) -> Result<(), RuntimeError> {
1874    if input.protocol_extension.is_some() {
1875        return Err(RuntimeError::new(
1876            RuntimeErrorCode::DurableEffectLiveProtocolExtension,
1877            "durable effect hosts do not support live protocol_extension inputs; encode replayable data in protocol_turn_options or persisted plugin state",
1878        ));
1879    }
1880    input
1881        .turn_context
1882        .live_plugin_inputs()
1883        .durable_effect_rejection()?;
1884    Ok(())
1885}
1886
1887async fn emit_turn_activity_to_sink(events: &dyn TurnActivitySink, activity: TurnActivity) {
1888    if !events.is_noop() {
1889        events.emit(activity).await;
1890    }
1891}
1892
1893/// Pump the turn driver's event channel into the host sinks while the run
1894/// future executes, then drain any events emitted between completion and the
1895/// sender dropping.
1896///
1897/// Both the fresh and resumed turn entry points construct a
1898/// `RuntimeTurnDriver`, kick off its run future, and need identical
1899/// event-pump/drain behavior before tearing the driver down. Only the driver
1900/// construction and post-run teardown differ, so each caller owns those and
1901/// shares this loop.
1902async fn drive_turn_to_completion<F>(
1903    run_future: F,
1904    event_rx: &mut mpsc::Receiver<RuntimeStreamEvent>,
1905    assembler: &mut TurnAssembler,
1906    child_usage_event_relay: &ChildUsageEventRelay,
1907    events: &dyn EventSink,
1908    turn_events: &dyn TurnActivitySink,
1909) -> Result<(crate::MessageSequence, usize), RuntimeError>
1910where
1911    F: std::future::Future<Output = Result<(crate::MessageSequence, usize), RuntimeError>>,
1912{
1913    let run_result = {
1914        let mut run_future = Box::pin(run_future);
1915        loop {
1916            tokio::select! {
1917                maybe_event = event_rx.recv() => {
1918                    if let Some(event) = maybe_event {
1919                        emit_runtime_stream_event_to_sinks(
1920                            events,
1921                            turn_events,
1922                            event,
1923                            assembler,
1924                        )
1925                        .await;
1926                    }
1927                }
1928                completed = run_future.as_mut() => {
1929                    child_usage_event_relay.clear();
1930                    break completed;
1931                }
1932            }
1933        }
1934    };
1935    while let Some(event) = event_rx.recv().await {
1936        emit_runtime_stream_event_to_sinks(events, turn_events, event, assembler).await;
1937    }
1938    run_result
1939}
1940
1941async fn emit_runtime_stream_event_to_sinks(
1942    events: &dyn EventSink,
1943    turn_events: &dyn TurnActivitySink,
1944    event: RuntimeStreamEvent,
1945    assembler: &mut TurnAssembler,
1946) {
1947    match event {
1948        RuntimeStreamEvent::Session(event) => {
1949            assembler.push(&event);
1950            emit_session_event_to_sink(events, event).await;
1951        }
1952        RuntimeStreamEvent::Turn(activity) => {
1953            emit_turn_activity_to_sink(turn_events, activity).await;
1954        }
1955    }
1956}
1957
1958#[cfg(test)]
1959mod tests {
1960    use super::agent_frame_follow_turn_id;
1961
1962    #[test]
1963    fn agent_frame_follow_turn_ids_are_distinct_and_deterministic() {
1964        assert_eq!(agent_frame_follow_turn_id("root-turn", 0), "root-turn");
1965        assert_eq!(
1966            agent_frame_follow_turn_id("root-turn", 1),
1967            "root-turn:agent-frame:1"
1968        );
1969        assert_eq!(
1970            agent_frame_follow_turn_id("root-turn", 2),
1971            "root-turn:agent-frame:2"
1972        );
1973    }
1974}