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