Skip to main content

ag_agent/app_server/
retry.rs

1//! Shared app-server restart and retry orchestration.
2
3use super::contract::{
4    AppServerFuture, AppServerTurnRequest, AppServerTurnResponse, BorrowedAppServerFuture,
5};
6use super::error::AppServerError;
7use super::prompt::{
8    instruction_delivery_mode_for_runtime, read_latest_session_output, turn_prompt_for_runtime,
9};
10use super::registry::{ActiveAppServerTurn, AppServerSessionRegistry};
11use crate::agent::ProtocolSchemaInstructionMode;
12use crate::model::turn_prompt::TurnPrompt;
13
14/// Callbacks for inspecting runtime state during turn execution.
15///
16/// Bundles the query functions that [`run_turn_with_restart_retry`] uses to
17/// check whether the runtime matches the current request, whether it restored
18/// provider-native context, and to extract identifiers.
19pub struct RuntimeInspector<Runtime> {
20    /// Returns `true` when the existing runtime is compatible with the request.
21    pub matches_request: fn(&Runtime, &AppServerTurnRequest) -> bool,
22    /// Returns the OS process id of the runtime, when available.
23    pub pid: fn(&Runtime) -> Option<u32>,
24    /// Returns the provider-native conversation id, when available.
25    pub provider_conversation_id: fn(&Runtime) -> Option<String>,
26    /// Returns `true` when the runtime bootstrapped by restoring prior context.
27    pub restored_context: fn(&Runtime) -> bool,
28}
29
30/// Runs one app-server turn with restart-and-retry semantics.
31///
32/// Runtime lifecycle details (`start`, per-turn execution, and shutdown) are
33/// injected by the provider. The function keeps a session-scoped runtime in
34/// `sessions`, invalidates it when request shape changes, and retries once
35/// after restarting the runtime when the first attempt fails.
36///
37/// Prompt transcript replay is only applied when a newly started runtime does
38/// not expose restored provider-native context for the session.
39///
40/// `schema_instruction_mode` is selected by the provider client so bootstrap
41/// prompts include the full JSON Schema only for transports that need
42/// prompt-side schema guidance.
43///
44/// # Errors
45/// Returns an error when runtime startup/execution fails, retry fails, or the
46/// session registry lock is unavailable.
47pub async fn run_turn_with_restart_retry<Runtime, StartRuntime, RunTurn, ShutdownRuntime>(
48    sessions: &AppServerSessionRegistry<Runtime>,
49    request: AppServerTurnRequest,
50    inspector: RuntimeInspector<Runtime>,
51    schema_instruction_mode: ProtocolSchemaInstructionMode,
52    mut start_runtime: StartRuntime,
53    mut run_turn_with_runtime: RunTurn,
54    mut shutdown_runtime: ShutdownRuntime,
55) -> Result<AppServerTurnResponse, AppServerError>
56where
57    StartRuntime: FnMut(&AppServerTurnRequest) -> AppServerFuture<Result<Runtime, AppServerError>>,
58    RunTurn: for<'scope> FnMut(
59        &'scope mut Runtime,
60        &'scope TurnPrompt,
61    ) -> BorrowedAppServerFuture<
62        'scope,
63        Result<(String, u64, u64), AppServerError>,
64    >,
65    ShutdownRuntime: for<'scope> FnMut(&'scope mut Runtime) -> BorrowedAppServerFuture<'scope, ()>,
66{
67    let session_id = request.session_id.clone();
68    let active_turn = sessions.register_active_turn(&session_id)?;
69    let session_runtime =
70        take_compatible_session_runtime(sessions, &request, &inspector, &mut shutdown_runtime)
71            .await?;
72
73    let had_existing_runtime = session_runtime.is_some();
74    let mut session_runtime = match session_runtime {
75        Some(existing_runtime) => existing_runtime,
76        None => start_runtime(&request).await?,
77    };
78    let first_replays = needs_replay(had_existing_runtime, &request, &inspector, &session_runtime);
79    let first_provider_conversation_id = (inspector.provider_conversation_id)(&session_runtime);
80    let first_prompt = build_attempt_prompt(
81        &request,
82        first_replays,
83        first_provider_conversation_id.as_deref(),
84        schema_instruction_mode,
85        &mut shutdown_runtime,
86        &mut session_runtime,
87    )
88    .await?;
89    let first_attempt = run_cancellable_turn_attempt(
90        &active_turn,
91        &mut session_runtime,
92        &first_prompt,
93        &mut run_turn_with_runtime,
94        &mut shutdown_runtime,
95    )
96    .await;
97    if let Ok((assistant_message, input_tokens, output_tokens)) = first_attempt {
98        return store_successful_runtime_response(
99            sessions,
100            session_id,
101            session_runtime,
102            first_replays,
103            (assistant_message, input_tokens, output_tokens),
104            &inspector,
105            &mut shutdown_runtime,
106        )
107        .await;
108    }
109
110    let first_error = first_attempt
111        .err()
112        .unwrap_or_else(|| AppServerError::Provider("App-server turn failed".to_string()));
113    if matches!(first_error, AppServerError::InterruptedByUser(_)) {
114        return Err(first_error);
115    }
116
117    shutdown_runtime(&mut session_runtime).await;
118    let mut restarted = start_runtime(&request).await?;
119    let retry_replays = needs_replay(false, &request, &inspector, &restarted);
120    let retry_provider_conversation_id = (inspector.provider_conversation_id)(&restarted);
121    let retry_prompt = build_attempt_prompt(
122        &request,
123        retry_replays,
124        retry_provider_conversation_id.as_deref(),
125        schema_instruction_mode,
126        &mut shutdown_runtime,
127        &mut restarted,
128    )
129    .await?;
130    match run_cancellable_turn_attempt(
131        &active_turn,
132        &mut restarted,
133        &retry_prompt,
134        &mut run_turn_with_runtime,
135        &mut shutdown_runtime,
136    )
137    .await
138    {
139        Ok(attempt_output) => {
140            store_successful_runtime_response(
141                sessions,
142                session_id,
143                restarted,
144                retry_replays,
145                attempt_output,
146                &inspector,
147                &mut shutdown_runtime,
148            )
149            .await
150        }
151        Err(retry_error) => {
152            if matches!(retry_error, AppServerError::InterruptedByUser(_)) {
153                return Err(retry_error);
154            }
155
156            shutdown_runtime(&mut restarted).await;
157
158            Err(AppServerError::RetryExhausted {
159                provider: sessions.provider_name(),
160                first_error: first_error.to_string(),
161                retry_error: retry_error.to_string(),
162            })
163        }
164    }
165}
166
167/// Takes the idle runtime for a request and shuts it down when it no longer
168/// matches the requested model or provider context.
169async fn take_compatible_session_runtime<Runtime, ShutdownRuntime>(
170    sessions: &AppServerSessionRegistry<Runtime>,
171    request: &AppServerTurnRequest,
172    inspector: &RuntimeInspector<Runtime>,
173    shutdown_runtime: &mut ShutdownRuntime,
174) -> Result<Option<Runtime>, AppServerError>
175where
176    ShutdownRuntime: for<'scope> FnMut(&'scope mut Runtime) -> BorrowedAppServerFuture<'scope, ()>,
177{
178    let mut session_runtime = sessions.take_session(&request.session_id)?;
179
180    if session_runtime
181        .as_ref()
182        .is_some_and(|runtime| !(inspector.matches_request)(runtime, request))
183    {
184        if let Some(runtime) = session_runtime.as_mut() {
185            shutdown_runtime(runtime).await;
186        }
187
188        session_runtime = None;
189    }
190
191    Ok(session_runtime)
192}
193
194/// Stores a successful runtime back into the idle registry and builds the
195/// normalized app-server response.
196///
197/// If the registry cannot accept the runtime, this shuts the runtime down
198/// before returning the lock error so app-server child processes do not leak.
199async fn store_successful_runtime_response<Runtime, ShutdownRuntime>(
200    sessions: &AppServerSessionRegistry<Runtime>,
201    session_id: String,
202    session_runtime: Runtime,
203    context_reset: bool,
204    attempt_output: (String, u64, u64),
205    inspector: &RuntimeInspector<Runtime>,
206    shutdown_runtime: &mut ShutdownRuntime,
207) -> Result<AppServerTurnResponse, AppServerError>
208where
209    ShutdownRuntime: for<'scope> FnMut(&'scope mut Runtime) -> BorrowedAppServerFuture<'scope, ()>,
210{
211    let (assistant_message, input_tokens, output_tokens) = attempt_output;
212    let pid = (inspector.pid)(&session_runtime);
213    let provider_conversation_id = (inspector.provider_conversation_id)(&session_runtime);
214
215    if let Err((error, mut leaked)) = sessions.store_session_or_recover(session_id, session_runtime)
216    {
217        shutdown_runtime(&mut leaked).await;
218
219        return Err(error);
220    }
221
222    Ok(AppServerTurnResponse {
223        assistant_message,
224        context_reset,
225        input_tokens,
226        output_tokens,
227        pid,
228        provider_conversation_id,
229    })
230}
231
232/// Runs one provider runtime attempt while watching the session-scoped
233/// app-server cancellation token.
234///
235/// `run_turn_with_restart_retry()` temporarily owns the runtime while a turn is
236/// in flight, so provider `shutdown_session()` cannot remove it from the idle
237/// registry. This helper gives that shutdown path a token to fire; when it
238/// fires, the running turn future is dropped, the runtime is shut down through
239/// the provider lifecycle hook, and the attempt returns a user-interruption
240/// error instead of retrying.
241async fn run_cancellable_turn_attempt<Runtime, RunTurn, ShutdownRuntime>(
242    active_turn: &ActiveAppServerTurn,
243    runtime: &mut Runtime,
244    prompt: &TurnPrompt,
245    run_turn_with_runtime: &mut RunTurn,
246    shutdown_runtime: &mut ShutdownRuntime,
247) -> Result<(String, u64, u64), AppServerError>
248where
249    RunTurn: for<'scope> FnMut(
250        &'scope mut Runtime,
251        &'scope TurnPrompt,
252    ) -> BorrowedAppServerFuture<
253        'scope,
254        Result<(String, u64, u64), AppServerError>,
255    >,
256    ShutdownRuntime: for<'scope> FnMut(&'scope mut Runtime) -> BorrowedAppServerFuture<'scope, ()>,
257{
258    let cancellation_token = active_turn.token();
259    if cancellation_token.is_cancelled() {
260        shutdown_runtime(runtime).await;
261
262        return Err(interrupted_by_user_error());
263    }
264
265    let turn_outcome = {
266        let turn_future = run_turn_with_runtime(runtime, prompt);
267        tokio::pin!(turn_future);
268        tokio::select! {
269            result = &mut turn_future => TurnAttemptOutcome::Completed(result),
270            () = cancellation_token.cancelled() => TurnAttemptOutcome::Interrupted,
271        }
272    };
273
274    match turn_outcome {
275        TurnAttemptOutcome::Completed(result) => result,
276        TurnAttemptOutcome::Interrupted => {
277            shutdown_runtime(runtime).await;
278
279            Err(interrupted_by_user_error())
280        }
281    }
282}
283
284/// Result of racing one app-server turn against cancellation.
285enum TurnAttemptOutcome {
286    /// The provider turn completed before cancellation fired.
287    Completed(Result<(String, u64, u64), AppServerError>),
288    /// The session cancellation token fired first.
289    Interrupted,
290}
291
292/// Builds the app-server interruption error shared by initial and retry turns.
293fn interrupted_by_user_error() -> AppServerError {
294    AppServerError::InterruptedByUser("[Stopped] Session interrupted by user.".to_string())
295}
296
297/// Returns `true` when the attempt should replay prior session output as
298/// context for the runtime.
299fn needs_replay<Runtime>(
300    had_existing_runtime: bool,
301    request: &AppServerTurnRequest,
302    inspector: &RuntimeInspector<Runtime>,
303    runtime: &Runtime,
304) -> bool {
305    !had_existing_runtime
306        && read_latest_session_output(request)
307            .as_deref()
308            .is_some_and(|session_output| !session_output.trim().is_empty())
309        && !(inspector.restored_context)(runtime)
310}
311
312/// Prepares the prompt for one turn attempt, shutting down the runtime on
313/// failure.
314async fn build_attempt_prompt<Runtime, ShutdownRuntime>(
315    request: &AppServerTurnRequest,
316    replays_context: bool,
317    current_provider_conversation_id: Option<&str>,
318    schema_instruction_mode: ProtocolSchemaInstructionMode,
319    shutdown_runtime: &mut ShutdownRuntime,
320    runtime: &mut Runtime,
321) -> Result<TurnPrompt, AppServerError>
322where
323    ShutdownRuntime: for<'scope> FnMut(&'scope mut Runtime) -> BorrowedAppServerFuture<'scope, ()>,
324{
325    let session_output = read_latest_session_output(request);
326    let instruction_delivery_mode = instruction_delivery_mode_for_runtime(
327        request,
328        current_provider_conversation_id,
329        replays_context,
330    );
331
332    match turn_prompt_for_runtime(
333        &request.prompt,
334        &request.request_kind,
335        session_output.as_deref(),
336        instruction_delivery_mode,
337        schema_instruction_mode,
338    ) {
339        Ok(prompt) => Ok(prompt),
340        Err(error) => {
341            shutdown_runtime(runtime).await;
342
343            Err(error)
344        }
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use std::path::PathBuf;
351    use std::sync::atomic::{AtomicUsize, Ordering};
352    use std::sync::{Arc, Mutex};
353
354    use super::*;
355    use crate::agent::{InstructionDeliveryMode, ProtocolSchemaInstructionMode};
356    use crate::channel::AgentRequestKind;
357    use crate::model::agent::ReasoningLevel;
358    use crate::model::turn_prompt::TurnPrompt;
359
360    struct TestRuntime {
361        model: String,
362    }
363
364    fn session_start_request_kind() -> AgentRequestKind {
365        AgentRequestKind::SessionStart
366    }
367
368    fn session_resume_request_kind(session_output: Option<&str>) -> AgentRequestKind {
369        AgentRequestKind::SessionResume {
370            session_output: session_output.map(ToString::to_string),
371        }
372    }
373
374    #[test]
375    fn take_session_returns_stored_runtime() {
376        // Arrange
377        let sessions = AppServerSessionRegistry::new("Test");
378        sessions
379            .store_session(
380                "session-1".to_string(),
381                TestRuntime {
382                    model: "model-a".to_string(),
383                },
384            )
385            .expect("store should succeed");
386
387        // Act
388        let session = sessions
389            .take_session("session-1")
390            .expect("take should succeed");
391
392        // Assert
393        assert_eq!(
394            session.map(|runtime| runtime.model),
395            Some("model-a".to_string())
396        );
397    }
398
399    #[test]
400    fn turn_prompt_for_runtime_adds_repo_root_path_instructions_without_context_reset() {
401        // Arrange
402        let prompt = "Implement feature";
403
404        // Act
405        let turn_prompt = turn_prompt_for_runtime(
406            prompt,
407            &session_start_request_kind(),
408            Some("prior context"),
409            InstructionDeliveryMode::BootstrapFull,
410            ProtocolSchemaInstructionMode::PromptSchema,
411        )
412        .expect("turn prompt should render");
413
414        // Assert
415        assert!(turn_prompt.contains("repository-root-relative POSIX paths"));
416        assert!(turn_prompt.contains("summary"));
417        assert!(turn_prompt.ends_with(prompt));
418    }
419
420    #[test]
421    fn turn_prompt_for_runtime_replays_session_output_after_context_reset_with_path_instructions() {
422        // Arrange
423        let prompt = "Implement feature";
424
425        // Act
426        let turn_prompt = turn_prompt_for_runtime(
427            prompt,
428            &session_resume_request_kind(Some("assistant: proposed plan")),
429            Some("assistant: proposed plan"),
430            InstructionDeliveryMode::BootstrapWithReplay,
431            ProtocolSchemaInstructionMode::PromptSchema,
432        )
433        .expect("turn prompt should render");
434
435        // Assert
436        assert!(turn_prompt.contains("repository-root-relative POSIX paths"));
437        assert!(turn_prompt.contains("Continue this session using the full transcript below."));
438        assert!(turn_prompt.contains("assistant: proposed plan"));
439        assert!(turn_prompt.contains(prompt));
440    }
441
442    #[test]
443    fn turn_prompt_for_runtime_uses_shared_protocol_wrapper_for_utility_prompts() {
444        // Arrange
445        let prompt = "Generate title";
446
447        // Act
448        let turn_prompt = turn_prompt_for_runtime(
449            prompt,
450            &AgentRequestKind::UtilityPrompt,
451            None,
452            InstructionDeliveryMode::BootstrapFull,
453            ProtocolSchemaInstructionMode::PromptSchema,
454        )
455        .expect("turn prompt should render");
456
457        // Assert
458        assert!(turn_prompt.contains("summary"));
459        assert!(turn_prompt.ends_with(prompt));
460    }
461
462    #[test]
463    fn read_latest_session_output_prefers_live_buffer_over_snapshot() {
464        // Arrange
465        let live_output = Arc::new(Mutex::new("live content from stream".to_string()));
466        let request = AppServerTurnRequest {
467            folder: PathBuf::from("/tmp"),
468            live_session_output: Some(live_output),
469            model: "model-a".to_string(),
470            prompt: "Do work".into(),
471            request_kind: session_resume_request_kind(Some("stale snapshot")),
472            provider_conversation_id: None,
473            persisted_instruction_conversation_id: None,
474            reasoning_level: ReasoningLevel::default(),
475            session_id: "session-1".to_string(),
476        };
477
478        // Act
479        let output = read_latest_session_output(&request);
480
481        // Assert
482        assert_eq!(output, Some("live content from stream".to_string()));
483    }
484
485    #[test]
486    fn read_latest_session_output_falls_back_to_snapshot_when_live_buffer_is_empty() {
487        // Arrange
488        let live_output = Arc::new(Mutex::new(String::new()));
489        let request = AppServerTurnRequest {
490            folder: PathBuf::from("/tmp"),
491            live_session_output: Some(live_output),
492            model: "model-a".to_string(),
493            prompt: "Do work".into(),
494            request_kind: session_resume_request_kind(Some("stale snapshot")),
495            provider_conversation_id: None,
496            persisted_instruction_conversation_id: None,
497            reasoning_level: ReasoningLevel::default(),
498            session_id: "session-1".to_string(),
499        };
500
501        // Act
502        let output = read_latest_session_output(&request);
503
504        // Assert
505        assert_eq!(output, Some("stale snapshot".to_string()));
506    }
507
508    #[test]
509    fn read_latest_session_output_falls_back_to_snapshot_when_no_live_buffer() {
510        // Arrange
511        let request = AppServerTurnRequest {
512            folder: PathBuf::from("/tmp"),
513            live_session_output: None,
514            model: "model-a".to_string(),
515            prompt: "Do work".into(),
516            request_kind: session_resume_request_kind(Some("stale snapshot")),
517            provider_conversation_id: None,
518            persisted_instruction_conversation_id: None,
519            reasoning_level: ReasoningLevel::default(),
520            session_id: "session-1".to_string(),
521        };
522
523        // Act
524        let output = read_latest_session_output(&request);
525
526        // Assert
527        assert_eq!(output, Some("stale snapshot".to_string()));
528    }
529
530    #[test]
531    fn read_latest_session_output_returns_none_when_both_are_absent() {
532        // Arrange
533        let request = AppServerTurnRequest {
534            folder: PathBuf::from("/tmp"),
535            live_session_output: None,
536            model: "model-a".to_string(),
537            prompt: "Do work".into(),
538            request_kind: session_start_request_kind(),
539            provider_conversation_id: None,
540            persisted_instruction_conversation_id: None,
541            reasoning_level: ReasoningLevel::default(),
542            session_id: "session-1".to_string(),
543        };
544
545        // Act
546        let output = read_latest_session_output(&request);
547
548        // Assert
549        assert_eq!(output, None);
550    }
551
552    #[tokio::test]
553    async fn run_turn_with_restart_retry_uses_live_output_on_retry() {
554        // Arrange
555        let sessions = AppServerSessionRegistry::new("Test");
556        let live_output = Arc::new(Mutex::new("streamed before crash".to_string()));
557        let request = AppServerTurnRequest {
558            folder: PathBuf::from("/tmp"),
559            live_session_output: Some(live_output),
560            model: "model-a".to_string(),
561            prompt: "Do work".into(),
562            request_kind: session_resume_request_kind(Some("stale snapshot")),
563            provider_conversation_id: None,
564            persisted_instruction_conversation_id: None,
565            reasoning_level: ReasoningLevel::default(),
566            session_id: "session-1".to_string(),
567        };
568        let captured_retry_prompt = Arc::new(Mutex::new(String::new()));
569
570        // Act
571        let response = run_turn_with_restart_retry(
572            &sessions,
573            request,
574            RuntimeInspector {
575                matches_request: |runtime: &TestRuntime, request| runtime.model == request.model,
576                pid: |_runtime| Some(42),
577                provider_conversation_id: |_runtime| None,
578                restored_context: |_runtime| false,
579            },
580            ProtocolSchemaInstructionMode::PromptSchema,
581            |request: &AppServerTurnRequest| {
582                let model = request.model.clone();
583
584                Box::pin(async move { Ok(TestRuntime { model }) })
585            },
586            {
587                let run_count = Arc::new(AtomicUsize::new(0));
588                let captured_retry_prompt = Arc::clone(&captured_retry_prompt);
589                move |_runtime: &mut TestRuntime, prompt: &TurnPrompt| {
590                    let attempt = run_count.fetch_add(1, Ordering::SeqCst);
591                    let prompt = prompt.to_string();
592                    let captured_retry_prompt = Arc::clone(&captured_retry_prompt);
593
594                    Box::pin(async move {
595                        if attempt == 0 {
596                            return Err(AppServerError::Provider("first failure".to_string()));
597                        }
598
599                        if let Ok(mut guard) = captured_retry_prompt.lock() {
600                            *guard = prompt;
601                        }
602
603                        Ok(("done".to_string(), 7, 3))
604                    })
605                }
606            },
607            |_runtime: &mut TestRuntime| Box::pin(async {}),
608        )
609        .await
610        .expect("retry should succeed");
611
612        // Assert
613        assert!(response.context_reset);
614        assert_eq!(response.provider_conversation_id, None);
615        let retry_prompt = captured_retry_prompt
616            .lock()
617            .map(|guard| guard.clone())
618            .unwrap_or_default();
619        assert!(
620            retry_prompt.contains("streamed before crash"),
621            "retry prompt should contain live output, not stale snapshot"
622        );
623        assert!(
624            !retry_prompt.contains("stale snapshot"),
625            "retry prompt should use live output instead of stale snapshot"
626        );
627    }
628
629    #[tokio::test]
630    async fn run_turn_with_restart_retry_restarts_once_after_first_failure() {
631        // Arrange
632        let sessions = AppServerSessionRegistry::new("Test");
633        let request = AppServerTurnRequest {
634            folder: PathBuf::from("/tmp"),
635            live_session_output: None,
636            model: "model-a".to_string(),
637            prompt: "Do work".into(),
638            request_kind: session_resume_request_kind(Some("previous output")),
639            provider_conversation_id: None,
640            persisted_instruction_conversation_id: None,
641            reasoning_level: ReasoningLevel::default(),
642            session_id: "session-1".to_string(),
643        };
644        let start_count = Arc::new(AtomicUsize::new(0));
645        let run_count = Arc::new(AtomicUsize::new(0));
646        let shutdown_count = Arc::new(AtomicUsize::new(0));
647
648        // Act
649        let response = run_turn_with_restart_retry(
650            &sessions,
651            request,
652            RuntimeInspector {
653                matches_request: |runtime: &TestRuntime, request| runtime.model == request.model,
654                pid: |_runtime| Some(42),
655                provider_conversation_id: |_runtime| None,
656                restored_context: |_runtime| false,
657            },
658            ProtocolSchemaInstructionMode::PromptSchema,
659            {
660                let start_count = Arc::clone(&start_count);
661                move |request: &AppServerTurnRequest| {
662                    let start_count = Arc::clone(&start_count);
663                    let model = request.model.clone();
664
665                    Box::pin(async move {
666                        start_count.fetch_add(1, Ordering::SeqCst);
667
668                        Ok(TestRuntime { model })
669                    })
670                }
671            },
672            {
673                let run_count = Arc::clone(&run_count);
674                move |_runtime, _prompt| {
675                    let attempt = run_count.fetch_add(1, Ordering::SeqCst);
676
677                    Box::pin(async move {
678                        if attempt == 0 {
679                            return Err(AppServerError::Provider("first failure".to_string()));
680                        }
681
682                        Ok(("done".to_string(), 7, 3))
683                    })
684                }
685            },
686            {
687                let shutdown_count = Arc::clone(&shutdown_count);
688                move |_runtime| {
689                    let shutdown_count = Arc::clone(&shutdown_count);
690
691                    Box::pin(async move {
692                        shutdown_count.fetch_add(1, Ordering::SeqCst);
693                    })
694                }
695            },
696        )
697        .await
698        .expect("retry should succeed");
699
700        // Assert
701        assert_eq!(response.assistant_message, "done");
702        assert!(response.context_reset);
703        assert_eq!((response.input_tokens, response.output_tokens), (7, 3));
704        assert_eq!(response.pid, Some(42));
705        assert_eq!(response.provider_conversation_id, None);
706        assert_eq!(start_count.load(Ordering::SeqCst), 2);
707        assert_eq!(run_count.load(Ordering::SeqCst), 2);
708        assert_eq!(shutdown_count.load(Ordering::SeqCst), 1);
709    }
710
711    #[tokio::test]
712    async fn run_turn_with_restart_retry_shutdown_signal_interrupts_in_flight_runtime() {
713        // Arrange
714        let sessions = AppServerSessionRegistry::new("Test");
715        let request = AppServerTurnRequest {
716            folder: PathBuf::from("/tmp"),
717            live_session_output: None,
718            model: "model-a".to_string(),
719            prompt: "Do work".into(),
720            request_kind: session_resume_request_kind(Some("previous output")),
721            provider_conversation_id: None,
722            persisted_instruction_conversation_id: None,
723            reasoning_level: ReasoningLevel::default(),
724            session_id: "session-1".to_string(),
725        };
726        let run_count = Arc::new(AtomicUsize::new(0));
727        let shutdown_count = Arc::new(AtomicUsize::new(0));
728
729        // Act
730        let result = run_turn_with_restart_retry(
731            &sessions,
732            request,
733            RuntimeInspector {
734                matches_request: |runtime: &TestRuntime, request| runtime.model == request.model,
735                pid: |_runtime| Some(42),
736                provider_conversation_id: |_runtime| None,
737                restored_context: |_runtime| false,
738            },
739            ProtocolSchemaInstructionMode::PromptSchema,
740            |request: &AppServerTurnRequest| {
741                let model = request.model.clone();
742
743                Box::pin(async move { Ok(TestRuntime { model }) })
744            },
745            {
746                let run_count = Arc::clone(&run_count);
747                let sessions = sessions.clone();
748                move |_runtime, _prompt| {
749                    let run_count = Arc::clone(&run_count);
750                    let sessions = sessions.clone();
751
752                    Box::pin(async move {
753                        run_count.fetch_add(1, Ordering::SeqCst);
754                        sessions
755                            .cancel_active_turn("session-1")
756                            .expect("cancel should signal active turn");
757                        std::future::pending::<Result<(String, u64, u64), AppServerError>>().await
758                    })
759                }
760            },
761            {
762                let shutdown_count = Arc::clone(&shutdown_count);
763                move |_runtime| {
764                    let shutdown_count = Arc::clone(&shutdown_count);
765
766                    Box::pin(async move {
767                        shutdown_count.fetch_add(1, Ordering::SeqCst);
768                    })
769                }
770            },
771        )
772        .await;
773
774        // Assert
775        assert!(matches!(result, Err(AppServerError::InterruptedByUser(_))));
776        assert_eq!(run_count.load(Ordering::SeqCst), 1);
777        assert_eq!(shutdown_count.load(Ordering::SeqCst), 1);
778    }
779
780    /// Verifies restored-context retries keep the user prompt while avoiding
781    /// transcript replay.
782    #[tokio::test]
783    async fn run_turn_with_restart_retry_skips_replay_when_runtime_restores_context() {
784        // Arrange
785        let sessions = AppServerSessionRegistry::new("Test");
786        let request = AppServerTurnRequest {
787            folder: PathBuf::from("/tmp"),
788            live_session_output: None,
789            model: "model-a".to_string(),
790            prompt: "Do work".into(),
791            request_kind: session_resume_request_kind(Some("previous output")),
792            provider_conversation_id: Some("thread-123".to_string()),
793            persisted_instruction_conversation_id: None,
794            reasoning_level: ReasoningLevel::default(),
795            session_id: "session-1".to_string(),
796        };
797        let captured_prompt = Arc::new(Mutex::new(String::new()));
798
799        // Act
800        let response = run_turn_with_restart_retry(
801            &sessions,
802            request,
803            RuntimeInspector {
804                matches_request: |runtime: &TestRuntime, request| runtime.model == request.model,
805                pid: |_runtime| Some(24),
806                provider_conversation_id: |_runtime| Some("thread-123".to_string()),
807                restored_context: |_runtime| true,
808            },
809            ProtocolSchemaInstructionMode::PromptSchema,
810            |request: &AppServerTurnRequest| {
811                let model = request.model.clone();
812
813                Box::pin(async move { Ok(TestRuntime { model }) })
814            },
815            {
816                let captured_prompt = Arc::clone(&captured_prompt);
817                move |_runtime: &mut TestRuntime, prompt: &TurnPrompt| {
818                    let prompt = prompt.to_string();
819                    let captured_prompt = Arc::clone(&captured_prompt);
820
821                    Box::pin(async move {
822                        if let Ok(mut guard) = captured_prompt.lock() {
823                            *guard = prompt;
824                        }
825
826                        Ok(("done".to_string(), 1, 1))
827                    })
828                }
829            },
830            |_runtime: &mut TestRuntime| Box::pin(async {}),
831        )
832        .await
833        .expect("turn should succeed");
834
835        // Assert
836        assert_eq!(response.assistant_message, "done");
837        assert!(!response.context_reset);
838        assert_eq!(
839            response.provider_conversation_id,
840            Some("thread-123".to_string())
841        );
842        assert_eq!(response.pid, Some(24));
843        let captured_prompt = captured_prompt
844            .lock()
845            .map(|guard| guard.clone())
846            .unwrap_or_default();
847        assert!(captured_prompt.contains("repository-root-relative POSIX paths"));
848        assert!(captured_prompt.ends_with("Do work"));
849        assert!(!captured_prompt.contains("previous output"));
850    }
851}