Skip to main content

mj_controller/controller/
resume.rs

1//! Resuming a stopped session onto a profile and target.
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use agent_client_protocol::schema::v1::ContentBlock;
8use anyhow::{Context, Result, bail, ensure};
9use rayon::prelude::*;
10use tokio_util::sync::CancellationToken;
11
12use crate::checkpoint_transfer::restore_command;
13use crate::session_manager::new_command_id;
14use mj_checkpoint::archive::{
15    CanonicalQueuedCommandKind, CanonicalSessionSnapshot, CheckpointRepositoryBundle, SystemGit,
16    checkpoint_bundle_prerequisites, read_checkpoint_repository_bundles, verify_archive_streaming,
17};
18use mj_checkpoint::checkpoint::CheckpointRestoreSpec;
19use mj_core::config::{Config, HarnessKind, ProjectRepository, mount_history_host};
20use mj_core::state::{MaterializedSession, SessionRecord, SessionResourceAllocation, SessionState};
21use mj_transcript::projection::materialized_session_from_canonical;
22
23use crate::targets::{
24    self, AdditionalMount, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec,
25    ProcessExecutor, ProvisionStage, ProvisionStageGuard,
26};
27use mj_core::relay::RelayCommand;
28
29use super::backend::{backend_locator, controller_github_token, validate_resource_allocation};
30use super::checkpoint::upload_checkpoint_spec;
31use super::provisioning::{
32    ProvisioningFailureDisposition, StagedExecutor, execute_concurrent_lanes,
33    install_attached_resources,
34};
35use super::readiness::{connect_started_worker, wait_for_native_session_in_stage};
36use super::worker_binary::{bridge_readiness_stage, start_worker, worker_probe_diagnosis};
37use super::worktree::{
38    PrimaryCheckoutRequirement, ResumeConversion, ResumePlan, apply_raw_to_workspace,
39    apply_workspace_to_raw, cleanup_managed_worktree, create_managed_worktree,
40    managed_worktree_checkout_exists, plan_raw_to_workspace,
41    preserve_retained_managed_worktree_branch, raw_checkout_divergence_notice,
42    raw_checkout_position, raw_checkout_snapshot, raw_conversion_preview, restore_managed_worktree,
43    resume_compatibility, retire_managed_worktree,
44};
45use super::{
46    Controller, SessionResumeOptions, execute_checked, now, selected_host_container_size,
47    target_profile_home,
48};
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ResumeRepositorySourceMismatch {
52    pub session_id: String,
53    pub bundle_id: String,
54    pub repository_id: String,
55    pub missing_commit: String,
56    pub archived_origin: String,
57    pub configured_origin: String,
58}
59
60pub use mj_core::state::ResumeRepositorySourceReceipt;
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum ResumeRepositorySourcePreflight {
64    Ready(ResumeRepositorySourceReceipt),
65    RepositoryMoved(ResumeRepositorySourceMismatch),
66    /// The resume converts a local checkout into an isolated workspace. The
67    /// receipt is already valid; the preview is what a person has to confirm
68    /// before the checkout is snapshotted and the session moves off this
69    /// machine.
70    ConvertingRawCheckout {
71        receipt: ResumeRepositorySourceReceipt,
72        preview: Box<mj_core::state::RawConversionPreview>,
73    },
74}
75
76struct ResumeRepositoryBundles {
77    checkpoint_sha256: String,
78    repositories: Vec<CheckpointRepositoryBundle>,
79}
80
81/// A small timing scope for the expensive resume phases. Target commands
82/// already trace their own durations; this covers controller-side work and
83/// lets an operator see where a slow resume spent its wall-clock budget.
84struct ResumePhaseTimer<'a> {
85    session_id: &'a str,
86    phase: &'static str,
87    started: Instant,
88}
89
90impl<'a> ResumePhaseTimer<'a> {
91    fn new(session_id: &'a str, phase: &'static str) -> Self {
92        Self {
93            session_id,
94            phase,
95            started: Instant::now(),
96        }
97    }
98}
99
100impl Drop for ResumePhaseTimer<'_> {
101    fn drop(&mut self) {
102        tracing::debug!(
103            session_id = self.session_id,
104            phase = self.phase,
105            elapsed_ms = self.started.elapsed().as_millis(),
106            "resume phase completed"
107        );
108    }
109}
110
111impl Controller {
112    /// Muse has one workspace root; native restore relocates its metadata.
113    pub(super) fn validate_muse_resume_destination(
114        &self,
115        source: &SessionRecord,
116        destination_harness: HarnessKind,
117        _target_id: &str,
118    ) -> Result<()> {
119        if destination_harness != HarnessKind::Muse {
120            return Ok(());
121        }
122        ensure!(
123            source.project_directory.is_some()
124                || self
125                    .config
126                    .bundles
127                    .get(&source.bundle_id)
128                    .is_none_or(|bundle| bundle.repositories.len() == 1),
129            "Muse Code ACP supports one workspace root; use a single-repository bundle"
130        );
131        Ok(())
132    }
133    /// Prove that each configured repository source still supplies the commit
134    /// boundary its checkpoint bundle expects, before provisioning anything,
135    /// and describe a local checkout's conversion so a person can confirm it.
136    pub fn preflight_resume_repository_sources(
137        &self,
138        session_id: &str,
139        target_id: &str,
140        executor: &(impl CommandExecutor + Sync),
141    ) -> Result<ResumeRepositorySourcePreflight> {
142        self.preflight_repository_sources(session_id, target_id, true, executor)
143    }
144
145    /// `describe_conversion` buys the conversion preview with a read of the
146    /// checkout and a question to its remote. The resume itself only needs to
147    /// know whether a configured source moved, and the person has already
148    /// confirmed by then, so it asks for the cheap answer.
149    fn preflight_repository_sources(
150        &self,
151        session_id: &str,
152        target_id: &str,
153        describe_conversion: bool,
154        executor: &(impl CommandExecutor + Sync),
155    ) -> Result<ResumeRepositorySourcePreflight> {
156        let session = self
157            .state
158            .sessions
159            .get(session_id)
160            .with_context(|| format!("unknown session {session_id}"))?;
161        let checkpoint = session
162            .checkpoint
163            .as_ref()
164            .context("session has no checkpoint")?;
165        let plan = resume_compatibility(session, &self.config, target_id)
166            .map_err(|reason| anyhow::anyhow!(reason))?;
167        if session.project_directory.is_some() {
168            debug_assert!(matches!(
169                plan,
170                ResumePlan::InPlace | ResumePlan::RawToWorkspace
171            ));
172            // A raw session resumes from its live checkout. Its synthetic
173            // bundle is only a grouping identity and may no longer be in the
174            // config; neither an in-place resume nor a raw-to-workspace
175            // conversion restores repository contents from that bundle.
176            let receipt = ResumeRepositorySourceReceipt {
177                session_id: session_id.to_owned(),
178                bundle_id: session.bundle_id.clone(),
179                checkpoint_sha256: checkpoint.sha256.clone(),
180                repositories: Vec::new(),
181            };
182            // A conversion reads the checkout and its remote so the person
183            // sees what will travel. Planning failures (no network remote, an
184            // unreachable remote, a dirty submodule) are this preflight's
185            // error, which every surface already reports.
186            if describe_conversion && plan == ResumePlan::RawToWorkspace {
187                let preview = raw_conversion_preview_for(session, &self.config, executor)?;
188                return Ok(ResumeRepositorySourcePreflight::ConvertingRawCheckout {
189                    receipt,
190                    preview: Box::new(preview),
191                });
192            }
193            return Ok(ResumeRepositorySourcePreflight::Ready(receipt));
194        }
195        let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
196        self.preflight_verified_repository_sources(
197            session_id,
198            ResumeRepositoryBundles {
199                checkpoint_sha256: checkpoint.sha256.clone(),
200                repositories,
201            },
202            None,
203            plan != ResumePlan::WorkspaceToRaw,
204            executor,
205        )
206    }
207
208    fn preflight_verified_repository_sources(
209        &self,
210        session_id: &str,
211        verified: ResumeRepositoryBundles,
212        skip_repository_id: Option<&str>,
213        use_archived_network_sources: bool,
214        executor: &(impl CommandExecutor + Sync),
215    ) -> Result<ResumeRepositorySourcePreflight> {
216        let session = self
217            .state
218            .sessions
219            .get(session_id)
220            .with_context(|| format!("unknown session {session_id}"))?;
221        ensure!(
222            verified.repositories.iter().all(|repository| {
223                !repository.metadata.origin.starts_with("mj-local:")
224                    && !repository.metadata.origin.starts_with("ext::")
225            }),
226            "resuming legacy host-bridge sessions is not supported; start a new network-backed session"
227        );
228        // The immutable archive supplies network provenance. Local source
229        // paths and later host configuration changes are irrelevant.
230        if use_archived_network_sources
231            && verified
232                .repositories
233                .iter()
234                .all(|repository| repository.metadata.remote_workspace)
235        {
236            return Ok(ResumeRepositorySourcePreflight::Ready(
237                ResumeRepositorySourceReceipt {
238                    session_id: session_id.to_owned(),
239                    bundle_id: session.bundle_id.clone(),
240                    checkpoint_sha256: verified.checkpoint_sha256,
241                    repositories: Vec::new(),
242                },
243            ));
244        }
245        if verified.repositories.is_empty() {
246            return Ok(ResumeRepositorySourcePreflight::Ready(
247                ResumeRepositorySourceReceipt {
248                    session_id: session_id.to_owned(),
249                    bundle_id: session.bundle_id.clone(),
250                    checkpoint_sha256: verified.checkpoint_sha256,
251                    repositories: Vec::new(),
252                },
253            ));
254        }
255        let bundle = self
256            .config
257            .bundles
258            .get(&session.bundle_id)
259            .with_context(|| format!("session bundle {:?} is missing", session.bundle_id))?;
260        let configured = verified
261            .repositories
262            .iter()
263            .map(|archived| {
264                bundle
265                    .repositories
266                    .iter()
267                    .find(|repository| repository.id == archived.metadata.id)
268                    .cloned()
269                    .with_context(|| {
270                        format!(
271                            "session bundle {:?} no longer contains repository {:?}",
272                            session.bundle_id, archived.metadata.id
273                        )
274                    })
275            })
276            .collect::<Result<Vec<_>>>()?;
277        let github_token = configured
278            .iter()
279            .any(|repository| repository.github.is_some())
280            .then(controller_github_token)
281            .flatten();
282        let outcomes = verified
283            .repositories
284            .par_iter()
285            .zip(configured.par_iter())
286            .map(|(archived, configured)| {
287                if skip_repository_id == Some(configured.id.as_str()) {
288                    return Ok(None);
289                }
290                checkpoint_source_missing_commit(
291                    configured,
292                    archived,
293                    executor,
294                    github_token.as_deref(),
295                )
296                .map(|missing_commit| {
297                    missing_commit.map(|missing_commit| ResumeRepositorySourceMismatch {
298                        session_id: session_id.to_owned(),
299                        bundle_id: session.bundle_id.clone(),
300                        repository_id: configured.id.clone(),
301                        missing_commit,
302                        archived_origin: archived.metadata.origin.clone(),
303                        configured_origin: configured.source_label(),
304                    })
305                })
306            })
307            .collect::<Vec<Result<Option<ResumeRepositorySourceMismatch>>>>();
308        for outcome in outcomes {
309            if let Some(mismatch) = outcome? {
310                return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(mismatch));
311            }
312        }
313        Ok(ResumeRepositorySourcePreflight::Ready(
314            ResumeRepositorySourceReceipt {
315                session_id: session_id.to_owned(),
316                bundle_id: session.bundle_id.clone(),
317                checkpoint_sha256: verified.checkpoint_sha256,
318                repositories: configured,
319            },
320        ))
321    }
322
323    fn repository_source_receipt_is_current(
324        &self,
325        session_id: &str,
326        receipt: &ResumeRepositorySourceReceipt,
327    ) -> bool {
328        let Some(session) = self.state.sessions.get(session_id) else {
329            return false;
330        };
331        if receipt.session_id != session_id
332            || receipt.bundle_id != session.bundle_id
333            || session
334                .checkpoint
335                .as_ref()
336                .map(|checkpoint| &checkpoint.sha256)
337                != Some(&receipt.checkpoint_sha256)
338        {
339            return false;
340        }
341        if receipt.repositories.is_empty() {
342            return true;
343        }
344        let Some(bundle) = self.config.bundles.get(&session.bundle_id) else {
345            return false;
346        };
347        receipt.repositories.iter().all(|expected| {
348            bundle
349                .repositories
350                .iter()
351                .any(|configured| configured == expected)
352        })
353    }
354
355    /// Validate a replacement first, then atomically save it and check the
356    /// remaining sources so multi-repository bundles can report the next moved
357    /// repository without ever provisioning a partial target.
358    pub fn replace_resume_repository_origin(
359        &mut self,
360        session_id: &str,
361        repository_id: &str,
362        replacement: &str,
363        executor: &(impl CommandExecutor + Sync),
364    ) -> Result<ResumeRepositorySourcePreflight> {
365        let session = self
366            .state
367            .sessions
368            .get(session_id)
369            .with_context(|| format!("unknown session {session_id}"))?;
370        let bundle_id = session.bundle_id.clone();
371        let checkpoint = session
372            .checkpoint
373            .as_ref()
374            .context("session has no checkpoint")?;
375        let replacement = replacement_repository_source(repository_id, replacement)?;
376        let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
377        let verified = ResumeRepositoryBundles {
378            checkpoint_sha256: checkpoint.sha256.clone(),
379            repositories,
380        };
381        let archived = verified
382            .repositories
383            .iter()
384            .find(|repository| repository.metadata.id == repository_id)
385            .with_context(|| format!("checkpoint does not contain repository {repository_id:?}"))?;
386        if let Some(missing_commit) = checkpoint_source_missing_commit(
387            &replacement,
388            archived,
389            executor,
390            controller_github_token().as_deref(),
391        )? {
392            return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(
393                ResumeRepositorySourceMismatch {
394                    session_id: session_id.to_owned(),
395                    bundle_id,
396                    repository_id: repository_id.to_owned(),
397                    missing_commit,
398                    archived_origin: archived.metadata.origin.clone(),
399                    configured_origin: replacement.source_label(),
400                },
401            ));
402        }
403        let (config, ()) = Config::update(|config| {
404            let bundle = config
405                .bundles
406                .get_mut(&bundle_id)
407                .with_context(|| format!("session bundle {bundle_id:?} is missing"))?;
408            let repository = bundle
409                .repositories
410                .iter_mut()
411                .find(|repository| repository.id == repository_id)
412                .with_context(|| {
413                    format!(
414                        "session bundle {:?} no longer contains repository {repository_id:?}",
415                        bundle_id
416                    )
417                })?;
418            repository.github = replacement.github.clone();
419            repository.local = replacement.local.clone();
420            Ok(())
421        })?;
422        self.config = config;
423        self.preflight_verified_repository_sources(
424            session_id,
425            verified,
426            Some(repository_id),
427            false,
428            executor,
429        )
430    }
431}
432
433/// Plan a local checkout's conversion into an isolated workspace and describe
434/// it, without changing anything.
435///
436/// The resume preflight and the browser's resume card both need this answer
437/// before a person confirms, and neither owns a [`Controller`] at that point,
438/// so it takes the record and the configuration directly.
439pub fn raw_conversion_preview_for(
440    session: &SessionRecord,
441    config: &Config,
442    executor: &(impl CommandExecutor + Sync),
443) -> Result<mj_core::state::RawConversionPreview> {
444    let conversion = plan_raw_to_workspace(session, config, executor)?;
445    raw_conversion_preview(session, &conversion, executor)
446}
447
448fn replacement_repository_source(id: &str, replacement: &str) -> Result<ProjectRepository> {
449    let replacement = replacement.trim();
450    ensure!(!replacement.is_empty(), "enter the repository's new origin");
451    let expanded = mj_core::path_input::expand_local(Path::new(replacement))?;
452    let path = expanded.as_path();
453    let (github, local) = if path.is_absolute() {
454        ensure!(
455            path.is_dir(),
456            "local repository {replacement:?} is not a directory"
457        );
458        (None, Some(mj_core::local_git::canonical_repository(path)?))
459    } else {
460        let github = crate::setup::github_repository_from_origin(replacement)
461            .context("origin must be a GitHub repository or an absolute local repository path")?;
462        (
463            Some(format!("{}/{}", github.owner, github.repository)),
464            None,
465        )
466    };
467    Ok(ProjectRepository {
468        id: id.to_owned(),
469        github,
470        local,
471        destination: PathBuf::from(id),
472        git_ref: None,
473    })
474}
475
476fn checkpoint_source_missing_commit(
477    configured: &ProjectRepository,
478    archived: &CheckpointRepositoryBundle,
479    executor: &impl CommandExecutor,
480    github_token: Option<&str>,
481) -> Result<Option<String>> {
482    let staging = tempfile::tempdir().context("create repository source preflight")?;
483    let repository = staging.path().join("repository.git");
484    checked_preflight_git(
485        executor,
486        CommandSpec::new(
487            "git",
488            [
489                "init".to_owned(),
490                "--bare".to_owned(),
491                "--quiet".to_owned(),
492                repository.to_string_lossy().into_owned(),
493            ],
494        )
495        .purpose("initialize repository source preflight"),
496    )?;
497    let missing = checkpoint_bundle_prerequisites(archived)?;
498    if missing.is_empty() {
499        let bundle = staging.path().join("checkpoint.bundle");
500        std::fs::write(&bundle, &archived.committed_bundle)
501            .context("write self-contained checkpoint bundle for source preflight")?;
502        checked_preflight_git(
503            executor,
504            checkpoint_bundle_import_command(&repository, &bundle),
505        )?;
506        return Ok(None);
507    }
508    // The restore clone will obtain the reachable ancestry. This probe only
509    // needs to establish that the source still serves each boundary object, so
510    // stop at that object instead of downloading and walking its whole graph.
511    for commit in missing {
512        let output = fetch_source_commit(executor, &repository, configured, &commit, github_token)?;
513        if output.status != 0 {
514            let stderr = String::from_utf8_lossy(&output.stderr);
515            if source_does_not_have_commit(&stderr) {
516                return Ok(Some(commit));
517            }
518            bail!(
519                "could not check configured source {:?}: {}",
520                configured.source_label(),
521                stderr.trim()
522            );
523        }
524    }
525    // Do not re-index the bundle against this deliberately shallow probe: the
526    // shallow marker would make Git report artificial connectivity failures.
527    // The real restore applies it to the full source clone.
528    Ok(None)
529}
530
531fn checkpoint_bundle_import_command(repository: &Path, bundle: &Path) -> CommandSpec {
532    let mut command = CommandSpec::new(
533        "git",
534        [
535            "-C".to_owned(),
536            repository.to_string_lossy().into_owned(),
537            "fetch".to_owned(),
538            "--no-tags".to_owned(),
539            bundle.to_string_lossy().into_owned(),
540            "HEAD".to_owned(),
541        ],
542    )
543    .purpose("validate self-contained checkpoint bundle");
544    command
545        .env
546        .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
547    command
548        .env
549        .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
550    command
551}
552
553fn fetch_source_commit(
554    executor: &impl CommandExecutor,
555    repository: &Path,
556    configured: &ProjectRepository,
557    commit: &str,
558    github_token: Option<&str>,
559) -> Result<CommandOutput> {
560    let mut arguments = Vec::new();
561    let mut token_auth = false;
562    let mut ssh_transport = false;
563    let source = if let Some(local) = &configured.local {
564        local.to_string_lossy().into_owned()
565    } else {
566        let source = configured
567            .github
568            .as_deref()
569            .context("repository source is missing")?;
570        let github = crate::setup::github_repository_from_origin(source)
571            .context("configured repository is not a GitHub source")?;
572        if github_token.is_some() {
573            token_auth = true;
574            arguments.extend([
575                "-c".to_owned(),
576                "credential.helper=".to_owned(),
577                "-c".to_owned(),
578                "credential.helper=!f() { if [ \"$1\" = get ]; then echo username=x-access-token; echo \"password=$GH_TOKEN\"; fi; }; f".to_owned(),
579            ]);
580            format!(
581                "https://github.com/{}/{}.git",
582                github.owner, github.repository
583            )
584        } else {
585            ssh_transport = true;
586            format!("git@github.com:{}/{}.git", github.owner, github.repository)
587        }
588    };
589    arguments.extend([
590        "-C".to_owned(),
591        repository.to_string_lossy().into_owned(),
592        "fetch".to_owned(),
593        "--no-tags".to_owned(),
594        "--depth=1".to_owned(),
595        "--filter=blob:none".to_owned(),
596        source,
597        commit.to_owned(),
598    ]);
599    let mut command = CommandSpec::new("git", arguments).purpose("check checkpoint base commit");
600    command
601        .env
602        .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
603    command
604        .env
605        .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
606    if token_auth {
607        let token = github_token.expect("token authentication requires a GitHub token");
608        command.env.insert("GH_TOKEN".to_owned(), token.to_owned());
609    }
610    if ssh_transport {
611        command.env.insert(
612            "GIT_SSH_COMMAND".to_owned(),
613            "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15"
614                .to_owned(),
615        );
616    }
617    executor.execute(&command)
618}
619
620fn source_does_not_have_commit(stderr: &str) -> bool {
621    let stderr = stderr.to_ascii_lowercase();
622    [
623        "not our ref",
624        "couldn't find remote ref",
625        "not a valid object name",
626        "no such ref was fetched",
627    ]
628    .iter()
629    .any(|needle| stderr.contains(needle))
630}
631
632fn checked_preflight_git(
633    executor: &impl CommandExecutor,
634    command: CommandSpec,
635) -> Result<CommandOutput> {
636    let output = executor.execute(&command)?;
637    ensure!(
638        output.status == 0,
639        "{}: {}",
640        command.purpose,
641        String::from_utf8_lossy(&output.stderr).trim()
642    );
643    Ok(output)
644}
645
646impl Controller {
647    /// Resume a stopped logical session on any configured profile and
648    /// target. Cross-harness resume restores Git and canonical history, starts
649    /// a fresh native session, and supplies the prior transcript as its first
650    /// context turn.
651    pub async fn resume_session_with_options(
652        &mut self,
653        session_id: &str,
654        profile_id: &str,
655        target_id: &str,
656        additional_mounts: Option<Vec<AdditionalMount>>,
657        resource_allocation: Option<SessionResourceAllocation>,
658    ) -> Result<MaterializedSession> {
659        self.resume_session_with_options_and_queue_disposition(
660            session_id,
661            profile_id,
662            target_id,
663            additional_mounts,
664            resource_allocation,
665            false,
666        )
667        .await
668    }
669
670    pub async fn resume_session_with_options_and_queue_disposition(
671        &mut self,
672        session_id: &str,
673        profile_id: &str,
674        target_id: &str,
675        additional_mounts: Option<Vec<AdditionalMount>>,
676        resource_allocation: Option<SessionResourceAllocation>,
677        discard_queue: bool,
678    ) -> Result<MaterializedSession> {
679        self.resume_session_controlled(
680            session_id,
681            profile_id,
682            target_id,
683            SessionResumeOptions {
684                additional_mounts,
685                resource_allocation,
686                discard_queue,
687            },
688            &ProcessExecutor,
689        )
690        .await
691    }
692
693    pub async fn resume_session_controlled(
694        &mut self,
695        session_id: &str,
696        profile_id: &str,
697        target_id: &str,
698        options: SessionResumeOptions,
699        executor: &(impl CommandExecutor + Sync),
700    ) -> Result<MaterializedSession> {
701        self.resume_session_controlled_with_repository_preflight(
702            session_id, profile_id, target_id, options, None, executor,
703        )
704        .await
705    }
706
707    pub async fn resume_session_controlled_with_repository_preflight(
708        &mut self,
709        session_id: &str,
710        profile_id: &str,
711        target_id: &str,
712        options: SessionResumeOptions,
713        repository_preflight: Option<ResumeRepositorySourceReceipt>,
714        executor: &(impl CommandExecutor + Sync),
715    ) -> Result<MaterializedSession> {
716        let SessionResumeOptions {
717            additional_mounts,
718            resource_allocation,
719            discard_queue,
720        } = options;
721        let previous = self
722            .state
723            .sessions
724            .get(session_id)
725            .with_context(|| format!("unknown session {session_id}"))?
726            .clone();
727        if !matches!(
728            previous.state,
729            SessionState::Stopped | SessionState::Lost | SessionState::Error
730        ) {
731            bail!("session {session_id} is not stopped, lost, or retryable");
732        }
733        let checkpoint = previous
734            .checkpoint
735            .as_ref()
736            .context("session has no checkpoint")?;
737        // A receipt for an isolated destination says nothing about a host
738        // checkout. Explicit moves to raw execution must check that source.
739        let moving_to_raw = previous.project_directory.is_none()
740            && self
741                .config
742                .targets
743                .get(target_id)
744                .is_some_and(mj_core::config::is_bare_project_target);
745        if moving_to_raw
746            || !repository_preflight.as_ref().is_some_and(|receipt| {
747                self.repository_source_receipt_is_current(session_id, receipt)
748            })
749        {
750            let _phase = ResumePhaseTimer::new(session_id, "preflight repository sources");
751            if let ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) =
752                self.preflight_repository_sources(session_id, target_id, false, executor)?
753            {
754                bail!(
755                    "checkpoint base commit {} is missing from configured source {:?} for repository {:?}; the repository may have moved (archived origin: {:?})",
756                    mismatch.missing_commit,
757                    mismatch.configured_origin,
758                    mismatch.repository_id,
759                    mismatch.archived_origin,
760                );
761            }
762        }
763        // Canonicalize before verification and keep this exact absolute path
764        // for the restore. A LocalBare worker shares the controller's
765        // filesystem, so it can consume the verified archive directly instead
766        // of copying a second large file into its worker root.
767        let archive_path = {
768            let _phase = ResumePhaseTimer::new(session_id, "verify checkpoint archive");
769            checkpoint.archive_path.canonicalize().with_context(|| {
770                format!(
771                    "resolve checkpoint archive {}",
772                    checkpoint.archive_path.display()
773                )
774            })?
775        };
776        ensure!(
777            archive_path.is_absolute() && archive_path.is_file(),
778            "checkpoint archive path is not an absolute regular file: {}",
779            archive_path.display()
780        );
781        // Take the snapshot out of the verified metadata and share it behind an
782        // `Arc`: on a long session it is tens of megabytes, and resume reads it
783        // from three places that used to hold private copies.
784        let mj_checkpoint::archive::VerifiedArchiveMetadata {
785            manifest: archive_manifest,
786            canonical_session,
787            archive_sha256,
788        } = {
789            let _phase = ResumePhaseTimer::new(session_id, "verify checkpoint archive contents");
790            verify_archive_streaming(&archive_path)?
791        };
792        if archive_sha256 != checkpoint.sha256 || archive_manifest.session.id != session_id {
793            bail!("persisted checkpoint verification failed");
794        }
795        ensure!(
796            archive_manifest.repositories.iter().all(|repository| {
797                !repository.metadata.origin.starts_with("mj-local:")
798                    && !repository.metadata.origin.starts_with("ext::")
799            }),
800            "resuming legacy host-bridge sessions is not supported; start a new network-backed session"
801        );
802        let canonical_session = Arc::new(canonical_session);
803        let profile = self
804            .config
805            .profiles
806            .get(profile_id)
807            .with_context(|| format!("unknown profile {profile_id:?}"))?
808            .clone();
809        ensure!(profile.enabled, "profile {profile_id:?} is disabled");
810        let target_template = self
811            .config
812            .targets
813            .get(target_id)
814            .with_context(|| format!("unknown target template {target_id:?}"))?
815            .clone();
816        // Decide the representation before the record changes, so an
817        // incompatible target fails here instead of during provisioning.
818        self.validate_muse_resume_destination(&previous, profile.kind, target_id)?;
819        ensure!(
820            profile.kind != HarnessKind::Muse || previous.additional_mounts.is_empty(),
821            "Muse Code ACP supports one workspace root; attached directories are unsupported"
822        );
823        let plan = resume_compatibility(&previous, &self.config, target_id)
824            .map_err(|reason| anyhow::anyhow!("{reason}"))?;
825        // A converting resume writes its own archive below, from the host
826        // checkout's own network remote, and provisioning reads that one. Every
827        // other isolated resume clones what its stored archive already names.
828        if !mj_core::config::is_bare_project_target(&target_template)
829            && plan != ResumePlan::RawToWorkspace
830        {
831            super::network_git::bundle_from_manifest(&archive_manifest)?;
832        }
833        if plan == ResumePlan::InPlace
834            && previous.managed_worktree.is_none()
835            && let Some(project_directory) = &previous.project_directory
836        {
837            self.validate_project_directory(target_id, project_directory, executor)
838                .context("raw project is unavailable for resume")?;
839        }
840        let conversion = match plan {
841            ResumePlan::InPlace => None,
842            ResumePlan::RawToWorkspace => Some(ResumeConversion::RawToWorkspace(
843                plan_raw_to_workspace(&previous, &self.config, executor)
844                    .context("prepare the raw checkout for its new target")?,
845            )),
846            ResumePlan::WorkspaceToRaw => Some(ResumeConversion::WorkspaceToRaw(
847                self.plan_workspace_to_raw(&previous, target_id, executor)
848                    .context("prepare a checkout for this session")?,
849            )),
850        };
851        let resource_allocation =
852            resource_allocation.or_else(|| previous.resource_allocation.clone());
853        let additional_mounts =
854            additional_mounts.unwrap_or_else(|| previous.additional_mounts.clone());
855        validate_resource_allocation(&target_template, resource_allocation.as_ref())?;
856        let selected_container_size =
857            selected_host_container_size(&target_template, resource_allocation.as_ref());
858        if !additional_mounts.is_empty() && mount_history_host(&target_template).is_none() {
859            bail!("attached resources are unsupported for this target");
860        }
861        targets::validate_additional_mounts(&additional_mounts)?;
862        let history_host = mount_history_host(&target_template);
863        let history_mounts = additional_mounts.clone();
864        if previous.state == SessionState::Error
865            && let Some(locator) = &previous.target
866        {
867            let backend = backend_locator(locator, &previous, &self.config)?;
868            targets::close_plan(&backend, session_id)?
869                .execute(executor)
870                .context("clean up target from failed resume")?;
871        }
872        let mut resume_notices = Vec::new();
873        // The raw-to-workspace notice is written where the conversion snapshot
874        // is taken, because it reports the branch the container arrives on.
875        if let Some(conversion) = conversion
876            .as_ref()
877            .and_then(ResumeConversion::workspace_to_raw)
878        {
879            resume_notices.push(format!(
880                "This session moved out of its {} target and into {}. Its branch {} is now {}.",
881                previous.target_template_id,
882                conversion.worktree.worktree_root.display(),
883                archive_manifest
884                    .repositories
885                    .first()
886                    .and_then(|repository| repository.metadata.branch.as_deref())
887                    .unwrap_or("a detached head"),
888                conversion.worktree.branch,
889            ));
890        }
891        let managed_checkout_present = previous
892            .managed_worktree
893            .as_ref()
894            .map(|worktree| managed_worktree_checkout_exists(executor, worktree))
895            .transpose()?
896            .unwrap_or(true);
897        // A checkout Mjolnir did not retire remains the truth for a raw session.
898        // A retired checkout is recreated from the branch and archive below.
899        if managed_checkout_present && let Some(project_directory) = &previous.project_directory {
900            match raw_checkout_position(&previous, &self.config, project_directory, executor) {
901                Ok(live) => resume_notices.extend(raw_checkout_divergence_notice(
902                    project_directory,
903                    archive_manifest
904                        .repositories
905                        .first()
906                        .map(|repository| &repository.metadata),
907                    &live,
908                )),
909                // Informational only: a resume must not fail because Mjolnir could
910                // not read where the checkout stands.
911                Err(error) => tracing::warn!(
912                    session_id,
913                    error = format!("{error:#}"),
914                    "could not read the raw checkout position for a resume notice"
915                ),
916            }
917        }
918        // Resolving the worker binary is local and costs microseconds, while
919        // the compaction below costs minutes and paid model requests. A resume
920        // that could never install a worker fails here rather than after all
921        // that work has been thrown away.
922        super::worker_binary::preflight_worker_binary(&target_template)?;
923        let same_harness = profile.kind == archive_manifest.session.harness_kind;
924        let native_continuity =
925            native_continuity_preserved(profile.kind, archive_manifest.session.harness_kind);
926        let context_bytes = crate::handoff::profile_handoff_bytes(Some(&profile));
927        // Cross-harness compaction is started alongside destination
928        // provisioning below. Clone only the configuration it reads so the
929        // controller can continue owning and mutating its session record.
930        let utility_config = (!native_continuity).then(|| self.config.clone());
931        let discard_queued_prompts = discard_queue || !same_harness;
932        // When this controller archived the session, its durable projection is
933        // already the archive's content. Reading one row decides that; a read
934        // failure or any mismatch rebuilds as before.
935        let stored_frontier = crate::database::materialized_event_frontier(session_id)
936            .unwrap_or_else(|error| {
937                tracing::warn!(
938                    session_id,
939                    error = format!("{error:#}"),
940                    "could not read the stored projection frontier; rebuilding it from the archive"
941                );
942                None
943            });
944        let rebuild_projection = projection_rebuild_required(
945            stored_frontier
946                .as_ref()
947                .map(|(ordinal, digest)| (*ordinal, digest.as_str())),
948            canonical_session.event_frontier,
949            &canonical_session.event_frontier_digest,
950        );
951        // Rebuilding the projection is a pure function of the archive and costs
952        // seconds on a long session. Start it now so it runs while the target is
953        // being provisioned; its result is awaited where it was consumed
954        // before, and the writes it feeds have not moved.
955        //
956        // A resume that fails before the result is needed drops the handle.
957        // `spawn_blocking` work cannot be cancelled, so the computation still
958        // finishes on the blocking pool and its result is discarded; it owns
959        // nothing but its own inputs, so nothing leaks beyond that CPU.
960        let projection_build = rebuild_projection.then(|| {
961            let canonical = Arc::clone(&canonical_session);
962            let session_id = session_id.to_owned();
963            tokio::task::spawn_blocking(move || {
964                materialized_session_from_canonical(session_id, &canonical)
965            })
966        });
967        let github_token = controller_github_token();
968
969        // The configuration gains the bundle before the record points at it, so
970        // no persisted session ever names a bundle that is not there.
971        if let Some(conversion) = conversion
972            .as_ref()
973            .and_then(ResumeConversion::raw_to_workspace)
974            && let Some(bundle) = &conversion.new_bundle
975        {
976            let (config, ()) = Config::update(|config| {
977                if let Some(existing) = config.bundles.get(&conversion.bundle_id) {
978                    ensure!(
979                        existing == bundle,
980                        "bundle {:?} was configured concurrently with a different definition; retry the resume",
981                        conversion.bundle_id
982                    );
983                } else {
984                    config
985                        .bundles
986                        .insert(conversion.bundle_id.clone(), bundle.clone());
987                }
988                Ok(())
989            })
990            .context("save the bundle for a converted raw session")?;
991            self.config = config;
992        }
993
994        let record = self.state.sessions.get_mut(session_id).unwrap();
995        record.harness_kind = profile.kind;
996        record.last_profile = profile_id.to_string();
997        record.target_template_id = target_id.to_string();
998        record.resource_allocation = resource_allocation;
999        record.additional_mounts = additional_mounts;
1000        record.target = None;
1001        record.native_session_id =
1002            native_continuity.then(|| archive_manifest.session.native_session_id.clone());
1003        record.state = SessionState::Provisioning;
1004        record.updated_at = now();
1005        record.last_error = None;
1006        match &conversion {
1007            Some(ResumeConversion::RawToWorkspace(conversion)) => {
1008                apply_raw_to_workspace(record, conversion);
1009            }
1010            Some(ResumeConversion::WorkspaceToRaw(conversion)) => {
1011                apply_workspace_to_raw(record, conversion);
1012            }
1013            None => {}
1014        }
1015        let resumed_project_directory = record.project_directory.clone();
1016        if let Some(host) = history_host {
1017            self.state.remember_mount_sources(host, &history_mounts);
1018            crate::database::remember_mount_sources(host, &history_mounts)?;
1019        }
1020        // The session's prompt history is filed under its bundle, so a
1021        // conversion moves the history with it before the record is persisted.
1022        if let Some(conversion) = conversion
1023            .as_ref()
1024            .and_then(ResumeConversion::raw_to_workspace)
1025        {
1026            crate::database::rebind_session_bundle(session_id, &conversion.bundle_id)?;
1027        }
1028        // Resume rewrites the record it resumes, including the attached
1029        // directories and the harness session id, so it writes the whole row.
1030        if let Some((host, size)) = selected_container_size.as_ref() {
1031            crate::database::save_session_with_container_size(
1032                &self.state.sessions[session_id],
1033                host,
1034                *size,
1035            )?;
1036        } else {
1037            crate::database::save_session(&self.state.sessions[session_id])?;
1038        }
1039        if let Some((host, size)) = selected_container_size.as_ref() {
1040            self.state.remember_container_size(host, *size);
1041        }
1042
1043        let mut recreated_managed_worktree = false;
1044        // Set once a conversion has written its archive, so the success path can
1045        // retire the archive it replaced and the failure path can remove it.
1046        let mut conversion_checkpoint_written: Option<mj_core::state::CheckpointMetadata> = None;
1047        let result = async {
1048            if let Some(worktree) = previous.managed_worktree.as_ref() {
1049                recreated_managed_worktree = restore_managed_worktree(executor, worktree)?;
1050                if recreated_managed_worktree && plan == ResumePlan::RawToWorkspace {
1051                    mj_checkpoint::checkpoint::restore_single_repository_onto_branch(
1052                        &archive_path,
1053                        &worktree.worktree_root,
1054                        &worktree.branch,
1055                        &SystemGit,
1056                    )
1057                    .context("restore the retired checkout before moving it into a target")?;
1058                }
1059            }
1060            // The record already names the worktree, so a failure here rolls
1061            // back through the same path that cleans up a new session's.
1062            if let Some(conversion) = conversion
1063                .as_ref()
1064                .and_then(ResumeConversion::workspace_to_raw)
1065            {
1066                if conversion.reuse_existing_branch {
1067                    let recovery_ref =
1068                        preserve_retained_managed_worktree_branch(executor, &conversion.worktree)?;
1069                    restore_managed_worktree(executor, &conversion.worktree)?;
1070                    resume_notices.push(format!(
1071                        "Before restoring this session's retained branch, Mjolnir preserved its tip at {recovery_ref}."
1072                    ));
1073                } else {
1074                    create_managed_worktree(
1075                        executor,
1076                        &conversion.worktree,
1077                        None,
1078                        PrimaryCheckoutRequirement::Any,
1079                    )?;
1080                }
1081                mj_checkpoint::checkpoint::restore_single_repository_onto_branch(
1082                    &archive_path,
1083                    &conversion.worktree.worktree_root,
1084                    &conversion.worktree.branch,
1085                    &SystemGit,
1086                )
1087                .context("restore this session's checkout")?;
1088            }
1089            // A local checkout becomes an isolated workspace by being
1090            // re-snapshotted into a new archive whose provenance is the
1091            // checkout's own network remote. Provisioning clones that remote,
1092            // and the restore below lays this snapshot over the fresh clone.
1093            if let Some(conversion) = conversion
1094                .as_ref()
1095                .and_then(ResumeConversion::raw_to_workspace)
1096            {
1097                let destination = PathBuf::from(
1098                    previous
1099                        .project_directory
1100                        .as_deref()
1101                        .context("a raw session has no project directory")?
1102                        .file_name()
1103                        .context("a raw project directory cannot be the filesystem root")?,
1104                );
1105                let snapshot = raw_checkout_snapshot(
1106                    &conversion.checkout,
1107                    &conversion.source,
1108                    &destination,
1109                    &SystemGit,
1110                )
1111                .context("snapshot the host checkout for its new target")?;
1112                resume_notices.push(conversion_notice(
1113                    target_id,
1114                    previous
1115                        .project_directory
1116                        .as_deref()
1117                        .unwrap_or(&conversion.checkout),
1118                    snapshot.metadata.branch.as_deref(),
1119                    conversion.retire.as_ref(),
1120                ));
1121                let archives = mj_core::config::sessions_dir();
1122                std::fs::create_dir_all(&archives).with_context(|| {
1123                    format!("create the checkpoint directory {}", archives.display())
1124                })?;
1125                // Named like every other managed archive, so an interrupted
1126                // conversion's file is reconciled away, with `converted`
1127                // marking where it came from.
1128                let output = archives.join(format!(
1129                    "{session_id}-converted-{}-{}.hel.zip",
1130                    previous
1131                        .checkpoint
1132                        .as_ref()
1133                        .map_or(0, |checkpoint| checkpoint.event_frontier),
1134                    new_command_id("archive")?
1135                ));
1136                let written = conversion_checkpoint(&archive_path, snapshot, &output)?;
1137                conversion_checkpoint_written = Some(written.clone());
1138                let record = self.state.sessions.get_mut(session_id).unwrap();
1139                record.checkpoint = Some(written);
1140                record.updated_at = now();
1141                // The whole row again: provisioning must read the converted
1142                // archive even if this process dies right here.
1143                if let Some((host, size)) = selected_container_size.as_ref() {
1144                    crate::database::save_session_with_container_size(
1145                        &self.state.sessions[session_id],
1146                        host,
1147                        *size,
1148                    )?;
1149                } else {
1150                    crate::database::save_session(&self.state.sessions[session_id])?;
1151                }
1152            }
1153            let utility_handoff = {
1154                let _provisioning = ResumePhaseTimer::new(session_id, "provision destination");
1155                if let Some(config) = utility_config.as_ref() {
1156                    Some(
1157                        provision_with_cross_harness_handoff(
1158                            self,
1159                            session_id,
1160                            executor,
1161                            github_token.as_deref(),
1162                            config,
1163                            &canonical_session,
1164                            context_bytes,
1165                        )
1166                        .context("prepare the cross-harness destination")?,
1167                    )
1168                } else {
1169                    self.provision_session_with_failure_disposition(
1170                        session_id,
1171                        executor,
1172                        github_token.as_deref(),
1173                        ProvisioningFailureDisposition::Preserve,
1174                    )
1175                    .await?;
1176                    None
1177                }
1178            };
1179            let (backend, worker_root) = self.worker_placement(session_id)?;
1180            let harness_home = target_profile_home(&backend, session_id, &profile);
1181            let workspace_root = if let Some(project_directory) = &resumed_project_directory {
1182                project_directory
1183                    .parent()
1184                    .context("bare project directory has no parent")?
1185                    .to_string_lossy()
1186                    .into_owned()
1187            } else {
1188                match &backend {
1189                    targets::TargetLocator::LocalPodman { .. }
1190                    | targets::TargetLocator::LocalDocker { .. }
1191                    | targets::TargetLocator::AppleContainer { .. }
1192                    | targets::TargetLocator::SshPodman { .. }
1193                    | targets::TargetLocator::SshDocker { .. } => "/workspace".to_string(),
1194                    targets::TargetLocator::AwsEc2 { workspace, .. }
1195                    | targets::TargetLocator::SshBare { workspace, .. } => workspace.clone(),
1196                    targets::TargetLocator::LocalBare { worker_root } => worker_root.clone(),
1197                }
1198            };
1199            let target_path = |path: &str| match &backend {
1200                targets::TargetLocator::AwsEc2 { .. }
1201                | targets::TargetLocator::SshBare { .. }
1202                    if !path.starts_with('/') =>
1203                {
1204                    PathBuf::from(format!("~/{path}"))
1205                }
1206                _ => PathBuf::from(path),
1207            };
1208            let remote_archive = format!("{worker_root}/restore.hel.zip");
1209            let remote_spec = format!("{worker_root}/restore-spec.json");
1210            // A conversion restores the archive it just wrote, not the raw one
1211            // the session was stopped with.
1212            let restored_archive = conversion_checkpoint_written
1213                .as_ref()
1214                .map_or(archive_path.as_path(), |checkpoint| {
1215                    checkpoint.archive_path.as_path()
1216                });
1217            let restore = CheckpointRestoreSpec {
1218                archive_path: restore_archive_path(
1219                    &backend,
1220                    restored_archive,
1221                    &target_path(&remote_archive),
1222                ),
1223                workspace_root: target_path(&workspace_root),
1224                relay_root: target_path(&worker_root),
1225                harness_home: target_path(&harness_home),
1226                // A local checkout converting into a workspace arrives as a
1227                // fresh clone of its own remote, and the conversion archive
1228                // carries the commits, dirty files, and branch that go over it.
1229                // An in-place managed checkout recreated from its retained
1230                // branch still needs the archive's dirty state.
1231                restore_repositories: (resumed_project_directory.is_none()
1232                    && conversion.is_none())
1233                    || plan == ResumePlan::RawToWorkspace
1234                    || (recreated_managed_worktree && plan == ResumePlan::InPlace),
1235                restore_native: native_continuity,
1236                // A move onto a checkout puts it somewhere the archive could
1237                // not have named, so the restored harness session is pointed at
1238                // the real working directory instead of the archived one. A
1239                // move into a target has no host directory left, and the
1240                // conversion archive already names the destination under
1241                // `/workspace`, so this stays empty there.
1242                primary_repository_root: conversion
1243                    .is_some()
1244                    .then(|| resumed_project_directory.clone())
1245                    .flatten()
1246                    .map(|directory| target_path(&directory.to_string_lossy())),
1247                discard_queued_prompts,
1248            };
1249            // A bare target keeps the closed session's worker root on the host.
1250            // Stop anything still writing there and clear the leftover relay
1251            // state, or the restore's seed loses to a stale snapshot whose
1252            // frontier no journal can support. This runs before the worker
1253            // binary is installed: a surviving daemon still holds the old one
1254            // open, and the install would land on a running executable.
1255            {
1256                let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1257                if let Some(command) = targets::clear_relay_state_plan(&backend, session_id)? {
1258                    execute_checked(syncing, command)?;
1259                }
1260                // Both lanes below write into the worker root, so it exists first.
1261                execute_checked(
1262                    syncing,
1263                    targets::command_on_locator(
1264                        &backend,
1265                        session_id,
1266                        vec!["mkdir".into(), "-p".into(), worker_root.clone()],
1267                        "create the session worker root",
1268                    )?,
1269                )?;
1270            }
1271            let staging = tempfile::tempdir().context("create restore staging")?;
1272            let local_spec = staging.path().join("restore-spec.json");
1273            std::fs::write(&local_spec, serde_json::to_vec_pretty(&restore)?)?;
1274            // Two independent lanes into the target. The checkpoint transfer
1275            // needs nothing from the worker install, and the worker install
1276            // is independent of archive upload, so both run concurrently.
1277            let controller = &*self;
1278            let backend_ref = &backend;
1279            let worker_root_ref = worker_root.as_str();
1280            let local_spec_ref = local_spec.as_path();
1281            execute_concurrent_lanes(
1282                || {
1283                    let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1284                    controller.prepare_worker_files(
1285                        session_id,
1286                        backend_ref,
1287                        worker_root_ref,
1288                        syncing,
1289                    )?;
1290                    super::provisioning::install_inherited_git_settings(
1291                        syncing,
1292                        backend_ref,
1293                        session_id,
1294                    )?;
1295                    Ok(())
1296                },
1297                || {
1298                    let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1299                    if should_upload_restore_archive(&backend) {
1300                        upload_checkpoint_spec(
1301                            restoring,
1302                            backend_ref,
1303                            session_id,
1304                            restored_archive,
1305                            &remote_archive,
1306                        )?;
1307                    }
1308                    upload_checkpoint_spec(
1309                        restoring,
1310                        backend_ref,
1311                        session_id,
1312                        local_spec_ref,
1313                        &remote_spec,
1314                    )
1315                },
1316            )?;
1317            {
1318                let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1319                execute_checked(
1320                    restoring,
1321                    restore_command(&backend, session_id, &remote_spec)?,
1322                )?;
1323            }
1324            {
1325                let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1326                install_attached_resources(
1327                    &self.state,
1328                    session_id,
1329                    &backend,
1330                    &worker_root,
1331                    syncing,
1332                )?;
1333
1334            }
1335            match projection_build {
1336                Some(build) => {
1337                    let mut restored_projection = build
1338                        .await
1339                        .context("rebuild the restored projection")?
1340                        .context("rebuild the restored projection")?;
1341                    if discard_queued_prompts {
1342                        restored_projection.queued_prompts.clear();
1343                    }
1344                    crate::database::save_materialized_session(&restored_projection)?;
1345                }
1346                // The stored projection already is the archived one. Only the
1347                // queue can still need changing.
1348                None if discard_queued_prompts => {
1349                    crate::database::replace_materialized_queued_prompts(session_id, &[])?;
1350                }
1351                None => {}
1352            }
1353            let readiness_stage = bridge_readiness_stage(&profile);
1354            let spec = self.reconnect_command(session_id)?;
1355            let readiness = async {
1356                let mut relay = {
1357                    let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
1358                    start_worker(executor, &backend, &worker_root)?;
1359                    connect_started_worker(&spec, session_id, executor, &backend, &worker_root)
1360                        .await?
1361                };
1362                let native_session_id =
1363                    wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
1364                Ok::<_, anyhow::Error>((relay, native_session_id))
1365            }
1366            .await;
1367            let (mut relay, native_session_id) = readiness
1368                .map_err(|error| worker_probe_diagnosis(executor, &backend, &worker_root, error))?;
1369            if native_continuity {
1370                if native_session_id != archive_manifest.session.native_session_id {
1371                    bail!(
1372                        "ACP loaded native session {native_session_id}, expected {}",
1373                        archive_manifest.session.native_session_id
1374                    );
1375                }
1376            } else {
1377                relay
1378                    .install_prompt_context(
1379                        utility_handoff
1380                            .clone()
1381                            .context("a resume into a fresh native session has no handoff")?,
1382                    )
1383                    .await?;
1384                if !discard_queue {
1385                    for prompt in &canonical_session.queued_prompts {
1386                        // A queued configuration change is replayed as itself;
1387                        // rebuilding it as a prompt would send `/model x` to
1388                        // the agent as text.
1389                        let command = match &prompt.kind {
1390                            CanonicalQueuedCommandKind::Prompt => RelayCommand::Prompt {
1391                                prompt: prompt
1392                                    .content
1393                                    .iter()
1394                                    .cloned()
1395                                    .map(serde_json::from_value)
1396                                    .collect::<serde_json::Result<Vec<ContentBlock>>>()?,
1397                            },
1398                            CanonicalQueuedCommandKind::SetConfig { key, value } => {
1399                                RelayCommand::SetConfig {
1400                                    key: key.clone(),
1401                                    value: value.clone(),
1402                                }
1403                            }
1404                        };
1405                        relay.submit(prompt.command_id.clone(), command).await?;
1406                    }
1407                }
1408            }
1409            // Last, and only once the resume has otherwise succeeded: a failure
1410            // before this point rolls the record back to a session whose
1411            // worktree still has to be there.
1412            if let Some(worktree) = conversion
1413                .as_ref()
1414                .and_then(ResumeConversion::raw_to_workspace)
1415                .and_then(|plan| plan.retire.as_ref())
1416                && let Err(error) = retire_managed_worktree(executor, worktree)
1417            {
1418                tracing::warn!(
1419                    session_id,
1420                    worktree = %worktree.worktree_root.display(),
1421                    error = format!("{error:#}"),
1422                    "could not retire the old managed worktree after resume"
1423                );
1424                resume_notices.push(worktree_cleanup_notice(&worktree.worktree_root, &error));
1425            }
1426            for notice in &resume_notices {
1427                let submitted = async {
1428                    let command_id = new_command_id("resume-notice")?;
1429                    relay
1430                        .submit(
1431                            command_id,
1432                            RelayCommand::RecordNotice {
1433                                text: notice.clone(),
1434                            },
1435                        )
1436                        .await
1437                }
1438                .await;
1439                // The conversation line is a courtesy. A relay that refuses it
1440                // has not damaged the resume, so report and carry on.
1441                if let Err(error) = submitted {
1442                    tracing::warn!(
1443                        session_id,
1444                        error = format!("{error:#}"),
1445                        "could not record a resume notice in the conversation"
1446                    );
1447                }
1448            }
1449            self.mark_worker_connected(session_id, Some(native_session_id))?;
1450            Ok::<_, anyhow::Error>(relay.sync().await?.materialized)
1451        }
1452        .await;
1453        match result {
1454            Ok(materialized) => {
1455                // The session now runs from the conversion archive, so the raw
1456                // one it replaced can go.
1457                if let Some(written) = &conversion_checkpoint_written {
1458                    super::checkpoint::prune_replaced_checkpoint(
1459                        previous.checkpoint.as_ref(),
1460                        written,
1461                    );
1462                }
1463                Ok(materialized)
1464            }
1465            Err(error) => {
1466                // The rollback puts back a record that names the previous
1467                // archive, so the conversion's archive is nothing but litter.
1468                if let Some(written) = &conversion_checkpoint_written
1469                    && let Err(remove_error) = std::fs::remove_file(&written.archive_path)
1470                    && remove_error.kind() != std::io::ErrorKind::NotFound
1471                {
1472                    tracing::warn!(
1473                        session_id,
1474                        path = %written.archive_path.display(),
1475                        "could not remove the conversion checkpoint after resume failed: {remove_error}"
1476                    );
1477                }
1478                // Put back whatever this resume could have written to the
1479                // durable projection. Both branches restore archived content,
1480                // so they are correct whether or not the write had happened
1481                // when the resume failed.
1482                if rebuild_projection {
1483                    match materialized_session_from_canonical(session_id, &canonical_session) {
1484                        Ok(previous_projection) => {
1485                            if let Err(restore_error) =
1486                                crate::database::save_materialized_session(&previous_projection)
1487                            {
1488                                tracing::error!(
1489                                    session_id,
1490                                    error = format!("{restore_error:#}"),
1491                                    "could not restore the durable projection after resume failed"
1492                                );
1493                            }
1494                        }
1495                        Err(restore_error) => {
1496                            tracing::error!(
1497                                session_id,
1498                                error = format!("{restore_error:#}"),
1499                                "could not rebuild the durable projection after resume failed"
1500                            );
1501                        }
1502                    }
1503                } else if discard_queued_prompts
1504                    && let Err(restore_error) = crate::database::replace_materialized_queued_prompts(
1505                        session_id,
1506                        &mj_transcript::projection::materialized_queued_prompts_from_canonical(
1507                            &canonical_session.queued_prompts,
1508                        ),
1509                    )
1510                {
1511                    tracing::error!(
1512                        session_id,
1513                        error = format!("{restore_error:#}"),
1514                        "could not restore queued prompts after resume failed"
1515                    );
1516                }
1517                Err(self.rollback_failed_resume(
1518                    session_id,
1519                    &previous,
1520                    recreated_managed_worktree,
1521                    error,
1522                    executor,
1523                )?)
1524            }
1525        }
1526    }
1527
1528    pub(super) fn rollback_failed_resume(
1529        &mut self,
1530        session_id: &str,
1531        previous: &SessionRecord,
1532        recreated_managed_worktree: bool,
1533        error: anyhow::Error,
1534        _executor: &impl CommandExecutor,
1535    ) -> Result<anyhow::Error> {
1536        let current = self
1537            .state
1538            .sessions
1539            .get(session_id)
1540            .with_context(|| format!("unknown session {session_id}"))?
1541            .clone();
1542        let cleanup = match current.target.as_ref() {
1543            Some(locator) => (|| -> Result<()> {
1544                let backend = backend_locator(locator, &current, &self.config)?;
1545                targets::close_plan(&backend, session_id)?
1546                    // Use a fresh executor: cancellation applies to the
1547                    // requested operation, not to its compensating cleanup.
1548                    .execute(&CancellableProcessExecutor::with_timeout(
1549                        Duration::from_secs(15),
1550                    ))
1551                    .map(|_| ())
1552            })(),
1553            None => Ok(()),
1554        };
1555        // A failed target teardown may leave its harness writing. Keep its
1556        // checkout intact until a later retry proves the process is stopped.
1557        let worktree_cleanup = if cleanup.is_err() {
1558            Ok(())
1559        } else {
1560            match (
1561                current.managed_worktree.as_ref(),
1562                previous.managed_worktree.as_ref(),
1563            ) {
1564                (_, Some(previous)) if recreated_managed_worktree => retire_managed_worktree(
1565                    &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1566                    previous,
1567                ),
1568                (Some(current), Some(previous)) if current == previous => Ok(()),
1569                (Some(worktree), _) => cleanup_managed_worktree(
1570                    &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1571                    worktree,
1572                ),
1573                (None, _) => Ok(()),
1574            }
1575        };
1576        let cleanup_error = [cleanup, worktree_cleanup]
1577            .into_iter()
1578            .filter_map(Result::err)
1579            .map(|cleanup_error| format!("{cleanup_error:#}"))
1580            .collect::<Vec<_>>()
1581            .join("; ");
1582        if !cleanup_error.is_empty() {
1583            tracing::warn!(
1584                session_id,
1585                error = %cleanup_error,
1586                "resume rollback cleanup reported failures"
1587            );
1588        }
1589        let original = format!("{error:#}");
1590        let record = self.state.sessions.get_mut(session_id).unwrap();
1591        let failure = apply_failed_resume_rollback(
1592            record,
1593            previous,
1594            &original,
1595            (!cleanup_error.is_empty()).then_some(cleanup_error),
1596        );
1597        // A conversion filed the session's prompt history under its new bundle.
1598        // The record went back, so the history goes back with it.
1599        if record.bundle_id != current.bundle_id {
1600            let bundle_id = record.bundle_id.clone();
1601            crate::database::rebind_session_bundle(session_id, &bundle_id)?;
1602        }
1603        // The rollback restores the record the resume replaced, attached
1604        // directories included, so it writes the whole row back.
1605        crate::database::save_session(&self.state.sessions[session_id])?;
1606        Ok(failure)
1607    }
1608}
1609
1610fn worktree_cleanup_notice(worktree_root: &Path, error: &anyhow::Error) -> String {
1611    format!(
1612        "Mjolnir could not remove the worktree at {}: {error:#}. Remove it with `git worktree remove --force {}`.",
1613        worktree_root.display(),
1614        worktree_root.display()
1615    )
1616}
1617
1618pub(super) fn apply_failed_resume_rollback(
1619    current: &mut SessionRecord,
1620    previous: &SessionRecord,
1621    original_error: &str,
1622    cleanup_error: Option<String>,
1623) -> anyhow::Error {
1624    match cleanup_error {
1625        None => {
1626            *current = previous.clone();
1627            current.state = SessionState::Stopped;
1628            current.target = None;
1629            current.updated_at = now();
1630            current.last_error = Some(format!("resume failed: {original_error}"));
1631            anyhow::anyhow!(original_error.to_owned())
1632        }
1633        Some(cleanup_error) => {
1634            let failure = format!(
1635                "{original_error}; cleanup of the partial resume target failed: {cleanup_error}"
1636            );
1637            // Keep the exact partial target and checkout ownership until
1638            // cleanup succeeds; the harness may still be writing there.
1639            // A container conversion has no new managed host checkout, so
1640            // retain the original host checkout that it has not retired yet.
1641            if current.managed_worktree.is_none() {
1642                current
1643                    .project_directory
1644                    .clone_from(&previous.project_directory);
1645                current
1646                    .managed_worktree
1647                    .clone_from(&previous.managed_worktree);
1648                current.bundle_id.clone_from(&previous.bundle_id);
1649            }
1650            current.state = SessionState::Error;
1651            current.updated_at = now();
1652            current.last_error = Some(format!("resume failed: {failure}"));
1653            anyhow::anyhow!(failure)
1654        }
1655    }
1656}
1657
1658/// What the conversation is told when a local session moves into a target.
1659fn conversion_notice(
1660    target_id: &str,
1661    checkout: &Path,
1662    branch: Option<&str>,
1663    retire: Option<&mj_core::state::ManagedWorktree>,
1664) -> String {
1665    let branch = branch.unwrap_or("a detached head");
1666    match retire {
1667        Some(worktree) => format!(
1668            "This session moved out of {} and into the {target_id} target, where its checkout is on {branch}. Its branch {} stays in {}.",
1669            checkout.display(),
1670            worktree.branch,
1671            worktree.source_repository.display()
1672        ),
1673        None => format!(
1674            "This session moved out of {} and into the {target_id} target, where its checkout is on {branch}. The checkout on this machine stays where it is.",
1675            checkout.display()
1676        ),
1677    }
1678}
1679
1680/// The archive a converting resume provisions and restores from: the previous
1681/// archive's session, conversation, and native state, with the host checkout's
1682/// snapshot as its only repository.
1683///
1684/// The snapshot carries network provenance, so this archive is what lets the
1685/// destination clone a real remote and later checkpoint like any other
1686/// isolated session.
1687fn conversion_checkpoint(
1688    previous_archive: &Path,
1689    snapshot: mj_checkpoint::archive::RepositorySnapshot,
1690    output: &Path,
1691) -> Result<mj_core::state::CheckpointMetadata> {
1692    let previous = mj_checkpoint::archive::read_archive_verified(previous_archive)
1693        .with_context(|| format!("read checkpoint archive {}", previous_archive.display()))?;
1694    // Native harness state and relay attachments travel byte for byte: a
1695    // conversion replaces repository content and nothing else.
1696    let native_artifacts = previous
1697        .manifest
1698        .payloads
1699        .iter()
1700        .filter_map(|descriptor| match &descriptor.role {
1701            mj_checkpoint::archive::PayloadRole::NativeArtifact { relative_path } => {
1702                Some((relative_path, descriptor))
1703            }
1704            _ => None,
1705        })
1706        .map(|(relative_path, descriptor)| {
1707            Ok(mj_checkpoint::archive::NativeArtifact {
1708                relative_path: relative_path.clone(),
1709                data: previous.payload(descriptor)?.to_vec(),
1710                mode: descriptor.mode,
1711            })
1712        })
1713        .collect::<Result<Vec<_>>>()?;
1714    let canonical_session = previous.canonical_session()?;
1715    let event_frontier = canonical_session.event_frontier;
1716    let written = mj_checkpoint::archive::write_archive_atomic(
1717        output,
1718        &mj_checkpoint::archive::ArchiveInput {
1719            session: previous.manifest.session.clone(),
1720            // Provenance for a person reading the archive; a restore reads
1721            // nothing from it, so the target it was captured on stands.
1722            target: previous.manifest.target.clone(),
1723            bundle: mj_checkpoint::archive::BundleManifest {
1724                id: previous.manifest.bundle.id.clone(),
1725                // A restore finds the primary repository by this id, and with
1726                // it the working directory the native transcript is rewritten
1727                // to, so it has to name the snapshot.
1728                primary_repository: snapshot.metadata.id.clone(),
1729            },
1730            canonical_session,
1731            native_artifacts,
1732            repositories: vec![snapshot],
1733        },
1734    )
1735    .with_context(|| format!("write the conversion archive {}", output.display()))?;
1736    Ok(mj_core::state::CheckpointMetadata {
1737        archive_path: output.to_path_buf(),
1738        sha256: written.archive_sha256,
1739        created_at: now(),
1740        event_frontier,
1741    })
1742}
1743
1744/// Whether a resume has to rebuild the durable projection from its archive.
1745///
1746/// The projection is a deterministic fold of the relay event chain, so a stored
1747/// projection standing at the archive's frontier *and* carrying the archive's
1748/// frontier digest already holds the archived content: same chain, same
1749/// ordinal, same result. Anything else - no stored row, a different ordinal, a
1750/// different digest, or a frontier that could not be read - rebuilds.
1751fn projection_rebuild_required(
1752    stored: Option<(u64, &str)>,
1753    archive_frontier: u64,
1754    archive_frontier_digest: &str,
1755) -> bool {
1756    stored != Some((archive_frontier, archive_frontier_digest))
1757}
1758
1759fn restore_archive_path(
1760    backend: &targets::TargetLocator,
1761    verified_archive: &Path,
1762    remote_archive: &Path,
1763) -> PathBuf {
1764    if matches!(backend, targets::TargetLocator::LocalBare { .. }) {
1765        verified_archive.to_path_buf()
1766    } else {
1767        remote_archive.to_path_buf()
1768    }
1769}
1770
1771fn should_upload_restore_archive(backend: &targets::TargetLocator) -> bool {
1772    !matches!(backend, targets::TargetLocator::LocalBare { .. })
1773}
1774
1775/// Provisioning currently performs its target plan synchronously inside an
1776/// async function. Run the network-bound cross-harness handoff on a joined
1777/// side runtime so it can make progress during that plan without borrowing
1778/// the mutable controller or leaving work behind on failure.
1779struct CrossHarnessProvisionExecutor<'a, E: CommandExecutor + ?Sized> {
1780    inner: &'a E,
1781    cancellation: CancellationToken,
1782}
1783
1784impl<E: CommandExecutor + ?Sized> CommandExecutor for CrossHarnessProvisionExecutor<'_, E> {
1785    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1786        if self.cancellation.is_cancelled() {
1787            bail!("operation cancelled while provisioning destination");
1788        }
1789        self.inner.execute(command)
1790    }
1791
1792    fn cancellation_requested(&self) -> bool {
1793        self.cancellation.is_cancelled() || self.inner.cancellation_requested()
1794    }
1795
1796    fn stage_started(&self, stage: ProvisionStage) {
1797        self.inner.stage_started(stage);
1798    }
1799
1800    fn stage_finished(&self, stage: ProvisionStage) {
1801        self.inner.stage_finished(stage);
1802    }
1803
1804    fn notify_notice(&self, notice: &str) {
1805        self.inner.notify_notice(notice);
1806    }
1807
1808    fn execute_with_stdin(
1809        &self,
1810        command: &CommandSpec,
1811        input: &mut (dyn std::io::Read + Send),
1812    ) -> Result<CommandOutput> {
1813        if self.cancellation.is_cancelled() {
1814            bail!("operation cancelled while provisioning destination");
1815        }
1816        self.inner.execute_with_stdin(command, input)
1817    }
1818}
1819
1820fn provision_with_cross_harness_handoff(
1821    controller: &mut Controller,
1822    session_id: &str,
1823    executor: &(impl CommandExecutor + Sync),
1824    github_token: Option<&str>,
1825    config: &Config,
1826    snapshot: &CanonicalSessionSnapshot,
1827    context_bytes: usize,
1828) -> Result<String> {
1829    let (_provision, handoff) = execute_joined_cross_harness_work(
1830        "cross-harness provisioning",
1831        move |cancellation| {
1832            let provision_executor = CrossHarnessProvisionExecutor {
1833                inner: executor,
1834                cancellation,
1835            };
1836            futures::executor::block_on(controller.provision_session_with_failure_disposition(
1837                session_id,
1838                &provision_executor,
1839                github_token,
1840                ProvisioningFailureDisposition::Preserve,
1841            ))
1842        },
1843        "cross-harness handoff",
1844        move |cancellation| {
1845            let runtime = tokio::runtime::Builder::new_current_thread()
1846                .enable_all()
1847                .build()
1848                .context("create cross-harness handoff runtime")?;
1849            runtime.block_on(utility_handoff_while_cancellable(
1850                session_id,
1851                config,
1852                snapshot,
1853                context_bytes,
1854                executor,
1855                cancellation,
1856            ))
1857        },
1858    )?;
1859    ensure!(
1860        !executor.cancellation_requested(),
1861        "operation cancelled while provisioning destination"
1862    );
1863    Ok(handoff)
1864}
1865
1866/// Run the two independent cross-harness lanes together, cancelling and
1867/// joining the peer as soon as either lane fails. The lane order is stable so
1868/// diagnostics do not depend on which worker happened to finish first.
1869fn execute_joined_cross_harness_work<A: Send, B: Send>(
1870    first_name: &'static str,
1871    first: impl FnOnce(CancellationToken) -> Result<A> + Send,
1872    second_name: &'static str,
1873    second: impl FnOnce(CancellationToken) -> Result<B> + Send,
1874) -> Result<(A, B)> {
1875    let cancellation = CancellationToken::new();
1876    std::thread::scope(|scope| {
1877        let first_cancel = cancellation.clone();
1878        let mut first_handle = Some(scope.spawn(move || first(first_cancel)));
1879        let second_cancel = cancellation.clone();
1880        let mut second_handle = Some(scope.spawn(move || second(second_cancel)));
1881        let mut first_result = None;
1882        let mut second_result = None;
1883
1884        while first_result.is_none() || second_result.is_none() {
1885            if first_result.is_none()
1886                && first_handle
1887                    .as_ref()
1888                    .is_some_and(|handle| handle.is_finished())
1889            {
1890                let handle = first_handle.take().expect("first lane handle present");
1891                first_result = Some(match handle.join() {
1892                    Ok(result) => result,
1893                    Err(panic) => {
1894                        cancellation.cancel();
1895                        Err(anyhow::anyhow!(
1896                            "{first_name} thread panicked: {}",
1897                            targets::command_thread_panic_message(panic.as_ref())
1898                        ))
1899                    }
1900                });
1901                if first_result.as_ref().is_some_and(Result::is_err) {
1902                    cancellation.cancel();
1903                }
1904            }
1905            if second_result.is_none()
1906                && second_handle
1907                    .as_ref()
1908                    .is_some_and(|handle| handle.is_finished())
1909            {
1910                let handle = second_handle.take().expect("second lane handle present");
1911                second_result = Some(match handle.join() {
1912                    Ok(result) => result,
1913                    Err(panic) => {
1914                        cancellation.cancel();
1915                        Err(anyhow::anyhow!(
1916                            "{second_name} thread panicked: {}",
1917                            targets::command_thread_panic_message(panic.as_ref())
1918                        ))
1919                    }
1920                });
1921                if second_result.as_ref().is_some_and(Result::is_err) {
1922                    cancellation.cancel();
1923                }
1924            }
1925            if first_result.is_none() || second_result.is_none() {
1926                std::thread::sleep(Duration::from_millis(10));
1927            }
1928        }
1929
1930        match (
1931            first_result.expect("first lane result received after joined handle"),
1932            second_result.expect("second lane result received after joined handle"),
1933        ) {
1934            (Err(first), Err(second)) => {
1935                Err(first.context(format!("{second_name} lane also failed: {second:#}")))
1936            }
1937            (Err(error), Ok(_)) => Err(error),
1938            (Ok(_), Err(error)) => Err(error),
1939            (Ok(first), Ok(second)) => Ok((first, second)),
1940        }
1941    })
1942}
1943
1944/// Whether the restored native session can carry the conversation into the
1945/// resumed session.
1946///
1947fn native_continuity_preserved(profile_kind: HarnessKind, archived_kind: HarnessKind) -> bool {
1948    profile_kind == archived_kind
1949}
1950
1951/// Discover a utility model and compact the cross-harness handoff while still
1952/// watching for cancellation. Discovery and compaction can both make several
1953/// network requests, so a cancelled resume must not wait them out.
1954async fn utility_handoff_while_cancellable(
1955    session_id: &str,
1956    config: &Config,
1957    snapshot: &CanonicalSessionSnapshot,
1958    context_bytes: usize,
1959    executor: &impl CommandExecutor,
1960    cancellation: CancellationToken,
1961) -> Result<String> {
1962    let _phase = ResumePhaseTimer::new(session_id, "cross-harness handoff");
1963    if executor.cancellation_requested() {
1964        bail!("operation cancelled while compacting the cross-harness handoff");
1965    }
1966    let _compacting = ProvisionStageGuard::new(executor, ProvisionStage::Compacting);
1967    let cancel = cancellation.child_token();
1968    let operation =
1969        crate::handoff::build_handoff_context(session_id, config, snapshot, context_bytes, &cancel);
1970    tokio::pin!(operation);
1971    loop {
1972        tokio::select! {
1973            context = &mut operation => return context,
1974            _ = cancellation.cancelled() => {
1975                cancel.cancel();
1976                bail!("operation cancelled while compacting the cross-harness handoff");
1977            }
1978            _ = tokio::time::sleep(super::readiness::CANCELLATION_POLL_INTERVAL) => {
1979                if executor.cancellation_requested() {
1980                    cancel.cancel();
1981                    bail!("operation cancelled while compacting the cross-harness handoff");
1982                }
1983            }
1984        }
1985    }
1986}
1987
1988#[cfg(test)]
1989mod tests {
1990    use std::cell::RefCell;
1991    use std::collections::BTreeMap;
1992    use std::path::{Path, PathBuf};
1993    use std::process::Command;
1994    use std::sync::{Barrier, Mutex};
1995
1996    use anyhow::Result;
1997
1998    use crate::controller::test_support::{
1999        FIXTURE_FETCH_URL, FixtureRemoteExecutor, checkout_with_network_remote,
2000        checkpoint_test_session, committed_repository, managed_worktree_session,
2001        network_remote_for, raw_session_on, resume_compatibility_config,
2002        write_checkpoint_archive_with_native_state, write_checkpoint_gate_archive,
2003    };
2004    use crate::controller::{Controller, SessionResumeOptions};
2005    use mj_checkpoint::archive::{GitCommandRunner, verify_archive_streaming};
2006    use mj_core::config::{
2007        Config, ContainerTemplate as ConfigContainer, HarnessProfile, ProjectBundle,
2008        ProjectRepository, TargetTemplate,
2009    };
2010    use mj_core::state::{SessionRecord, SessionState, State, TargetLocator};
2011    use mj_transcript::projection::materialized_session_from_canonical;
2012
2013    use crate::targets::{CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
2014
2015    use super::*;
2016
2017    /// A person choosing a container for a local session has to see what the
2018    /// move does before it happens, and a person resuming the same session in
2019    /// place must not be asked anything.
2020    #[test]
2021    fn a_local_checkout_resuming_into_a_container_preflights_its_conversion() {
2022        let (checkout, _remote_parent, remote) = checkout_with_network_remote();
2023        std::fs::write(checkout.path().join("untracked.txt"), "u".repeat(2048)).unwrap();
2024        let mut session = raw_session_on("local-bare", &checkout.path().to_string_lossy());
2025        session.checkpoint = Some(mj_core::state::CheckpointMetadata {
2026            archive_path: checkout.path().join("unused.hel.zip"),
2027            sha256: "a".repeat(64),
2028            created_at: "2026-09-14T00:00:00Z".into(),
2029            event_frontier: 3,
2030        });
2031        let session_id = session.id.clone();
2032        let controller = Controller {
2033            config: resume_compatibility_config(),
2034            state: State {
2035                sessions: BTreeMap::from([(session_id.clone(), session)]),
2036                ..State::default()
2037            },
2038        };
2039        let executor = FixtureRemoteExecutor { remote };
2040
2041        let converting = controller
2042            .preflight_resume_repository_sources(&session_id, "podman", &executor)
2043            .unwrap();
2044        let ResumeRepositorySourcePreflight::ConvertingRawCheckout { receipt, preview } =
2045            converting
2046        else {
2047            panic!("a container destination converts the checkout, got {converting:?}");
2048        };
2049        assert_eq!(receipt.session_id, session_id);
2050        assert_eq!(preview.fetch_url, FIXTURE_FETCH_URL);
2051        assert_eq!(preview.untracked_files, 1);
2052        assert!(preview.host_checkout_retained);
2053
2054        assert!(
2055            matches!(
2056                controller
2057                    .preflight_resume_repository_sources(&session_id, "local-bare", &executor)
2058                    .unwrap(),
2059                ResumeRepositorySourcePreflight::Ready(_)
2060            ),
2061            "resuming in place asks nothing"
2062        );
2063    }
2064
2065    const RESUME_ROLLBACK_TEST_CHILD: &str = "MJ_RESUME_ROLLBACK_TEST_CHILD";
2066    const RETIRED_WORKTREE_RESUME_TEST_CHILD: &str = "MJ_RETIRED_WORKTREE_RESUME_TEST_CHILD";
2067    const WORKER_PREFLIGHT_TEST_CHILD: &str = "MJ_WORKER_PREFLIGHT_TEST_CHILD";
2068
2069    #[test]
2070    fn muse_resume_allows_workspace_relocation_before_provisioning() {
2071        let mut config = resume_compatibility_config();
2072        config
2073            .targets
2074            .insert("other-container".into(), config.targets["podman"].clone());
2075        let controller = Controller {
2076            config,
2077            state: State::default(),
2078        };
2079        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2080        session.harness_kind = HarnessKind::Muse;
2081        assert!(
2082            controller
2083                .validate_muse_resume_destination(&session, HarnessKind::Muse, "podman")
2084                .is_ok()
2085        );
2086        assert!(
2087            controller
2088                .validate_muse_resume_destination(&session, HarnessKind::Muse, "other-container")
2089                .is_ok()
2090        );
2091        controller
2092            .validate_muse_resume_destination(&session, HarnessKind::Muse, "ssh-bare")
2093            .unwrap();
2094        assert!(
2095            controller
2096                .validate_muse_resume_destination(&session, HarnessKind::Codex, "ssh-bare")
2097                .is_ok()
2098        );
2099        assert_eq!(session.state, SessionState::Running);
2100    }
2101
2102    /// Compaction costs minutes and paid model requests; resolving the worker
2103    /// binary is local and costs microseconds. A cross-harness resume that
2104    /// cannot produce a worker must say so before it compacts anything.
2105    #[test]
2106    fn a_resume_preflights_the_worker_binary_before_compacting() {
2107        // MJ_WORKER_BINARY, MJ_DATA_DIR, and MJ_CONFIG_DIR are process-global,
2108        // so run the half that sets them in an exact child test.
2109        if std::env::var_os(WORKER_PREFLIGHT_TEST_CHILD).is_none() {
2110            let directory = tempfile::tempdir().unwrap();
2111            let test_name = format!(
2112                "{}::a_resume_preflights_the_worker_binary_before_compacting",
2113                module_path!()
2114                    .strip_prefix("mj_controller::")
2115                    .unwrap_or(module_path!())
2116            );
2117            let output = Command::new(std::env::current_exe().unwrap())
2118                .args(["--exact", &test_name, "--nocapture"])
2119                .env(WORKER_PREFLIGHT_TEST_CHILD, "1")
2120                .env("MJ_DATA_DIR", directory.path().join("data"))
2121                .env("MJ_CONFIG_DIR", directory.path().join("config"))
2122                // Names a worker binary that is not there, which is how a
2123                // machine without an installed worker fails the same lookup.
2124                .env("MJ_WORKER_BINARY", directory.path().join("absent-worker"))
2125                .output()
2126                .unwrap();
2127            assert!(
2128                output.status.success(),
2129                "isolated worker preflight test failed\nstdout:\n{}\nstderr:\n{}",
2130                String::from_utf8_lossy(&output.stdout),
2131                String::from_utf8_lossy(&output.stderr)
2132            );
2133            return;
2134        }
2135        // Alone in this child process, so it installs the one writer.
2136        let _writer = crate::database::install_isolated_test_writer();
2137
2138        let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
2139        let archive_directory = data_directory.join("archives");
2140        std::fs::create_dir_all(&archive_directory).unwrap();
2141        let session_id = "0123456789abcdef0123456789abcdef";
2142        let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
2143        let repository = committed_repository();
2144        let mut session = managed_worktree_session(repository.path(), session_id);
2145        session.checkpoint = Some(checkpoint);
2146
2147        let profile_home = data_directory.join("profile");
2148        std::fs::create_dir_all(&profile_home).unwrap();
2149        let mut config = resume_compatibility_config();
2150        // The archive was written by Codex, so resuming onto Claude is a
2151        // cross-harness resume and would compact the transcript.
2152        config.profiles.insert(
2153            "claude".into(),
2154            HarnessProfile {
2155                enabled: true,
2156                kind: mj_core::config::HarnessKind::Claude,
2157                home: profile_home,
2158                environment: BTreeMap::new(),
2159                context_window_bytes: None,
2160                guardian_review_model: None,
2161            },
2162        );
2163        let mut controller = Controller {
2164            config,
2165            state: State {
2166                sessions: BTreeMap::from([(session_id.into(), session)]),
2167                ..State::default()
2168            },
2169        };
2170        crate::database::save_state(&controller.state).unwrap();
2171
2172        let error = tokio::runtime::Builder::new_current_thread()
2173            .enable_all()
2174            .build()
2175            .unwrap()
2176            .block_on(controller.resume_session_controlled(
2177                session_id,
2178                "claude",
2179                "local-bare",
2180                SessionResumeOptions {
2181                    additional_mounts: None,
2182                    resource_allocation: None,
2183                    discard_queue: false,
2184                },
2185                &ProcessExecutor,
2186            ))
2187            .unwrap_err();
2188
2189        let detail = format!("{error:#}");
2190        assert!(
2191            detail.contains("preflight the worker binary before resuming"),
2192            "{detail}"
2193        );
2194        assert!(detail.contains("absent-worker"), "{detail}");
2195        assert!(
2196            !detail.contains("compact the cross-harness handoff transcript"),
2197            "compaction must not run for a resume that cannot install a worker: {detail}"
2198        );
2199        assert_eq!(
2200            controller.state.sessions[session_id].state,
2201            SessionState::Stopped
2202        );
2203    }
2204
2205    #[test]
2206    fn network_resume_ignores_host_history_but_an_explicit_raw_move_checks_it() {
2207        let directory = tempfile::tempdir().unwrap();
2208        let repository = committed_repository();
2209        let session_id = "0123456789abcdef0123456789abcdef";
2210        let mut session = checkpoint_test_session(session_id);
2211        session.state = SessionState::Stopped;
2212        session.checkpoint = Some(
2213            super::super::test_support::write_network_checkpoint_archive(
2214                directory.path(),
2215                session_id,
2216                0,
2217            ),
2218        );
2219        let mut config = resume_compatibility_config();
2220        config.bundles.insert(
2221            "project".into(),
2222            super::super::test_support::local_bundle(repository.path()),
2223        );
2224        let controller = Controller {
2225            config,
2226            state: State {
2227                sessions: BTreeMap::from([(session_id.into(), session)]),
2228                ..State::default()
2229            },
2230        };
2231        assert!(matches!(
2232            controller
2233                .preflight_resume_repository_sources(session_id, "podman", &ProcessExecutor,)
2234                .unwrap(),
2235            ResumeRepositorySourcePreflight::Ready(_)
2236        ));
2237        let result = controller
2238            .preflight_resume_repository_sources(session_id, "local-bare", &ProcessExecutor)
2239            .unwrap();
2240        let ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) = result else {
2241            panic!("moving into a host checkout must detect its missing archive base");
2242        };
2243        assert_eq!(mismatch.missing_commit, "a".repeat(40));
2244        assert!(!repository.path().join(".mj/worktrees").exists());
2245    }
2246
2247    #[test]
2248    fn raw_in_place_preflight_does_not_require_its_synthetic_bundle() {
2249        struct UnusedExecutor;
2250
2251        impl CommandExecutor for UnusedExecutor {
2252            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2253                panic!("raw in-place preflight ran {}", command.purpose);
2254            }
2255        }
2256
2257        let directory = tempfile::tempdir().unwrap();
2258        let session_id = "0123456789abcdef0123456789abcdef";
2259        let mut session = checkpoint_test_session(session_id);
2260        session.checkpoint = Some(write_checkpoint_gate_archive(
2261            directory.path(),
2262            session_id,
2263            3,
2264        ));
2265        session.bundle_id = "remote-project-a66373eef659f856".into();
2266        session.target_template_id = "localhost".into();
2267        session.project_directory = Some("/mnt/optane/bifrost-fird".into());
2268        let controller = Controller {
2269            config: Config {
2270                targets: BTreeMap::from([("localhost".into(), TargetTemplate::LocalBare)]),
2271                // The raw checkout is still usable even though its synthetic
2272                // grouping bundle has disappeared from the config.
2273                bundles: BTreeMap::new(),
2274                ..Config::default()
2275            },
2276            state: State {
2277                sessions: BTreeMap::from([(session_id.into(), session)]),
2278                ..State::default()
2279            },
2280        };
2281
2282        let preflight = controller
2283            .preflight_resume_repository_sources(session_id, "localhost", &UnusedExecutor)
2284            .unwrap();
2285        let ResumeRepositorySourcePreflight::Ready(receipt) = preflight else {
2286            panic!("raw in-place resume unexpectedly needs a repository replacement");
2287        };
2288        assert!(controller.repository_source_receipt_is_current(session_id, &receipt));
2289    }
2290
2291    #[test]
2292    fn repository_preflight_distinguishes_the_original_source_from_a_reused_name() {
2293        fn git(repository: &Path, arguments: &[&str]) {
2294            let output = SystemGit
2295                .run(
2296                    repository,
2297                    &mj_checkpoint::archive::GitCommand {
2298                        arguments: arguments.iter().map(std::ffi::OsString::from).collect(),
2299                        stdin: Vec::new(),
2300                        env: Vec::new(),
2301                    },
2302                )
2303                .unwrap();
2304            assert_eq!(
2305                output.status,
2306                0,
2307                "git {arguments:?}: {}",
2308                String::from_utf8_lossy(&output.stderr)
2309            );
2310        }
2311
2312        let directory = tempfile::tempdir().unwrap();
2313        let origin = directory.path().join("original");
2314        std::fs::create_dir(&origin).unwrap();
2315        git(&origin, &["init", "-q", "-b", "main"]);
2316        git(&origin, &["config", "user.name", "Hel Test"]);
2317        git(&origin, &["config", "user.email", "hel@example.test"]);
2318        git(&origin, &["commit", "--allow-empty", "-qm", "base"]);
2319        let source = directory.path().join("source");
2320        git(
2321            directory.path(),
2322            &["clone", "-q", origin.to_str().unwrap(), "source"],
2323        );
2324        git(&source, &["config", "user.name", "Hel Test"]);
2325        git(&source, &["config", "user.email", "hel@example.test"]);
2326        git(&source, &["commit", "--allow-empty", "-qm", "session"]);
2327        let snapshot = mj_checkpoint::archive::collect_git_snapshot(
2328            &SystemGit,
2329            &source,
2330            &mj_checkpoint::archive::GitCollectionSpec {
2331                id: "project".into(),
2332                relative_destination: "project".into(),
2333                history: mj_checkpoint::archive::GitHistoryMode::SessionDelta,
2334                origin_override: None,
2335            },
2336        )
2337        .unwrap();
2338        let configured = ProjectRepository {
2339            id: "project".into(),
2340            github: None,
2341            local: Some(origin.clone()),
2342            destination: "project".into(),
2343            git_ref: None,
2344        };
2345        assert_eq!(
2346            checkpoint_source_missing_commit(
2347                &configured,
2348                &CheckpointRepositoryBundle {
2349                    metadata: snapshot.metadata.clone(),
2350                    committed_bundle: snapshot.committed_bundle.clone(),
2351                },
2352                &ProcessExecutor,
2353                None,
2354            )
2355            .unwrap(),
2356            None
2357        );
2358
2359        let replacement = directory.path().join("replacement");
2360        std::fs::create_dir(&replacement).unwrap();
2361        git(&replacement, &["init", "-q", "-b", "main"]);
2362        git(&replacement, &["config", "user.name", "Hel Test"]);
2363        git(&replacement, &["config", "user.email", "hel@example.test"]);
2364        git(
2365            &replacement,
2366            &["commit", "--allow-empty", "-qm", "different history"],
2367        );
2368        let configured = ProjectRepository {
2369            local: Some(replacement),
2370            ..configured
2371        };
2372        assert!(
2373            checkpoint_source_missing_commit(
2374                &configured,
2375                &CheckpointRepositoryBundle {
2376                    metadata: snapshot.metadata,
2377                    committed_bundle: snapshot.committed_bundle,
2378                },
2379                &ProcessExecutor,
2380                None,
2381            )
2382            .unwrap()
2383            .is_some()
2384        );
2385    }
2386
2387    #[test]
2388    fn repository_preflight_checks_independent_sources_concurrently_and_receipts_are_scoped() {
2389        struct ConcurrentSourceExecutor {
2390            source_checks: Barrier,
2391        }
2392
2393        impl CommandExecutor for ConcurrentSourceExecutor {
2394            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2395                if command.purpose == "check checkpoint base commit" {
2396                    self.source_checks.wait();
2397                }
2398                Ok(CommandOutput {
2399                    status: 0,
2400                    stdout: Vec::new(),
2401                    stderr: Vec::new(),
2402                })
2403            }
2404        }
2405
2406        let directory = tempfile::tempdir().unwrap();
2407        let session_id = "0123456789abcdef0123456789abcdef";
2408        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 3);
2409        let repositories = ["one", "two"]
2410            .map(|id| ProjectRepository {
2411                id: id.into(),
2412                github: None,
2413                local: Some(PathBuf::from(format!("/origin/{id}"))),
2414                destination: id.into(),
2415                git_ref: None,
2416            })
2417            .to_vec();
2418        let mut session = checkpoint_test_session(session_id);
2419        session.checkpoint = Some(checkpoint.clone());
2420        let mut controller = Controller {
2421            config: Config {
2422                bundles: BTreeMap::from([(
2423                    session.bundle_id.clone(),
2424                    ProjectBundle {
2425                        primary_repo: "one".into(),
2426                        repositories: repositories.clone(),
2427                    },
2428                )]),
2429                ..Config::default()
2430            },
2431            state: State {
2432                sessions: BTreeMap::from([(session_id.into(), session)]),
2433                ..State::default()
2434            },
2435        };
2436        let verified = ResumeRepositoryBundles {
2437            checkpoint_sha256: checkpoint.sha256,
2438            repositories: repositories
2439                .iter()
2440                .map(|repository| CheckpointRepositoryBundle {
2441                    metadata: mj_checkpoint::archive::RepositoryMetadata {
2442                        push_urls: Vec::new(),
2443                        remote_workspace: false,
2444                        id: repository.id.clone(),
2445                        relative_destination: repository.destination.clone(),
2446                        origin: repository.source_label(),
2447                        base_commit: String::new(),
2448                        head_commit: if repository.id == "one" {
2449                            "a".repeat(40)
2450                        } else {
2451                            "b".repeat(40)
2452                        },
2453                        branch: Some("main".into()),
2454                    },
2455                    committed_bundle: Vec::new(),
2456                })
2457                .collect(),
2458        };
2459        let executor = ConcurrentSourceExecutor {
2460            source_checks: Barrier::new(2),
2461        };
2462        let pool = rayon::ThreadPoolBuilder::new()
2463            .num_threads(2)
2464            .build()
2465            .unwrap();
2466        let preflight = pool
2467            .install(|| {
2468                controller.preflight_verified_repository_sources(
2469                    session_id, verified, None, false, &executor,
2470                )
2471            })
2472            .unwrap();
2473        let ResumeRepositorySourcePreflight::Ready(receipt) = preflight else {
2474            panic!("expected repository source receipt");
2475        };
2476        assert!(controller.repository_source_receipt_is_current(session_id, &receipt));
2477
2478        controller
2479            .config
2480            .bundles
2481            .values_mut()
2482            .next()
2483            .unwrap()
2484            .repositories[0]
2485            .local = Some(PathBuf::from("/different-origin"));
2486        assert!(!controller.repository_source_receipt_is_current(session_id, &receipt));
2487    }
2488
2489    #[test]
2490    fn repository_preflight_checks_declared_boundary_without_importing_delta_bundle() {
2491        struct RecordingExecutor {
2492            commands: Mutex<Vec<CommandSpec>>,
2493        }
2494
2495        impl CommandExecutor for RecordingExecutor {
2496            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2497                self.commands.lock().unwrap().push(command.clone());
2498                Ok(CommandOutput {
2499                    status: 0,
2500                    stdout: Vec::new(),
2501                    stderr: Vec::new(),
2502                })
2503            }
2504        }
2505
2506        let prerequisite = "a".repeat(40);
2507        let head = "b".repeat(40);
2508        let archived = CheckpointRepositoryBundle {
2509            metadata: mj_checkpoint::archive::RepositoryMetadata {
2510                push_urls: Vec::new(),
2511                remote_workspace: false,
2512                id: "project".into(),
2513                relative_destination: "project".into(),
2514                origin: "https://github.com/archived/should-not-be-contacted.git".into(),
2515                base_commit: prerequisite.clone(),
2516                head_commit: head.clone(),
2517                branch: Some("main".into()),
2518            },
2519            committed_bundle: format!(
2520                "# v2 git bundle\n-{prerequisite} base\n{head} HEAD\n\nPACKnot-read"
2521            )
2522            .into_bytes(),
2523        };
2524        let configured = ProjectRepository {
2525            id: "project".into(),
2526            github: Some("configured/project".into()),
2527            local: None,
2528            destination: "project".into(),
2529            git_ref: None,
2530        };
2531        let executor = RecordingExecutor {
2532            commands: Mutex::new(Vec::new()),
2533        };
2534
2535        assert_eq!(
2536            checkpoint_source_missing_commit(
2537                &configured,
2538                &archived,
2539                &executor,
2540                Some("secret-token")
2541            )
2542            .unwrap(),
2543            None
2544        );
2545
2546        let commands = executor.commands.into_inner().unwrap();
2547        assert_eq!(commands.len(), 2, "commands: {commands:?}");
2548        assert_eq!(
2549            commands
2550                .iter()
2551                .map(|command| command.purpose.as_str())
2552                .collect::<Vec<_>>(),
2553            [
2554                "initialize repository source preflight",
2555                "check checkpoint base commit"
2556            ]
2557        );
2558        let source_check = &commands[1];
2559        assert!(
2560            source_check
2561                .args
2562                .iter()
2563                .any(|argument| argument == "credential.helper=")
2564        );
2565        assert_eq!(
2566            source_check
2567                .env
2568                .get("GIT_NO_LAZY_FETCH")
2569                .map(String::as_str),
2570            Some("1")
2571        );
2572        assert_eq!(
2573            source_check
2574                .env
2575                .get("GIT_TERMINAL_PROMPT")
2576                .map(String::as_str),
2577            Some("0")
2578        );
2579        assert_eq!(
2580            source_check.args.last().map(String::as_str),
2581            Some(prerequisite.as_str())
2582        );
2583        assert!(
2584            !source_check
2585                .args
2586                .iter()
2587                .any(|argument| argument.contains("archived"))
2588        );
2589    }
2590
2591    #[test]
2592    fn self_contained_bundle_validation_cannot_lazy_fetch_or_prompt() {
2593        let command = checkpoint_bundle_import_command(
2594            Path::new("/tmp/repository.git"),
2595            Path::new("/tmp/checkpoint.bundle"),
2596        );
2597        assert_eq!(
2598            command.env.get("GIT_NO_LAZY_FETCH").map(String::as_str),
2599            Some("1")
2600        );
2601        assert_eq!(
2602            command.env.get("GIT_TERMINAL_PROMPT").map(String::as_str),
2603            Some("0")
2604        );
2605    }
2606
2607    #[test]
2608    fn lost_bundle_sessions_reach_resume_compatibility_before_the_record_changes() {
2609        struct UnusedExecutor;
2610
2611        impl CommandExecutor for UnusedExecutor {
2612            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2613                panic!("resume ran {} before rejecting the target", command.program);
2614            }
2615        }
2616
2617        let directory = tempfile::tempdir().unwrap();
2618        let session_id = "0123456789abcdef0123456789abcdef";
2619        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 3);
2620        let mut session = checkpoint_test_session(session_id);
2621        session.state = SessionState::Lost;
2622        session.checkpoint = Some(checkpoint);
2623        let previous = session.clone();
2624        let profile_home = directory.path().join("profile");
2625        std::fs::create_dir_all(&profile_home).unwrap();
2626        let mut config = Config::default();
2627        config.profiles.insert(
2628            "codex".into(),
2629            HarnessProfile {
2630                enabled: true,
2631                kind: mj_core::config::HarnessKind::Codex,
2632                home: profile_home,
2633                environment: BTreeMap::new(),
2634                context_window_bytes: None,
2635                guardian_review_model: None,
2636            },
2637        );
2638        config
2639            .targets
2640            .insert("localhost".into(), TargetTemplate::LocalBare);
2641        let mut controller = Controller {
2642            config,
2643            state: State {
2644                sessions: BTreeMap::from([(session_id.into(), session)]),
2645                ..State::default()
2646            },
2647        };
2648
2649        let error = tokio::runtime::Builder::new_current_thread()
2650            .enable_all()
2651            .build()
2652            .unwrap()
2653            .block_on(controller.resume_session_controlled(
2654                session_id,
2655                "codex",
2656                "localhost",
2657                SessionResumeOptions {
2658                    additional_mounts: None,
2659                    resource_allocation: None,
2660                    discard_queue: false,
2661                },
2662                &UnusedExecutor,
2663            ))
2664            .unwrap_err();
2665
2666        let detail = format!("{error:#}");
2667        assert!(detail.contains("created from a project bundle"), "{detail}");
2668        assert!(
2669            detail.contains("resume it on a container, SSH, or EC2 target"),
2670            "{detail}"
2671        );
2672        assert_eq!(controller.state.sessions[session_id], previous);
2673    }
2674    /// Records what it ran and blocks every command on a barrier sized to
2675    /// both lanes, so a run only finishes if the second lane started before
2676    /// the first one's command returned.
2677    struct BarrierExecutor {
2678        seen: Mutex<Vec<String>>,
2679        barrier: Barrier,
2680    }
2681
2682    impl CommandExecutor for BarrierExecutor {
2683        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2684            self.seen.lock().unwrap().push(command.purpose.clone());
2685            self.barrier.wait();
2686            Ok(CommandOutput {
2687                status: 0,
2688                stdout: Vec::new(),
2689                stderr: Vec::new(),
2690            })
2691        }
2692    }
2693
2694    fn lane_command(purpose: &str) -> CommandSpec {
2695        CommandSpec::new("hel", ["worker"]).purpose(purpose)
2696    }
2697
2698    /// Launch progress must not claim "Start" while the target is still
2699    /// receiving the worker binary, the checkpoint archive and the restore.
2700    /// Everything before the daemon launch reports as Sync; the launch itself
2701    /// names its own stage, so a Sync-labelled executor cannot relabel it.
2702    #[test]
2703    fn start_begins_at_the_worker_launch_not_at_the_transfers_before_it() {
2704        struct RecordingExecutor {
2705            commands: RefCell<Vec<CommandSpec>>,
2706        }
2707        impl CommandExecutor for RecordingExecutor {
2708            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2709                self.commands.borrow_mut().push(command.clone());
2710                Ok(CommandOutput {
2711                    status: 0,
2712                    stdout: Vec::new(),
2713                    stderr: Vec::new(),
2714                })
2715            }
2716        }
2717
2718        let session_id = "0123456789abcdef0123456789abcdef";
2719        let worker_root = format!("/var/lib/hel/workers/{session_id}");
2720        let executor = RecordingExecutor {
2721            commands: RefCell::new(Vec::new()),
2722        };
2723        let syncing = StagedExecutor::new(&executor, ProvisionStage::Syncing);
2724        let backend = targets::TargetLocator::LocalPodman {
2725            container_id: "abcdef0123456789".into(),
2726            workspace_storage: Default::default(),
2727        };
2728
2729        upload_checkpoint_spec(
2730            &syncing,
2731            &backend,
2732            session_id,
2733            Path::new("/archives/session.hel.zip"),
2734            &format!("{worker_root}/restore.hel.zip"),
2735        )
2736        .unwrap();
2737        execute_checked(
2738            &syncing,
2739            restore_command(
2740                &backend,
2741                session_id,
2742                &format!("{worker_root}/restore-spec.json"),
2743            )
2744            .unwrap(),
2745        )
2746        .unwrap();
2747        // Deliberately run the launch through the Sync-labelled executor: it
2748        // must still report Start.
2749        start_worker(&syncing, &backend, &worker_root).unwrap();
2750
2751        let stages = executor
2752            .commands
2753            .borrow()
2754            .iter()
2755            .map(|command| (command.purpose.clone(), command.stage))
2756            .collect::<Vec<_>>();
2757        assert_eq!(
2758            stages,
2759            vec![
2760                (
2761                    "upload checkpoint specification".to_owned(),
2762                    Some(ProvisionStage::Syncing)
2763                ),
2764                (
2765                    "restore target checkpoint".to_owned(),
2766                    Some(ProvisionStage::Syncing)
2767                ),
2768                (
2769                    "start detached Mjolnir worker".to_owned(),
2770                    Some(ProvisionStage::Starting)
2771                ),
2772            ]
2773        );
2774    }
2775    #[test]
2776    fn independent_target_lanes_run_at_the_same_time() {
2777        let executor = BarrierExecutor {
2778            seen: Mutex::new(Vec::new()),
2779            barrier: Barrier::new(2),
2780        };
2781
2782        execute_concurrent_lanes(
2783            || execute_checked(&executor, lane_command("install the worker")).map(|_| ()),
2784            || execute_checked(&executor, lane_command("upload the checkpoint")).map(|_| ()),
2785        )
2786        .unwrap();
2787
2788        let mut seen = executor.seen.into_inner().unwrap();
2789        seen.sort();
2790        assert_eq!(seen, ["install the worker", "upload the checkpoint"]);
2791    }
2792    #[test]
2793    fn a_lane_failure_is_reported_in_lane_order_and_never_abandons_the_other_lane() {
2794        let reached = Mutex::new(Vec::new());
2795
2796        // The first lane fails slowly and the second immediately, so a
2797        // completion-order report could only pick the second.
2798        let error = execute_concurrent_lanes(
2799            || -> Result<()> {
2800                std::thread::sleep(Duration::from_millis(50));
2801                bail!("worker install failed")
2802            },
2803            || -> Result<()> {
2804                reached.lock().unwrap().push("second");
2805                bail!("checkpoint upload failed")
2806            },
2807        )
2808        .unwrap_err();
2809
2810        assert_eq!(error.to_string(), "worker install failed");
2811        assert_eq!(
2812            *reached.lock().unwrap(),
2813            ["second"],
2814            "a failing first lane must not cut the second one short"
2815        );
2816
2817        let error = execute_concurrent_lanes(
2818            || Ok(()),
2819            || -> Result<()> { bail!("checkpoint upload failed") },
2820        )
2821        .unwrap_err();
2822        assert_eq!(error.to_string(), "checkpoint upload failed");
2823    }
2824
2825    #[test]
2826    fn cross_harness_lanes_prove_overlap_with_handshake_channels() {
2827        let (provision_started_tx, provision_started_rx) = std::sync::mpsc::sync_channel::<()>(1);
2828        let (handoff_started_tx, handoff_started_rx) = std::sync::mpsc::sync_channel::<()>(1);
2829        let (provision_seen_handoff_tx, provision_seen_handoff_rx) =
2830            std::sync::mpsc::sync_channel::<()>(1);
2831        let (handoff_seen_provision_tx, handoff_seen_provision_rx) =
2832            std::sync::mpsc::sync_channel::<()>(1);
2833
2834        execute_joined_cross_harness_work(
2835            "provision",
2836            move |_cancellation| -> Result<()> {
2837                provision_started_tx
2838                    .send(())
2839                    .map_err(|error| anyhow::anyhow!("signal provisioning start: {error}"))?;
2840                handoff_started_rx
2841                    .recv_timeout(Duration::from_secs(2))
2842                    .map_err(|error| anyhow::anyhow!("wait for handoff start: {error}"))?;
2843                provision_seen_handoff_tx
2844                    .send(())
2845                    .map_err(|error| anyhow::anyhow!("signal provisioning overlap: {error}"))?;
2846                Ok(())
2847            },
2848            "handoff",
2849            move |_cancellation| -> Result<()> {
2850                handoff_started_tx
2851                    .send(())
2852                    .map_err(|error| anyhow::anyhow!("signal handoff start: {error}"))?;
2853                provision_started_rx
2854                    .recv_timeout(Duration::from_secs(2))
2855                    .map_err(|error| anyhow::anyhow!("wait for provisioning start: {error}"))?;
2856                handoff_seen_provision_tx
2857                    .send(())
2858                    .map_err(|error| anyhow::anyhow!("signal handoff overlap: {error}"))?;
2859                Ok(())
2860            },
2861        )
2862        .unwrap();
2863
2864        assert!(provision_seen_handoff_rx.recv().is_ok());
2865        assert!(handoff_seen_provision_rx.recv().is_ok());
2866    }
2867
2868    #[test]
2869    fn cross_harness_lane_failure_cancels_and_joins_the_peer() {
2870        let (handoff_started_tx, handoff_started_rx) = std::sync::mpsc::sync_channel::<()>(1);
2871        let (handoff_joined_tx, handoff_joined_rx) = std::sync::mpsc::sync_channel::<()>(1);
2872
2873        let error = execute_joined_cross_harness_work(
2874            "provision",
2875            move |_cancellation| -> Result<()> {
2876                handoff_started_rx
2877                    .recv_timeout(Duration::from_secs(2))
2878                    .map_err(|error| anyhow::anyhow!("wait for handoff start: {error}"))?;
2879                bail!("provisioning failed after handoff started");
2880            },
2881            "handoff",
2882            move |cancellation| -> Result<()> {
2883                handoff_started_tx
2884                    .send(())
2885                    .map_err(|error| anyhow::anyhow!("signal handoff start: {error}"))?;
2886                let runtime = tokio::runtime::Builder::new_current_thread()
2887                    .enable_all()
2888                    .build()
2889                    .map_err(|error| {
2890                        anyhow::anyhow!("create cancellation test runtime: {error}")
2891                    })?;
2892                runtime
2893                    .block_on(async {
2894                        tokio::time::timeout(Duration::from_secs(2), cancellation.cancelled()).await
2895                    })
2896                    .map_err(|error| anyhow::anyhow!("peer was not cancelled: {error}"))?;
2897                handoff_joined_tx
2898                    .send(())
2899                    .map_err(|error| anyhow::anyhow!("signal handoff join: {error}"))?;
2900                Ok(())
2901            },
2902        )
2903        .unwrap_err();
2904
2905        assert_eq!(
2906            error.to_string(),
2907            "provisioning failed after handoff started"
2908        );
2909        assert!(handoff_joined_rx.recv().is_ok());
2910    }
2911
2912    #[test]
2913    fn a_projection_standing_at_the_archived_frontier_is_reused() {
2914        let digest = "a".repeat(64);
2915        let other = "b".repeat(64);
2916
2917        assert!(!projection_rebuild_required(
2918            Some((82_000, &digest)),
2919            82_000,
2920            &digest
2921        ));
2922
2923        for stored in [
2924            // Same ordinal, different event chain.
2925            Some((82_000, other.as_str())),
2926            // Behind the archive, and ahead of it.
2927            Some((81_999, digest.as_str())),
2928            Some((82_001, digest.as_str())),
2929            // No projection stored, or none that could be read.
2930            None,
2931        ] {
2932            assert!(
2933                projection_rebuild_required(stored, 82_000, &digest),
2934                "{stored:?} must not be mistaken for the archived projection"
2935            );
2936        }
2937    }
2938
2939    #[test]
2940    fn local_bare_restore_reuses_verified_absolute_archive_without_upload() {
2941        let archive = Path::new("/var/lib/hel/archives/session.hel.zip");
2942        let remote = Path::new("/var/lib/hel/workers/session/restore.hel.zip");
2943        let local = targets::TargetLocator::LocalBare {
2944            worker_root: "/var/lib/hel/workers/session".into(),
2945        };
2946        let container = targets::TargetLocator::LocalPodman {
2947            container_id: "container".into(),
2948            workspace_storage: Default::default(),
2949        };
2950
2951        assert_eq!(restore_archive_path(&local, archive, remote), archive);
2952        assert!(!should_upload_restore_archive(&local));
2953        assert_eq!(restore_archive_path(&container, archive, remote), remote);
2954        assert!(should_upload_restore_archive(&container));
2955    }
2956
2957    #[test]
2958    fn cross_harness_provision_cancellation_stops_the_next_command() {
2959        struct RecordingExecutor {
2960            commands: Mutex<Vec<String>>,
2961        }
2962
2963        impl CommandExecutor for RecordingExecutor {
2964            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2965                self.commands.lock().unwrap().push(command.purpose.clone());
2966                Ok(CommandOutput {
2967                    status: 0,
2968                    stdout: Vec::new(),
2969                    stderr: Vec::new(),
2970                })
2971            }
2972        }
2973
2974        let inner = RecordingExecutor {
2975            commands: Mutex::new(Vec::new()),
2976        };
2977        let cancellation = CancellationToken::new();
2978        let provision = CrossHarnessProvisionExecutor {
2979            inner: &inner,
2980            cancellation: cancellation.clone(),
2981        };
2982        let command = CommandSpec::new("hel", ["worker"]).purpose("provision target");
2983        provision.execute(&command).unwrap();
2984        cancellation.cancel();
2985
2986        let error = provision.execute(&command).unwrap_err();
2987        assert!(error.to_string().contains("cancelled while provisioning"));
2988        assert_eq!(
2989            inner.commands.lock().unwrap().as_slice(),
2990            ["provision target"]
2991        );
2992    }
2993
2994    #[test]
2995    fn failed_resume_rolls_back_only_after_target_cleanup() {
2996        let previous = SessionRecord {
2997            mjolnir_subagents: None,
2998            create_managed_worktree: None,
2999            workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
3000            archived: false,
3001            container_cpus: None,
3002            container_memory: None,
3003            id: "0123456789abcdef0123456789abcdef".into(),
3004            title: "imported session".into(),
3005            harness_kind: mj_core::config::HarnessKind::Codex,
3006            last_profile: "codex-old".into(),
3007            bundle_id: "project".into(),
3008            project_directory: None,
3009            managed_worktree: None,
3010            target_template_id: "podman-old".into(),
3011            resource_allocation: None,
3012            additional_mounts: Vec::new(),
3013            state: SessionState::Stopped,
3014            target: None,
3015            native_session_id: Some("native-session".into()),
3016            acp_session_title: None,
3017            session_title_override: None,
3018            created_at: "2026-08-12T00:00:00Z".into(),
3019            updated_at: "2026-08-12T00:00:00Z".into(),
3020            viewed_through_event_ordinal: 0,
3021            draft_input: String::new(),
3022            last_error: None,
3023            last_checkpoint_error: None,
3024            checkpoint: None,
3025        };
3026        let partial_target = TargetLocator::LocalPodman {
3027            container_id: "partial-container".into(),
3028            workspace_storage: Default::default(),
3029        };
3030        let mut cleaned = previous.clone();
3031        cleaned.state = SessionState::Error;
3032        cleaned.last_profile = "codex-new".into();
3033        cleaned.target = Some(partial_target.clone());
3034
3035        let failure =
3036            apply_failed_resume_rollback(&mut cleaned, &previous, "worker upload failed", None);
3037
3038        assert_eq!(cleaned.state, SessionState::Stopped);
3039        assert_eq!(cleaned.last_profile, "codex-old");
3040        assert_eq!(cleaned.target, None);
3041        assert_eq!(failure.to_string(), "worker upload failed");
3042        assert_eq!(
3043            cleaned.last_error.as_deref(),
3044            Some("resume failed: worker upload failed")
3045        );
3046
3047        let mut cleanup_failed = previous.clone();
3048        cleanup_failed.state = SessionState::Error;
3049        cleanup_failed.last_profile = "codex-new".into();
3050        cleanup_failed.target = Some(partial_target.clone());
3051        let partial_checkout = crate::controller::test_support::managed_raw_session(
3052            mj_core::state::ManagedWorktreeTarget::Local,
3053        );
3054        cleanup_failed.project_directory = partial_checkout.project_directory.clone();
3055        cleanup_failed.managed_worktree = partial_checkout.managed_worktree.clone();
3056
3057        let failure = apply_failed_resume_rollback(
3058            &mut cleanup_failed,
3059            &previous,
3060            "worker upload failed",
3061            Some("podman rm failed".into()),
3062        );
3063
3064        assert_eq!(cleanup_failed.state, SessionState::Error);
3065        assert_eq!(cleanup_failed.last_profile, "codex-new");
3066        assert_eq!(cleanup_failed.target, Some(partial_target));
3067        assert_eq!(
3068            cleanup_failed.project_directory,
3069            partial_checkout.project_directory
3070        );
3071        assert_eq!(
3072            cleanup_failed.managed_worktree,
3073            partial_checkout.managed_worktree
3074        );
3075        assert!(failure.to_string().contains("cleanup"));
3076    }
3077    #[test]
3078    fn failed_worktree_cleanup_notice_names_mjolnir_and_the_recovery_command() {
3079        let notice = worktree_cleanup_notice(
3080            Path::new("/workspace/project"),
3081            &anyhow::anyhow!("permission denied"),
3082        );
3083
3084        assert!(
3085            notice.starts_with(
3086                "Mjolnir could not remove the worktree at /workspace/project: permission denied."
3087            ),
3088            "{notice}"
3089        );
3090        assert!(
3091            notice.contains("`git worktree remove --force /workspace/project`"),
3092            "{notice}"
3093        );
3094        assert!(!notice.contains("Hel"), "{notice}");
3095    }
3096    #[test]
3097    fn failed_resume_provisioning_preserves_checkpoint_and_projection_lineage() {
3098        // MJ_DATA_DIR is process-global, so run the database-backed half in an
3099        // exact child test instead of racing unrelated tests in this process.
3100        if std::env::var_os(RESUME_ROLLBACK_TEST_CHILD).is_none() {
3101            let directory = tempfile::tempdir().unwrap();
3102            let test_name = format!(
3103                "{}::failed_resume_provisioning_preserves_checkpoint_and_projection_lineage",
3104                module_path!()
3105                    .strip_prefix("mj_controller::")
3106                    .unwrap_or(module_path!())
3107            );
3108            let output = Command::new(std::env::current_exe().unwrap())
3109                .args(["--exact", &test_name, "--nocapture"])
3110                .env(RESUME_ROLLBACK_TEST_CHILD, "1")
3111                // A remote target needs a portable worker; any existing file
3112                // satisfies the preflight so the test reaches provisioning.
3113                .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
3114                .env("MJ_DATA_DIR", directory.path())
3115                .env("GH_TOKEN", "test-token")
3116                .output()
3117                .unwrap();
3118            assert!(
3119                output.status.success(),
3120                "isolated resume rollback test failed\nstdout:\n{}\nstderr:\n{}",
3121                String::from_utf8_lossy(&output.stdout),
3122                String::from_utf8_lossy(&output.stderr)
3123            );
3124            return;
3125        }
3126        // Alone in this child process, so it installs the one writer.
3127        let _writer = crate::database::install_isolated_test_writer();
3128
3129        /// Provisioning runs after the resumed record is persisted, so the
3130        /// durable mounts read here are the ones resume just committed.
3131        #[derive(Default)]
3132        struct FailingPreflightExecutor {
3133            mounts_during_provisioning: Mutex<Option<Vec<AdditionalMount>>>,
3134        }
3135
3136        impl CommandExecutor for FailingPreflightExecutor {
3137            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3138                // Provisioning probes the mount source's filesystem before it
3139                // builds the run arguments; a local disk keeps the overlay.
3140                if command.program == "stat" {
3141                    return Ok(CommandOutput {
3142                        status: 0,
3143                        stdout: b"ext4\n".to_vec(),
3144                        stderr: Vec::new(),
3145                    });
3146                }
3147                assert_eq!(command.program, "podman");
3148                let mut observed = self.mounts_during_provisioning.lock().unwrap();
3149                if observed.is_none() {
3150                    let durable = crate::database::load_state().unwrap();
3151                    *observed = Some(
3152                        durable.sessions["0123456789abcdef0123456789abcdef"]
3153                            .additional_mounts
3154                            .clone(),
3155                    );
3156                }
3157                Ok(CommandOutput {
3158                    status: 1,
3159                    stdout: Vec::new(),
3160                    stderr: b"podman is temporarily unavailable".to_vec(),
3161                })
3162            }
3163        }
3164
3165        let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
3166        let archive_directory = data_directory.join("archives");
3167        std::fs::create_dir_all(&archive_directory).unwrap();
3168        let session_id = "0123456789abcdef0123456789abcdef";
3169        let checkpoint = super::super::test_support::write_network_checkpoint_archive(
3170            &archive_directory,
3171            session_id,
3172            7,
3173        );
3174        let archive = verify_archive_streaming(&checkpoint.archive_path).unwrap();
3175        let expected_projection =
3176            materialized_session_from_canonical(session_id, &archive.canonical_session).unwrap();
3177
3178        let mut session = checkpoint_test_session(session_id);
3179        session.state = SessionState::Stopped;
3180        session.checkpoint = Some(checkpoint.clone());
3181        session.additional_mounts = vec![AdditionalMount {
3182            source: PathBuf::from("/host/old"),
3183            destination: PathBuf::from("/mnt/old"),
3184            read_only: false,
3185        }];
3186        let previous = session.clone();
3187        let resumed_mounts = vec![AdditionalMount {
3188            source: PathBuf::from("/host/new"),
3189            destination: PathBuf::from("/mnt/new"),
3190            read_only: false,
3191        }];
3192        let profile_home = data_directory.join("profile");
3193        std::fs::create_dir_all(&profile_home).unwrap();
3194        let mut config = Config::default();
3195        config.profiles.insert(
3196            "codex".into(),
3197            HarnessProfile {
3198                enabled: true,
3199                kind: mj_core::config::HarnessKind::Codex,
3200                home: profile_home,
3201                environment: BTreeMap::new(),
3202                context_window_bytes: None,
3203                guardian_review_model: None,
3204            },
3205        );
3206        config.bundles.insert(
3207            "project".into(),
3208            ProjectBundle {
3209                primary_repo: "project".into(),
3210                repositories: vec![ProjectRepository {
3211                    id: "project".into(),
3212                    github: None,
3213                    local: Some(data_directory.join("host-clone-that-no-longer-exists")),
3214                    destination: "project".into(),
3215                    git_ref: None,
3216                }],
3217            },
3218        );
3219        config.targets.insert(
3220            "podman".into(),
3221            TargetTemplate::LocalPodman {
3222                container: ConfigContainer {
3223                    image: "example.invalid/hel-test:latest".into(),
3224                    pull_policy: Default::default(),
3225                    platform: None,
3226                    cpus: None,
3227                    memory: None,
3228                    environment: BTreeMap::new(),
3229                    workspace_storage: Default::default(),
3230                },
3231            },
3232        );
3233        let mut controller = Controller {
3234            config,
3235            state: State {
3236                sessions: BTreeMap::from([(session_id.into(), session)]),
3237                ..State::default()
3238            },
3239        };
3240        crate::database::save_state(&controller.state).unwrap();
3241        crate::database::save_materialized_session(&expected_projection).unwrap();
3242
3243        let runtime = tokio::runtime::Builder::new_current_thread()
3244            .enable_all()
3245            .build()
3246            .unwrap();
3247        let executor = FailingPreflightExecutor::default();
3248        let error = runtime
3249            .block_on(controller.resume_session_controlled(
3250                session_id,
3251                "codex",
3252                "podman",
3253                SessionResumeOptions {
3254                    additional_mounts: Some(resumed_mounts.clone()),
3255                    resource_allocation: None,
3256                    discard_queue: false,
3257                },
3258                &executor,
3259            ))
3260            .unwrap_err();
3261        let detail = format!("{error:#}");
3262        assert!(
3263            detail.contains("podman is temporarily unavailable"),
3264            "{detail}"
3265        );
3266        assert!(!detail.contains("returned to stopped"), "{detail}");
3267        assert!(!detail.contains("unknown session"), "{detail}");
3268        assert_eq!(
3269            executor.mounts_during_provisioning.into_inner().unwrap(),
3270            Some(resumed_mounts)
3271        );
3272
3273        let retained = controller.state.sessions.get(session_id).unwrap();
3274        assert_eq!(retained.state, SessionState::Stopped);
3275        assert_eq!(retained.checkpoint, previous.checkpoint);
3276        assert_eq!(retained.managed_worktree, previous.managed_worktree);
3277        assert!(checkpoint.archive_path.is_file());
3278
3279        let durable = crate::database::load_state().unwrap();
3280        let durable_session = durable.sessions.get(session_id).unwrap();
3281        assert_eq!(durable_session.state, SessionState::Stopped);
3282        assert_eq!(durable_session.checkpoint, previous.checkpoint);
3283        assert_eq!(
3284            durable_session.additional_mounts,
3285            previous.additional_mounts
3286        );
3287        assert_eq!(
3288            crate::database::load_materialized_session(session_id).unwrap(),
3289            Some(expected_projection)
3290        );
3291    }
3292    #[test]
3293    fn failed_resume_retires_a_checkout_it_recreated() {
3294        if std::env::var_os(RETIRED_WORKTREE_RESUME_TEST_CHILD).is_none() {
3295            let directory = tempfile::tempdir().unwrap();
3296            let test_name = format!(
3297                "{}::failed_resume_retires_a_checkout_it_recreated",
3298                module_path!()
3299                    .strip_prefix("mj_controller::")
3300                    .unwrap_or(module_path!())
3301            );
3302            let output = Command::new(std::env::current_exe().unwrap())
3303                .args(["--exact", &test_name, "--nocapture"])
3304                .env(RETIRED_WORKTREE_RESUME_TEST_CHILD, "1")
3305                // A remote target needs a portable worker; any existing file
3306                // satisfies the preflight so the test reaches provisioning.
3307                .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
3308                .env("MJ_DATA_DIR", directory.path().join("data"))
3309                .env("MJ_CONFIG_DIR", directory.path().join("config"))
3310                .output()
3311                .unwrap();
3312            assert!(
3313                output.status.success(),
3314                "isolated retired-worktree resume test failed\nstdout:\n{}\nstderr:\n{}",
3315                String::from_utf8_lossy(&output.stdout),
3316                String::from_utf8_lossy(&output.stderr)
3317            );
3318            return;
3319        }
3320        // Alone in this child process, so it installs the one writer.
3321        let _writer = crate::database::install_isolated_test_writer();
3322
3323        struct FailAfterWorktreeRestore;
3324
3325        impl CommandExecutor for FailAfterWorktreeRestore {
3326            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3327                if matches!(command.program.as_str(), "git" | "mkdir") {
3328                    return ProcessExecutor.execute(command);
3329                }
3330                Ok(CommandOutput {
3331                    status: 1,
3332                    stdout: Vec::new(),
3333                    stderr: b"stop after recreating the checkout".to_vec(),
3334                })
3335            }
3336        }
3337
3338        let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
3339        let archive_directory = data_directory.join("archives");
3340        std::fs::create_dir_all(&archive_directory).unwrap();
3341        let session_id = "0123456789abcdef0123456789abcdef";
3342        let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
3343        let repository = committed_repository();
3344        let mut session = managed_worktree_session(repository.path(), session_id);
3345        session.checkpoint = Some(checkpoint);
3346        let worktree = session.managed_worktree.clone().unwrap();
3347        retire_managed_worktree(&ProcessExecutor, &worktree).unwrap();
3348        assert!(!worktree.worktree_root.exists());
3349
3350        let profile_home = data_directory.join("profile");
3351        std::fs::create_dir_all(&profile_home).unwrap();
3352        let mut config = resume_compatibility_config();
3353        config.profiles.insert(
3354            "codex".into(),
3355            HarnessProfile {
3356                enabled: true,
3357                kind: mj_core::config::HarnessKind::Codex,
3358                home: profile_home,
3359                environment: BTreeMap::new(),
3360                context_window_bytes: None,
3361                guardian_review_model: None,
3362            },
3363        );
3364        let mut controller = Controller {
3365            config,
3366            state: State {
3367                sessions: BTreeMap::from([(session_id.into(), session)]),
3368                ..State::default()
3369            },
3370        };
3371        crate::database::save_state(&controller.state).unwrap();
3372
3373        let error = tokio::runtime::Builder::new_current_thread()
3374            .enable_all()
3375            .build()
3376            .unwrap()
3377            .block_on(controller.resume_session_controlled(
3378                session_id,
3379                "codex",
3380                "local-bare",
3381                SessionResumeOptions {
3382                    additional_mounts: None,
3383                    resource_allocation: None,
3384                    discard_queue: false,
3385                },
3386                &FailAfterWorktreeRestore,
3387            ))
3388            .unwrap_err();
3389
3390        assert!(
3391            format!("{error:#}").contains("stop after recreating the checkout"),
3392            "{error:#}"
3393        );
3394        assert!(!worktree.worktree_root.exists());
3395        assert_eq!(
3396            controller.state.sessions[session_id].state,
3397            SessionState::Stopped
3398        );
3399        let branch = Command::new("git")
3400            .arg("-C")
3401            .arg(repository.path())
3402            .args([
3403                "show-ref",
3404                "--verify",
3405                &format!("refs/heads/{}", worktree.branch),
3406            ])
3407            .status()
3408            .unwrap();
3409        assert!(branch.success(), "resume rollback must retain the branch");
3410    }
3411    #[test]
3412    fn a_conversion_archive_carries_the_checkouts_remote_and_the_conversation() {
3413        let directory = tempfile::tempdir().unwrap();
3414        let session_id = "0123456789abcdef0123456789abcdef";
3415        let previous = write_checkpoint_archive_with_native_state(directory.path(), session_id, 7);
3416        let (checkout, _remote_parent, _remote) = checkout_with_network_remote();
3417        let source =
3418            mj_core::remote_git::resolve_local_repository(checkout.path(), &ProcessExecutor)
3419                .unwrap();
3420        let dirname = PathBuf::from(checkout.path().file_name().unwrap());
3421        let snapshot =
3422            raw_checkout_snapshot(checkout.path(), &source, &dirname, &SystemGit).unwrap();
3423
3424        let output = directory.path().join("converted.hel.zip");
3425        let converted = conversion_checkpoint(&previous.archive_path, snapshot, &output).unwrap();
3426
3427        // The archive provisioning will read: a real network clone of the
3428        // checkout's own remote, landing where the archive says.
3429        let verified = mj_checkpoint::archive::read_archive_verified(&output).unwrap();
3430        assert_eq!(converted.archive_path, output);
3431        assert_eq!(converted.sha256, verified.archive_sha256);
3432        assert_eq!(converted.event_frontier, previous.event_frontier);
3433        let bundle =
3434            crate::controller::network_git::bundle_from_manifest(&verified.manifest).unwrap();
3435        assert_eq!(bundle.primary, dirname.to_string_lossy());
3436        assert_eq!(bundle.repositories.len(), 1);
3437        assert_eq!(
3438            bundle.repositories[0].url.as_deref(),
3439            Some(FIXTURE_FETCH_URL)
3440        );
3441        assert_eq!(bundle.repositories[0].push_urls, [FIXTURE_FETCH_URL]);
3442        assert_eq!(
3443            bundle.repositories[0].destination,
3444            dirname.to_string_lossy()
3445        );
3446
3447        // Everything the conversation is made of comes across untouched.
3448        let original =
3449            mj_checkpoint::archive::read_archive_verified(&previous.archive_path).unwrap();
3450        assert_eq!(
3451            verified.canonical_session().unwrap(),
3452            original.canonical_session().unwrap()
3453        );
3454        assert_eq!(verified.manifest.session, original.manifest.session);
3455        assert_eq!(native_state(&original), native_state(&verified));
3456        assert!(
3457            !native_state(&verified).is_empty(),
3458            "the fixture has native state"
3459        );
3460    }
3461
3462    /// Every native payload of an archive as (path, mode, bytes).
3463    fn native_state(
3464        archive: &mj_checkpoint::archive::VerifiedArchive,
3465    ) -> Vec<(PathBuf, u32, Vec<u8>)> {
3466        archive
3467            .manifest
3468            .payloads
3469            .iter()
3470            .filter_map(|descriptor| match &descriptor.role {
3471                mj_checkpoint::archive::PayloadRole::NativeArtifact { relative_path } => Some((
3472                    relative_path.clone(),
3473                    descriptor.mode,
3474                    archive.payload(descriptor).unwrap().to_vec(),
3475                )),
3476                _ => None,
3477            })
3478            .collect()
3479    }
3480    const RAW_CONVERSION_TEST_CHILD: &str = "MJ_RAW_CONVERSION_TEST_CHILD";
3481    #[test]
3482    fn a_failed_raw_conversion_keeps_the_checkout_and_its_previous_checkpoint() {
3483        // MJ_DATA_DIR and MJ_CONFIG_DIR are process-global, so run the half
3484        // that writes them in an exact child test.
3485        if std::env::var_os(RAW_CONVERSION_TEST_CHILD).is_none() {
3486            let directory = tempfile::tempdir().unwrap();
3487            let test_name = format!(
3488                "{}::a_failed_raw_conversion_keeps_the_checkout_and_its_previous_checkpoint",
3489                module_path!()
3490                    .strip_prefix("mj_controller::")
3491                    .unwrap_or(module_path!())
3492            );
3493            let output = Command::new(std::env::current_exe().unwrap())
3494                .args(["--exact", &test_name, "--nocapture"])
3495                .env(RAW_CONVERSION_TEST_CHILD, "1")
3496                // A remote target needs a portable worker; any existing file
3497                // satisfies the preflight so the test reaches provisioning.
3498                .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
3499                .env("MJ_DATA_DIR", directory.path().join("data"))
3500                .env("MJ_CONFIG_DIR", directory.path().join("config"))
3501                .env("GH_TOKEN", "test-token")
3502                .output()
3503                .unwrap();
3504            assert!(
3505                output.status.success(),
3506                "isolated raw conversion test failed\nstdout:\n{}\nstderr:\n{}",
3507                String::from_utf8_lossy(&output.stdout),
3508                String::from_utf8_lossy(&output.stderr)
3509            );
3510            return;
3511        }
3512        // Alone in this child process, so it installs the one writer.
3513        let _writer = crate::database::install_isolated_test_writer();
3514
3515        /// Real Git, no container runtime. Provisioning fails at preflight,
3516        /// after the conversion has already reshaped the record.
3517        struct GitWithoutPodmanExecutor;
3518
3519        impl CommandExecutor for GitWithoutPodmanExecutor {
3520            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3521                if command.program == "git" {
3522                    return ProcessExecutor.execute(command);
3523                }
3524                Ok(CommandOutput {
3525                    status: 1,
3526                    stdout: Vec::new(),
3527                    stderr: b"podman is temporarily unavailable".to_vec(),
3528                })
3529            }
3530        }
3531
3532        let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
3533        let archive_directory = data_directory.join("archives");
3534        std::fs::create_dir_all(&archive_directory).unwrap();
3535        std::fs::create_dir_all(mj_core::config::config_dir()).unwrap();
3536        let session_id = "0123456789abcdef0123456789abcdef";
3537        let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
3538
3539        let repository = committed_repository();
3540        // An isolated workspace is a clone of a network remote, so the
3541        // checkout that converts has to have one, with its base pushed.
3542        let (_remote_parent, _remote) = network_remote_for(repository.path());
3543        let mut session = managed_worktree_session(repository.path(), session_id);
3544        session.checkpoint = Some(checkpoint.clone());
3545        let worktree = session.managed_worktree.clone().unwrap();
3546        let previous = session.clone();
3547
3548        let profile_home = data_directory.join("profile");
3549        std::fs::create_dir_all(&profile_home).unwrap();
3550        let mut config = resume_compatibility_config();
3551        config.profiles.insert(
3552            "codex".into(),
3553            HarnessProfile {
3554                enabled: true,
3555                kind: mj_core::config::HarnessKind::Codex,
3556                home: profile_home,
3557                environment: BTreeMap::new(),
3558                context_window_bytes: None,
3559                guardian_review_model: None,
3560            },
3561        );
3562        // Production controllers read this configuration from disk; bundle
3563        // updates now deliberately reload it under the transaction lock.
3564        config.save().unwrap();
3565        let original_config = config.clone();
3566        let mut controller = Controller {
3567            config,
3568            state: State {
3569                sessions: BTreeMap::from([(session_id.into(), session)]),
3570                ..State::default()
3571            },
3572        };
3573        crate::database::save_state(&controller.state).unwrap();
3574
3575        let error = tokio::runtime::Builder::new_current_thread()
3576            .enable_all()
3577            .build()
3578            .unwrap()
3579            .block_on(controller.resume_session_controlled(
3580                session_id,
3581                "codex",
3582                "podman",
3583                SessionResumeOptions {
3584                    additional_mounts: None,
3585                    resource_allocation: None,
3586                    discard_queue: false,
3587                },
3588                &GitWithoutPodmanExecutor,
3589            ))
3590            .unwrap_err();
3591        // The conversion ran: it wrote its archive and reshaped the record,
3592        // and then the destination could not be provisioned.
3593        assert!(
3594            format!("{error:#}").contains("podman is temporarily unavailable"),
3595            "{error:#}"
3596        );
3597        assert!(!format!("{error:#}").contains("returned to stopped"));
3598
3599        // A conversion installs a bundle for the checkout it converts, and
3600        // reuses it on a retry. Nothing else about the configuration moves.
3601        let mut expected_config = original_config.clone();
3602        let (bundle_id, bundle) = mj_core::config::Config::load()
3603            .unwrap()
3604            .bundles
3605            .into_iter()
3606            .next()
3607            .expect("the conversion installed a bundle for the checkout");
3608        expected_config.bundles.insert(bundle_id, bundle);
3609        assert_eq!(
3610            controller.config,
3611            expected_config.clone().with_local_targets()
3612        );
3613        assert_eq!(
3614            mj_core::config::Config::load_from(&mj_core::config::config_path()).unwrap(),
3615            expected_config
3616        );
3617
3618        let retained = controller.state.sessions.get(session_id).unwrap();
3619        assert_eq!(retained.state, SessionState::Stopped);
3620        assert_eq!(retained.checkpoint, Some(checkpoint.clone()));
3621        assert_eq!(retained.project_directory, previous.project_directory);
3622        assert_eq!(retained.managed_worktree, previous.managed_worktree);
3623        assert_eq!(retained.bundle_id, previous.bundle_id);
3624        assert!(worktree.worktree_root.is_dir(), "the checkout stays put");
3625        assert!(
3626            checkpoint.archive_path.is_file(),
3627            "the previous archive is what the rolled-back record names"
3628        );
3629        let durable = crate::database::load_state().unwrap();
3630        assert_eq!(durable.sessions[session_id].checkpoint, Some(checkpoint));
3631        // The conversion archive is litter once the resume has failed, and the
3632        // directory it was written in proves the conversion really ran.
3633        let sessions = mj_core::config::sessions_dir();
3634        assert!(sessions.is_dir(), "the conversion wrote an archive");
3635        let leftover: Vec<_> = std::fs::read_dir(&sessions)
3636            .map(|entries| {
3637                entries
3638                    .map(|entry| entry.unwrap().file_name())
3639                    .filter(|name| name.to_string_lossy().ends_with(".hel.zip"))
3640                    .collect()
3641            })
3642            .unwrap_or_default();
3643        assert!(
3644            leftover.is_empty(),
3645            "{leftover:?} in {}",
3646            sessions.display()
3647        );
3648    }
3649
3650    /// Resuming into a different harness cannot reload the archived native
3651    /// session, so the transcript is handed over as the first context instead.
3652    #[test]
3653    fn only_the_same_harness_keeps_native_continuity_on_resume() {
3654        use mj_core::config::HarnessKind;
3655
3656        assert!(super::native_continuity_preserved(
3657            HarnessKind::Codex,
3658            HarnessKind::Codex
3659        ));
3660        assert!(super::native_continuity_preserved(
3661            HarnessKind::Claude,
3662            HarnessKind::Claude
3663        ));
3664        assert!(!super::native_continuity_preserved(
3665            HarnessKind::Claude,
3666            HarnessKind::Codex
3667        ));
3668    }
3669}