Skip to main content

ao_core/
restore.rs

1//! Restore a previously-terminated session back into a live runtime.
2//!
3//! Mirrors `restore()` in `packages/core/src/session-manager.ts` (`restore`
4//! starting at line 2254), stripped to what Slice 1 needs:
5//!
6//! 1. Find the session on disk by full uuid or a short-id prefix.
7//! 2. **Enrich with live runtime state** — if the stored status still says
8//!    e.g. `working` but the tmux session is actually gone, flip it to
9//!    `terminated` in-memory so the `is_restorable` check passes. This
10//!    matches `enrichSessionWithRuntimeState(session, plugins, true)` in TS.
11//! 3. Refuse restore if the session is non-restorable (e.g. `merged`).
12//! 4. Verify the workspace is still usable via `Workspace::exists()`. The
13//!    plugin's own check catches corrupted/partially-removed workspaces
14//!    (e.g. directory present but `.git` gone) that a plain
15//!    `Path::exists()` would miss. Slice 1 does not auto-recreate
16//!    workspaces — if it's gone, the user has to `ao-rs spawn` a fresh
17//!    session. (TS has an optional `workspace.restore` plugin hook for
18//!    this; Phase D keeps the trait surface small.)
19//! 5. Best-effort `runtime.destroy` on the old handle in case it's still
20//!    lingering (tmux can survive the agent process exiting — see the
21//!    `// step 6` comment in the TS reference).
22//! 6. `runtime.create` with the agent's launch command/env, reusing the
23//!    previous handle string as the tmux name so users can re-attach by
24//!    the same identifier.
25//! 7. Persist: `status = spawning`, `activity = None`, new `runtime_handle`.
26//!
27//! 8. Re-deliver the session's initial prompt (spawn parity): after the runtime
28//!    is recreated, send the same initial prompt the agent would receive at
29//!    spawn-time so it can resume context without manual `ao-rs send`.
30
31use crate::{
32    error::{AoError, Result},
33    session_manager::SessionManager,
34    traits::{Agent, Runtime, Workspace},
35    types::{Session, SessionStatus},
36};
37
38/// Outcome of a successful restore, returned so the caller can pretty-print.
39#[derive(Debug, Clone)]
40pub struct RestoreOutcome {
41    pub session: Session,
42    /// Launch command actually handed to the runtime. Useful for CLI output.
43    pub launch_command: String,
44    /// New runtime handle (usually the same tmux name as before).
45    pub runtime_handle: String,
46    /// Whether we successfully re-delivered the initial prompt to the agent.
47    ///
48    /// Restore still succeeds if this fails (best-effort), but callers may
49    /// want to surface a warning suggesting a manual resend.
50    pub prompt_sent: bool,
51}
52
53/// Restore a session by full uuid or any unambiguous prefix.
54///
55/// Takes the plugin deps as `&dyn` references so the same code runs under
56/// tests (with mocks) and in the real CLI (with tmux + claude-code). No
57/// generic parameters — keeps callers clean.
58pub async fn restore_session(
59    id_or_prefix: &str,
60    sessions: &SessionManager,
61    runtime: &dyn Runtime,
62    agent: &dyn Agent,
63    workspace: &dyn Workspace,
64) -> Result<RestoreOutcome> {
65    // ---- 1. Locate the session on disk ----
66    let mut session = sessions.find_by_prefix(id_or_prefix).await?;
67
68    // ---- 2. Enrich status with live runtime liveness ----
69    //
70    // A session that crashed mid-`working` never gets a chance to transition
71    // through the lifecycle loop — so without this probe, `is_restorable`
72    // would see `working` (non-terminal) and refuse.
73    if let Some(handle) = session.runtime_handle.as_deref() {
74        let alive = runtime.is_alive(handle).await.unwrap_or(false);
75        if !alive && !session.status.is_terminal() {
76            session.status = SessionStatus::Terminated;
77        }
78    } else if !session.status.is_terminal() {
79        // No handle at all and still says running → definitely dead.
80        session.status = SessionStatus::Terminated;
81    }
82
83    // ---- 3. Restorability gate ----
84    if !session.is_restorable() {
85        return Err(AoError::Runtime(format!(
86            "session {} is not restorable (status={})",
87            session.id,
88            session.status.as_str()
89        )));
90    }
91
92    // ---- 4. Workspace must still be usable ----
93    //
94    // Delegate the check to the plugin so it can apply backend-specific
95    // validation (e.g. git-backed workspaces verify the working tree is
96    // still recognised by git, not just present on disk).
97    let workspace_path = session
98        .workspace_path
99        .clone()
100        .ok_or_else(|| AoError::Workspace("session has no workspace_path".into()))?;
101    if !workspace.exists(&workspace_path).await? {
102        return Err(AoError::Workspace(format!(
103            "workspace missing: {}",
104            workspace_path.display()
105        )));
106    }
107
108    // ---- 5. Best-effort cleanup of the stale runtime ----
109    if let Some(handle) = session.runtime_handle.as_deref() {
110        // We don't care if this errors — the whole point is that the old
111        // runtime is expected to be gone already.
112        let _ = runtime.destroy(handle).await;
113    }
114
115    // ---- 6. Re-spawn via the runtime ----
116    //
117    // Reuse the previous handle as the new tmux session name so users who
118    // muscle-memory'd `tmux attach -t <short-id>` still land on the right
119    // pane. Falls back to the first 8 chars of the uuid (same rule as
120    // `spawn`) if there was no prior handle for some reason.
121    let new_name = session
122        .runtime_handle
123        .clone()
124        .unwrap_or_else(|| session.id.0.chars().take(8).collect());
125
126    let launch_command = agent.launch_command(&session);
127    let env = agent.environment(&session);
128
129    let new_handle = runtime
130        .create(&new_name, &workspace_path, &launch_command, &env)
131        .await?;
132
133    // ---- 7. Persist ----
134    session.runtime_handle = Some(new_handle.clone());
135    session.status = SessionStatus::Spawning;
136    session.activity = None;
137    sessions.save(&session).await?;
138
139    // ---- 8. Re-deliver the initial prompt (best-effort) ----
140    let prompt = agent.initial_prompt(&session);
141    let prompt_sent = if prompt.trim().is_empty() {
142        false
143    } else {
144        runtime.send_message(&new_handle, &prompt).await.is_ok()
145    };
146
147    Ok(RestoreOutcome {
148        session,
149        launch_command,
150        runtime_handle: new_handle,
151        prompt_sent,
152    })
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::types::{now_ms, ActivityState, SessionId, WorkspaceCreateConfig};
159    use async_trait::async_trait;
160    use std::path::{Path, PathBuf};
161    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
162    use std::sync::Mutex;
163    use std::time::{SystemTime, UNIX_EPOCH};
164
165    fn unique_temp_dir(label: &str) -> PathBuf {
166        static COUNTER: AtomicUsize = AtomicUsize::new(0);
167        let nanos = SystemTime::now()
168            .duration_since(UNIX_EPOCH)
169            .unwrap()
170            .as_nanos();
171        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
172        std::env::temp_dir().join(format!("ao-rs-restore-{label}-{nanos}-{n}"))
173    }
174
175    /// Records every runtime call so tests can assert on ordering.
176    #[derive(Default)]
177    struct RecorderRuntime {
178        alive: AtomicBool,
179        calls: Mutex<Vec<String>>,
180        messages: Mutex<Vec<String>>,
181    }
182
183    impl RecorderRuntime {
184        fn new(alive: bool) -> Self {
185            Self {
186                alive: AtomicBool::new(alive),
187                calls: Mutex::new(Vec::new()),
188                messages: Mutex::new(Vec::new()),
189            }
190        }
191        fn calls(&self) -> Vec<String> {
192            self.calls.lock().unwrap().clone()
193        }
194        fn messages(&self) -> Vec<String> {
195            self.messages.lock().unwrap().clone()
196        }
197    }
198
199    #[async_trait]
200    impl Runtime for RecorderRuntime {
201        async fn create(
202            &self,
203            session_id: &str,
204            _cwd: &Path,
205            _launch_command: &str,
206            _env: &[(String, String)],
207        ) -> Result<String> {
208            self.calls
209                .lock()
210                .unwrap()
211                .push(format!("create:{session_id}"));
212            // Echo the name back like the real tmux runtime does.
213            Ok(session_id.to_string())
214        }
215        async fn send_message(&self, handle: &str, _msg: &str) -> Result<()> {
216            self.calls.lock().unwrap().push(format!("send:{handle}"));
217            self.messages.lock().unwrap().push(_msg.to_string());
218            Ok(())
219        }
220        async fn is_alive(&self, _handle: &str) -> Result<bool> {
221            Ok(self.alive.load(Ordering::SeqCst))
222        }
223        async fn destroy(&self, handle: &str) -> Result<()> {
224            self.calls.lock().unwrap().push(format!("destroy:{handle}"));
225            Ok(())
226        }
227    }
228
229    struct StubAgent;
230    #[async_trait]
231    impl Agent for StubAgent {
232        fn launch_command(&self, _s: &Session) -> String {
233            "mock-launch".into()
234        }
235        fn environment(&self, _s: &Session) -> Vec<(String, String)> {
236            vec![]
237        }
238        fn initial_prompt(&self, _s: &Session) -> String {
239            "hello from restore".into()
240        }
241        async fn detect_activity(&self, _s: &Session) -> Result<ActivityState> {
242            Ok(ActivityState::Ready)
243        }
244    }
245
246    /// Thin workspace stub that relies on the trait's default `exists()`
247    /// (i.e. a plain `Path::exists` probe). Restore tests use it to drive
248    /// the "workspace is on disk → proceed" branch.
249    struct StubWorkspace;
250    #[async_trait]
251    impl Workspace for StubWorkspace {
252        async fn create(&self, _cfg: &WorkspaceCreateConfig) -> Result<PathBuf> {
253            Ok(PathBuf::from("/tmp/ws"))
254        }
255        async fn destroy(&self, _workspace_path: &Path) -> Result<()> {
256            Ok(())
257        }
258    }
259
260    /// Workspace stub whose `exists()` returns a configurable value without
261    /// touching the filesystem. Lets the "restore fails cleanly when the
262    /// workspace plugin reports corrupted" test run even if the directory
263    /// itself is present on disk.
264    struct ExistsWorkspace {
265        reports_exists: bool,
266    }
267    #[async_trait]
268    impl Workspace for ExistsWorkspace {
269        async fn create(&self, _cfg: &WorkspaceCreateConfig) -> Result<PathBuf> {
270            Ok(PathBuf::from("/tmp/ws"))
271        }
272        async fn destroy(&self, _workspace_path: &Path) -> Result<()> {
273            Ok(())
274        }
275        async fn exists(&self, _workspace_path: &Path) -> Result<bool> {
276            Ok(self.reports_exists)
277        }
278    }
279
280    /// Build a persisted session whose workspace is a real directory we
281    /// can `cd` into — restore() insists the worktree still exists.
282    async fn persist_session(
283        manager: &SessionManager,
284        id: &str,
285        status: SessionStatus,
286        workspace: &Path,
287    ) -> Session {
288        let session = Session {
289            id: SessionId(id.into()),
290            project_id: "demo".into(),
291            status,
292            agent: "claude-code".into(),
293            agent_config: None,
294            branch: format!("ao-{id}"),
295            task: "restored task".into(),
296            workspace_path: Some(workspace.to_path_buf()),
297            runtime_handle: Some("old-handle".into()),
298            runtime: "tmux".into(),
299            activity: None,
300            created_at: now_ms(),
301            cost: None,
302            issue_id: None,
303            issue_url: None,
304            claimed_pr_number: None,
305            claimed_pr_url: None,
306            initial_prompt_override: None,
307            spawned_by: None,
308            last_merge_conflict_dispatched: None,
309            last_review_backlog_fingerprint: None,
310        };
311        manager.save(&session).await.unwrap();
312        session
313    }
314
315    #[tokio::test]
316    async fn restore_terminal_session_respawns_runtime_and_persists_spawning() {
317        let base = unique_temp_dir("ok");
318        let ws = base.join("ws");
319        std::fs::create_dir_all(&ws).unwrap();
320
321        let manager = SessionManager::new(base.clone());
322        persist_session(&manager, "sess-ok", SessionStatus::Terminated, &ws).await;
323
324        let rt = RecorderRuntime::new(false);
325        let agent = StubAgent;
326
327        let out = restore_session("sess-ok", &manager, &rt, &agent, &StubWorkspace)
328            .await
329            .unwrap();
330
331        // Destroy (best-effort cleanup) must precede create in the call log.
332        let calls = rt.calls();
333        let destroy_idx = calls.iter().position(|c| c == "destroy:old-handle");
334        let create_idx = calls.iter().position(|c| c == "create:old-handle");
335        let send_idx = calls.iter().position(|c| c == "send:old-handle");
336        assert!(destroy_idx.is_some(), "destroy not called: {calls:?}");
337        assert!(create_idx.is_some(), "create not called: {calls:?}");
338        assert!(destroy_idx < create_idx, "destroy must come before create");
339        assert!(send_idx.is_some(), "send not called: {calls:?}");
340        assert!(create_idx < send_idx, "create must come before send");
341
342        assert_eq!(out.session.status, SessionStatus::Spawning);
343        assert_eq!(out.session.activity, None);
344        assert_eq!(out.runtime_handle, "old-handle");
345        assert_eq!(out.launch_command, "mock-launch");
346        assert!(out.prompt_sent, "expected prompt_sent=true");
347
348        let msgs = rt.messages();
349        assert_eq!(msgs.len(), 1, "expected exactly one message: {msgs:?}");
350        assert!(
351            !msgs[0].trim().is_empty(),
352            "expected non-empty prompt, got: {:?}",
353            msgs[0]
354        );
355
356        // And the persisted state matches.
357        let reread = manager.list().await.unwrap();
358        assert_eq!(reread.len(), 1);
359        assert_eq!(reread[0].status, SessionStatus::Spawning);
360
361        let _ = std::fs::remove_dir_all(&base);
362    }
363
364    #[tokio::test]
365    async fn restore_missing_runtime_handle_creates_new_handle_without_destroy() {
366        let base = unique_temp_dir("no-handle");
367        let ws = base.join("ws");
368        std::fs::create_dir_all(&ws).unwrap();
369
370        let manager = SessionManager::new(base.clone());
371        // Persist a terminal session that somehow lost its runtime_handle.
372        let mut s =
373            persist_session(&manager, "sess-nohandle", SessionStatus::Terminated, &ws).await;
374        s.runtime_handle = None;
375        manager.save(&s).await.unwrap();
376
377        let rt = RecorderRuntime::new(false);
378        let out = restore_session("sess-nohandle", &manager, &rt, &StubAgent, &StubWorkspace)
379            .await
380            .unwrap();
381
382        // No prior handle → no destroy call.
383        let calls = rt.calls();
384        assert!(
385            !calls.iter().any(|c| c.starts_with("destroy:")),
386            "unexpected destroy call(s): {calls:?}"
387        );
388        // The name used should fall back to first 8 chars of the uuid.
389        assert!(
390            calls.iter().any(|c| c == "create:sess-noh"),
391            "expected create with short id (sess-noh), got calls: {calls:?}"
392        );
393        assert_eq!(out.runtime_handle, "sess-noh");
394        assert_eq!(out.session.status, SessionStatus::Spawning);
395        assert!(out.prompt_sent, "expected prompt_sent=true");
396
397        let reread = manager.find_by_prefix("sess-nohandle").await.unwrap();
398        assert_eq!(reread.runtime_handle.as_deref(), Some("sess-noh"));
399
400        let _ = std::fs::remove_dir_all(&base);
401    }
402
403    #[tokio::test]
404    async fn crashed_working_session_is_enriched_to_terminated_then_restored() {
405        // Session on disk says `working` but the runtime probe reports dead
406        // — exactly the TS `enrichSessionWithRuntimeState` case.
407        let base = unique_temp_dir("enrich");
408        let ws = base.join("ws");
409        std::fs::create_dir_all(&ws).unwrap();
410
411        let manager = SessionManager::new(base.clone());
412        persist_session(&manager, "sess-crash", SessionStatus::Working, &ws).await;
413
414        let rt = RecorderRuntime::new(false); // dead
415        let out = restore_session("sess-crash", &manager, &rt, &StubAgent, &StubWorkspace)
416            .await
417            .unwrap();
418
419        assert_eq!(out.session.status, SessionStatus::Spawning);
420        assert!(out.prompt_sent, "expected prompt_sent=true");
421
422        let _ = std::fs::remove_dir_all(&base);
423    }
424
425    #[tokio::test]
426    async fn merged_session_is_not_restorable() {
427        let base = unique_temp_dir("merged");
428        let ws = base.join("ws");
429        std::fs::create_dir_all(&ws).unwrap();
430
431        let manager = SessionManager::new(base.clone());
432        persist_session(&manager, "sess-merged", SessionStatus::Merged, &ws).await;
433
434        let rt = RecorderRuntime::new(false);
435        let err = restore_session("sess-merged", &manager, &rt, &StubAgent, &StubWorkspace)
436            .await
437            .unwrap_err();
438        assert!(
439            format!("{err}").contains("not restorable"),
440            "unexpected: {err}"
441        );
442
443        // Persisted state must be untouched.
444        let reread = manager.list().await.unwrap();
445        assert_eq!(reread[0].status, SessionStatus::Merged);
446
447        let _ = std::fs::remove_dir_all(&base);
448    }
449
450    #[tokio::test]
451    async fn missing_workspace_errors_before_touching_runtime() {
452        let base = unique_temp_dir("nows");
453        let manager = SessionManager::new(base.clone());
454        persist_session(
455            &manager,
456            "sess-ghost",
457            SessionStatus::Terminated,
458            &PathBuf::from("/nonexistent/ao-rs/does-not-exist"),
459        )
460        .await;
461
462        let rt = RecorderRuntime::new(false);
463        let err = restore_session("sess-ghost", &manager, &rt, &StubAgent, &StubWorkspace)
464            .await
465            .unwrap_err();
466        assert!(format!("{err}").contains("workspace missing"), "got: {err}");
467        // Runtime must not have been touched.
468        assert!(
469            rt.calls().is_empty(),
470            "runtime was called: {:?}",
471            rt.calls()
472        );
473
474        let _ = std::fs::remove_dir_all(&base);
475    }
476
477    #[tokio::test]
478    async fn corrupted_workspace_reports_missing_via_plugin_exists() {
479        // Directory is on disk but the plugin reports it as not usable
480        // (e.g. worktree's `git rev-parse` check failed). Restore must
481        // surface "workspace missing" and never touch the runtime.
482        let base = unique_temp_dir("corrupt");
483        let ws = base.join("ws");
484        std::fs::create_dir_all(&ws).unwrap();
485
486        let manager = SessionManager::new(base.clone());
487        persist_session(&manager, "sess-corrupt", SessionStatus::Terminated, &ws).await;
488
489        let rt = RecorderRuntime::new(false);
490        let workspace = ExistsWorkspace {
491            reports_exists: false,
492        };
493        let err = restore_session("sess-corrupt", &manager, &rt, &StubAgent, &workspace)
494            .await
495            .unwrap_err();
496        assert!(format!("{err}").contains("workspace missing"), "got: {err}");
497        assert!(
498            rt.calls().is_empty(),
499            "runtime was called: {:?}",
500            rt.calls()
501        );
502
503        let _ = std::fs::remove_dir_all(&base);
504    }
505
506    #[tokio::test]
507    async fn unknown_session_id_errors() {
508        let base = unique_temp_dir("missing");
509        let manager = SessionManager::new(base.clone());
510        let rt = RecorderRuntime::new(false);
511        let err = restore_session("nope", &manager, &rt, &StubAgent, &StubWorkspace)
512            .await
513            .unwrap_err();
514        assert!(matches!(err, AoError::SessionNotFound(_)), "got {err:?}");
515        let _ = std::fs::remove_dir_all(&base);
516    }
517
518    #[tokio::test]
519    async fn ambiguous_prefix_errors() {
520        let base = unique_temp_dir("ambig");
521        let ws = base.join("ws");
522        std::fs::create_dir_all(&ws).unwrap();
523        let manager = SessionManager::new(base.clone());
524        persist_session(&manager, "abcd-1111", SessionStatus::Terminated, &ws).await;
525        persist_session(&manager, "abcd-2222", SessionStatus::Terminated, &ws).await;
526
527        let rt = RecorderRuntime::new(false);
528        let err = restore_session("abcd", &manager, &rt, &StubAgent, &StubWorkspace)
529            .await
530            .unwrap_err();
531        assert!(format!("{err}").contains("ambiguous"), "got: {err}");
532        let _ = std::fs::remove_dir_all(&base);
533    }
534
535    #[tokio::test]
536    async fn prefix_match_resolves_to_unique_session() {
537        let base = unique_temp_dir("prefix");
538        let ws = base.join("ws");
539        std::fs::create_dir_all(&ws).unwrap();
540        let manager = SessionManager::new(base.clone());
541        persist_session(
542            &manager,
543            "deadbeef-uuid-long",
544            SessionStatus::Terminated,
545            &ws,
546        )
547        .await;
548
549        let rt = RecorderRuntime::new(false);
550        let out = restore_session("deadbeef", &manager, &rt, &StubAgent, &StubWorkspace)
551            .await
552            .unwrap();
553        assert_eq!(out.session.id.0, "deadbeef-uuid-long");
554        assert!(out.prompt_sent, "expected prompt_sent=true");
555        let _ = std::fs::remove_dir_all(&base);
556    }
557}