Skip to main content

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