Skip to main content

agentmux_srv/backend/
resume_preflight.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Answer "will this pane resume its conversation, or start a new one?" at
5//! **pane-open time**, before anything is spawned.
6//!
7//! ## Why this exists
8//!
9//! The persistent controller spawns lazily, on the first message — so every
10//! signal that reports what actually happened to a resume
11//! (`agentmux_session_outcome`, `session:resume_failed`,
12//! SPEC_AGENT_PANE_HISTORY_ALIGNMENT_2026_08_05.md Part A) can only speak
13//! *after* the user has already typed. The lived experience that produces is
14//! the one this module exists to remove: the pane opens showing a long prior
15//! conversation, the user types into it, and only then does the transcript
16//! clear and announce a new session. The information was knowable the whole
17//! time; nothing asked for it.
18//!
19//! ## Why it can be known in advance
20//!
21//! `--resume <sid>` fails for exactly one reason in practice: the session's
22//! `.jsonl` isn't under the `CLAUDE_CONFIG_DIR` the CLI is about to run with.
23//! That is a file-existence question, and
24//! `session_backfill::session_is_reachable` answers it using the same path
25//! scheme and the same home-dir expansion the spawn path itself uses. So the
26//! preflight is not a heuristic or a guess about CLI behaviour — it evaluates
27//! the same condition the CLI will, just earlier.
28//!
29//! ## Faithfulness to the real spawn path
30//!
31//! [`preflight`] deliberately mirrors the decision sequence in
32//! `persistent/spawn.rs` and `persistent/resume_retry.rs` rather than
33//! modelling an idealized one, so its verdict and the eventual outcome
34//! can't disagree:
35//!
36//! | Pane state | Spawn does | Verdict |
37//! |---|---|---|
38//! | sid held, transcript present | `--resume <sid>` succeeds | [`Verdict::Resume`] |
39//! | sid held, transcript missing, another session on disk | rejected → `retry_after_resume_failure` → `find_recovery_session_id` resumes that one | [`Verdict::Recover`] |
40//! | sid held, transcript missing, nothing else on disk | rejected → retry finds nothing → blank | [`Verdict::Fresh`] |
41//! | no sid, pane renders history whose session is reachable | first spawn continues it (`find_continuation_session_id`) | [`Verdict::Resume`] |
42//! | no sid otherwise | spawns with no `--resume`; **no disk scan runs on this path** | [`Verdict::Fresh`] |
43//! | provider has no resume flag | resume isn't a concept here | [`Verdict::Unknown`] |
44//!
45//! The no-sid rows are the case
46//! `docs/status/STATUS_CROSS_CHANNEL_RESUME_STALE_SESSION_ID_2026_08_20.md`
47//! recorded: a pane opened in a new channel or version shows the agent's
48//! whole prior conversation, but its spawn held no id. Since
49//! SPEC_DURABLE_CONVERSATION_MEMORY_2026_09_23.md §5 P0a the spawn continues
50//! the session of that rendered history. It still never scans the provider's
51//! dir for some other session: one may be sitting there —
52//! [`Preflight::recoverable_session_id`] reports it when so — but "largest
53//! on disk" can be an archived conversation or a subagent's, so it stays
54//! evidence, not a verdict.
55
56use std::time::Instant;
57
58use crate::backend::session_backfill;
59
60/// What will happen to this pane's conversation on its next spawn.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Verdict {
63    /// The exact session this pane would resume is present and reachable.
64    Resume,
65    /// The held session id is unreachable, but a real session for this working
66    /// dir is on disk and the recovery path will pick it up — continuity
67    /// survives, after a visible "Reconnecting…" pause.
68    Recover,
69    /// The next spawn starts a conversation with none of the prior turns.
70    Fresh,
71    /// Not determinable — the provider has no simple-flag resume, or the pane
72    /// carries no working dir / config dir to check against. Callers should
73    /// stay silent rather than guess.
74    Unknown,
75}
76
77impl Verdict {
78    /// Wire form, matching the TS union in `srv-types.d.ts`.
79    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/// One line in the pane's progress list while the preflight runs. Mirrors the
90/// launcher splash's `StageEntry` row (`crates/launcher/src/splash_core.rs`) so
91/// the two read as the same idea in two places.
92#[derive(Debug, Clone)]
93pub struct Step {
94    pub id: &'static str,
95    pub label: String,
96    /// Did this step find what it was looking for? A `false` here is normal
97    /// (it's how the sequence narrows), not an error.
98    pub ok: bool,
99    pub detail: String,
100    pub duration_ms: u64,
101}
102
103/// The full answer, including the trail of how it was reached.
104#[derive(Debug, Clone)]
105pub struct Preflight {
106    pub verdict: Verdict,
107    /// The session that would actually end up loaded — the held id for
108    /// [`Verdict::Resume`], the recovered one for [`Verdict::Recover`], `None`
109    /// otherwise.
110    pub session_id: Option<String>,
111    /// A real session found on disk that the next spawn will NOT reach for.
112    /// Only ever `Some` alongside [`Verdict::Fresh`], where it's the evidence
113    /// that this pane's history is recoverable in principle even though
114    /// nothing will recover it today (see the module doc's fourth row).
115    pub recoverable_session_id: Option<String>,
116    pub steps: Vec<Step>,
117    pub duration_ms: u64,
118}
119
120/// Everything [`preflight`] needs, lifted out of block meta by the caller so
121/// this stays a pure-ish function over plain values (one `is_file` and at most
122/// one `read_dir` of a single directory — no store, no block, no RPC types).
123#[derive(Debug, Clone, Default)]
124pub struct PreflightInput {
125    /// `agent:resume_flag` — empty means this provider has no `--resume`.
126    pub resume_flag: String,
127    /// `agent:sessionid` — the id the next spawn would attempt, if any.
128    pub session_id: String,
129    /// `cmd:cwd` — unexpanded is fine; expansion matches the spawn path.
130    pub working_dir: String,
131    /// `CLAUDE_CONFIG_DIR` out of `cmd:env`.
132    pub config_dir: String,
133    /// The session of the history this pane renders
134    /// (`persistent::pane_history_session_id`). A first spawn with no
135    /// `session_id` continues it when reachable. Empty when there is none.
136    pub history_session_id: String,
137    /// The head of the agent's segment chain
138    /// (`continuity_segments::chain_head`), for the spawn's resume gate
139    /// (SPEC_RESUME_GATE_AND_SAME_IDENTITY_CONTINUATION_2026_09_25.md §4.2).
140    /// `None` when the pane has no agent UID or the agent no chain.
141    pub chain_head: Option<crate::backend::continuity_segments::Head>,
142    /// The identity `config_dir` is signed in as
143    /// (`account_email::identity_key_from_oauth_dir`), for the gate's
144    /// identity check. `None` when unknown.
145    pub identity_key: Option<String>,
146    /// The chain head of the agent this one was forked from, when it has no
147    /// chain of its own (`continuity_segments::fork_source_head`).
148    pub fork_source: Option<crate::backend::continuity_segments::Head>,
149}
150
151/// The spawn's resume gate (`PersistentSubprocessController::apply_resume_gate_with`),
152/// mirrored: a candidate that isn't the chain head is redirected to the head
153/// when reachable here, else refused. `None` when the candidate stands.
154fn 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
239/// Decide, without spawning anything, what the next spawn will do to this
240/// pane's conversation. See the module doc for the mapping this mirrors.
241pub 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    // 1. Can this provider resume at all?
257    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        // Without a config dir there's nowhere to look; guessing "fresh" here
264        // would warn on panes we know nothing about.
265        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    // 2. Is there a session id to resume in the first place?
271    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        // The first spawn continues the conversation this pane renders when
275        // `--resume` can reach it (module doc, fourth row).
276        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            // Relocation only, as the spawn does: a first spawn resumes a
294            // session it can't reach here only by relocating it.
295            if let Some((verdict, sid)) = gate(input, &sid, true, &mut steps) {
296                return finish(verdict, sid, None, steps);
297            }
298        }
299        // Report what's on disk, but don't let it change the verdict — the
300        // spawn only continues the pane's own history, never a disk scan.
301        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    // 3. Would `--resume <sid>` actually find it? Same check the CLI makes.
324    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    // 4. It's unreachable — the spawn will be rejected and recovery will run.
332    //    Mirror `find_recovery_session_id`, including its refusal to "recover"
333    //    the very id that just failed.
334    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    /// Build a config dir containing `projects/<slug>/<sid>.jsonl` files of the
359    /// given sizes, mirroring Claude Code's own on-disk layout.
360    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    /// The STATUS_STALE_RESUME_LIVE_REPRO_AND_FIX_PLAN_2026_08_23 §2 shape: the
396    /// held pointer is a real but superseded session that no longer exists
397    /// here, while the genuinely-live one sits on disk. Continuity survives via
398    /// the recovery path, so the pane must NOT be warned that it's losing the
399    /// conversation.
400    #[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    /// `find_recovery_session_id` refuses to "recover" the id that just failed;
409    /// the preflight must refuse it too, or it would promise a resume that the
410    /// real path is specifically coded to reject.
411    #[test]
412    fn the_failed_id_is_never_offered_back_as_a_recovery() {
413        let cfg = config_dir_with(WORK_DIR, &[]);
414        // The dir exists but holds nothing — the only "candidate" would be the
415        // attempted id itself if the scan somehow returned it.
416        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    /// The cross-channel open this module exists for: no pointer at all, but
431    /// the real conversation is right there on disk. The verdict is still
432    /// `Fresh` when the pane renders no history of its own — the spawn never
433    /// scans the provider's dir — and the reachable session is reported
434    /// separately as evidence, not as a promise.
435    #[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    /// The 2026-09-23 incident: a pane in a new version renders the agent's
449    /// prior conversation but holds no id. The spawn now continues it.
450    #[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    /// Staying silent matters as much as warning: a provider with no resume
484    /// flag, or a pane with no config dir, must not be told it's about to lose
485    /// a conversation we never had any way to check.
486    #[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    /// The steps are the pane's progress list, so every path must produce a
500    /// non-empty, labelled trail — including the early returns.
501    #[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    // ── the resume gate, mirrored (SPEC_RESUME_GATE_AND_SAME_IDENTITY_CONTINUATION §4.2) ──
528
529    /// The pane holds S1 and both files are here, but the agent's
530    /// conversation moved on to S2: the spawn resumes S2, so the verdict
531    /// names S2.
532    #[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    /// The head lives under another login: the spawn resumes nothing, so the
543    /// held (stale but reachable) session must not be promised.
544    #[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    /// The config dir now signs in as someone else: the head's file is here,
577    /// but resuming it would cross identities.
578    #[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    /// A rebuild onto a new login of the same identity: the head is only
590    /// under the old login, and the spawn will relocate and fork it.
591    #[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}