1use std::time::Instant;
57
58use crate::backend::session_backfill;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Verdict {
63 Resume,
65 Recover,
69 Fresh,
71 Unknown,
75}
76
77impl Verdict {
78 pub fn as_str(self) -> &'static str {
80 match self {
81 Verdict::Resume => "resume",
82 Verdict::Recover => "recover",
83 Verdict::Fresh => "fresh",
84 Verdict::Unknown => "unknown",
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
93pub struct Step {
94 pub id: &'static str,
95 pub label: String,
96 pub ok: bool,
99 pub detail: String,
100 pub duration_ms: u64,
101}
102
103#[derive(Debug, Clone)]
105pub struct Preflight {
106 pub verdict: Verdict,
107 pub session_id: Option<String>,
111 pub recoverable_session_id: Option<String>,
116 pub steps: Vec<Step>,
117 pub duration_ms: u64,
118}
119
120#[derive(Debug, Clone, Default)]
124pub struct PreflightInput {
125 pub resume_flag: String,
127 pub session_id: String,
129 pub working_dir: String,
131 pub config_dir: String,
133 pub history_session_id: String,
137 pub chain_head: Option<crate::backend::continuity_segments::Head>,
142 pub identity_key: Option<String>,
146 pub fork_source: Option<crate::backend::continuity_segments::Head>,
149}
150
151fn gate(input: &PreflightInput, candidate: &str, relocate_only: bool, steps: &mut Vec<Step>) -> Option<(Verdict, Option<String>)> {
155 use crate::backend::continuity_relocate::can_relocate;
156 use crate::backend::continuity_segments::{resume_gate, GateInput, ResumeGate};
157 let t = Instant::now();
158 let gate_input = GateInput {
159 candidate,
160 head: input.chain_head.as_ref(),
161 identity: input.identity_key.as_deref(),
162 poisoned: None,
163 config_dir: &input.config_dir,
164 cwd: &input.working_dir,
165 fork_source: input.fork_source.as_ref(),
166 };
167 let decision = resume_gate(
168 &gate_input,
169 |sid| {
170 session_backfill::session_file_path(&input.config_dir, &input.working_dir, sid)
171 .and_then(|p| std::fs::metadata(p).ok())
172 .filter(|m| m.is_file())
173 .map(|m| m.len())
174 },
175 can_relocate,
176 );
177 if relocate_only && !matches!(decision, ResumeGate::Relocate { .. } | ResumeGate::ForkFrom { .. }) {
178 return None;
179 }
180 match decision {
181 ResumeGate::Allow => None,
182 ResumeGate::Fork { head } => {
183 steps.push(step(
184 "chain",
185 "Checking the agent's conversation",
186 true,
187 format!("{head} continued outside AgentMux; continuing it as a fork"),
188 t,
189 ));
190 Some((Verdict::Resume, Some(head)))
191 }
192 ResumeGate::Relocate { head, from_config_dir } => {
193 steps.push(step(
194 "chain",
195 "Checking the agent's conversation",
196 true,
197 format!("{head} is under another login of this identity ({from_config_dir}); continuing it there"),
198 t,
199 ));
200 Some((Verdict::Resume, Some(head)))
201 }
202 ResumeGate::ForkFrom { head, .. } => {
203 steps.push(step(
204 "chain",
205 "Checking the agent's conversation",
206 true,
207 format!("a fork's first start; continuing {head}, the conversation it was forked from"),
208 t,
209 ));
210 Some((Verdict::Resume, Some(head)))
211 }
212 ResumeGate::Redirect { head } => {
213 steps.push(step("chain", "Checking the agent's conversation", true, format!("moved on to {head}"), t));
214 Some((Verdict::Resume, Some(head)))
215 }
216 ResumeGate::Refuse { head } => {
217 steps.push(step(
218 "chain",
219 "Checking the agent's conversation",
220 false,
221 format!("in {head}, which this spawn can't resume here"),
222 t,
223 ));
224 Some((Verdict::Fresh, None))
225 }
226 }
227}
228
229fn step(id: &'static str, label: &str, ok: bool, detail: impl Into<String>, started: Instant) -> Step {
230 Step {
231 id,
232 label: label.to_string(),
233 ok,
234 detail: detail.into(),
235 duration_ms: started.elapsed().as_millis() as u64,
236 }
237}
238
239pub fn preflight(input: &PreflightInput) -> Preflight {
242 let overall = Instant::now();
243 let mut steps: Vec<Step> = Vec::new();
244
245 let finish = |verdict: Verdict,
246 session_id: Option<String>,
247 recoverable_session_id: Option<String>,
248 steps: Vec<Step>| Preflight {
249 verdict,
250 session_id,
251 recoverable_session_id,
252 steps,
253 duration_ms: overall.elapsed().as_millis() as u64,
254 };
255
256 let t = Instant::now();
258 if input.resume_flag.is_empty() {
259 steps.push(step("provider", "Checking provider", false, "no resume flag", t));
260 return finish(Verdict::Unknown, None, None, steps);
261 }
262 if input.config_dir.is_empty() {
263 steps.push(step("provider", "Checking provider", false, "no config dir", t));
266 return finish(Verdict::Unknown, None, None, steps);
267 }
268 steps.push(step("provider", "Checking provider", true, input.resume_flag.clone(), t));
269
270 let t = Instant::now();
272 if input.session_id.is_empty() {
273 steps.push(step("session-id", "Resolving session id", false, "none recorded", t));
274 if !input.history_session_id.is_empty() {
277 let t = Instant::now();
278 let sid = input.history_session_id.clone();
279 if session_backfill::session_is_reachable(&input.config_dir, &input.working_dir, &sid) {
280 steps.push(step("history", "Continuing this pane's conversation", true, sid.clone(), t));
281 if let Some((verdict, sid)) = gate(input, &sid, false, &mut steps) {
282 return finish(verdict, sid, None, steps);
283 }
284 return finish(Verdict::Resume, Some(sid), None, steps);
285 }
286 steps.push(step(
287 "history",
288 "Continuing this pane's conversation",
289 false,
290 format!("{sid} not under this config dir"),
291 t,
292 ));
293 if let Some((verdict, sid)) = gate(input, &sid, true, &mut steps) {
296 return finish(verdict, sid, None, steps);
297 }
298 }
299 let t = Instant::now();
302 let on_disk = session_backfill::find_largest_session_for_working_dir(
303 &input.config_dir,
304 &input.working_dir,
305 );
306 match &on_disk {
307 Some(sid) => steps.push(step(
308 "scan",
309 "Scanning for recoverable sessions",
310 false,
311 format!("found {sid}, but a no-resume spawn won't load it"),
312 t,
313 )),
314 None => steps.push(step("scan", "Scanning for recoverable sessions", false, "none on disk", t)),
315 }
316 return finish(Verdict::Fresh, None, on_disk, steps);
317 }
318 steps.push(step("session-id", "Resolving session id", true, input.session_id.clone(), t));
319 if let Some((verdict, sid)) = gate(input, &input.session_id, false, &mut steps) {
320 return finish(verdict, sid, None, steps);
321 }
322
323 let t = Instant::now();
325 if session_backfill::session_is_reachable(&input.config_dir, &input.working_dir, &input.session_id) {
326 steps.push(step("transcript", "Locating transcript", true, "reachable", t));
327 return finish(Verdict::Resume, Some(input.session_id.clone()), None, steps);
328 }
329 steps.push(step("transcript", "Locating transcript", false, "not under this config dir", t));
330
331 let t = Instant::now();
335 let recovered = session_backfill::find_largest_session_for_working_dir(
336 &input.config_dir,
337 &input.working_dir,
338 )
339 .filter(|sid| sid != &input.session_id);
340 match recovered {
341 Some(sid) => {
342 steps.push(step("recovery", "Checking recovery", true, sid.clone(), t));
343 finish(Verdict::Recover, Some(sid), None, steps)
344 }
345 None => {
346 steps.push(step("recovery", "Checking recovery", false, "nothing to recover", t));
347 finish(Verdict::Fresh, None, None, steps)
348 }
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use std::fs;
356 use std::path::Path;
357
358 fn config_dir_with(working_dir: &str, sessions: &[(&str, usize)]) -> tempfile::TempDir {
361 let tmp = tempfile::tempdir().unwrap();
362 let slug = crate::backend::claude_layout::project_dir_name(working_dir);
363 let dir = tmp.path().join("projects").join(slug);
364 fs::create_dir_all(&dir).unwrap();
365 for (sid, size) in sessions {
366 fs::write(dir.join(format!("{sid}.jsonl")), "x".repeat(*size)).unwrap();
367 }
368 tmp
369 }
370
371 fn input(config_dir: &Path, working_dir: &str, session_id: &str) -> PreflightInput {
372 PreflightInput {
373 resume_flag: "--resume".to_string(),
374 session_id: session_id.to_string(),
375 working_dir: working_dir.to_string(),
376 config_dir: config_dir.to_string_lossy().to_string(),
377 history_session_id: String::new(),
378 chain_head: None,
379 fork_source: None,
380 identity_key: None,
381 }
382 }
383
384 const WORK_DIR: &str = "/home/dev/agents/agentx";
385
386 #[test]
387 fn a_reachable_session_resumes() {
388 let cfg = config_dir_with(WORK_DIR, &[("sid-live", 4096)]);
389 let out = preflight(&input(cfg.path(), WORK_DIR, "sid-live"));
390 assert_eq!(out.verdict, Verdict::Resume);
391 assert_eq!(out.session_id.as_deref(), Some("sid-live"));
392 assert_eq!(out.recoverable_session_id, None);
393 }
394
395 #[test]
401 fn an_unreachable_session_with_another_on_disk_recovers() {
402 let cfg = config_dir_with(WORK_DIR, &[("sid-real", 900_000)]);
403 let out = preflight(&input(cfg.path(), WORK_DIR, "sid-superseded"));
404 assert_eq!(out.verdict, Verdict::Recover);
405 assert_eq!(out.session_id.as_deref(), Some("sid-real"));
406 }
407
408 #[test]
412 fn the_failed_id_is_never_offered_back_as_a_recovery() {
413 let cfg = config_dir_with(WORK_DIR, &[]);
414 let out = preflight(&input(cfg.path(), WORK_DIR, "sid-gone"));
417 assert_eq!(out.verdict, Verdict::Fresh);
418 assert_eq!(out.session_id, None);
419 }
420
421 #[test]
422 fn an_unreachable_session_with_nothing_on_disk_is_fresh() {
423 let cfg = tempfile::tempdir().unwrap();
424 let out = preflight(&input(cfg.path(), WORK_DIR, "sid-gone"));
425 assert_eq!(out.verdict, Verdict::Fresh);
426 assert_eq!(out.session_id, None);
427 assert_eq!(out.recoverable_session_id, None);
428 }
429
430 #[test]
436 fn no_session_id_is_fresh_even_when_a_session_exists_on_disk() {
437 let cfg = config_dir_with(WORK_DIR, &[("sid-orphaned", 2_000_000)]);
438 let out = preflight(&input(cfg.path(), WORK_DIR, ""));
439 assert_eq!(out.verdict, Verdict::Fresh);
440 assert_eq!(out.session_id, None);
441 assert_eq!(
442 out.recoverable_session_id.as_deref(),
443 Some("sid-orphaned"),
444 "the orphaned session must be reported so the UI can say history exists",
445 );
446 }
447
448 #[test]
451 fn no_session_id_but_reachable_rendered_history_resumes_it() {
452 let cfg = config_dir_with(WORK_DIR, &[("sid-rendered", 500), ("sid-bigger", 2_000_000)]);
453 let mut inp = input(cfg.path(), WORK_DIR, "");
454 inp.history_session_id = "sid-rendered".to_string();
455 let out = preflight(&inp);
456 assert_eq!(out.verdict, Verdict::Resume);
457 assert_eq!(
458 out.session_id.as_deref(),
459 Some("sid-rendered"),
460 "the rendered history's session wins, not the largest on disk",
461 );
462 }
463
464 #[test]
465 fn no_session_id_and_unreachable_rendered_history_is_fresh() {
466 let cfg = config_dir_with(WORK_DIR, &[("sid-orphaned", 2_000_000)]);
467 let mut inp = input(cfg.path(), WORK_DIR, "");
468 inp.history_session_id = "sid-other-account".to_string();
469 let out = preflight(&inp);
470 assert_eq!(out.verdict, Verdict::Fresh);
471 assert_eq!(out.recoverable_session_id.as_deref(), Some("sid-orphaned"));
472 assert!(out.steps.iter().any(|s| s.id == "history" && !s.ok));
473 }
474
475 #[test]
476 fn no_session_id_and_no_transcripts_is_plainly_fresh() {
477 let cfg = tempfile::tempdir().unwrap();
478 let out = preflight(&input(cfg.path(), WORK_DIR, ""));
479 assert_eq!(out.verdict, Verdict::Fresh);
480 assert_eq!(out.recoverable_session_id, None);
481 }
482
483 #[test]
487 fn an_unknowable_pane_reports_unknown_rather_than_guessing() {
488 let cfg = tempfile::tempdir().unwrap();
489
490 let mut no_flag = input(cfg.path(), WORK_DIR, "sid");
491 no_flag.resume_flag = String::new();
492 assert_eq!(preflight(&no_flag).verdict, Verdict::Unknown);
493
494 let mut no_config = input(cfg.path(), WORK_DIR, "sid");
495 no_config.config_dir = String::new();
496 assert_eq!(preflight(&no_config).verdict, Verdict::Unknown);
497 }
498
499 #[test]
502 fn every_path_reports_labelled_steps() {
503 let cfg = config_dir_with(WORK_DIR, &[("sid-live", 10)]);
504 for inp in [
505 input(cfg.path(), WORK_DIR, "sid-live"),
506 input(cfg.path(), WORK_DIR, "sid-missing"),
507 input(cfg.path(), WORK_DIR, ""),
508 PreflightInput::default(),
509 ] {
510 let out = preflight(&inp);
511 assert!(!out.steps.is_empty(), "every verdict must show its work");
512 assert!(
513 out.steps.iter().all(|s| !s.label.is_empty() && !s.id.is_empty()),
514 "every step needs an id and a human label",
515 );
516 }
517 }
518
519 fn head(sid: &str, identity: Option<&str>) -> crate::backend::continuity_segments::Head {
520 crate::backend::continuity_segments::Head {
521 session_id: sid.into(),
522 identity_key: identity.map(str::to_string),
523 ..Default::default()
524 }
525 }
526
527 #[test]
533 fn a_held_session_that_is_not_the_chain_head_resumes_the_head() {
534 let cfg = config_dir_with(WORK_DIR, &[("sid-old", 4096), ("sid-head", 4096)]);
535 let mut i = input(cfg.path(), WORK_DIR, "sid-old");
536 i.chain_head = Some(head("sid-head", None));
537 let out = preflight(&i);
538 assert_eq!(out.verdict, Verdict::Resume);
539 assert_eq!(out.session_id.as_deref(), Some("sid-head"));
540 }
541
542 #[test]
545 fn a_held_session_that_is_not_the_chain_head_starts_fresh_when_the_head_is_elsewhere() {
546 let cfg = config_dir_with(WORK_DIR, &[("sid-old", 4096)]);
547 let mut i = input(cfg.path(), WORK_DIR, "sid-old");
548 i.chain_head = Some(head("sid-elsewhere", None));
549 let out = preflight(&i);
550 assert_eq!(out.verdict, Verdict::Fresh);
551 assert_eq!(out.session_id, None);
552 }
553
554 #[test]
555 fn a_rendered_history_that_is_not_the_chain_head_resumes_the_head() {
556 let cfg = config_dir_with(WORK_DIR, &[("sid-old", 4096), ("sid-head", 4096)]);
557 let mut i = input(cfg.path(), WORK_DIR, "");
558 i.history_session_id = "sid-old".into();
559 i.chain_head = Some(head("sid-head", None));
560 let out = preflight(&i);
561 assert_eq!(out.verdict, Verdict::Resume);
562 assert_eq!(out.session_id.as_deref(), Some("sid-head"));
563 }
564
565 #[test]
566 fn the_chain_head_itself_resumes_unchanged() {
567 let cfg = config_dir_with(WORK_DIR, &[("sid-live", 4096)]);
568 let mut i = input(cfg.path(), WORK_DIR, "sid-live");
569 i.chain_head = Some(head("sid-live", None));
570 let out = preflight(&i);
571 assert_eq!(out.verdict, Verdict::Resume);
572 assert_eq!(out.session_id.as_deref(), Some("sid-live"));
573 assert!(out.steps.iter().all(|s| s.id != "chain"), "no gate step when the candidate is the head");
574 }
575
576 #[test]
579 fn the_head_under_another_identity_starts_fresh() {
580 let cfg = config_dir_with(WORK_DIR, &[("sid-live", 4096)]);
581 let mut i = input(cfg.path(), WORK_DIR, "sid-live");
582 i.chain_head = Some(head("sid-live", Some("k-old")));
583 i.identity_key = Some("k-new".into());
584 let out = preflight(&i);
585 assert_eq!(out.verdict, Verdict::Fresh);
586 assert_eq!(out.session_id, None);
587 }
588
589 #[test]
592 fn a_head_under_another_login_of_the_same_identity_resumes() {
593 let new_login = config_dir_with(WORK_DIR, &[]);
594 let old_login = config_dir_with(WORK_DIR, &[]);
595 let slug = crate::backend::claude_layout::project_dir_name(WORK_DIR);
596 fs::write(old_login.path().join("projects").join(&slug).join("sid-head.jsonl"), "{\"type\":\"user\"}\n").unwrap();
597 let head = crate::backend::continuity_segments::Head {
598 session_id: "sid-head".into(),
599 identity_key: Some("k1".into()),
600 config_dir: Some(old_login.path().to_string_lossy().to_string()),
601 provider: "claude".into(),
602 cwd: WORK_DIR.into(),
603 provider_bytes_end: None,
604 };
605
606 let mut held = input(new_login.path(), WORK_DIR, "sid-head");
607 held.chain_head = Some(head.clone());
608 held.identity_key = Some("k1".into());
609 let out = preflight(&held);
610 assert_eq!(out.verdict, Verdict::Resume);
611 assert_eq!(out.session_id.as_deref(), Some("sid-head"));
612
613 let mut first = input(new_login.path(), WORK_DIR, "");
614 first.history_session_id = "sid-head".into();
615 first.chain_head = Some(head.clone());
616 first.identity_key = Some("k1".into());
617 assert_eq!(preflight(&first).verdict, Verdict::Resume, "a first spawn relocates its history");
618
619 first.identity_key = Some("k-other".into());
620 assert_eq!(preflight(&first).verdict, Verdict::Fresh, "never across identities");
621 }
622}