Skip to main content

ag_agent/app_server/
retry.rs

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