1use crate::{
32 error::{AoError, Result},
33 session_manager::SessionManager,
34 traits::{Agent, Runtime, Workspace},
35 types::{Session, SessionStatus},
36};
37
38#[derive(Debug, Clone)]
40pub struct RestoreOutcome {
41 pub session: Session,
42 pub launch_command: String,
44 pub runtime_handle: String,
46 pub prompt_sent: bool,
51}
52
53pub 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 let mut session = sessions.find_by_prefix(id_or_prefix).await?;
67
68 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 session.status = SessionStatus::Terminated;
81 }
82
83 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 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 if let Some(handle) = session.runtime_handle.as_deref() {
110 let _ = runtime.destroy(handle).await;
113 }
114
115 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 session.runtime_handle = Some(new_handle.clone());
135 session.status = SessionStatus::Spawning;
136 session.activity = None;
137 sessions.save(&session).await?;
138
139 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 #[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 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 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 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 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 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 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 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 let calls = rt.calls();
384 assert!(
385 !calls.iter().any(|c| c.starts_with("destroy:")),
386 "unexpected destroy call(s): {calls:?}"
387 );
388 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 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); 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 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 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 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}