Skip to main content

mj_controller/controller/
resume.rs

1//! Resuming a stopped session onto a profile and target.
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use agent_client_protocol::schema::v1::ContentBlock;
8use anyhow::{Context, Result, bail, ensure};
9use rayon::prelude::*;
10use tokio_util::sync::CancellationToken;
11
12use crate::checkpoint_transfer::restore_command;
13use crate::session_manager::new_command_id;
14use mj_checkpoint::archive::{
15    CanonicalQueuedCommandKind, CanonicalSessionSnapshot, CheckpointRepositoryBundle, SystemGit,
16    checkpoint_bundle_prerequisites, read_checkpoint_repository_bundles, verify_archive_streaming,
17};
18use mj_checkpoint::checkpoint::CheckpointRestoreSpec;
19use mj_core::config::{Config, HarnessKind, ProjectRepository, mount_history_host};
20use mj_core::state::{MaterializedSession, SessionRecord, SessionResourceAllocation, SessionState};
21use mj_transcript::projection::materialized_session_from_canonical;
22
23use crate::targets::{
24    self, AdditionalMount, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec,
25    ProcessExecutor, ProvisionStage, ProvisionStageGuard,
26};
27use mj_core::relay::RelayCommand;
28
29use super::backend::{backend_locator, controller_github_token, validate_resource_allocation};
30use super::checkpoint::upload_checkpoint_spec;
31use super::provisioning::{
32    ProvisioningFailureDisposition, StagedExecutor, execute_concurrent_lanes,
33    install_attached_resources,
34};
35use super::readiness::{connect_started_worker, wait_for_native_session_in_stage};
36use super::worker_binary::{bridge_readiness_stage, start_worker, worker_probe_diagnosis};
37use super::worktree::{
38    PrimaryCheckoutRequirement, ResumeConversion, ResumePlan, apply_raw_to_workspace,
39    apply_workspace_to_raw, cleanup_managed_worktree, create_managed_worktree,
40    managed_worktree_checkout_exists, plan_raw_to_workspace,
41    preserve_retained_managed_worktree_branch, raw_checkout_divergence_notice,
42    raw_checkout_position, raw_checkout_snapshot, raw_conversion_preview, restore_managed_worktree,
43    resume_compatibility, retire_managed_worktree,
44};
45use super::{
46    Controller, SessionResumeOptions, execute_checked, now, selected_host_container_size,
47    target_profile_home,
48};
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ResumeRepositorySourceMismatch {
52    pub session_id: String,
53    pub bundle_id: String,
54    pub repository_id: String,
55    pub missing_commit: String,
56    pub archived_origin: String,
57    pub configured_origin: String,
58}
59
60pub use mj_core::state::ResumeRepositorySourceReceipt;
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum ResumeRepositorySourcePreflight {
64    Ready(ResumeRepositorySourceReceipt),
65    RepositoryMoved(ResumeRepositorySourceMismatch),
66    /// The resume converts a local checkout into an isolated workspace. The
67    /// receipt is already valid; the preview is what a person has to confirm
68    /// before the checkout is snapshotted and the session moves off this
69    /// machine.
70    ConvertingRawCheckout {
71        receipt: ResumeRepositorySourceReceipt,
72        preview: Box<mj_core::state::RawConversionPreview>,
73    },
74}
75
76struct ResumeRepositoryBundles {
77    checkpoint_sha256: String,
78    repositories: Vec<CheckpointRepositoryBundle>,
79}
80
81/// A small timing scope for the expensive resume phases. Target commands
82/// already trace their own durations; this covers controller-side work and
83/// lets an operator see where a slow resume spent its wall-clock budget.
84struct ResumePhaseTimer<'a> {
85    session_id: &'a str,
86    phase: &'static str,
87    started: Instant,
88}
89
90impl<'a> ResumePhaseTimer<'a> {
91    fn new(session_id: &'a str, phase: &'static str) -> Self {
92        Self {
93            session_id,
94            phase,
95            started: Instant::now(),
96        }
97    }
98}
99
100impl Drop for ResumePhaseTimer<'_> {
101    fn drop(&mut self) {
102        tracing::debug!(
103            session_id = self.session_id,
104            phase = self.phase,
105            elapsed_ms = self.started.elapsed().as_millis(),
106            "resume phase completed"
107        );
108    }
109}
110
111impl Controller {
112    /// Muse has one workspace root; native restore relocates its metadata.
113    pub(super) fn validate_muse_resume_destination(
114        &self,
115        source: &SessionRecord,
116        destination_harness: HarnessKind,
117        _target_id: &str,
118    ) -> Result<()> {
119        if destination_harness != HarnessKind::Muse {
120            return Ok(());
121        }
122        ensure!(
123            source.project_directory.is_some()
124                || self
125                    .config
126                    .bundles
127                    .get(&source.bundle_id)
128                    .is_none_or(|bundle| bundle.repositories.len() == 1),
129            "Muse Code ACP supports one workspace root; use a single-repository bundle"
130        );
131        Ok(())
132    }
133    /// Prove that each configured repository source still supplies the commit
134    /// boundary its checkpoint bundle expects, before provisioning anything,
135    /// and describe a local checkout's conversion so a person can confirm it.
136    pub fn preflight_resume_repository_sources(
137        &self,
138        session_id: &str,
139        target_id: &str,
140        executor: &(impl CommandExecutor + Sync),
141    ) -> Result<ResumeRepositorySourcePreflight> {
142        self.preflight_repository_sources(session_id, target_id, true, executor)
143    }
144
145    /// `describe_conversion` buys the conversion preview with a read of the
146    /// checkout and a question to its remote. The resume itself only needs to
147    /// know whether a configured source moved, and the person has already
148    /// confirmed by then, so it asks for the cheap answer.
149    fn preflight_repository_sources(
150        &self,
151        session_id: &str,
152        target_id: &str,
153        describe_conversion: bool,
154        executor: &(impl CommandExecutor + Sync),
155    ) -> Result<ResumeRepositorySourcePreflight> {
156        let session = self
157            .state
158            .sessions
159            .get(session_id)
160            .with_context(|| format!("unknown session {session_id}"))?;
161        let checkpoint = session
162            .checkpoint
163            .as_ref()
164            .context("session has no checkpoint")?;
165        let plan = resume_compatibility(session, &self.config, target_id)
166            .map_err(|reason| anyhow::anyhow!(reason))?;
167        if session.project_directory.is_some() {
168            debug_assert!(matches!(
169                plan,
170                ResumePlan::InPlace | ResumePlan::RawToWorkspace
171            ));
172            // A raw session resumes from its live checkout. Its synthetic
173            // bundle is only a grouping identity and may no longer be in the
174            // config; neither an in-place resume nor a raw-to-workspace
175            // conversion restores repository contents from that bundle.
176            let receipt = ResumeRepositorySourceReceipt {
177                session_id: session_id.to_owned(),
178                bundle_id: session.bundle_id.clone(),
179                checkpoint_sha256: checkpoint.sha256.clone(),
180                repositories: Vec::new(),
181            };
182            // A conversion reads the checkout and its remote so the person
183            // sees what will travel. Planning failures (no network remote, an
184            // unreachable remote, a dirty submodule) are this preflight's
185            // error, which every surface already reports.
186            if describe_conversion && plan == ResumePlan::RawToWorkspace {
187                let preview = raw_conversion_preview_for(session, &self.config, executor)?;
188                return Ok(ResumeRepositorySourcePreflight::ConvertingRawCheckout {
189                    receipt,
190                    preview: Box::new(preview),
191                });
192            }
193            return Ok(ResumeRepositorySourcePreflight::Ready(receipt));
194        }
195        let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
196        self.preflight_verified_repository_sources(
197            session_id,
198            ResumeRepositoryBundles {
199                checkpoint_sha256: checkpoint.sha256.clone(),
200                repositories,
201            },
202            None,
203            plan != ResumePlan::WorkspaceToRaw,
204            executor,
205        )
206    }
207
208    fn preflight_verified_repository_sources(
209        &self,
210        session_id: &str,
211        verified: ResumeRepositoryBundles,
212        skip_repository_id: Option<&str>,
213        use_archived_network_sources: bool,
214        executor: &(impl CommandExecutor + Sync),
215    ) -> Result<ResumeRepositorySourcePreflight> {
216        let session = self
217            .state
218            .sessions
219            .get(session_id)
220            .with_context(|| format!("unknown session {session_id}"))?;
221        ensure!(
222            verified.repositories.iter().all(|repository| {
223                !repository.metadata.origin.starts_with("mj-local:")
224                    && !repository.metadata.origin.starts_with("ext::")
225            }),
226            "resuming legacy host-bridge sessions is not supported; start a new network-backed session"
227        );
228        // The immutable archive supplies network provenance. Local source
229        // paths and later host configuration changes are irrelevant.
230        if use_archived_network_sources
231            && verified
232                .repositories
233                .iter()
234                .all(|repository| repository.metadata.remote_workspace)
235        {
236            return Ok(ResumeRepositorySourcePreflight::Ready(
237                ResumeRepositorySourceReceipt {
238                    session_id: session_id.to_owned(),
239                    bundle_id: session.bundle_id.clone(),
240                    checkpoint_sha256: verified.checkpoint_sha256,
241                    repositories: Vec::new(),
242                },
243            ));
244        }
245        if verified.repositories.is_empty() {
246            return Ok(ResumeRepositorySourcePreflight::Ready(
247                ResumeRepositorySourceReceipt {
248                    session_id: session_id.to_owned(),
249                    bundle_id: session.bundle_id.clone(),
250                    checkpoint_sha256: verified.checkpoint_sha256,
251                    repositories: Vec::new(),
252                },
253            ));
254        }
255        let bundle = self
256            .config
257            .bundles
258            .get(&session.bundle_id)
259            .with_context(|| format!("session bundle {:?} is missing", session.bundle_id))?;
260        let configured = verified
261            .repositories
262            .iter()
263            .map(|archived| {
264                bundle
265                    .repositories
266                    .iter()
267                    .find(|repository| repository.id == archived.metadata.id)
268                    .cloned()
269                    .with_context(|| {
270                        format!(
271                            "session bundle {:?} no longer contains repository {:?}",
272                            session.bundle_id, archived.metadata.id
273                        )
274                    })
275            })
276            .collect::<Result<Vec<_>>>()?;
277        let github_token = configured
278            .iter()
279            .any(|repository| repository.github.is_some())
280            .then(controller_github_token)
281            .flatten();
282        let outcomes = verified
283            .repositories
284            .par_iter()
285            .zip(configured.par_iter())
286            .map(|(archived, configured)| {
287                if skip_repository_id == Some(configured.id.as_str()) {
288                    return Ok(None);
289                }
290                checkpoint_source_missing_commit(
291                    configured,
292                    archived,
293                    executor,
294                    github_token.as_deref(),
295                )
296                .map(|missing_commit| {
297                    missing_commit.map(|missing_commit| ResumeRepositorySourceMismatch {
298                        session_id: session_id.to_owned(),
299                        bundle_id: session.bundle_id.clone(),
300                        repository_id: configured.id.clone(),
301                        missing_commit,
302                        archived_origin: archived.metadata.origin.clone(),
303                        configured_origin: configured.source_label(),
304                    })
305                })
306            })
307            .collect::<Vec<Result<Option<ResumeRepositorySourceMismatch>>>>();
308        for outcome in outcomes {
309            if let Some(mismatch) = outcome? {
310                return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(mismatch));
311            }
312        }
313        Ok(ResumeRepositorySourcePreflight::Ready(
314            ResumeRepositorySourceReceipt {
315                session_id: session_id.to_owned(),
316                bundle_id: session.bundle_id.clone(),
317                checkpoint_sha256: verified.checkpoint_sha256,
318                repositories: configured,
319            },
320        ))
321    }
322
323    fn repository_source_receipt_is_current(
324        &self,
325        session_id: &str,
326        receipt: &ResumeRepositorySourceReceipt,
327    ) -> bool {
328        let Some(session) = self.state.sessions.get(session_id) else {
329            return false;
330        };
331        if receipt.session_id != session_id
332            || receipt.bundle_id != session.bundle_id
333            || session
334                .checkpoint
335                .as_ref()
336                .map(|checkpoint| &checkpoint.sha256)
337                != Some(&receipt.checkpoint_sha256)
338        {
339            return false;
340        }
341        if receipt.repositories.is_empty() {
342            return true;
343        }
344        let Some(bundle) = self.config.bundles.get(&session.bundle_id) else {
345            return false;
346        };
347        receipt.repositories.iter().all(|expected| {
348            bundle
349                .repositories
350                .iter()
351                .any(|configured| configured == expected)
352        })
353    }
354
355    /// Validate a replacement first, then atomically save it and check the
356    /// remaining sources so multi-repository bundles can report the next moved
357    /// repository without ever provisioning a partial target.
358    pub fn replace_resume_repository_origin(
359        &mut self,
360        session_id: &str,
361        repository_id: &str,
362        replacement: &str,
363        executor: &(impl CommandExecutor + Sync),
364    ) -> Result<ResumeRepositorySourcePreflight> {
365        let session = self
366            .state
367            .sessions
368            .get(session_id)
369            .with_context(|| format!("unknown session {session_id}"))?;
370        let bundle_id = session.bundle_id.clone();
371        let checkpoint = session
372            .checkpoint
373            .as_ref()
374            .context("session has no checkpoint")?;
375        let replacement = replacement_repository_source(repository_id, replacement)?;
376        let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
377        let verified = ResumeRepositoryBundles {
378            checkpoint_sha256: checkpoint.sha256.clone(),
379            repositories,
380        };
381        let archived = verified
382            .repositories
383            .iter()
384            .find(|repository| repository.metadata.id == repository_id)
385            .with_context(|| format!("checkpoint does not contain repository {repository_id:?}"))?;
386        if let Some(missing_commit) = checkpoint_source_missing_commit(
387            &replacement,
388            archived,
389            executor,
390            controller_github_token().as_deref(),
391        )? {
392            return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(
393                ResumeRepositorySourceMismatch {
394                    session_id: session_id.to_owned(),
395                    bundle_id,
396                    repository_id: repository_id.to_owned(),
397                    missing_commit,
398                    archived_origin: archived.metadata.origin.clone(),
399                    configured_origin: replacement.source_label(),
400                },
401            ));
402        }
403        let (config, ()) = Config::update(|config| {
404            let bundle = config
405                .bundles
406                .get_mut(&bundle_id)
407                .with_context(|| format!("session bundle {bundle_id:?} is missing"))?;
408            let repository = bundle
409                .repositories
410                .iter_mut()
411                .find(|repository| repository.id == repository_id)
412                .with_context(|| {
413                    format!(
414                        "session bundle {:?} no longer contains repository {repository_id:?}",
415                        bundle_id
416                    )
417                })?;
418            repository.github = replacement.github.clone();
419            repository.local = replacement.local.clone();
420            Ok(())
421        })?;
422        self.config = config;
423        self.preflight_verified_repository_sources(
424            session_id,
425            verified,
426            Some(repository_id),
427            false,
428            executor,
429        )
430    }
431}
432
433/// Plan a local checkout's conversion into an isolated workspace and describe
434/// it, without changing anything.
435///
436/// The resume preflight and the browser's resume card both need this answer
437/// before a person confirms, and neither owns a [`Controller`] at that point,
438/// so it takes the record and the configuration directly.
439/// How a restore prepares the worker root it is about to install into.
440pub(super) enum WorkerRootReset {
441    /// Today's behaviour for a freshly provisioned target: clear leftover relay
442    /// state on a bare target, then make sure the worker root exists.
443    FreshTarget,
444    /// The environment is being kept and only the harness replaced. Stop the
445    /// live daemon, clear relay state, unlink the installed worker files, and
446    /// remove the previous per-session profile home. Runs on every locator.
447    InPlace {
448        /// The profile home to delete, or `None` when the session ran straight
449        /// out of the user's own profile directory.
450        previous_profile_root: Option<String>,
451    },
452}
453
454/// Everything the restore tail of a resume needs, once the destination target
455/// is ready and the session record already describes the destination.
456///
457/// The head of a resume differs a great deal between a fresh environment and an
458/// in-place harness swap; from here on the two are the same work.
459pub(super) struct RestoreIntoTarget<'a> {
460    /// The destination profile, which decides the harness home inside the target.
461    pub profile: &'a mj_core::config::HarnessProfile,
462    pub archive: &'a VerifiedResumeArchive,
463    /// The archive the target actually restores: the conversion's own archive
464    /// when a resume wrote one, otherwise the verified checkpoint.
465    pub restored_archive: &'a Path,
466    pub resumed_project_directory: Option<PathBuf>,
467    pub resumed_container_workspace: Option<PathBuf>,
468    pub restore_repositories: bool,
469    /// True when this resume converted a checkout, which is the only case where
470    /// the restored harness session is pointed at a directory the archive could
471    /// not have named.
472    pub primary_repository_root_from_conversion: bool,
473    pub native_continuity: bool,
474    pub discard_queued_prompts: bool,
475    /// Whether the archived queue is resubmitted to a fresh native session.
476    pub replay_queue: bool,
477    /// The compacted handoff a cross-harness restore installs as its first
478    /// prompt context. Required whenever `native_continuity` is false.
479    pub utility_handoff: Option<String>,
480    pub projection_build: Option<tokio::task::JoinHandle<Result<MaterializedSession>>>,
481    /// Conversation lines to record once the destination answers.
482    pub resume_notices: Vec<String>,
483    pub install_attached_resources: bool,
484    pub worker_root_reset: WorkerRootReset,
485    /// A managed checkout to retire once, and only once, the restore succeeded.
486    pub retire_after_ready: Option<&'a mj_core::state::ManagedWorktree>,
487}
488
489impl Controller {
490    /// Install the worker, restore the archive into the target, start the
491    /// harness, and report the projection the destination answers with.
492    pub(super) async fn restore_into_target(
493        &mut self,
494        session_id: &str,
495        restore: RestoreIntoTarget<'_>,
496        executor: &(impl CommandExecutor + Sync),
497    ) -> Result<MaterializedSession> {
498        let RestoreIntoTarget {
499            profile,
500            archive,
501            restored_archive,
502            resumed_project_directory,
503            resumed_container_workspace,
504            restore_repositories,
505            primary_repository_root_from_conversion,
506            native_continuity,
507            discard_queued_prompts,
508            replay_queue,
509            utility_handoff,
510            projection_build,
511            mut resume_notices,
512            install_attached_resources: should_install_attached_resources,
513            worker_root_reset,
514            retire_after_ready,
515        } = restore;
516        let archive_manifest = &archive.manifest;
517        let canonical_session = &archive.canonical_session;
518        let (backend, worker_root) = self.worker_placement(session_id)?;
519        let harness_home = target_profile_home(&backend, session_id, profile);
520        let workspace_root = if let Some(project_directory) = &resumed_project_directory {
521            project_directory
522                .parent()
523                .context("bare project directory has no parent")?
524                .to_string_lossy()
525                .into_owned()
526        } else {
527            super::network_git::workspace_root(&backend, resumed_container_workspace.as_deref())
528        };
529        let target_path = |path: &str| match &backend {
530            targets::TargetLocator::AwsEc2 { .. } | targets::TargetLocator::SshBare { .. }
531                if !path.starts_with('/') =>
532            {
533                PathBuf::from(format!("~/{path}"))
534            }
535            _ => PathBuf::from(path),
536        };
537        let remote_archive = format!("{worker_root}/restore.hel.zip");
538        let remote_spec = format!("{worker_root}/restore-spec.json");
539        let restore = CheckpointRestoreSpec {
540            archive_path: restore_archive_path(
541                &backend,
542                restored_archive,
543                &target_path(&remote_archive),
544            ),
545            workspace_root: target_path(&workspace_root),
546            relay_root: target_path(&worker_root),
547            harness_home: target_path(&harness_home),
548            // A local checkout converting into a workspace arrives as a
549            // fresh clone of its own remote, and the conversion archive
550            // carries the commits, dirty files, and branch that go over it.
551            // An in-place managed checkout recreated from its retained
552            // branch still needs the archive's dirty state.
553            restore_repositories,
554            restore_native: native_continuity,
555            // A move onto a checkout puts it somewhere the archive could
556            // not have named, so the restored harness session is pointed at
557            // the real working directory instead of the archived one. A
558            // move into a target has no host directory left, and the
559            // conversion archive already names the destination under
560            // `/workspace`, so this stays empty there.
561            primary_repository_root: primary_repository_root_from_conversion
562                .then(|| resumed_project_directory.clone())
563                .flatten()
564                .map(|directory| target_path(&directory.to_string_lossy())),
565            discard_queued_prompts,
566        };
567        // Prepare the worker root before the worker binary is installed:
568        // a surviving daemon still holds the old binary open, and the
569        // install would land on a running executable.
570        {
571            let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
572            match &worker_root_reset {
573                // A bare target keeps the closed session's worker root on
574                // the host. Stop anything still writing there and clear the
575                // leftover relay state, or the restore's seed loses to a
576                // stale snapshot whose frontier no journal can support.
577                WorkerRootReset::FreshTarget => {
578                    if let Some(command) = targets::clear_relay_state_plan(&backend, session_id)? {
579                        execute_checked(syncing, command)?;
580                    }
581                    // Both lanes below write into the worker root, so it
582                    // exists first.
583                    execute_checked(
584                        syncing,
585                        targets::command_on_locator(
586                            &backend,
587                            session_id,
588                            vec!["mkdir".into(), "-p".into(), worker_root.clone()],
589                            "create the session worker root",
590                        )?,
591                    )?;
592                }
593                // The environment survives this restore, so the old
594                // harness has to be taken out of it: its daemon, its relay
595                // state, the installed worker files, and its profile home.
596                // The same command recreates the worker root.
597                WorkerRootReset::InPlace {
598                    previous_profile_root,
599                } => {
600                    execute_checked(
601                        syncing,
602                        targets::in_place_worker_reset_plan(
603                            &backend,
604                            session_id,
605                            previous_profile_root.as_deref(),
606                        )?,
607                    )?;
608                }
609            }
610        }
611        let staging = tempfile::tempdir().context("create restore staging")?;
612        let local_spec = staging.path().join("restore-spec.json");
613        std::fs::write(&local_spec, serde_json::to_vec_pretty(&restore)?)?;
614        // Two independent lanes into the target. The checkpoint transfer
615        // needs nothing from the worker install, and the worker install
616        // is independent of archive upload, so both run concurrently.
617        let controller = &*self;
618        let backend_ref = &backend;
619        let worker_root_ref = worker_root.as_str();
620        let local_spec_ref = local_spec.as_path();
621        execute_concurrent_lanes(
622            || {
623                let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
624                controller.prepare_worker_files(
625                    session_id,
626                    backend_ref,
627                    worker_root_ref,
628                    syncing,
629                )?;
630                super::provisioning::install_inherited_git_settings(
631                    syncing,
632                    backend_ref,
633                    session_id,
634                )?;
635                Ok(())
636            },
637            || {
638                let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
639                if should_upload_restore_archive(&backend) {
640                    upload_checkpoint_spec(
641                        restoring,
642                        backend_ref,
643                        session_id,
644                        restored_archive,
645                        &remote_archive,
646                    )?;
647                }
648                upload_checkpoint_spec(
649                    restoring,
650                    backend_ref,
651                    session_id,
652                    local_spec_ref,
653                    &remote_spec,
654                )
655            },
656        )?;
657        {
658            let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
659            execute_checked(
660                restoring,
661                restore_command(&backend, session_id, &remote_spec)?,
662            )?;
663        }
664        if should_install_attached_resources {
665            let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
666            install_attached_resources(&self.state, session_id, &backend, &worker_root, syncing)?;
667        }
668        match projection_build {
669            Some(build) => {
670                let mut restored_projection = build
671                    .await
672                    .context("rebuild the restored projection")?
673                    .context("rebuild the restored projection")?;
674                if discard_queued_prompts {
675                    restored_projection.queued_prompts.clear();
676                }
677                crate::database::save_materialized_session(&restored_projection)?;
678            }
679            // The stored projection already is the archived one. Only the
680            // queue can still need changing.
681            None if discard_queued_prompts => {
682                crate::database::replace_materialized_queued_prompts(session_id, &[])?;
683            }
684            None => {}
685        }
686        let readiness_stage = bridge_readiness_stage(profile);
687        let spec = self.reconnect_command(session_id)?;
688        let readiness = async {
689            let mut relay = {
690                let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
691                start_worker(executor, &backend, &worker_root)?;
692                connect_started_worker(&spec, session_id, executor, &backend, &worker_root).await?
693            };
694            let native_session_id =
695                wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
696            Ok::<_, anyhow::Error>((relay, native_session_id))
697        }
698        .await;
699        let (mut relay, native_session_id) = readiness
700            .map_err(|error| worker_probe_diagnosis(executor, &backend, &worker_root, error))?;
701        if native_continuity {
702            if native_session_id != archive_manifest.session.native_session_id {
703                bail!(
704                    "ACP loaded native session {native_session_id}, expected {}",
705                    archive_manifest.session.native_session_id
706                );
707            }
708        } else {
709            relay
710                .install_prompt_context(
711                    utility_handoff
712                        .clone()
713                        .context("a resume into a fresh native session has no handoff")?,
714                )
715                .await?;
716            if replay_queue {
717                for prompt in &canonical_session.queued_prompts {
718                    // A queued configuration change is replayed as itself;
719                    // rebuilding it as a prompt would send `/model x` to
720                    // the agent as text.
721                    let command = match &prompt.kind {
722                        CanonicalQueuedCommandKind::Prompt => RelayCommand::Prompt {
723                            prompt: prompt
724                                .content
725                                .iter()
726                                .cloned()
727                                .map(serde_json::from_value)
728                                .collect::<serde_json::Result<Vec<ContentBlock>>>()?,
729                        },
730                        CanonicalQueuedCommandKind::SetConfig { key, value } => {
731                            RelayCommand::SetConfig {
732                                key: key.clone(),
733                                value: value.clone(),
734                            }
735                        }
736                    };
737                    relay.submit(prompt.command_id.clone(), command).await?;
738                }
739            }
740        }
741        // Last, and only once the resume has otherwise succeeded: a failure
742        // before this point rolls the record back to a session whose
743        // worktree still has to be there.
744        if let Some(worktree) = retire_after_ready
745            && let Err(error) = retire_managed_worktree(executor, worktree)
746        {
747            tracing::warn!(
748                session_id,
749                worktree = %worktree.worktree_root.display(),
750                error = format!("{error:#}"),
751                "could not retire the old managed worktree after resume"
752            );
753            resume_notices.push(worktree_cleanup_notice(&worktree.worktree_root, &error));
754        }
755        for notice in &resume_notices {
756            let submitted = async {
757                let command_id = new_command_id("resume-notice")?;
758                relay
759                    .submit(
760                        command_id,
761                        RelayCommand::RecordNotice {
762                            text: notice.clone(),
763                        },
764                    )
765                    .await
766            }
767            .await;
768            // The conversation line is a courtesy. A relay that refuses it
769            // has not damaged the resume, so report and carry on.
770            if let Err(error) = submitted {
771                tracing::warn!(
772                    session_id,
773                    error = format!("{error:#}"),
774                    "could not record a resume notice in the conversation"
775                );
776            }
777        }
778        self.mark_worker_connected(session_id, Some(native_session_id))?;
779        Ok(relay.sync().await?.materialized)
780    }
781}
782
783/// One checkpoint archive that has been read end to end and proved to be the
784/// one the session record names.
785///
786/// The canonical snapshot is shared behind an `Arc`: on a long session it is
787/// tens of megabytes, and a resume reads it from several places that would
788/// otherwise hold private copies.
789pub(super) struct VerifiedResumeArchive {
790    /// The archive's absolute, canonical path. A LocalBare worker shares the
791    /// controller's filesystem, so it can consume this file directly instead of
792    /// receiving a second large copy in its worker root.
793    pub archive_path: PathBuf,
794    pub manifest: mj_checkpoint::archive::ArchiveManifest,
795    pub canonical_session: Arc<CanonicalSessionSnapshot>,
796}
797
798/// Read and verify the archive a stopped session will be restored from.
799///
800/// Canonicalizing first keeps one exact absolute path for the restore. The
801/// digest and session id must both match the record, and a legacy host-bridge
802/// archive is refused here rather than part way through a restore.
803pub(super) fn verify_resume_checkpoint(
804    session_id: &str,
805    checkpoint: &mj_core::state::CheckpointMetadata,
806) -> Result<VerifiedResumeArchive> {
807    let archive_path = {
808        let _phase = ResumePhaseTimer::new(session_id, "verify checkpoint archive");
809        checkpoint.archive_path.canonicalize().with_context(|| {
810            format!(
811                "resolve checkpoint archive {}",
812                checkpoint.archive_path.display()
813            )
814        })?
815    };
816    ensure!(
817        archive_path.is_absolute() && archive_path.is_file(),
818        "checkpoint archive path is not an absolute regular file: {}",
819        archive_path.display()
820    );
821    let mj_checkpoint::archive::VerifiedArchiveMetadata {
822        manifest,
823        canonical_session,
824        archive_sha256,
825    } = {
826        let _phase = ResumePhaseTimer::new(session_id, "verify checkpoint archive contents");
827        verify_archive_streaming(&archive_path)?
828    };
829    if archive_sha256 != checkpoint.sha256 || manifest.session.id != session_id {
830        bail!("persisted checkpoint verification failed");
831    }
832    ensure!(
833        manifest.repositories.iter().all(|repository| {
834            !repository.metadata.origin.starts_with("mj-local:")
835                && !repository.metadata.origin.starts_with("ext::")
836        }),
837        "resuming legacy host-bridge sessions is not supported; start a new network-backed session"
838    );
839    Ok(VerifiedResumeArchive {
840        archive_path,
841        manifest,
842        canonical_session: Arc::new(canonical_session),
843    })
844}
845
846pub fn raw_conversion_preview_for(
847    session: &SessionRecord,
848    config: &Config,
849    executor: &(impl CommandExecutor + Sync),
850) -> Result<mj_core::state::RawConversionPreview> {
851    let conversion = plan_raw_to_workspace(session, config, executor)?;
852    raw_conversion_preview(session, &conversion, executor)
853}
854
855fn replacement_repository_source(id: &str, replacement: &str) -> Result<ProjectRepository> {
856    let replacement = replacement.trim();
857    ensure!(!replacement.is_empty(), "enter the repository's new origin");
858    let expanded = mj_core::path_input::expand_local(Path::new(replacement))?;
859    let path = expanded.as_path();
860    let (github, local) = if path.is_absolute() {
861        ensure!(
862            path.is_dir(),
863            "local repository {replacement:?} is not a directory"
864        );
865        (None, Some(mj_core::local_git::canonical_repository(path)?))
866    } else {
867        let github = crate::setup::github_repository_from_origin(replacement)
868            .context("origin must be a GitHub repository or an absolute local repository path")?;
869        (
870            Some(format!("{}/{}", github.owner, github.repository)),
871            None,
872        )
873    };
874    Ok(ProjectRepository {
875        id: id.to_owned(),
876        github,
877        local,
878        destination: PathBuf::from(id),
879        git_ref: None,
880    })
881}
882
883fn checkpoint_source_missing_commit(
884    configured: &ProjectRepository,
885    archived: &CheckpointRepositoryBundle,
886    executor: &impl CommandExecutor,
887    github_token: Option<&str>,
888) -> Result<Option<String>> {
889    let staging = tempfile::tempdir().context("create repository source preflight")?;
890    let repository = staging.path().join("repository.git");
891    checked_preflight_git(
892        executor,
893        CommandSpec::new(
894            "git",
895            [
896                "init".to_owned(),
897                "--bare".to_owned(),
898                "--quiet".to_owned(),
899                repository.to_string_lossy().into_owned(),
900            ],
901        )
902        .purpose("initialize repository source preflight"),
903    )?;
904    let missing = checkpoint_bundle_prerequisites(archived)?;
905    if missing.is_empty() {
906        let bundle = staging.path().join("checkpoint.bundle");
907        std::fs::write(&bundle, &archived.committed_bundle)
908            .context("write self-contained checkpoint bundle for source preflight")?;
909        checked_preflight_git(
910            executor,
911            checkpoint_bundle_import_command(&repository, &bundle),
912        )?;
913        return Ok(None);
914    }
915    // The restore clone will obtain the reachable ancestry. This probe only
916    // needs to establish that the source still serves each boundary object, so
917    // stop at that object instead of downloading and walking its whole graph.
918    for commit in missing {
919        let output = fetch_source_commit(executor, &repository, configured, &commit, github_token)?;
920        if output.status != 0 {
921            let stderr = String::from_utf8_lossy(&output.stderr);
922            if source_does_not_have_commit(&stderr) {
923                return Ok(Some(commit));
924            }
925            bail!(
926                "could not check configured source {:?}: {}",
927                configured.source_label(),
928                stderr.trim()
929            );
930        }
931    }
932    // Do not re-index the bundle against this deliberately shallow probe: the
933    // shallow marker would make Git report artificial connectivity failures.
934    // The real restore applies it to the full source clone.
935    Ok(None)
936}
937
938fn checkpoint_bundle_import_command(repository: &Path, bundle: &Path) -> CommandSpec {
939    let mut command = CommandSpec::new(
940        "git",
941        [
942            "-C".to_owned(),
943            repository.to_string_lossy().into_owned(),
944            "fetch".to_owned(),
945            "--no-tags".to_owned(),
946            bundle.to_string_lossy().into_owned(),
947            "HEAD".to_owned(),
948        ],
949    )
950    .purpose("validate self-contained checkpoint bundle");
951    command
952        .env
953        .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
954    command
955        .env
956        .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
957    command
958}
959
960fn fetch_source_commit(
961    executor: &impl CommandExecutor,
962    repository: &Path,
963    configured: &ProjectRepository,
964    commit: &str,
965    github_token: Option<&str>,
966) -> Result<CommandOutput> {
967    let mut arguments = Vec::new();
968    let mut token_auth = false;
969    let mut ssh_transport = false;
970    let source = if let Some(local) = &configured.local {
971        local.to_string_lossy().into_owned()
972    } else {
973        let source = configured
974            .github
975            .as_deref()
976            .context("repository source is missing")?;
977        let github = crate::setup::github_repository_from_origin(source)
978            .context("configured repository is not a GitHub source")?;
979        if github_token.is_some() {
980            token_auth = true;
981            arguments.extend([
982                "-c".to_owned(),
983                "credential.helper=".to_owned(),
984                "-c".to_owned(),
985                "credential.helper=!f() { if [ \"$1\" = get ]; then echo username=x-access-token; echo \"password=$GH_TOKEN\"; fi; }; f".to_owned(),
986            ]);
987            format!(
988                "https://github.com/{}/{}.git",
989                github.owner, github.repository
990            )
991        } else {
992            ssh_transport = true;
993            format!("git@github.com:{}/{}.git", github.owner, github.repository)
994        }
995    };
996    arguments.extend([
997        "-C".to_owned(),
998        repository.to_string_lossy().into_owned(),
999        "fetch".to_owned(),
1000        "--no-tags".to_owned(),
1001        "--depth=1".to_owned(),
1002        "--filter=blob:none".to_owned(),
1003        source,
1004        commit.to_owned(),
1005    ]);
1006    let mut command = CommandSpec::new("git", arguments).purpose("check checkpoint base commit");
1007    command
1008        .env
1009        .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
1010    command
1011        .env
1012        .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
1013    if token_auth {
1014        let token = github_token.expect("token authentication requires a GitHub token");
1015        command.env.insert("GH_TOKEN".to_owned(), token.to_owned());
1016    }
1017    if ssh_transport {
1018        command.env.insert(
1019            "GIT_SSH_COMMAND".to_owned(),
1020            "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15"
1021                .to_owned(),
1022        );
1023    }
1024    executor.execute(&command)
1025}
1026
1027fn source_does_not_have_commit(stderr: &str) -> bool {
1028    let stderr = stderr.to_ascii_lowercase();
1029    [
1030        "not our ref",
1031        "couldn't find remote ref",
1032        "not a valid object name",
1033        "no such ref was fetched",
1034    ]
1035    .iter()
1036    .any(|needle| stderr.contains(needle))
1037}
1038
1039fn checked_preflight_git(
1040    executor: &impl CommandExecutor,
1041    command: CommandSpec,
1042) -> Result<CommandOutput> {
1043    let output = executor.execute(&command)?;
1044    ensure!(
1045        output.status == 0,
1046        "{}: {}",
1047        command.purpose,
1048        String::from_utf8_lossy(&output.stderr).trim()
1049    );
1050    Ok(output)
1051}
1052
1053impl Controller {
1054    /// Resume a stopped logical session on any configured profile and
1055    /// target. Cross-harness resume restores Git and canonical history, starts
1056    /// a fresh native session, and supplies the prior transcript as its first
1057    /// context turn.
1058    pub async fn resume_session_with_options(
1059        &mut self,
1060        session_id: &str,
1061        profile_id: &str,
1062        target_id: &str,
1063        additional_mounts: Option<Vec<AdditionalMount>>,
1064        resource_allocation: Option<SessionResourceAllocation>,
1065    ) -> Result<MaterializedSession> {
1066        self.resume_session_with_options_and_queue_disposition(
1067            session_id,
1068            profile_id,
1069            target_id,
1070            additional_mounts,
1071            resource_allocation,
1072            false,
1073        )
1074        .await
1075    }
1076
1077    pub async fn resume_session_with_options_and_queue_disposition(
1078        &mut self,
1079        session_id: &str,
1080        profile_id: &str,
1081        target_id: &str,
1082        additional_mounts: Option<Vec<AdditionalMount>>,
1083        resource_allocation: Option<SessionResourceAllocation>,
1084        discard_queue: bool,
1085    ) -> Result<MaterializedSession> {
1086        self.resume_session_controlled(
1087            session_id,
1088            profile_id,
1089            target_id,
1090            SessionResumeOptions {
1091                additional_mounts,
1092                resource_allocation,
1093                discard_queue,
1094            },
1095            &ProcessExecutor,
1096        )
1097        .await
1098    }
1099
1100    pub async fn resume_session_controlled(
1101        &mut self,
1102        session_id: &str,
1103        profile_id: &str,
1104        target_id: &str,
1105        options: SessionResumeOptions,
1106        executor: &(impl CommandExecutor + Sync),
1107    ) -> Result<MaterializedSession> {
1108        self.resume_session_controlled_with_repository_preflight(
1109            session_id, profile_id, target_id, options, None, executor,
1110        )
1111        .await
1112    }
1113
1114    pub async fn resume_session_controlled_with_repository_preflight(
1115        &mut self,
1116        session_id: &str,
1117        profile_id: &str,
1118        target_id: &str,
1119        options: SessionResumeOptions,
1120        repository_preflight: Option<ResumeRepositorySourceReceipt>,
1121        executor: &(impl CommandExecutor + Sync),
1122    ) -> Result<MaterializedSession> {
1123        let SessionResumeOptions {
1124            additional_mounts,
1125            resource_allocation,
1126            discard_queue,
1127        } = options;
1128        let previous = self
1129            .state
1130            .sessions
1131            .get(session_id)
1132            .with_context(|| format!("unknown session {session_id}"))?
1133            .clone();
1134        if !matches!(
1135            previous.state,
1136            SessionState::Stopped | SessionState::Lost | SessionState::Error
1137        ) {
1138            bail!("session {session_id} is not stopped, lost, or retryable");
1139        }
1140        let checkpoint = previous
1141            .checkpoint
1142            .as_ref()
1143            .context("session has no checkpoint")?;
1144        // A receipt for an isolated destination says nothing about a host
1145        // checkout. Explicit moves to raw execution must check that source.
1146        let moving_to_raw = previous.project_directory.is_none()
1147            && self
1148                .config
1149                .targets
1150                .get(target_id)
1151                .is_some_and(mj_core::config::is_bare_project_target);
1152        if moving_to_raw
1153            || !repository_preflight.as_ref().is_some_and(|receipt| {
1154                self.repository_source_receipt_is_current(session_id, receipt)
1155            })
1156        {
1157            let _phase = ResumePhaseTimer::new(session_id, "preflight repository sources");
1158            if let ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) =
1159                self.preflight_repository_sources(session_id, target_id, false, executor)?
1160            {
1161                bail!(
1162                    "checkpoint base commit {} is missing from configured source {:?} for repository {:?}; the repository may have moved (archived origin: {:?})",
1163                    mismatch.missing_commit,
1164                    mismatch.configured_origin,
1165                    mismatch.repository_id,
1166                    mismatch.archived_origin,
1167                );
1168            }
1169        }
1170        let verified_archive = verify_resume_checkpoint(session_id, checkpoint)?;
1171        let archive_path = verified_archive.archive_path.clone();
1172        let archive_manifest = &verified_archive.manifest;
1173        let canonical_session = Arc::clone(&verified_archive.canonical_session);
1174        let profile = self
1175            .config
1176            .profiles
1177            .get(profile_id)
1178            .with_context(|| format!("unknown profile {profile_id:?}"))?
1179            .clone();
1180        ensure!(profile.enabled, "profile {profile_id:?} is disabled");
1181        let target_template = self
1182            .config
1183            .targets
1184            .get(target_id)
1185            .with_context(|| format!("unknown target template {target_id:?}"))?
1186            .clone();
1187        // Decide the representation before the record changes, so an
1188        // incompatible target fails here instead of during provisioning.
1189        self.validate_muse_resume_destination(&previous, profile.kind, target_id)?;
1190        ensure!(
1191            profile.kind != HarnessKind::Muse || previous.additional_mounts.is_empty(),
1192            "Muse Code ACP supports one workspace root; attached directories are unsupported"
1193        );
1194        let plan = resume_compatibility(&previous, &self.config, target_id)
1195            .map_err(|reason| anyhow::anyhow!("{reason}"))?;
1196        // A converting resume writes its own archive below, from the host
1197        // checkout's own network remote, and provisioning reads that one. Every
1198        // other isolated resume clones what its stored archive already names.
1199        if !mj_core::config::is_bare_project_target(&target_template)
1200            && plan != ResumePlan::RawToWorkspace
1201        {
1202            super::network_git::bundle_from_manifest(archive_manifest)?;
1203        }
1204        if plan == ResumePlan::InPlace
1205            && previous.managed_worktree.is_none()
1206            && let Some(project_directory) = &previous.project_directory
1207        {
1208            self.validate_project_directory(target_id, project_directory, executor)
1209                .context("raw project is unavailable for resume")?;
1210        }
1211        let conversion = match plan {
1212            ResumePlan::InPlace => None,
1213            ResumePlan::RawToWorkspace => Some(ResumeConversion::RawToWorkspace(
1214                plan_raw_to_workspace(&previous, &self.config, executor)
1215                    .context("prepare the raw checkout for its new target")?,
1216            )),
1217            ResumePlan::WorkspaceToRaw => Some(ResumeConversion::WorkspaceToRaw(
1218                self.plan_workspace_to_raw(&previous, target_id, executor)
1219                    .context("prepare a checkout for this session")?,
1220            )),
1221        };
1222        let resource_allocation =
1223            resource_allocation.or_else(|| previous.resource_allocation.clone());
1224        let additional_mounts =
1225            additional_mounts.unwrap_or_else(|| previous.additional_mounts.clone());
1226        validate_resource_allocation(&target_template, resource_allocation.as_ref())?;
1227        let selected_container_size =
1228            selected_host_container_size(&target_template, resource_allocation.as_ref());
1229        if !additional_mounts.is_empty() && mount_history_host(&target_template).is_none() {
1230            bail!("attached resources are unsupported for this target");
1231        }
1232        targets::validate_additional_mounts(&additional_mounts)?;
1233        let history_host = mount_history_host(&target_template);
1234        let history_mounts = additional_mounts.clone();
1235        if previous.state == SessionState::Error
1236            && let Some(locator) = &previous.target
1237        {
1238            let backend = backend_locator(locator, &previous, &self.config)?;
1239            targets::close_plan(&backend, session_id)?
1240                .execute(executor)
1241                .context("clean up target from failed resume")?;
1242        }
1243        let mut resume_notices = Vec::new();
1244        // The raw-to-workspace notice is written where the conversion snapshot
1245        // is taken, because it reports the branch the container arrives on.
1246        if let Some(conversion) = conversion
1247            .as_ref()
1248            .and_then(ResumeConversion::workspace_to_raw)
1249        {
1250            resume_notices.push(format!(
1251                "This session moved out of its {} target and into {}. Its branch {} is now {}.",
1252                previous.target_template_id,
1253                conversion.worktree.worktree_root.display(),
1254                archive_manifest
1255                    .repositories
1256                    .first()
1257                    .and_then(|repository| repository.metadata.branch.as_deref())
1258                    .unwrap_or("a detached head"),
1259                conversion.worktree.branch,
1260            ));
1261        }
1262        let managed_checkout_present = previous
1263            .managed_worktree
1264            .as_ref()
1265            .map(|worktree| managed_worktree_checkout_exists(executor, worktree))
1266            .transpose()?
1267            .unwrap_or(true);
1268        // A checkout Mjolnir did not retire remains the truth for a raw session.
1269        // A retired checkout is recreated from the branch and archive below.
1270        if managed_checkout_present && let Some(project_directory) = &previous.project_directory {
1271            match raw_checkout_position(&previous, &self.config, project_directory, executor) {
1272                Ok(live) => resume_notices.extend(raw_checkout_divergence_notice(
1273                    project_directory,
1274                    archive_manifest
1275                        .repositories
1276                        .first()
1277                        .map(|repository| &repository.metadata),
1278                    &live,
1279                )),
1280                // Informational only: a resume must not fail because Mjolnir could
1281                // not read where the checkout stands.
1282                Err(error) => tracing::warn!(
1283                    session_id,
1284                    error = format!("{error:#}"),
1285                    "could not read the raw checkout position for a resume notice"
1286                ),
1287            }
1288        }
1289        // Resolving the worker binary is local and costs microseconds, while
1290        // the compaction below costs minutes and paid model requests. A resume
1291        // that could never install a worker fails here rather than after all
1292        // that work has been thrown away.
1293        super::worker_binary::preflight_worker_binary(&target_template)?;
1294        let same_harness = profile.kind == archive_manifest.session.harness_kind;
1295        let native_continuity =
1296            native_continuity_preserved(profile.kind, archive_manifest.session.harness_kind);
1297        let context_bytes = crate::handoff::profile_handoff_bytes(Some(&profile));
1298        // Cross-harness compaction is started alongside destination
1299        // provisioning below. Clone only the configuration it reads so the
1300        // controller can continue owning and mutating its session record.
1301        let utility_config = (!native_continuity).then(|| self.config.clone());
1302        let discard_queued_prompts = discard_queue || !same_harness;
1303        // When this controller archived the session, its durable projection is
1304        // already the archive's content. Reading one row decides that; a read
1305        // failure or any mismatch rebuilds as before.
1306        let stored_frontier = crate::database::materialized_event_frontier(session_id)
1307            .unwrap_or_else(|error| {
1308                tracing::warn!(
1309                    session_id,
1310                    error = format!("{error:#}"),
1311                    "could not read the stored projection frontier; rebuilding it from the archive"
1312                );
1313                None
1314            });
1315        let rebuild_projection = projection_rebuild_required(
1316            stored_frontier
1317                .as_ref()
1318                .map(|(ordinal, digest)| (*ordinal, digest.as_str())),
1319            canonical_session.event_frontier,
1320            &canonical_session.event_frontier_digest,
1321        );
1322        // Rebuilding the projection is a pure function of the archive and costs
1323        // seconds on a long session. Start it now so it runs while the target is
1324        // being provisioned; its result is awaited where it was consumed
1325        // before, and the writes it feeds have not moved.
1326        //
1327        // A resume that fails before the result is needed drops the handle.
1328        // `spawn_blocking` work cannot be cancelled, so the computation still
1329        // finishes on the blocking pool and its result is discarded; it owns
1330        // nothing but its own inputs, so nothing leaks beyond that CPU.
1331        let projection_build = rebuild_projection.then(|| {
1332            let canonical = Arc::clone(&canonical_session);
1333            let session_id = session_id.to_owned();
1334            tokio::task::spawn_blocking(move || {
1335                materialized_session_from_canonical(session_id, &canonical)
1336            })
1337        });
1338        let github_token = controller_github_token();
1339
1340        // The configuration gains the bundle before the record points at it, so
1341        // no persisted session ever names a bundle that is not there.
1342        if let Some(conversion) = conversion
1343            .as_ref()
1344            .and_then(ResumeConversion::raw_to_workspace)
1345            && let Some(bundle) = &conversion.new_bundle
1346        {
1347            let (config, ()) = Config::update(|config| {
1348                if let Some(existing) = config.bundles.get(&conversion.bundle_id) {
1349                    ensure!(
1350                        existing == bundle,
1351                        "bundle {:?} was configured concurrently with a different definition; retry the resume",
1352                        conversion.bundle_id
1353                    );
1354                } else {
1355                    config
1356                        .bundles
1357                        .insert(conversion.bundle_id.clone(), bundle.clone());
1358                }
1359                Ok(())
1360            })
1361            .context("save the bundle for a converted raw session")?;
1362            self.config = config;
1363        }
1364
1365        // A session that leaves a non-container target for a container one is
1366        // built a container it never had, so it starts using the per-session
1367        // workspace path here if it predates them. A session that already ran
1368        // in a container keeps its path: the harness's recorded working
1369        // directory has to survive the resume.
1370        let moving_into_first_container = self
1371            .config
1372            .targets
1373            .get(target_id)
1374            .is_some_and(mj_core::config::is_container_target)
1375            && !self
1376                .config
1377                .targets
1378                .get(&previous.target_template_id)
1379                .is_some_and(mj_core::config::is_container_target);
1380        let record = self.state.sessions.get_mut(session_id).unwrap();
1381        if record.container_workspace.is_none() && moving_into_first_container {
1382            record.container_workspace = Some(targets::new_container_workspace(session_id)?);
1383        }
1384        record.harness_kind = profile.kind;
1385        record.last_profile = profile_id.to_string();
1386        record.target_template_id = target_id.to_string();
1387        record.resource_allocation = resource_allocation;
1388        record.additional_mounts = additional_mounts;
1389        record.target = None;
1390        record.native_session_id =
1391            native_continuity.then(|| archive_manifest.session.native_session_id.clone());
1392        record.state = SessionState::Provisioning;
1393        record.updated_at = now();
1394        record.last_error = None;
1395        match &conversion {
1396            Some(ResumeConversion::RawToWorkspace(conversion)) => {
1397                apply_raw_to_workspace(record, conversion);
1398            }
1399            Some(ResumeConversion::WorkspaceToRaw(conversion)) => {
1400                apply_workspace_to_raw(record, conversion);
1401            }
1402            None => {}
1403        }
1404        let resumed_project_directory = record.project_directory.clone();
1405        let resumed_container_workspace = record.container_workspace.clone();
1406        if let Some(host) = history_host {
1407            self.state.remember_mount_sources(host, &history_mounts);
1408            crate::database::remember_mount_sources(host, &history_mounts)?;
1409        }
1410        // The session's prompt history is filed under its bundle, so a
1411        // conversion moves the history with it before the record is persisted.
1412        if let Some(conversion) = conversion
1413            .as_ref()
1414            .and_then(ResumeConversion::raw_to_workspace)
1415        {
1416            crate::database::rebind_session_bundle(session_id, &conversion.bundle_id)?;
1417        }
1418        // Resume rewrites the record it resumes, including the attached
1419        // directories and the harness session id, so it writes the whole row.
1420        if let Some((host, size)) = selected_container_size.as_ref() {
1421            crate::database::save_session_with_container_size(
1422                &self.state.sessions[session_id],
1423                host,
1424                *size,
1425            )?;
1426        } else {
1427            crate::database::save_session(&self.state.sessions[session_id])?;
1428        }
1429        if let Some((host, size)) = selected_container_size.as_ref() {
1430            self.state.remember_container_size(host, *size);
1431        }
1432
1433        let mut recreated_managed_worktree = false;
1434        // Set once a conversion has written its archive, so the success path can
1435        // retire the archive it replaced and the failure path can remove it.
1436        let mut conversion_checkpoint_written: Option<mj_core::state::CheckpointMetadata> = None;
1437        let result = async {
1438            if let Some(worktree) = previous.managed_worktree.as_ref() {
1439                recreated_managed_worktree = restore_managed_worktree(executor, worktree)?;
1440                if recreated_managed_worktree && plan == ResumePlan::RawToWorkspace {
1441                    mj_checkpoint::checkpoint::restore_single_repository_onto_branch(
1442                        &archive_path,
1443                        &worktree.worktree_root,
1444                        &worktree.branch,
1445                        &SystemGit,
1446                    )
1447                    .context("restore the retired checkout before moving it into a target")?;
1448                }
1449            }
1450            // The record already names the worktree, so a failure here rolls
1451            // back through the same path that cleans up a new session's.
1452            if let Some(conversion) = conversion
1453                .as_ref()
1454                .and_then(ResumeConversion::workspace_to_raw)
1455            {
1456                if conversion.reuse_existing_branch {
1457                    let recovery_ref =
1458                        preserve_retained_managed_worktree_branch(executor, &conversion.worktree)?;
1459                    restore_managed_worktree(executor, &conversion.worktree)?;
1460                    resume_notices.push(format!(
1461                        "Before restoring this session's retained branch, Mjolnir preserved its tip at {recovery_ref}."
1462                    ));
1463                } else {
1464                    create_managed_worktree(
1465                        executor,
1466                        &conversion.worktree,
1467                        None,
1468                        PrimaryCheckoutRequirement::Any,
1469                    )?;
1470                }
1471                mj_checkpoint::checkpoint::restore_single_repository_onto_branch(
1472                    &archive_path,
1473                    &conversion.worktree.worktree_root,
1474                    &conversion.worktree.branch,
1475                    &SystemGit,
1476                )
1477                .context("restore this session's checkout")?;
1478            }
1479            // A local checkout becomes an isolated workspace by being
1480            // re-snapshotted into a new archive whose provenance is the
1481            // checkout's own network remote. Provisioning clones that remote,
1482            // and the restore below lays this snapshot over the fresh clone.
1483            if let Some(conversion) = conversion
1484                .as_ref()
1485                .and_then(ResumeConversion::raw_to_workspace)
1486            {
1487                let destination = PathBuf::from(
1488                    previous
1489                        .project_directory
1490                        .as_deref()
1491                        .context("a raw session has no project directory")?
1492                        .file_name()
1493                        .context("a raw project directory cannot be the filesystem root")?,
1494                );
1495                let snapshot = raw_checkout_snapshot(
1496                    &conversion.checkout,
1497                    &conversion.source,
1498                    &destination,
1499                    &SystemGit,
1500                )
1501                .context("snapshot the host checkout for its new target")?;
1502                resume_notices.push(conversion_notice(
1503                    target_id,
1504                    previous
1505                        .project_directory
1506                        .as_deref()
1507                        .unwrap_or(&conversion.checkout),
1508                    snapshot.metadata.branch.as_deref(),
1509                    conversion.retire.as_ref(),
1510                ));
1511                let archives = mj_core::config::sessions_dir();
1512                std::fs::create_dir_all(&archives).with_context(|| {
1513                    format!("create the checkpoint directory {}", archives.display())
1514                })?;
1515                // Named like every other managed archive, so an interrupted
1516                // conversion's file is reconciled away, with `converted`
1517                // marking where it came from.
1518                let output = archives.join(format!(
1519                    "{session_id}-converted-{}-{}.hel.zip",
1520                    previous
1521                        .checkpoint
1522                        .as_ref()
1523                        .map_or(0, |checkpoint| checkpoint.event_frontier),
1524                    new_command_id("archive")?
1525                ));
1526                let written = conversion_checkpoint(&archive_path, snapshot, &output)?;
1527                conversion_checkpoint_written = Some(written.clone());
1528                let record = self.state.sessions.get_mut(session_id).unwrap();
1529                record.checkpoint = Some(written);
1530                record.updated_at = now();
1531                // The whole row again: provisioning must read the converted
1532                // archive even if this process dies right here.
1533                if let Some((host, size)) = selected_container_size.as_ref() {
1534                    crate::database::save_session_with_container_size(
1535                        &self.state.sessions[session_id],
1536                        host,
1537                        *size,
1538                    )?;
1539                } else {
1540                    crate::database::save_session(&self.state.sessions[session_id])?;
1541                }
1542            }
1543            let utility_handoff = {
1544                let _provisioning = ResumePhaseTimer::new(session_id, "provision destination");
1545                if let Some(config) = utility_config.as_ref() {
1546                    Some(
1547                        provision_with_cross_harness_handoff(
1548                            self,
1549                            session_id,
1550                            executor,
1551                            github_token.as_deref(),
1552                            config,
1553                            &canonical_session,
1554                            context_bytes,
1555                        )
1556                        .context("prepare the cross-harness destination")?,
1557                    )
1558                } else {
1559                    self.provision_session_with_failure_disposition(
1560                        session_id,
1561                        executor,
1562                        github_token.as_deref(),
1563                        ProvisioningFailureDisposition::Preserve,
1564                    )
1565                    .await?;
1566                    None
1567                }
1568            };
1569            let restore_repositories = (resumed_project_directory.is_none()
1570                && conversion.is_none())
1571                || plan == ResumePlan::RawToWorkspace
1572                || (recreated_managed_worktree && plan == ResumePlan::InPlace);
1573            // A conversion restores the archive it just wrote, not the raw one
1574            // the session was stopped with.
1575            let restored_archive = conversion_checkpoint_written
1576                .as_ref()
1577                .map_or(archive_path.as_path(), |checkpoint| {
1578                    checkpoint.archive_path.as_path()
1579                });
1580            self.restore_into_target(
1581                session_id,
1582                RestoreIntoTarget {
1583                    profile: &profile,
1584                    archive: &verified_archive,
1585                    restored_archive,
1586                    resumed_project_directory,
1587                    resumed_container_workspace,
1588                    restore_repositories,
1589                    primary_repository_root_from_conversion: conversion.is_some(),
1590                    native_continuity,
1591                    discard_queued_prompts,
1592                    replay_queue: !discard_queue,
1593                    utility_handoff,
1594                    projection_build,
1595                    resume_notices,
1596                    install_attached_resources: true,
1597                    worker_root_reset: WorkerRootReset::FreshTarget,
1598                    retire_after_ready: conversion
1599                        .as_ref()
1600                        .and_then(ResumeConversion::raw_to_workspace)
1601                        .and_then(|plan| plan.retire.as_ref()),
1602                },
1603                executor,
1604            )
1605            .await
1606        }
1607        .await;
1608        match result {
1609            Ok(materialized) => {
1610                // The session now runs from the conversion archive, so the raw
1611                // one it replaced can go.
1612                if let Some(written) = &conversion_checkpoint_written {
1613                    super::checkpoint::prune_replaced_checkpoint(
1614                        previous.checkpoint.as_ref(),
1615                        written,
1616                    );
1617                }
1618                Ok(materialized)
1619            }
1620            Err(error) => {
1621                // The rollback puts back a record that names the previous
1622                // archive, so the conversion's archive is nothing but litter.
1623                if let Some(written) = &conversion_checkpoint_written
1624                    && let Err(remove_error) = std::fs::remove_file(&written.archive_path)
1625                    && remove_error.kind() != std::io::ErrorKind::NotFound
1626                {
1627                    tracing::warn!(
1628                        session_id,
1629                        path = %written.archive_path.display(),
1630                        "could not remove the conversion checkpoint after resume failed: {remove_error}"
1631                    );
1632                }
1633                // Put back whatever this resume could have written to the
1634                // durable projection. Both branches restore archived content,
1635                // so they are correct whether or not the write had happened
1636                // when the resume failed.
1637                if rebuild_projection {
1638                    match materialized_session_from_canonical(session_id, &canonical_session) {
1639                        Ok(previous_projection) => {
1640                            if let Err(restore_error) =
1641                                crate::database::save_materialized_session(&previous_projection)
1642                            {
1643                                tracing::error!(
1644                                    session_id,
1645                                    error = format!("{restore_error:#}"),
1646                                    "could not restore the durable projection after resume failed"
1647                                );
1648                            }
1649                        }
1650                        Err(restore_error) => {
1651                            tracing::error!(
1652                                session_id,
1653                                error = format!("{restore_error:#}"),
1654                                "could not rebuild the durable projection after resume failed"
1655                            );
1656                        }
1657                    }
1658                } else if discard_queued_prompts
1659                    && let Err(restore_error) = crate::database::replace_materialized_queued_prompts(
1660                        session_id,
1661                        &mj_transcript::projection::materialized_queued_prompts_from_canonical(
1662                            &canonical_session.queued_prompts,
1663                        ),
1664                    )
1665                {
1666                    tracing::error!(
1667                        session_id,
1668                        error = format!("{restore_error:#}"),
1669                        "could not restore queued prompts after resume failed"
1670                    );
1671                }
1672                Err(self.rollback_failed_resume(
1673                    session_id,
1674                    &previous,
1675                    recreated_managed_worktree,
1676                    error,
1677                    executor,
1678                )?)
1679            }
1680        }
1681    }
1682
1683    pub(super) fn rollback_failed_resume(
1684        &mut self,
1685        session_id: &str,
1686        previous: &SessionRecord,
1687        recreated_managed_worktree: bool,
1688        error: anyhow::Error,
1689        _executor: &impl CommandExecutor,
1690    ) -> Result<anyhow::Error> {
1691        let current = self
1692            .state
1693            .sessions
1694            .get(session_id)
1695            .with_context(|| format!("unknown session {session_id}"))?
1696            .clone();
1697        let cleanup = match current.target.as_ref() {
1698            Some(locator) => (|| -> Result<()> {
1699                let backend = backend_locator(locator, &current, &self.config)?;
1700                targets::close_plan(&backend, session_id)?
1701                    // Use a fresh executor: cancellation applies to the
1702                    // requested operation, not to its compensating cleanup.
1703                    .execute(&CancellableProcessExecutor::with_timeout(
1704                        Duration::from_secs(15),
1705                    ))
1706                    .map(|_| ())
1707            })(),
1708            None => Ok(()),
1709        };
1710        // A failed target teardown may leave its harness writing. Keep its
1711        // checkout intact until a later retry proves the process is stopped.
1712        let worktree_cleanup = if cleanup.is_err() {
1713            Ok(())
1714        } else {
1715            match (
1716                current.managed_worktree.as_ref(),
1717                previous.managed_worktree.as_ref(),
1718            ) {
1719                (_, Some(previous)) if recreated_managed_worktree => retire_managed_worktree(
1720                    &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1721                    previous,
1722                ),
1723                (Some(current), Some(previous)) if current == previous => Ok(()),
1724                // The failed resume created this worktree and its branch, and
1725                // no harness ever ran in it, so the rollback removes both.
1726                (Some(worktree), _) => cleanup_managed_worktree(
1727                    &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1728                    worktree,
1729                    crate::controller::BranchDisposition::Delete,
1730                ),
1731                (None, _) => Ok(()),
1732            }
1733        };
1734        let cleanup_error = [cleanup, worktree_cleanup]
1735            .into_iter()
1736            .filter_map(Result::err)
1737            .map(|cleanup_error| format!("{cleanup_error:#}"))
1738            .collect::<Vec<_>>()
1739            .join("; ");
1740        if !cleanup_error.is_empty() {
1741            tracing::warn!(
1742                session_id,
1743                error = %cleanup_error,
1744                "resume rollback cleanup reported failures"
1745            );
1746        }
1747        let original = format!("{error:#}");
1748        let record = self.state.sessions.get_mut(session_id).unwrap();
1749        let failure = apply_failed_resume_rollback(
1750            record,
1751            previous,
1752            &original,
1753            (!cleanup_error.is_empty()).then_some(cleanup_error),
1754        );
1755        // A conversion filed the session's prompt history under its new bundle.
1756        // The record went back, so the history goes back with it.
1757        if record.bundle_id != current.bundle_id {
1758            let bundle_id = record.bundle_id.clone();
1759            crate::database::rebind_session_bundle(session_id, &bundle_id)?;
1760        }
1761        // The rollback restores the record the resume replaced, attached
1762        // directories included, so it writes the whole row back.
1763        crate::database::save_session(&self.state.sessions[session_id])?;
1764        Ok(failure)
1765    }
1766}
1767
1768fn worktree_cleanup_notice(worktree_root: &Path, error: &anyhow::Error) -> String {
1769    format!(
1770        "Mjolnir could not remove the worktree at {}: {error:#}. Remove it with `git worktree remove --force {}`.",
1771        worktree_root.display(),
1772        worktree_root.display()
1773    )
1774}
1775
1776pub(super) fn apply_failed_resume_rollback(
1777    current: &mut SessionRecord,
1778    previous: &SessionRecord,
1779    original_error: &str,
1780    cleanup_error: Option<String>,
1781) -> anyhow::Error {
1782    match cleanup_error {
1783        None => {
1784            *current = previous.clone();
1785            current.state = SessionState::Stopped;
1786            current.target = None;
1787            current.updated_at = now();
1788            current.last_error = Some(format!("resume failed: {original_error}"));
1789            anyhow::anyhow!(original_error.to_owned())
1790        }
1791        Some(cleanup_error) => {
1792            let failure = format!(
1793                "{original_error}; cleanup of the partial resume target failed: {cleanup_error}"
1794            );
1795            // Keep the exact partial target and checkout ownership until
1796            // cleanup succeeds; the harness may still be writing there.
1797            // A container conversion has no new managed host checkout, so
1798            // retain the original host checkout that it has not retired yet.
1799            if current.managed_worktree.is_none() {
1800                current
1801                    .project_directory
1802                    .clone_from(&previous.project_directory);
1803                current
1804                    .managed_worktree
1805                    .clone_from(&previous.managed_worktree);
1806                current.bundle_id.clone_from(&previous.bundle_id);
1807            }
1808            current.state = SessionState::Error;
1809            current.updated_at = now();
1810            current.last_error = Some(format!("resume failed: {failure}"));
1811            anyhow::anyhow!(failure)
1812        }
1813    }
1814}
1815
1816/// What the conversation is told when a local session moves into a target.
1817fn conversion_notice(
1818    target_id: &str,
1819    checkout: &Path,
1820    branch: Option<&str>,
1821    retire: Option<&mj_core::state::ManagedWorktree>,
1822) -> String {
1823    let branch = branch.unwrap_or("a detached head");
1824    match retire {
1825        Some(worktree) => format!(
1826            "This session moved out of {} and into the {target_id} target, where its checkout is on {branch}. Its branch {} stays in {}.",
1827            checkout.display(),
1828            worktree.branch,
1829            worktree.source_repository.display()
1830        ),
1831        None => format!(
1832            "This session moved out of {} and into the {target_id} target, where its checkout is on {branch}. The checkout on this machine stays where it is.",
1833            checkout.display()
1834        ),
1835    }
1836}
1837
1838/// The archive a converting resume provisions and restores from: the previous
1839/// archive's session, conversation, and native state, with the host checkout's
1840/// snapshot as its only repository.
1841///
1842/// The snapshot carries network provenance, so this archive is what lets the
1843/// destination clone a real remote and later checkpoint like any other
1844/// isolated session.
1845fn conversion_checkpoint(
1846    previous_archive: &Path,
1847    snapshot: mj_checkpoint::archive::RepositorySnapshot,
1848    output: &Path,
1849) -> Result<mj_core::state::CheckpointMetadata> {
1850    let previous = mj_checkpoint::archive::read_archive_verified(previous_archive)
1851        .with_context(|| format!("read checkpoint archive {}", previous_archive.display()))?;
1852    // Native harness state and relay attachments travel byte for byte: a
1853    // conversion replaces repository content and nothing else.
1854    let native_artifacts = previous
1855        .manifest
1856        .payloads
1857        .iter()
1858        .filter_map(|descriptor| match &descriptor.role {
1859            mj_checkpoint::archive::PayloadRole::NativeArtifact { relative_path } => {
1860                Some((relative_path, descriptor))
1861            }
1862            _ => None,
1863        })
1864        .map(|(relative_path, descriptor)| {
1865            Ok(mj_checkpoint::archive::NativeArtifact {
1866                relative_path: relative_path.clone(),
1867                data: previous.payload(descriptor)?.to_vec(),
1868                mode: descriptor.mode,
1869            })
1870        })
1871        .collect::<Result<Vec<_>>>()?;
1872    let canonical_session = previous.canonical_session()?;
1873    let event_frontier = canonical_session.event_frontier;
1874    let written = mj_checkpoint::archive::write_archive_atomic(
1875        output,
1876        &mj_checkpoint::archive::ArchiveInput {
1877            session: previous.manifest.session.clone(),
1878            // Provenance for a person reading the archive; a restore reads
1879            // nothing from it, so the target it was captured on stands.
1880            target: previous.manifest.target.clone(),
1881            bundle: mj_checkpoint::archive::BundleManifest {
1882                id: previous.manifest.bundle.id.clone(),
1883                // A restore finds the primary repository by this id, and with
1884                // it the working directory the native transcript is rewritten
1885                // to, so it has to name the snapshot.
1886                primary_repository: snapshot.metadata.id.clone(),
1887            },
1888            canonical_session,
1889            native_artifacts,
1890            repositories: vec![snapshot],
1891        },
1892    )
1893    .with_context(|| format!("write the conversion archive {}", output.display()))?;
1894    Ok(mj_core::state::CheckpointMetadata {
1895        archive_path: output.to_path_buf(),
1896        sha256: written.archive_sha256,
1897        created_at: now(),
1898        event_frontier,
1899    })
1900}
1901
1902/// Whether a resume has to rebuild the durable projection from its archive.
1903///
1904/// The projection is a deterministic fold of the relay event chain, so a stored
1905/// projection standing at the archive's frontier *and* carrying the archive's
1906/// frontier digest already holds the archived content: same chain, same
1907/// ordinal, same result. Anything else - no stored row, a different ordinal, a
1908/// different digest, or a frontier that could not be read - rebuilds.
1909fn projection_rebuild_required(
1910    stored: Option<(u64, &str)>,
1911    archive_frontier: u64,
1912    archive_frontier_digest: &str,
1913) -> bool {
1914    stored != Some((archive_frontier, archive_frontier_digest))
1915}
1916
1917fn restore_archive_path(
1918    backend: &targets::TargetLocator,
1919    verified_archive: &Path,
1920    remote_archive: &Path,
1921) -> PathBuf {
1922    if matches!(backend, targets::TargetLocator::LocalBare { .. }) {
1923        verified_archive.to_path_buf()
1924    } else {
1925        remote_archive.to_path_buf()
1926    }
1927}
1928
1929fn should_upload_restore_archive(backend: &targets::TargetLocator) -> bool {
1930    !matches!(backend, targets::TargetLocator::LocalBare { .. })
1931}
1932
1933/// Provisioning currently performs its target plan synchronously inside an
1934/// async function. Run the network-bound cross-harness handoff on a joined
1935/// side runtime so it can make progress during that plan without borrowing
1936/// the mutable controller or leaving work behind on failure.
1937struct CrossHarnessProvisionExecutor<'a, E: CommandExecutor + ?Sized> {
1938    inner: &'a E,
1939    cancellation: CancellationToken,
1940}
1941
1942impl<E: CommandExecutor + ?Sized> CommandExecutor for CrossHarnessProvisionExecutor<'_, E> {
1943    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1944        if self.cancellation.is_cancelled() {
1945            bail!("operation cancelled while provisioning destination");
1946        }
1947        self.inner.execute(command)
1948    }
1949
1950    fn cancellation_requested(&self) -> bool {
1951        self.cancellation.is_cancelled() || self.inner.cancellation_requested()
1952    }
1953
1954    fn stage_started(&self, stage: ProvisionStage) {
1955        self.inner.stage_started(stage);
1956    }
1957
1958    fn stage_finished(&self, stage: ProvisionStage) {
1959        self.inner.stage_finished(stage);
1960    }
1961
1962    fn notify_notice(&self, notice: &str) {
1963        self.inner.notify_notice(notice);
1964    }
1965
1966    fn execute_with_stdin(
1967        &self,
1968        command: &CommandSpec,
1969        input: &mut (dyn std::io::Read + Send),
1970    ) -> Result<CommandOutput> {
1971        if self.cancellation.is_cancelled() {
1972            bail!("operation cancelled while provisioning destination");
1973        }
1974        self.inner.execute_with_stdin(command, input)
1975    }
1976}
1977
1978fn provision_with_cross_harness_handoff(
1979    controller: &mut Controller,
1980    session_id: &str,
1981    executor: &(impl CommandExecutor + Sync),
1982    github_token: Option<&str>,
1983    config: &Config,
1984    snapshot: &CanonicalSessionSnapshot,
1985    context_bytes: usize,
1986) -> Result<String> {
1987    let (_provision, handoff) = execute_joined_cross_harness_work(
1988        "cross-harness provisioning",
1989        move |cancellation| {
1990            let provision_executor = CrossHarnessProvisionExecutor {
1991                inner: executor,
1992                cancellation,
1993            };
1994            futures::executor::block_on(controller.provision_session_with_failure_disposition(
1995                session_id,
1996                &provision_executor,
1997                github_token,
1998                ProvisioningFailureDisposition::Preserve,
1999            ))
2000        },
2001        "cross-harness handoff",
2002        move |cancellation| {
2003            let runtime = tokio::runtime::Builder::new_current_thread()
2004                .enable_all()
2005                .build()
2006                .context("create cross-harness handoff runtime")?;
2007            runtime.block_on(utility_handoff_while_cancellable(
2008                session_id,
2009                config,
2010                snapshot,
2011                context_bytes,
2012                executor,
2013                cancellation,
2014            ))
2015        },
2016    )?;
2017    ensure!(
2018        !executor.cancellation_requested(),
2019        "operation cancelled while provisioning destination"
2020    );
2021    Ok(handoff)
2022}
2023
2024/// Run the two independent cross-harness lanes together, cancelling and
2025/// joining the peer as soon as either lane fails. The lane order is stable so
2026/// diagnostics do not depend on which worker happened to finish first.
2027fn execute_joined_cross_harness_work<A: Send, B: Send>(
2028    first_name: &'static str,
2029    first: impl FnOnce(CancellationToken) -> Result<A> + Send,
2030    second_name: &'static str,
2031    second: impl FnOnce(CancellationToken) -> Result<B> + Send,
2032) -> Result<(A, B)> {
2033    let cancellation = CancellationToken::new();
2034    std::thread::scope(|scope| {
2035        let first_cancel = cancellation.clone();
2036        let mut first_handle = Some(scope.spawn(move || first(first_cancel)));
2037        let second_cancel = cancellation.clone();
2038        let mut second_handle = Some(scope.spawn(move || second(second_cancel)));
2039        let mut first_result = None;
2040        let mut second_result = None;
2041
2042        while first_result.is_none() || second_result.is_none() {
2043            if first_result.is_none()
2044                && first_handle
2045                    .as_ref()
2046                    .is_some_and(|handle| handle.is_finished())
2047            {
2048                let handle = first_handle.take().expect("first lane handle present");
2049                first_result = Some(match handle.join() {
2050                    Ok(result) => result,
2051                    Err(panic) => {
2052                        cancellation.cancel();
2053                        Err(anyhow::anyhow!(
2054                            "{first_name} thread panicked: {}",
2055                            targets::command_thread_panic_message(panic.as_ref())
2056                        ))
2057                    }
2058                });
2059                if first_result.as_ref().is_some_and(Result::is_err) {
2060                    cancellation.cancel();
2061                }
2062            }
2063            if second_result.is_none()
2064                && second_handle
2065                    .as_ref()
2066                    .is_some_and(|handle| handle.is_finished())
2067            {
2068                let handle = second_handle.take().expect("second lane handle present");
2069                second_result = Some(match handle.join() {
2070                    Ok(result) => result,
2071                    Err(panic) => {
2072                        cancellation.cancel();
2073                        Err(anyhow::anyhow!(
2074                            "{second_name} thread panicked: {}",
2075                            targets::command_thread_panic_message(panic.as_ref())
2076                        ))
2077                    }
2078                });
2079                if second_result.as_ref().is_some_and(Result::is_err) {
2080                    cancellation.cancel();
2081                }
2082            }
2083            if first_result.is_none() || second_result.is_none() {
2084                std::thread::sleep(Duration::from_millis(10));
2085            }
2086        }
2087
2088        match (
2089            first_result.expect("first lane result received after joined handle"),
2090            second_result.expect("second lane result received after joined handle"),
2091        ) {
2092            (Err(first), Err(second)) => {
2093                Err(first.context(format!("{second_name} lane also failed: {second:#}")))
2094            }
2095            (Err(error), Ok(_)) => Err(error),
2096            (Ok(_), Err(error)) => Err(error),
2097            (Ok(first), Ok(second)) => Ok((first, second)),
2098        }
2099    })
2100}
2101
2102/// Whether the restored native session can carry the conversation into the
2103/// resumed session.
2104///
2105fn native_continuity_preserved(profile_kind: HarnessKind, archived_kind: HarnessKind) -> bool {
2106    profile_kind == archived_kind
2107}
2108
2109/// Discover a utility model and compact the cross-harness handoff while still
2110/// watching for cancellation. Discovery and compaction can both make several
2111/// network requests, so a cancelled resume must not wait them out.
2112async fn utility_handoff_while_cancellable(
2113    session_id: &str,
2114    config: &Config,
2115    snapshot: &CanonicalSessionSnapshot,
2116    context_bytes: usize,
2117    executor: &impl CommandExecutor,
2118    cancellation: CancellationToken,
2119) -> Result<String> {
2120    let _phase = ResumePhaseTimer::new(session_id, "cross-harness handoff");
2121    if executor.cancellation_requested() {
2122        bail!("operation cancelled while compacting the cross-harness handoff");
2123    }
2124    let _compacting = ProvisionStageGuard::new(executor, ProvisionStage::Compacting);
2125    let cancel = cancellation.child_token();
2126    let operation =
2127        crate::handoff::build_handoff_context(session_id, config, snapshot, context_bytes, &cancel);
2128    tokio::pin!(operation);
2129    loop {
2130        tokio::select! {
2131            context = &mut operation => return context,
2132            _ = cancellation.cancelled() => {
2133                cancel.cancel();
2134                bail!("operation cancelled while compacting the cross-harness handoff");
2135            }
2136            _ = tokio::time::sleep(super::readiness::CANCELLATION_POLL_INTERVAL) => {
2137                if executor.cancellation_requested() {
2138                    cancel.cancel();
2139                    bail!("operation cancelled while compacting the cross-harness handoff");
2140                }
2141            }
2142        }
2143    }
2144}
2145
2146mod in_place;
2147
2148#[cfg(test)]
2149mod tests;