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, validate_resource_allocation};
30use super::checkpoint::upload_checkpoint_spec;
31use super::github_app::{GithubAppTokenProvider, github_repositories};
32use super::provisioning::{
33    ProvisioningFailureDisposition, StagedExecutor, execute_concurrent_lanes,
34    install_attached_resources,
35};
36use super::readiness::{
37    connect_started_worker, wait_for_harness_prepared_in_stage, wait_for_native_session_in_stage,
38};
39use super::worker_binary::{bridge_readiness_stage, start_worker_durably, worker_probe_diagnosis};
40use super::worktree::{
41    PrimaryCheckoutRequirement, ResumeConversion, ResumePlan, apply_raw_to_workspace,
42    apply_workspace_to_raw, cleanup_managed_worktree, create_managed_worktree,
43    managed_worktree_checkout_exists, managed_worktree_target, plan_raw_to_workspace_for_session,
44    preserve_retained_managed_worktree_branch, raw_checkout_divergence_notice,
45    raw_checkout_position_with_checkout, raw_checkout_snapshot,
46    raw_conversion_preview_with_checkout, restore_managed_worktree,
47    resume_compatibility_with_checkout, retire_managed_worktree,
48};
49use super::{
50    Controller, SessionResumeOptions, execute_checked, now, selected_host_container_size,
51    target_profile_home,
52};
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ResumeRepositorySourceMismatch {
56    pub session_id: String,
57    pub bundle_id: String,
58    pub repository_id: String,
59    pub missing_commit: String,
60    pub archived_origin: String,
61    pub configured_origin: String,
62}
63
64pub use mj_core::state::ResumeRepositorySourceReceipt;
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum ResumeRepositorySourcePreflight {
68    Ready(ResumeRepositorySourceReceipt),
69    RepositoryMoved(ResumeRepositorySourceMismatch),
70    /// The resume converts a local checkout into an isolated workspace. The
71    /// receipt is already valid; the preview is what a person has to confirm
72    /// before the checkout is snapshotted and the session moves off this
73    /// machine.
74    ConvertingRawCheckout {
75        receipt: ResumeRepositorySourceReceipt,
76        preview: Box<mj_core::state::RawConversionPreview>,
77    },
78}
79
80struct ResumeRepositoryBundles {
81    checkpoint_sha256: String,
82    repositories: Vec<CheckpointRepositoryBundle>,
83}
84
85/// A small timing scope for the expensive resume phases. Target commands
86/// already trace their own durations; this covers controller-side work and
87/// lets an operator see where a slow resume spent its wall-clock budget.
88struct ResumePhaseTimer<'a> {
89    session_id: &'a str,
90    phase: &'static str,
91    started: Instant,
92}
93
94impl<'a> ResumePhaseTimer<'a> {
95    fn new(session_id: &'a str, phase: &'static str) -> Self {
96        Self {
97            session_id,
98            phase,
99            started: Instant::now(),
100        }
101    }
102}
103
104impl Drop for ResumePhaseTimer<'_> {
105    fn drop(&mut self) {
106        tracing::debug!(
107            session_id = self.session_id,
108            phase = self.phase,
109            elapsed_ms = self.started.elapsed().as_millis(),
110            "resume phase completed"
111        );
112    }
113}
114
115impl Controller {
116    /// Muse has one workspace root; native restore relocates its metadata.
117    pub(super) fn validate_muse_resume_destination(
118        &self,
119        source: &SessionRecord,
120        destination_harness: HarnessKind,
121        _target_id: &str,
122    ) -> Result<()> {
123        if destination_harness != HarnessKind::Muse {
124            return Ok(());
125        }
126        let checkout = self.state.checkout(&source.id)?;
127        ensure!(
128            checkout.project_directory().is_some()
129                || source
130                    .project_bundle(&self.config)
131                    .is_none_or(|bundle| bundle.repositories.len() == 1),
132            "Muse Code ACP supports one workspace root; use a single-repository bundle"
133        );
134        Ok(())
135    }
136    /// Prove that each configured repository source still supplies the commit
137    /// boundary its checkpoint bundle expects, before provisioning anything,
138    /// and describe a local checkout's conversion so a person can confirm it.
139    pub async fn preflight_resume_repository_sources(
140        &self,
141        session_id: &str,
142        target_id: &str,
143        executor: &(impl CommandExecutor + Sync),
144    ) -> Result<ResumeRepositorySourcePreflight> {
145        let github_token = self
146            .github_token_for_repository_preflight(session_id)
147            .await?;
148        self.preflight_repository_sources(
149            session_id,
150            target_id,
151            true,
152            github_token.as_deref(),
153            executor,
154        )
155    }
156
157    /// Preflight when the caller already resolved the session's token and must
158    /// keep the remaining repository checks synchronous.
159    pub fn preflight_resume_repository_sources_with_token(
160        &self,
161        session_id: &str,
162        target_id: &str,
163        github_token: Option<&str>,
164        executor: &(impl CommandExecutor + Sync),
165    ) -> Result<ResumeRepositorySourcePreflight> {
166        self.preflight_repository_sources(session_id, target_id, true, github_token, executor)
167    }
168
169    /// `describe_conversion` buys the conversion preview with a read of the
170    /// checkout and a question to its remote. The resume itself only needs to
171    /// know whether a configured source moved, and the person has already
172    /// confirmed by then, so it asks for the cheap answer.
173    fn preflight_repository_sources(
174        &self,
175        session_id: &str,
176        target_id: &str,
177        describe_conversion: bool,
178        github_token: Option<&str>,
179        executor: &(impl CommandExecutor + Sync),
180    ) -> Result<ResumeRepositorySourcePreflight> {
181        let session = self
182            .state
183            .sessions
184            .get(session_id)
185            .with_context(|| format!("unknown session {session_id}"))?;
186        let checkpoint = session
187            .checkpoint
188            .as_ref()
189            .context("session has no checkpoint")?;
190        let checkout = self.state.checkout(session_id)?;
191        let plan = resume_compatibility_with_checkout(session, &checkout, &self.config, target_id)
192            .map_err(|reason| anyhow::anyhow!(reason))?;
193        if checkout.project_directory().is_some() {
194            debug_assert!(matches!(
195                plan,
196                ResumePlan::InPlace | ResumePlan::RawToWorkspace
197            ));
198            // A raw session resumes from its live checkout. Its accepted
199            // snapshot can outlive its configuration entry, so raw preflight
200            // does not require the bundle to remain configured.
201            let receipt = ResumeRepositorySourceReceipt {
202                session_id: session_id.to_owned(),
203                bundle_id: session.bundle_id.clone(),
204                checkpoint_sha256: checkpoint.sha256.clone(),
205                repositories: Vec::new(),
206            };
207            // A conversion reads the checkout and its remote so the person
208            // sees what will travel. Planning failures (no network remote, an
209            // unreachable remote, a dirty submodule) are this preflight's
210            // error, which every surface already reports.
211            if describe_conversion && plan == ResumePlan::RawToWorkspace {
212                let preview =
213                    raw_conversion_preview_for(session, &checkout, &self.config, executor)?;
214                return Ok(ResumeRepositorySourcePreflight::ConvertingRawCheckout {
215                    receipt,
216                    preview: Box::new(preview),
217                });
218            }
219            return Ok(ResumeRepositorySourcePreflight::Ready(receipt));
220        }
221        let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
222        self.preflight_verified_repository_sources(
223            session_id,
224            ResumeRepositoryBundles {
225                checkpoint_sha256: checkpoint.sha256.clone(),
226                repositories,
227            },
228            None,
229            plan != ResumePlan::WorkspaceToRaw,
230            github_token,
231            executor,
232        )
233    }
234
235    fn preflight_verified_repository_sources(
236        &self,
237        session_id: &str,
238        verified: ResumeRepositoryBundles,
239        skip_repository_id: Option<&str>,
240        use_archived_network_sources: bool,
241        github_token: Option<&str>,
242        executor: &(impl CommandExecutor + Sync),
243    ) -> Result<ResumeRepositorySourcePreflight> {
244        let session = self
245            .state
246            .sessions
247            .get(session_id)
248            .with_context(|| format!("unknown session {session_id}"))?;
249        ensure!(
250            verified.repositories.iter().all(|repository| {
251                !repository.metadata.origin.starts_with("mj-local:")
252                    && !repository.metadata.origin.starts_with("ext::")
253            }),
254            "resuming legacy host-bridge sessions is not supported; start a new network-backed session"
255        );
256        // The immutable archive supplies network provenance. Local source
257        // paths and later host configuration changes are irrelevant.
258        if use_archived_network_sources
259            && verified
260                .repositories
261                .iter()
262                .all(|repository| repository.metadata.remote_workspace)
263        {
264            return Ok(ResumeRepositorySourcePreflight::Ready(
265                ResumeRepositorySourceReceipt {
266                    session_id: session_id.to_owned(),
267                    bundle_id: session.bundle_id.clone(),
268                    checkpoint_sha256: verified.checkpoint_sha256,
269                    repositories: Vec::new(),
270                },
271            ));
272        }
273        if verified.repositories.is_empty() {
274            return Ok(ResumeRepositorySourcePreflight::Ready(
275                ResumeRepositorySourceReceipt {
276                    session_id: session_id.to_owned(),
277                    bundle_id: session.bundle_id.clone(),
278                    checkpoint_sha256: verified.checkpoint_sha256,
279                    repositories: Vec::new(),
280                },
281            ));
282        }
283        let bundle = session
284            .project_bundle(&self.config)
285            .with_context(|| format!("session bundle {:?} is missing", session.bundle_id))?;
286        let configured = verified
287            .repositories
288            .iter()
289            .map(|archived| {
290                bundle
291                    .repositories
292                    .iter()
293                    .find(|repository| repository.id == archived.metadata.id)
294                    .cloned()
295                    .with_context(|| {
296                        format!(
297                            "session bundle {:?} no longer contains repository {:?}",
298                            session.bundle_id, archived.metadata.id
299                        )
300                    })
301            })
302            .collect::<Result<Vec<_>>>()?;
303        let outcomes = verified
304            .repositories
305            .par_iter()
306            .zip(configured.par_iter())
307            .map(move |(archived, configured)| {
308                if skip_repository_id == Some(configured.id.as_str()) {
309                    return Ok(None);
310                }
311                checkpoint_source_missing_commit(
312                    configured,
313                    session
314                        .project
315                        .as_ref()
316                        .and_then(|project| project.network_sources.get(&configured.id)),
317                    archived,
318                    executor,
319                    github_token,
320                )
321                .map(|missing_commit| {
322                    missing_commit.map(|missing_commit| ResumeRepositorySourceMismatch {
323                        session_id: session_id.to_owned(),
324                        bundle_id: session.bundle_id.clone(),
325                        repository_id: configured.id.clone(),
326                        missing_commit,
327                        archived_origin: archived.metadata.origin.clone(),
328                        configured_origin: configured.source_label(),
329                    })
330                })
331            })
332            .collect::<Vec<Result<Option<ResumeRepositorySourceMismatch>>>>();
333        for outcome in outcomes {
334            if let Some(mismatch) = outcome? {
335                return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(mismatch));
336            }
337        }
338        Ok(ResumeRepositorySourcePreflight::Ready(
339            ResumeRepositorySourceReceipt {
340                session_id: session_id.to_owned(),
341                bundle_id: session.bundle_id.clone(),
342                checkpoint_sha256: verified.checkpoint_sha256,
343                repositories: configured,
344            },
345        ))
346    }
347
348    fn repository_source_receipt_is_current(
349        &self,
350        session_id: &str,
351        receipt: &ResumeRepositorySourceReceipt,
352    ) -> bool {
353        let Some(session) = self.state.sessions.get(session_id) else {
354            return false;
355        };
356        if receipt.session_id != session_id
357            || receipt.bundle_id != session.bundle_id
358            || session
359                .checkpoint
360                .as_ref()
361                .map(|checkpoint| &checkpoint.sha256)
362                != Some(&receipt.checkpoint_sha256)
363        {
364            return false;
365        }
366        if receipt.repositories.is_empty() {
367            return true;
368        }
369        let Some(bundle) = session.project_bundle(&self.config) else {
370            return false;
371        };
372        receipt.repositories.iter().all(|expected| {
373            bundle
374                .repositories
375                .iter()
376                .any(|configured| configured == expected)
377        })
378    }
379
380    /// Validate a replacement first, then atomically save it and check the
381    /// remaining sources so multi-repository bundles can report the next moved
382    /// repository without ever provisioning a partial target.
383    pub async fn replace_resume_repository_origin(
384        &mut self,
385        session_id: &str,
386        repository_id: &str,
387        replacement: &str,
388        executor: &(impl CommandExecutor + Sync),
389    ) -> Result<ResumeRepositorySourcePreflight> {
390        self.replace_resume_repository_origin_with_token(
391            session_id,
392            repository_id,
393            replacement,
394            executor,
395        )
396        .await
397    }
398
399    async fn replace_resume_repository_origin_with_token(
400        &mut self,
401        session_id: &str,
402        repository_id: &str,
403        replacement: &str,
404        executor: &(impl CommandExecutor + Sync),
405    ) -> Result<ResumeRepositorySourcePreflight> {
406        let session = self
407            .state
408            .sessions
409            .get(session_id)
410            .with_context(|| format!("unknown session {session_id}"))?;
411        let bundle_id = session.bundle_id.clone();
412        let checkpoint = session
413            .checkpoint
414            .as_ref()
415            .context("session has no checkpoint")?;
416        let replacement = replacement_repository_source(repository_id, replacement)?;
417        let replacement_network = session
418            .project
419            .as_ref()
420            .map(|_| mj_core::remote_git::resolve_repository(&replacement, executor))
421            .transpose()?;
422        let previous = session.clone();
423        let mut project = session.project.clone();
424        if let Some(project) = &mut project {
425            let repository = project
426                .bundle
427                .repositories
428                .iter_mut()
429                .find(|repository| repository.id == repository_id)
430                .context("accepted project no longer contains the repaired repository")?;
431            repository.github = replacement.github.clone();
432            repository.local = replacement.local.clone();
433            project.network_sources.insert(
434                repository_id.to_owned(),
435                replacement_network
436                    .clone()
437                    .expect("accepted project source resolved above"),
438            );
439        }
440        let github_token = if let Some(app) = self.config.github.app.as_ref() {
441            if let Some(project) = &project {
442                let bundle = project.bundle.clone();
443                let network_sources = project.network_sources.clone();
444                let repositories = tokio::task::spawn_blocking(move || {
445                    github_repositories(&bundle, Some(&network_sources), &ProcessExecutor)
446                })
447                .await
448                .context("replacement GitHub repository source task failed")??;
449                let provider = GithubAppTokenProvider::shared(app)?;
450                let scope = provider
451                    .token_for_owner_repo_pairs(&bundle_id, &repositories)
452                    .await
453                    .map_err(super::GithubBundleSelectionError::into_anyhow)?;
454                match scope {
455                    Some(scope) => Some(
456                        provider
457                            .token_for_installation(
458                                scope.installation_id,
459                                &scope.repositories,
460                                app.session_permissions.as_ref(),
461                            )
462                            .await?,
463                    ),
464                    None => None,
465                }
466            } else {
467                None
468            }
469        } else {
470            self.github_token_for_session(session_id).await?
471        };
472        let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
473        let verified = ResumeRepositoryBundles {
474            checkpoint_sha256: checkpoint.sha256.clone(),
475            repositories,
476        };
477        let archived = verified
478            .repositories
479            .iter()
480            .find(|repository| repository.metadata.id == repository_id)
481            .with_context(|| format!("checkpoint does not contain repository {repository_id:?}"))?;
482        if let Some(missing_commit) = checkpoint_source_missing_commit(
483            &replacement,
484            replacement_network.as_ref(),
485            archived,
486            executor,
487            github_token.as_deref(),
488        )? {
489            return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(
490                ResumeRepositorySourceMismatch {
491                    session_id: session_id.to_owned(),
492                    bundle_id,
493                    repository_id: repository_id.to_owned(),
494                    missing_commit,
495                    archived_origin: archived.metadata.origin.clone(),
496                    configured_origin: replacement.source_label(),
497                },
498            ));
499        }
500        // A merged project can use different repository IDs. Its destination
501        // and identity identify the authoring entry; the session keeps its IDs.
502        let saved = crate::database::saved_project(&bundle_id)?;
503        let config_repository_id = saved
504            .as_ref()
505            .and_then(|(_, saved)| {
506                let accepted = previous.project.as_ref()?;
507                let original = accepted
508                    .bundle
509                    .repositories
510                    .iter()
511                    .find(|repository| repository.id == repository_id)?;
512                saved
513                    .bundle
514                    .repositories
515                    .iter()
516                    .find(|repository| {
517                        repository.destination == original.destination
518                            && saved.identities.get(&repository.id)
519                                == accepted.identities.get(repository_id)
520                    })
521                    .map(|repository| repository.id.clone())
522            })
523            .unwrap_or_else(|| repository_id.to_owned());
524        let config_bundle_id = saved
525            .as_ref()
526            .map_or(bundle_id.as_str(), |(id, _)| id.as_str());
527        let (config, ()) = Config::update(|config| {
528            if project.is_some() && !config.bundles.contains_key(config_bundle_id) {
529                return Ok(());
530            }
531            let bundle = config
532                .bundles
533                .get_mut(config_bundle_id)
534                .with_context(|| format!("session bundle {bundle_id:?} is missing"))?;
535            let repository = bundle
536                .repositories
537                .iter_mut()
538                .find(|repository| repository.id == config_repository_id)
539                .with_context(|| {
540                    format!(
541                        "session bundle {:?} no longer contains repository {repository_id:?}",
542                        bundle_id
543                    )
544                })?;
545            repository.github = replacement.github.clone();
546            repository.local = replacement.local.clone();
547            Ok(())
548        })?;
549        self.config = config;
550        if let Some(project) = project {
551            self.state
552                .sessions
553                .get_mut(session_id)
554                .expect("session checked above")
555                .project = Some(project);
556            self.persist_session_transition_or_restore(
557                session_id,
558                &previous,
559                "save repaired project source",
560            )?;
561        }
562        self.preflight_verified_repository_sources(
563            session_id,
564            verified,
565            Some(repository_id),
566            false,
567            github_token.as_deref(),
568            executor,
569        )
570    }
571}
572
573/// Plan a local checkout's conversion into an isolated workspace and describe
574/// it, without changing anything.
575///
576/// The resume preflight and the browser's resume card both need this answer
577/// before a person confirms, and neither owns a [`Controller`] at that point,
578/// so it takes the record and the configuration directly.
579/// How a restore prepares the worker root it is about to install into.
580pub(super) enum WorkerRootReset {
581    /// Today's behaviour for a freshly provisioned target: clear leftover relay
582    /// state on a bare target, then make sure the worker root exists.
583    FreshTarget,
584    /// The environment is being kept and only the harness replaced. Stop the
585    /// live daemon, clear relay state, unlink the installed worker files, and
586    /// remove the previous per-session profile home. Runs on every locator.
587    InPlace {
588        /// The per-session profile root the replaced harness ran from.
589        previous_profile_root: String,
590    },
591}
592
593/// Everything the restore tail of a resume needs, once the destination target
594/// is ready and the session record already describes the destination.
595///
596/// The head of a resume differs a great deal between a fresh environment and an
597/// in-place harness swap; from here on the two are the same work.
598pub(super) struct RestoreIntoTarget<'a> {
599    /// The destination profile, which decides the harness home inside the target.
600    pub profile: &'a mj_core::config::HarnessProfile,
601    pub archive: &'a VerifiedResumeArchive,
602    /// The archive the target actually restores: the conversion's own archive
603    /// when a resume wrote one, otherwise the verified checkpoint.
604    pub restored_archive: &'a Path,
605    pub resumed_project_directory: Option<PathBuf>,
606    pub resumed_container_workspace: Option<PathBuf>,
607    pub restore_repositories: bool,
608    pub native_continuity: bool,
609    pub discard_queued_prompts: bool,
610    /// Whether the archived queue is resubmitted to a fresh native session.
611    pub replay_queue: bool,
612    /// The compacted handoff a cross-harness restore installs as its first
613    /// prompt context. Required whenever `native_continuity` is false.
614    pub utility_handoff: Option<String>,
615    pub projection_build: Option<tokio::task::JoinHandle<Result<MaterializedSession>>>,
616    /// Conversation lines to record once the destination answers.
617    pub resume_notices: Vec<String>,
618    /// Refresh the retained-child roster immediately before prompt context and
619    /// the conversation notice are installed.
620    pub include_in_place_subagents: bool,
621    pub install_attached_resources: bool,
622    pub worker_root_reset: WorkerRootReset,
623    /// A managed checkout to retire once, and only once, the restore succeeded.
624    pub retire_after_ready: Option<&'a mj_core::state::ManagedWorktree>,
625}
626
627impl Controller {
628    /// Install the worker, restore the archive into the target, start the
629    /// harness, and report the projection the destination answers with.
630    pub(super) async fn restore_into_target(
631        &mut self,
632        session_id: &str,
633        restore: RestoreIntoTarget<'_>,
634        executor: &(impl CommandExecutor + Sync),
635    ) -> Result<MaterializedSession> {
636        crate::worker_lifecycle::run(session_id, "restore into target", executor, async {
637            let RestoreIntoTarget {
638                profile,
639                archive,
640                restored_archive,
641                resumed_project_directory,
642                resumed_container_workspace,
643                restore_repositories,
644                native_continuity,
645                discard_queued_prompts,
646                replay_queue,
647                utility_handoff,
648                projection_build,
649                mut resume_notices,
650                include_in_place_subagents,
651                install_attached_resources: should_install_attached_resources,
652                worker_root_reset,
653                retire_after_ready,
654            } = restore;
655            let archive_manifest = &archive.manifest;
656            let canonical_session = &archive.canonical_session;
657            // Sub-agents the suspend stopped: the model hears about them on its
658            // first prompt, and the person in a conversation line. The list is
659            // kept until the relay has the note, so a resume that fails tells
660            // the next one.
661            let stopped_subagents = crate::database::load_stopped_subagents(session_id)
662                .unwrap_or_else(|error| {
663                    tracing::warn!(
664                        session_id,
665                        error = format!("{error:#}"),
666                        "could not read the sub-agents this session's suspend stopped"
667                    );
668                    Vec::new()
669                });
670            let stopped_subagents_context =
671                mj_core::subagent::stopped_subagents_prompt_context(&stopped_subagents);
672            let reopen_move_subagents =
673                matches!(&worker_root_reset, WorkerRootReset::InPlace { .. })
674                    && super::move_session::worker_subagent_queue_enabled(
675                        &self.state.sessions[session_id]
676                            .subagents
677                            .clone()
678                            .unwrap_or_default(),
679                        profile.kind,
680                        self.state.subagents.get(session_id),
681                        self.config
682                            .agent_mailboxes_enabled_for(&self.state.sessions[session_id]),
683                    );
684            resume_notices.extend(mj_core::subagent::stopped_subagents_notice(
685                &stopped_subagents,
686            ));
687            let mut prompt_contexts = Vec::new();
688            prompt_contexts.extend(stopped_subagents_context);
689            let (backend, worker_root) = self.worker_placement(session_id)?;
690            let harness_home = target_profile_home(&backend, session_id, profile);
691            let workspace_root = if let Some(project_directory) = &resumed_project_directory {
692                project_directory
693                    .parent()
694                    .context("bare project directory has no parent")?
695                    .to_string_lossy()
696                    .into_owned()
697            } else {
698                super::network_git::workspace_root(&backend, resumed_container_workspace.as_deref())
699            };
700            let target_path = |path: &str| match &backend {
701                targets::TargetLocator::AwsEc2 { .. } | targets::TargetLocator::SshBare { .. }
702                    if !path.starts_with('/') =>
703                {
704                    PathBuf::from(format!("~/{path}"))
705                }
706                _ => PathBuf::from(path),
707            };
708            let remote_archive = format!("{worker_root}/restore.hel.zip");
709            let remote_spec = format!("{worker_root}/restore-spec.json");
710            use mj_checkpoint::checkpoint::QueueRestorePolicy;
711            let move_admission =
712                crate::database::load_move_operation(session_id)?.is_some_and(|operation| {
713                    operation.phase == mj_core::state::MovePhase::ResumingDestination
714                        && !operation.queue_admission_started
715                        && operation.queue == mj_core::state::ResumeQueueDisposition::Start
716                });
717            let queue_policy = if move_admission || (!native_continuity && replay_queue) {
718                QueueRestorePolicy::Defer
719            } else if discard_queued_prompts {
720                QueueRestorePolicy::Discard
721            } else {
722                QueueRestorePolicy::Restore
723            };
724            let restore = CheckpointRestoreSpec {
725                archive_path: restore_archive_path(
726                    &backend,
727                    restored_archive,
728                    &target_path(&remote_archive),
729                ),
730                workspace_root: target_path(&workspace_root),
731                relay_root: target_path(&worker_root),
732                harness_home: target_path(&harness_home),
733                // A local checkout converting into a workspace arrives as a
734                // fresh clone of its own remote, and the conversion archive
735                // carries the commits, dirty files, and branch that go over it.
736                // An in-place managed checkout recreated from its retained
737                // branch still needs the archive's dirty state.
738                restore_repositories,
739                restore_native: native_continuity,
740                // A session with a project directory launches its harness there,
741                // spelled exactly as recorded (the worker launch configuration's
742                // `cwd`), so the restored harness session is keyed by that same
743                // text. Rebuilding it from the archive's repository layout loses a
744                // trailing separator, which Grok Build keys by, and cannot name
745                // the checkout a move put the session on. A session in a target
746                // workspace has no project directory, and the archive's layout
747                // names its directory under `/workspace`.
748                primary_repository_root: resumed_project_directory
749                    .as_ref()
750                    .map(|directory| target_path(&directory.to_string_lossy())),
751                queue_policy,
752            };
753            // Prepare the worker root before the worker binary is installed:
754            // a surviving daemon still holds the old binary open, and the
755            // install would land on a running executable.
756            {
757                let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
758                match &worker_root_reset {
759                    // A bare target keeps the closed session's worker root on
760                    // the host. Stop anything still writing there and clear the
761                    // leftover relay state, or the restore's seed loses to a
762                    // stale snapshot whose frontier no journal can support.
763                    WorkerRootReset::FreshTarget => {
764                        if let Some(command) =
765                            targets::clear_relay_state_plan(&backend, session_id)?
766                        {
767                            execute_checked(syncing, command)?;
768                        }
769                        // Both lanes below write into the worker root, so it
770                        // exists first.
771                        execute_checked(
772                            syncing,
773                            targets::command_on_locator(
774                                &backend,
775                                session_id,
776                                vec!["mkdir".into(), "-p".into(), worker_root.clone()],
777                                "create the session worker root",
778                            )?,
779                        )?;
780                    }
781                    // The environment survives this restore, so the old
782                    // harness has to be taken out of it: its daemon, its relay
783                    // state, the installed worker files, and its profile home.
784                    // The same command recreates the worker root.
785                    WorkerRootReset::InPlace {
786                        previous_profile_root,
787                    } => {
788                        execute_checked(
789                            syncing,
790                            targets::in_place_worker_reset_plan(
791                                &backend,
792                                session_id,
793                                previous_profile_root,
794                            )?,
795                        )?;
796                    }
797                }
798            }
799            let staging = tempfile::tempdir().context("create restore staging")?;
800            let local_spec = staging.path().join("restore-spec.json");
801            std::fs::write(&local_spec, serde_json::to_vec_pretty(&restore)?)?;
802            // Two independent lanes into the target. The checkpoint transfer
803            // needs nothing from the worker install, and the worker install
804            // is independent of archive upload, so both run concurrently.
805            let controller = &*self;
806            let backend_ref = &backend;
807            let worker_root_ref = worker_root.as_str();
808            let local_spec_ref = local_spec.as_path();
809            // The archive lands in the worker root and unpacks into the
810            // workspace and the harness home; each filesystem needs room.
811            crate::target_storage::ensure_room_for(
812                &backend,
813                [
814                    worker_root.as_str(),
815                    workspace_root.as_str(),
816                    harness_home.as_str(),
817                ],
818                || {
819                    Ok(std::fs::metadata(restored_archive)
820                        .with_context(|| format!("measure {}", restored_archive.display()))?
821                        .len())
822                },
823                "restore the checkpoint",
824            )?;
825            execute_concurrent_lanes(
826                || {
827                    let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
828                    controller.prepare_worker_files(
829                        session_id,
830                        backend_ref,
831                        worker_root_ref,
832                        syncing,
833                    )?;
834                    super::provisioning::install_inherited_git_settings(
835                        syncing,
836                        backend_ref,
837                        session_id,
838                    )?;
839                    Ok(())
840                },
841                || {
842                    let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
843                    if should_upload_restore_archive(&backend) {
844                        upload_checkpoint_spec(
845                            restoring,
846                            backend_ref,
847                            session_id,
848                            restored_archive,
849                            &remote_archive,
850                        )?;
851                    }
852                    upload_checkpoint_spec(
853                        restoring,
854                        backend_ref,
855                        session_id,
856                        local_spec_ref,
857                        &remote_spec,
858                    )
859                },
860            )?;
861            {
862                let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
863                execute_checked(
864                    restoring,
865                    restore_command(&backend, session_id, &remote_spec)?,
866                )?;
867            }
868            if should_install_attached_resources {
869                let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
870                install_attached_resources(
871                    &self.state,
872                    session_id,
873                    &backend,
874                    &worker_root,
875                    syncing,
876                )?;
877            }
878            match projection_build {
879                Some(build) => {
880                    let mut restored_projection = build
881                        .await
882                        .context("rebuild the restored projection")?
883                        .context("rebuild the restored projection")?;
884                    if discard_queued_prompts {
885                        restored_projection.queued_prompts.clear();
886                    }
887                    crate::database::save_materialized_session(&restored_projection)?;
888                }
889                // The stored projection already is the archived one. Only the
890                // queue can still need changing.
891                None if discard_queued_prompts => {
892                    crate::database::replace_materialized_queued_prompts(session_id, &[])?;
893                }
894                None => {}
895            }
896            let readiness_stage = bridge_readiness_stage(profile);
897            let spec = self.reconnect_command(session_id)?;
898            let readiness = async {
899                let mut relay = {
900                    let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
901                    start_worker_durably(
902                        &crate::worker_lifecycle::require(session_id)?,
903                        self.state.sessions[session_id]
904                            .target
905                            .as_ref()
906                            .context("worker start has no durable target")?,
907                        executor,
908                        &backend,
909                        &worker_root,
910                    )?;
911                    connect_started_worker(&spec, session_id, executor, &backend, &worker_root)
912                        .await?
913                };
914                if reopen_move_subagents {
915                    // The worker builds its sub-agent queue while it prepares the
916                    // harness and refuses admission changes until then. Reopen
917                    // before the native session exists, so a prompt the restored
918                    // relay starts on its own cannot find admission closed.
919                    wait_for_harness_prepared_in_stage(
920                        &mut relay,
921                        executor,
922                        readiness_stage.clone(),
923                        profile.kind,
924                    )
925                    .await?;
926                    relay
927                        .set_subagent_admission(true)
928                        .await
929                        .context("reopen sub-agent requests on the replacement worker")?;
930                }
931                if include_in_place_subagents {
932                    let session_id = session_id.to_owned();
933                    let (context, _) = tokio::task::spawn_blocking(move || {
934                        load_in_place_subagent_prompt_data(&session_id)
935                    })
936                    .await
937                    .context("join the retained sub-agent roster read")??;
938                    prompt_contexts.extend(context);
939                }
940                // Installed before the harness is ready, so a queued prompt the
941                // restored relay starts on its own cannot claim the hidden
942                // context first. The relay hands it to one prompt only.
943                if !prompt_contexts.is_empty() {
944                    relay
945                        .install_prompt_context(prompt_contexts.join("\n\n"))
946                        .await
947                        .context("tell the resumed session about its sub-agents")?;
948                }
949                let native_session_id = wait_for_native_session_in_stage(
950                    &mut relay,
951                    executor,
952                    readiness_stage,
953                    profile.kind,
954                )
955                .await?;
956                let owner = crate::worker_lifecycle::require(session_id)?;
957                crate::database::finish_worker_restart(session_id, owner.operation_id())?;
958                Ok::<_, anyhow::Error>((relay, native_session_id))
959            }
960            .await;
961            let (mut relay, native_session_id) = readiness
962                .map_err(|error| worker_probe_diagnosis(executor, &backend, &worker_root, error))?;
963            if native_continuity {
964                if !restored_native_session_accepted(
965                    &archive_manifest.session.native_session_id,
966                    &native_session_id,
967                    relay
968                        .operational()
969                        .replaced_unused_native_session_id
970                        .as_deref(),
971                ) {
972                    bail!(
973                        "ACP loaded native session {native_session_id}, expected {}",
974                        archive_manifest.session.native_session_id
975                    );
976                }
977            } else {
978                relay
979                    .install_prompt_context(
980                        utility_handoff
981                            .clone()
982                            .context("a resume into a fresh native session has no handoff")?,
983                    )
984                    .await?;
985                if replay_queue {
986                    for prompt in &canonical_session.queued_prompts {
987                        // A queued configuration change is replayed as itself;
988                        // rebuilding it as a prompt would send `/model x` to
989                        // the agent as text.
990                        let command = match &prompt.kind {
991                            CanonicalQueuedCommandKind::Prompt => RelayCommand::Prompt {
992                                prompt: prompt
993                                    .content
994                                    .iter()
995                                    .cloned()
996                                    .map(serde_json::from_value)
997                                    .collect::<serde_json::Result<Vec<ContentBlock>>>()?,
998                            },
999                            CanonicalQueuedCommandKind::SetConfig { key, value } => {
1000                                RelayCommand::SetConfig {
1001                                    key: key.clone(),
1002                                    value: value.clone(),
1003                                }
1004                            }
1005                        };
1006                        relay.submit(prompt.command_id.clone(), command).await?;
1007                    }
1008                }
1009            }
1010            // Last, and only once the resume has otherwise succeeded: a failure
1011            // before this point rolls the record back to a session whose
1012            // worktree still has to be there.
1013            if let Some(worktree) = retire_after_ready
1014                && let Err(error) = retire_managed_worktree(executor, worktree)
1015            {
1016                tracing::warn!(
1017                    session_id,
1018                    worktree = %worktree.worktree_root.display(),
1019                    error = format!("{error:#}"),
1020                    "could not retire the old managed worktree after resume"
1021                );
1022                resume_notices.push(worktree_cleanup_notice(&worktree.worktree_root, &error));
1023            }
1024            if include_in_place_subagents {
1025                let session_id = session_id.to_owned();
1026                let (_, notice) = tokio::task::spawn_blocking(move || {
1027                    load_in_place_subagent_prompt_data(&session_id)
1028                })
1029                .await
1030                .context("join the retained sub-agent notice roster read")??;
1031                resume_notices.extend(notice);
1032            }
1033            for notice in &resume_notices {
1034                let submitted = async {
1035                    let command_id = new_command_id("resume-notice")?;
1036                    relay
1037                        .submit(
1038                            command_id,
1039                            RelayCommand::RecordNotice {
1040                                text: notice.clone(),
1041                            },
1042                        )
1043                        .await
1044                }
1045                .await;
1046                // The conversation line is a courtesy. A relay that refuses it
1047                // has not damaged the resume, so report and carry on.
1048                if let Err(error) = submitted {
1049                    tracing::warn!(
1050                        session_id,
1051                        error = format!("{error:#}"),
1052                        "could not record a resume notice in the conversation"
1053                    );
1054                }
1055            }
1056            self.mark_worker_connected(session_id, Some(native_session_id))?;
1057            let materialized = relay.sync().await?.materialized;
1058            if !stopped_subagents.is_empty() {
1059                let delivered = stopped_subagents
1060                    .iter()
1061                    .map(|child| child.child_session_id.clone())
1062                    .collect::<Vec<_>>();
1063                // The relay owns the note now. Failing to forget the list only
1064                // means a later resume tells the model again.
1065                if let Err(error) = crate::database::clear_stopped_subagents(session_id, &delivered)
1066                {
1067                    tracing::warn!(
1068                        session_id,
1069                        error = format!("{error:#}"),
1070                        "could not clear the stopped sub-agents after telling the resumed session"
1071                    );
1072                }
1073            }
1074            Ok(materialized)
1075        })
1076        .await
1077    }
1078}
1079
1080/// One checkpoint archive that has been read end to end and proved to be the
1081/// one the session record names.
1082///
1083/// The canonical snapshot is shared behind an `Arc`: on a long session it is
1084/// tens of megabytes, and a resume reads it from several places that would
1085/// otherwise hold private copies.
1086pub(super) struct VerifiedResumeArchive {
1087    /// The archive's absolute, canonical path. A LocalBare worker shares the
1088    /// controller's filesystem, so it can consume this file directly instead of
1089    /// receiving a second large copy in its worker root.
1090    pub archive_path: PathBuf,
1091    pub manifest: mj_checkpoint::archive::ArchiveManifest,
1092    pub canonical_session: Arc<CanonicalSessionSnapshot>,
1093}
1094
1095fn load_in_place_subagent_prompt_data(
1096    session_id: &str,
1097) -> Result<(Option<String>, Option<String>)> {
1098    let state = crate::database::load_state()
1099        .context("load current retained sub-agents for the in-place resume")?;
1100    let children = super::move_session::move_children(&state, session_id);
1101    Ok((
1102        mj_core::subagent::in_place_subagents_prompt_context(&children),
1103        mj_core::subagent::in_place_subagents_notice(&children),
1104    ))
1105}
1106
1107/// Read and verify the archive a stopped session will be restored from.
1108///
1109/// Canonicalizing first keeps one exact absolute path for the restore. The
1110/// digest and session id must both match the record, and a legacy host-bridge
1111/// archive is refused here rather than part way through a restore.
1112pub(super) fn verify_resume_checkpoint(
1113    session_id: &str,
1114    checkpoint: &mj_core::state::CheckpointMetadata,
1115) -> Result<VerifiedResumeArchive> {
1116    let archive_path = {
1117        let _phase = ResumePhaseTimer::new(session_id, "verify checkpoint archive");
1118        checkpoint.archive_path.canonicalize().with_context(|| {
1119            format!(
1120                "resolve checkpoint archive {}",
1121                checkpoint.archive_path.display()
1122            )
1123        })?
1124    };
1125    ensure!(
1126        archive_path.is_absolute() && archive_path.is_file(),
1127        "checkpoint archive path is not an absolute regular file: {}",
1128        archive_path.display()
1129    );
1130    let mj_checkpoint::archive::VerifiedArchiveMetadata {
1131        manifest,
1132        canonical_session,
1133        archive_sha256,
1134    } = {
1135        let _phase = ResumePhaseTimer::new(session_id, "verify checkpoint archive contents");
1136        verify_archive_streaming(&archive_path)?
1137    };
1138    if archive_sha256 != checkpoint.sha256 || manifest.session.id != session_id {
1139        bail!("persisted checkpoint verification failed");
1140    }
1141    ensure!(
1142        manifest.repositories.iter().all(|repository| {
1143            !repository.metadata.origin.starts_with("mj-local:")
1144                && !repository.metadata.origin.starts_with("ext::")
1145        }),
1146        "resuming legacy host-bridge sessions is not supported; start a new network-backed session"
1147    );
1148    Ok(VerifiedResumeArchive {
1149        archive_path,
1150        manifest,
1151        canonical_session: Arc::new(canonical_session),
1152    })
1153}
1154
1155pub fn raw_conversion_preview_for(
1156    session: &SessionRecord,
1157    checkout: &mj_core::state::Checkout<'_>,
1158    config: &Config,
1159    executor: &(impl CommandExecutor + Sync),
1160) -> Result<mj_core::state::RawConversionPreview> {
1161    let conversion = plan_raw_to_workspace_for_session(session, checkout, config, executor)?;
1162    raw_conversion_preview_with_checkout(session, &conversion, executor)
1163}
1164
1165fn replacement_repository_source(id: &str, replacement: &str) -> Result<ProjectRepository> {
1166    let replacement = replacement.trim();
1167    ensure!(!replacement.is_empty(), "enter the repository's new origin");
1168    let expanded = mj_core::path_input::expand_local(Path::new(replacement))?;
1169    let path = expanded.as_path();
1170    let (github, local) = if path.is_absolute() {
1171        ensure!(
1172            path.is_dir(),
1173            "local repository {replacement:?} is not a directory"
1174        );
1175        (None, Some(mj_core::local_git::canonical_repository(path)?))
1176    } else {
1177        let github = crate::setup::github_repository_from_origin(replacement)
1178            .context("origin must be a GitHub repository or an absolute local repository path")?;
1179        (
1180            Some(format!("{}/{}", github.owner, github.repository)),
1181            None,
1182        )
1183    };
1184    Ok(ProjectRepository {
1185        id: id.to_owned(),
1186        github,
1187        local,
1188        destination: PathBuf::from(id),
1189        git_ref: None,
1190    })
1191}
1192
1193fn checkpoint_source_missing_commit(
1194    configured: &ProjectRepository,
1195    accepted_source: Option<&mj_core::remote_git::NetworkGitSource>,
1196    archived: &CheckpointRepositoryBundle,
1197    executor: &impl CommandExecutor,
1198    github_token: Option<&str>,
1199) -> Result<Option<String>> {
1200    let staging = tempfile::tempdir().context("create repository source preflight")?;
1201    let repository = staging.path().join("repository.git");
1202    checked_preflight_git(
1203        executor,
1204        CommandSpec::new(
1205            "git",
1206            [
1207                "init".to_owned(),
1208                "--bare".to_owned(),
1209                "--quiet".to_owned(),
1210                repository.to_string_lossy().into_owned(),
1211            ],
1212        )
1213        .purpose("initialize repository source preflight"),
1214    )?;
1215    let missing = checkpoint_bundle_prerequisites(archived)?;
1216    if missing.is_empty() {
1217        let bundle = staging.path().join("checkpoint.bundle");
1218        std::fs::write(&bundle, &archived.committed_bundle)
1219            .context("write self-contained checkpoint bundle for source preflight")?;
1220        checked_preflight_git(
1221            executor,
1222            checkpoint_bundle_import_command(&repository, &bundle),
1223        )?;
1224        return Ok(None);
1225    }
1226    // The restore clone will obtain the reachable ancestry. This probe only
1227    // needs to establish that the source still serves each boundary object, so
1228    // stop at that object instead of downloading and walking its whole graph.
1229    for commit in missing {
1230        let output = fetch_source_commit(
1231            executor,
1232            &repository,
1233            configured,
1234            accepted_source,
1235            &commit,
1236            github_token,
1237        )?;
1238        if output.status != 0 {
1239            let stderr = String::from_utf8_lossy(&output.stderr);
1240            if source_does_not_have_commit(&stderr) {
1241                return Ok(Some(commit));
1242            }
1243            bail!(
1244                "could not check configured source {:?}: {}",
1245                configured.source_label(),
1246                stderr.trim()
1247            );
1248        }
1249    }
1250    // Do not re-index the bundle against this deliberately shallow probe: the
1251    // shallow marker would make Git report artificial connectivity failures.
1252    // The real restore applies it to the full source clone.
1253    Ok(None)
1254}
1255
1256fn checkpoint_bundle_import_command(repository: &Path, bundle: &Path) -> CommandSpec {
1257    let mut command = CommandSpec::new(
1258        "git",
1259        [
1260            "-C".to_owned(),
1261            repository.to_string_lossy().into_owned(),
1262            "fetch".to_owned(),
1263            "--no-tags".to_owned(),
1264            bundle.to_string_lossy().into_owned(),
1265            "HEAD".to_owned(),
1266        ],
1267    )
1268    .purpose("validate self-contained checkpoint bundle");
1269    command
1270        .env
1271        .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
1272    command
1273        .env
1274        .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
1275    command
1276}
1277
1278fn fetch_source_commit(
1279    executor: &impl CommandExecutor,
1280    repository: &Path,
1281    configured: &ProjectRepository,
1282    accepted_source: Option<&mj_core::remote_git::NetworkGitSource>,
1283    commit: &str,
1284    github_token: Option<&str>,
1285) -> Result<CommandOutput> {
1286    let mut arguments = Vec::new();
1287    let mut token_auth = false;
1288    let mut ssh_transport = false;
1289    let source = if let Some(source) = accepted_source {
1290        source.fetch_url.clone()
1291    } else if let Some(local) = &configured.local {
1292        local.to_string_lossy().into_owned()
1293    } else {
1294        let source = configured
1295            .github
1296            .as_deref()
1297            .context("repository source is missing")?;
1298        let github = crate::setup::github_repository_from_origin(source)
1299            .context("configured repository is not a GitHub source")?;
1300        if github_token.is_some() {
1301            token_auth = true;
1302            arguments.extend([
1303                "-c".to_owned(),
1304                "credential.helper=".to_owned(),
1305                "-c".to_owned(),
1306                "credential.helper=!f() { if [ \"$1\" = get ]; then echo username=x-access-token; echo \"password=$GH_TOKEN\"; fi; }; f".to_owned(),
1307            ]);
1308            format!(
1309                "https://github.com/{}/{}.git",
1310                github.owner, github.repository
1311            )
1312        } else {
1313            ssh_transport = true;
1314            format!("git@github.com:{}/{}.git", github.owner, github.repository)
1315        }
1316    };
1317    if accepted_source.is_some() {
1318        if source.starts_with("https://github.com/") && github_token.is_some() {
1319            token_auth = true;
1320            arguments.extend([
1321                "-c".to_owned(), "credential.helper=".to_owned(), "-c".to_owned(),
1322                "credential.helper=!f() { if [ \"$1\" = get ]; then echo username=x-access-token; echo \"password=$GH_TOKEN\"; fi; }; f".to_owned(),
1323            ]);
1324        }
1325        ssh_transport = source.starts_with("git@") || source.starts_with("ssh://");
1326    }
1327    arguments.extend([
1328        "-C".to_owned(),
1329        repository.to_string_lossy().into_owned(),
1330        "fetch".to_owned(),
1331        "--no-tags".to_owned(),
1332        "--depth=1".to_owned(),
1333        "--filter=blob:none".to_owned(),
1334        source,
1335        commit.to_owned(),
1336    ]);
1337    let mut command = CommandSpec::new("git", arguments).purpose("check checkpoint base commit");
1338    command
1339        .env
1340        .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
1341    command
1342        .env
1343        .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
1344    if token_auth {
1345        let token = github_token.expect("token authentication requires a GitHub token");
1346        command.env.insert("GH_TOKEN".to_owned(), token.to_owned());
1347    }
1348    if ssh_transport {
1349        command.env.insert(
1350            "GIT_SSH_COMMAND".to_owned(),
1351            "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15"
1352                .to_owned(),
1353        );
1354    }
1355    executor.execute(&command)
1356}
1357
1358fn source_does_not_have_commit(stderr: &str) -> bool {
1359    let stderr = stderr.to_ascii_lowercase();
1360    [
1361        "not our ref",
1362        "couldn't find remote ref",
1363        "not a valid object name",
1364        "no such ref was fetched",
1365    ]
1366    .iter()
1367    .any(|needle| stderr.contains(needle))
1368}
1369
1370fn checked_preflight_git(
1371    executor: &impl CommandExecutor,
1372    command: CommandSpec,
1373) -> Result<CommandOutput> {
1374    let output = executor.execute(&command)?;
1375    ensure!(
1376        output.status == 0,
1377        "{}: {}",
1378        command.purpose,
1379        String::from_utf8_lossy(&output.stderr).trim()
1380    );
1381    Ok(output)
1382}
1383
1384impl Controller {
1385    /// Resume a stopped logical session on any configured profile and
1386    /// target. Cross-harness resume restores Git and canonical history, starts
1387    /// a fresh native session, and supplies the prior transcript as its first
1388    /// context turn.
1389    pub async fn resume_session_with_options(
1390        &mut self,
1391        session_id: &str,
1392        profile_id: &str,
1393        target_id: &str,
1394        additional_mounts: Option<Vec<AdditionalMount>>,
1395        resource_allocation: Option<SessionResourceAllocation>,
1396    ) -> Result<MaterializedSession> {
1397        self.resume_session_with_options_and_queue_disposition(
1398            session_id,
1399            profile_id,
1400            target_id,
1401            additional_mounts,
1402            resource_allocation,
1403            false,
1404        )
1405        .await
1406    }
1407
1408    pub async fn resume_session_with_options_and_queue_disposition(
1409        &mut self,
1410        session_id: &str,
1411        profile_id: &str,
1412        target_id: &str,
1413        additional_mounts: Option<Vec<AdditionalMount>>,
1414        resource_allocation: Option<SessionResourceAllocation>,
1415        discard_queue: bool,
1416    ) -> Result<MaterializedSession> {
1417        self.resume_session_controlled(
1418            session_id,
1419            profile_id,
1420            target_id,
1421            SessionResumeOptions {
1422                additional_mounts,
1423                resource_allocation,
1424                discard_queue,
1425            },
1426            &ProcessExecutor,
1427        )
1428        .await
1429    }
1430
1431    pub async fn resume_session_controlled(
1432        &mut self,
1433        session_id: &str,
1434        profile_id: &str,
1435        target_id: &str,
1436        options: SessionResumeOptions,
1437        executor: &(impl CommandExecutor + Sync),
1438    ) -> Result<MaterializedSession> {
1439        self.resume_session_controlled_with_repository_preflight(
1440            session_id, profile_id, target_id, options, None, executor,
1441        )
1442        .await
1443    }
1444
1445    pub async fn resume_session_controlled_with_repository_preflight(
1446        &mut self,
1447        session_id: &str,
1448        profile_id: &str,
1449        target_id: &str,
1450        options: SessionResumeOptions,
1451        repository_preflight: Option<ResumeRepositorySourceReceipt>,
1452        executor: &(impl CommandExecutor + Sync),
1453    ) -> Result<MaterializedSession> {
1454        self.resume_session_with_origin(
1455            session_id,
1456            profile_id,
1457            target_id,
1458            options,
1459            repository_preflight,
1460            None,
1461            executor,
1462        )
1463        .await
1464    }
1465
1466    pub(in crate::controller) async fn resume_session_for_move(
1467        &mut self,
1468        operation: &mj_core::state::MoveOperation,
1469        executor: &(impl CommandExecutor + Sync),
1470    ) -> Result<MaterializedSession> {
1471        self.resume_session_with_origin(
1472            &operation.selection.session_id,
1473            operation
1474                .selection
1475                .profile_id
1476                .as_deref()
1477                .context("Move profile missing")?,
1478            operation
1479                .selection
1480                .target_template_id
1481                .as_deref()
1482                .context("Move target missing")?,
1483            SessionResumeOptions {
1484                additional_mounts: operation.selection.additional_mounts.clone(),
1485                resource_allocation: operation.selection.resource_allocation.clone(),
1486                discard_queue: true,
1487            },
1488            None,
1489            Some(operation),
1490            executor,
1491        )
1492        .await
1493    }
1494
1495    #[allow(clippy::too_many_arguments)]
1496    async fn resume_session_with_origin(
1497        &mut self,
1498        session_id: &str,
1499        profile_id: &str,
1500        target_id: &str,
1501        options: SessionResumeOptions,
1502        repository_preflight: Option<ResumeRepositorySourceReceipt>,
1503        move_operation: Option<&mj_core::state::MoveOperation>,
1504        executor: &(impl CommandExecutor + Sync),
1505    ) -> Result<MaterializedSession> {
1506        crate::worker_lifecycle::run(session_id, "resume session with origin", executor, async {
1507            crate::worker_lifecycle::require(session_id)?.verify_cached_target(&self.state)?;
1508        let transferring_workspace = move_operation.is_some();
1509        if let Some(operation) = crate::database::load_move_operation(session_id)? {
1510            ensure!(
1511                transferring_workspace || !operation.holds_source_environment(),
1512                "a profile switch retains this environment; retry Move instead of recreating it with Resume"
1513            );
1514        }
1515        let SessionResumeOptions {
1516            additional_mounts,
1517            resource_allocation,
1518            discard_queue,
1519        } = options;
1520        let mut previous = self
1521            .state
1522            .sessions
1523            .get(session_id)
1524            .with_context(|| format!("unknown session {session_id}"))?
1525            .clone();
1526        let previous_checkout = self.state.checkout(session_id)?;
1527        let previous_project_directory = previous_checkout
1528            .project_directory()
1529            .map(|path| path.to_path_buf());
1530        let mut previous_managed_worktree = match &previous_checkout {
1531            mj_core::state::Checkout::ManagedWorktree { worktree, .. } => Some((*worktree).clone()),
1532            _ => None,
1533        };
1534        drop(previous_checkout);
1535        if !(matches!(
1536            previous.state,
1537            SessionState::Stopped | SessionState::Lost | SessionState::Error
1538        ) || (transferring_workspace && previous.state == SessionState::Closing))
1539        {
1540            bail!("session {session_id} is not stopped, lost, or retryable");
1541        }
1542        let checkpoint = move_operation
1543            .and_then(|op| op.handoff.as_ref())
1544            .or(previous.checkpoint.as_ref())
1545            .context("session has no checkpoint")?;
1546        // A receipt for an isolated destination says nothing about a host
1547        // checkout. Explicit moves to raw execution must check that source.
1548        let moving_to_raw = previous_project_directory.is_none()
1549            && self
1550                .config
1551                .targets
1552                .get(target_id)
1553                .is_some_and(mj_core::config::is_bare_project_target);
1554        if !transferring_workspace
1555            && (moving_to_raw
1556                || !repository_preflight.as_ref().is_some_and(|receipt| {
1557                    self.repository_source_receipt_is_current(session_id, receipt)
1558                }))
1559        {
1560            let _phase = ResumePhaseTimer::new(session_id, "preflight repository sources");
1561            if let ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) =
1562                self.preflight_repository_sources(
1563                    session_id,
1564                    target_id,
1565                    false,
1566                    self.github_token_for_repository_preflight(session_id)
1567                        .await?
1568                        .as_deref(),
1569                    executor,
1570                )?
1571            {
1572                bail!(
1573                    "checkpoint base commit {} is missing from configured source {:?} for repository {:?}; the repository may have moved (archived origin: {:?})",
1574                    mismatch.missing_commit,
1575                    mismatch.configured_origin,
1576                    mismatch.repository_id,
1577                    mismatch.archived_origin,
1578                );
1579            }
1580        }
1581        let verified_archive = verify_resume_checkpoint(session_id, checkpoint)?;
1582        let archive_path = verified_archive.archive_path.clone();
1583        let archive_manifest = &verified_archive.manifest;
1584        let canonical_session = Arc::clone(&verified_archive.canonical_session);
1585        let profile = self
1586            .config
1587            .profiles
1588            .get(profile_id)
1589            .with_context(|| format!("unknown profile {profile_id:?}"))?
1590            .clone();
1591        ensure!(profile.enabled, "profile {profile_id:?} is disabled");
1592        let target_template = self
1593            .config
1594            .targets
1595            .get(target_id)
1596            .with_context(|| format!("unknown target template {target_id:?}"))?
1597            .clone();
1598        // Decide the representation before the record changes, so an
1599        // incompatible target fails here instead of during provisioning.
1600        self.validate_muse_resume_destination(&previous, profile.kind, target_id)?;
1601        ensure!(
1602            profile.kind != HarnessKind::Muse || previous.additional_mounts.is_empty(),
1603            "Muse Code ACP supports one workspace root; attached directories are unsupported"
1604        );
1605        let checkout = self.state.checkout(session_id)?;
1606        let plan = resume_compatibility_with_checkout(
1607            &previous,
1608            &checkout,
1609            &self.config,
1610            target_id,
1611        )
1612            .map_err(|reason| anyhow::anyhow!("{reason}"))?;
1613        // A worktree that stays put is reached with the machine's current ssh
1614        // options; only its location had to match (J-21).
1615        if plan == ResumePlan::InPlace
1616            && previous_managed_worktree.is_some()
1617            && let Some(worktree) = previous.managed_worktree.as_mut()
1618            && let Ok(current) = managed_worktree_target(&target_template)
1619        {
1620            worktree.target = current;
1621            let refreshed_checkout = self.state.checkout_for_record(session_id, &previous)?;
1622            previous_managed_worktree = refreshed_checkout.effective().managed_worktree().cloned();
1623        }
1624        // A converting resume writes its own archive below, from the accepted
1625        // repository's selected network source, and provisioning reads that
1626        // one. Every other isolated resume clones what its stored archive names.
1627        if !mj_core::config::is_bare_project_target(&target_template)
1628            && plan != ResumePlan::RawToWorkspace
1629            && !transferring_workspace
1630        {
1631            super::network_git::bundle_from_manifest(archive_manifest)?;
1632        }
1633        if plan == ResumePlan::InPlace
1634            && matches!(checkout.effective(), mj_core::state::Checkout::Attached { .. })
1635            && let Some(project_directory) = checkout.project_directory()
1636        {
1637            self.validate_project_directory(target_id, project_directory, executor)
1638                .context("raw project is unavailable for resume")?;
1639        }
1640        let conversion = match plan {
1641            ResumePlan::InPlace => None,
1642            ResumePlan::RawToWorkspace => Some(ResumeConversion::RawToWorkspace(Box::new(
1643                plan_raw_to_workspace_for_session(&previous, &checkout, &self.config, executor)
1644                    .context("prepare the raw checkout for its new target")?,
1645            ))),
1646            ResumePlan::WorkspaceToRaw => Some(ResumeConversion::WorkspaceToRaw(
1647                self.plan_workspace_to_raw(&previous, target_id, executor)
1648                    .context("prepare a checkout for this session")?,
1649            )),
1650        };
1651        let resource_allocation =
1652            resource_allocation.or_else(|| previous.resource_allocation.clone());
1653        let additional_mounts =
1654            additional_mounts.unwrap_or_else(|| previous.additional_mounts.clone());
1655        validate_resource_allocation(&target_template, resource_allocation.as_ref())?;
1656        let selected_container_size =
1657            selected_host_container_size(&target_template, resource_allocation.as_ref());
1658        if !additional_mounts.is_empty() && mount_history_host(&target_template).is_none() {
1659            bail!("attached resources are unsupported for this target");
1660        }
1661        targets::validate_additional_mounts(&additional_mounts)?;
1662        let history_host = mount_history_host(&target_template);
1663        let history_mounts = additional_mounts.clone();
1664        if !transferring_workspace
1665            && previous.state == SessionState::Error
1666            && let Some(locator) = &previous.target
1667        {
1668            let backend = backend_locator(locator, &previous, &self.config)?;
1669            targets::close_plan(&backend, session_id)?
1670                .execute(executor)
1671                .context("clean up target from failed resume")?;
1672        }
1673        let mut resume_notices = Vec::new();
1674        // The raw-to-workspace notice is written where the conversion snapshot
1675        // is taken, because it reports the branch the container arrives on.
1676        if let Some(conversion) = conversion
1677            .as_ref()
1678            .and_then(ResumeConversion::workspace_to_raw)
1679        {
1680            resume_notices.push(format!(
1681                "This session moved out of its {} target and into {}. Its branch {} is now {}.",
1682                previous.target_template_id,
1683                conversion.worktree.worktree_root.display(),
1684                archive_manifest
1685                    .repositories
1686                    .first()
1687                    .and_then(|repository| repository.metadata.branch.as_deref())
1688                    .unwrap_or("a detached head"),
1689                conversion.worktree.branch,
1690            ));
1691        }
1692        let managed_checkout_present = previous_managed_worktree
1693            .as_ref()
1694            .map(|worktree| managed_worktree_checkout_exists(executor, worktree))
1695            .transpose()?
1696            .unwrap_or(true);
1697        // A checkout Mjolnir did not retire remains the truth for a raw session.
1698        // A retired checkout is recreated from the branch and archive below.
1699        if managed_checkout_present
1700            && let Some(project_directory) = previous_project_directory.as_deref()
1701        {
1702            let checkout = self.state.checkout(session_id)?;
1703            match raw_checkout_position_with_checkout(
1704                &previous,
1705                &checkout,
1706                &self.config,
1707                project_directory,
1708                executor,
1709            ) {
1710                Ok(live) => resume_notices.extend(raw_checkout_divergence_notice(
1711                    project_directory,
1712                    archive_manifest
1713                        .repositories
1714                        .first()
1715                        .map(|repository| &repository.metadata),
1716                    &live,
1717                )),
1718                // Informational only: a resume must not fail because Mjolnir could
1719                // not read where the checkout stands.
1720                Err(error) => tracing::warn!(
1721                    session_id,
1722                    error = format!("{error:#}"),
1723                    "could not read the raw checkout position for a resume notice"
1724                ),
1725            }
1726        }
1727        // Resolve the worker before compaction costs minutes and paid model
1728        // requests, including probing the platform of an existing SSH host. A resume
1729        // that could never install a worker fails here rather than after all
1730        // that work has been thrown away.
1731        super::worker_binary::preflight_worker_binary(&target_template, executor)?;
1732        let same_harness = profile.kind == archive_manifest.session.harness_kind;
1733        let native_continuity =
1734            native_continuity_preserved(profile.kind, archive_manifest.session.harness_kind);
1735        let context_bytes = crate::handoff::profile_handoff_bytes(Some(&profile));
1736        // Cross-harness compaction is started alongside destination
1737        // provisioning below. Clone only the configuration it reads so the
1738        // controller can continue owning and mutating its session record.
1739        let utility_config = (!native_continuity).then(|| self.config.clone());
1740        let discard_queued_prompts = discard_queue || !same_harness;
1741        // When this controller archived the session, its durable projection is
1742        // already the archive's content. Reading one row decides that; a read
1743        // failure or any mismatch rebuilds as before.
1744        let stored_frontier = crate::database::materialized_event_frontier(session_id)
1745            .unwrap_or_else(|error| {
1746                tracing::warn!(
1747                    session_id,
1748                    error = format!("{error:#}"),
1749                    "could not read the stored projection frontier; rebuilding it from the archive"
1750                );
1751                None
1752            });
1753        let rebuild_projection = projection_rebuild_required(
1754            stored_frontier
1755                .as_ref()
1756                .map(|(ordinal, digest)| (*ordinal, digest.as_str())),
1757            canonical_session.event_frontier,
1758            &canonical_session.event_frontier_digest,
1759        );
1760        // Rebuilding the projection is a pure function of the archive and costs
1761        // seconds on a long session. Start it now so it runs while the target is
1762        // being provisioned; its result is awaited where it was consumed
1763        // before, and the writes it feeds have not moved.
1764        //
1765        // A resume that fails before the result is needed drops the handle.
1766        // `spawn_blocking` work cannot be cancelled, so the computation still
1767        // finishes on the blocking pool and its result is discarded; it owns
1768        // nothing but its own inputs, so nothing leaks beyond that CPU.
1769        let projection_build = rebuild_projection.then(|| {
1770            let canonical = Arc::clone(&canonical_session);
1771            let session_id = session_id.to_owned();
1772            tokio::task::spawn_blocking(move || {
1773                materialized_session_from_canonical(session_id, &canonical)
1774            })
1775        });
1776        let github_token = self.github_token_for_session(session_id).await?;
1777
1778        // A session that leaves a non-container target for a container one is
1779        // built a container it never had, so it starts using the per-session
1780        // workspace path here if it predates them. A session that already ran
1781        // in a container keeps its path: the harness's recorded working
1782        // directory has to survive the resume.
1783        let moving_into_first_container = self
1784            .config
1785            .targets
1786            .get(target_id)
1787            .is_some_and(mj_core::config::is_container_target)
1788            && !self
1789                .config
1790                .targets
1791                .get(&previous.target_template_id)
1792                .is_some_and(mj_core::config::is_container_target);
1793        let record = self.state.sessions.get_mut(session_id).unwrap();
1794        if record.container_workspace.is_none() && moving_into_first_container {
1795            record.container_workspace = Some(targets::new_container_workspace(session_id)?);
1796        }
1797        record.harness_kind = profile.kind;
1798        record.last_profile = profile_id.to_string();
1799        record.target_template_id = target_id.to_string();
1800        record.target_runtime = Some(
1801            self.config
1802                .targets
1803                .get(target_id)
1804                .context("resume target disappeared")?
1805                .into(),
1806        );
1807        record.resource_allocation = resource_allocation;
1808        record.additional_mounts = additional_mounts;
1809        record.target = None;
1810        record.native_session_id =
1811            native_continuity.then(|| archive_manifest.session.native_session_id.clone());
1812        record.state = SessionState::Provisioning;
1813        record.updated_at = now();
1814        record.last_error = None;
1815        match &conversion {
1816            Some(ResumeConversion::RawToWorkspace(conversion)) => {
1817                apply_raw_to_workspace(record, conversion)?;
1818            }
1819            Some(ResumeConversion::WorkspaceToRaw(conversion)) => {
1820                apply_workspace_to_raw(record, conversion);
1821            }
1822            None => {
1823                if let (Some(worktree), Some(refreshed)) = (
1824                    record.managed_worktree.as_mut(),
1825                    previous_managed_worktree.as_ref(),
1826                ) {
1827                    worktree.target = refreshed.target.clone();
1828                }
1829            }
1830        }
1831        let resumed_project_directory = record
1832            .checkout()
1833            .project_directory()
1834            .map(|path| path.to_path_buf());
1835        let resumed_container_workspace = record.container_workspace.clone();
1836        if let Some(host) = history_host {
1837            self.state.remember_mount_sources(host, &history_mounts);
1838            crate::database::remember_mount_sources(host, &history_mounts)?;
1839        }
1840        // Resume changes target resources, while titles and drafts remain
1841        // owned by their independent writers throughout provisioning.
1842        crate::database::save_resumed_session(
1843            &self.state.sessions[session_id],
1844            selected_container_size
1845                .as_ref()
1846                .map(|(host, size)| (host.as_str(), *size)),
1847        )?;
1848        if let Some((host, size)) = selected_container_size.as_ref() {
1849            self.state.remember_container_size(host, *size);
1850        }
1851
1852        let mut recreated_managed_worktree = false;
1853        // Set once a conversion has written its archive, so the success path can
1854        // retire the archive it replaced and the failure path can remove it.
1855        let mut conversion_checkpoint_written: Option<mj_core::state::CheckpointMetadata> = None;
1856        let result = async {
1857            if let Some(worktree) = previous_managed_worktree.as_ref() {
1858                recreated_managed_worktree = restore_managed_worktree(executor, worktree)?;
1859                if !transferring_workspace && recreated_managed_worktree && plan == ResumePlan::RawToWorkspace {
1860                    if worktree.kind == mj_core::state::ManagedCheckoutKind::Clone {
1861                        mj_checkpoint::checkpoint::restore_single_repository_into_checkout(
1862                            &archive_path, &worktree.worktree_root, &SystemGit,
1863                        )?;
1864                    } else {
1865                        mj_checkpoint::checkpoint::restore_single_repository_onto_branch(
1866                            &archive_path, &worktree.worktree_root, &worktree.branch, &SystemGit,
1867                        )?;
1868                    }
1869                }
1870            }
1871            // The record already names the worktree, so a failure here rolls
1872            // back through the same path that cleans up a new session's.
1873            if let Some(conversion) = conversion
1874                .as_ref()
1875                .and_then(ResumeConversion::workspace_to_raw)
1876            {
1877                if conversion.reuse_existing_branch {
1878                    let recovery_ref =
1879                        preserve_retained_managed_worktree_branch(executor, &conversion.worktree)?;
1880                    restore_managed_worktree(executor, &conversion.worktree)?;
1881                    resume_notices.push(format!(
1882                        "Before restoring this session's retained branch, Mjolnir preserved its tip at {recovery_ref}."
1883                    ));
1884                } else {
1885                    create_managed_worktree(
1886                        executor,
1887                        &conversion.worktree,
1888                        None,
1889                        PrimaryCheckoutRequirement::Any,
1890                    )?;
1891                }
1892                if transferring_workspace {
1893                    // Move installs its separately verified Git/file transfer below.
1894                } else if conversion.worktree.kind == mj_core::state::ManagedCheckoutKind::Clone {
1895                    mj_checkpoint::checkpoint::restore_single_repository_into_checkout(
1896                        &archive_path, &conversion.worktree.worktree_root, &SystemGit,
1897                    )?;
1898                } else {
1899                    mj_checkpoint::checkpoint::restore_single_repository_onto_branch(
1900                        &archive_path,
1901                        &conversion.worktree.worktree_root,
1902                        &conversion.worktree.branch,
1903                        &SystemGit,
1904                    )?;
1905                }
1906            }
1907            // A local checkout becomes an isolated workspace by being
1908            // re-snapshotted into a new archive whose provenance is the
1909            // accepted repository's network source. Provisioning clones that
1910            // source, and the restore below lays this snapshot over the clone.
1911            if let Some(conversion) = conversion
1912                .as_ref()
1913                .and_then(ResumeConversion::raw_to_workspace)
1914                && !transferring_workspace
1915            {
1916                let snapshot = raw_checkout_snapshot(
1917                    &conversion.checkout,
1918                    &conversion.repository_id,
1919                    &conversion.source,
1920                    &conversion.destination,
1921                    &SystemGit,
1922                    conversion.retire.as_ref().is_some_and(|checkout| {
1923                        checkout.kind == mj_core::state::ManagedCheckoutKind::Clone
1924                    }),
1925                )
1926                .context("snapshot the host checkout for its new target")?;
1927                resume_notices.push(conversion_notice(
1928                    target_id,
1929                    previous_project_directory
1930                        .as_deref()
1931                        .unwrap_or(&conversion.checkout),
1932                    snapshot.metadata.branch.as_deref(),
1933                    conversion.retire.as_ref(),
1934                ));
1935                let archives = mj_core::config::sessions_dir();
1936                std::fs::create_dir_all(&archives).with_context(|| {
1937                    format!("create the checkpoint directory {}", archives.display())
1938                })?;
1939                // Named like every other managed archive, so an interrupted
1940                // conversion's file is reconciled away, with `converted`
1941                // marking where it came from.
1942                let output = archives.join(format!(
1943                    "{session_id}-converted-{}-{}.hel.zip",
1944                    previous
1945                        .checkpoint
1946                        .as_ref()
1947                        .map_or(0, |checkpoint| checkpoint.event_frontier),
1948                    new_command_id("archive")?
1949                ));
1950                let written = conversion_checkpoint(&archive_path, snapshot, &output)?;
1951                conversion_checkpoint_written = Some(written.clone());
1952                let record = self.state.sessions.get_mut(session_id).unwrap();
1953                record.checkpoint = Some(written);
1954                record.updated_at = now();
1955                // Persist the converted archive before provisioning without
1956                // restoring client-owned fields from this earlier snapshot.
1957                crate::database::save_resumed_session(
1958                    &self.state.sessions[session_id],
1959                    selected_container_size.as_ref().map(|(host, size)| (host.as_str(), *size)),
1960                )?;
1961            }
1962            let utility_handoff = {
1963                let _provisioning = ResumePhaseTimer::new(session_id, "provision destination");
1964                if let Some(config) = utility_config.as_ref() {
1965                    Some(
1966                        provision_with_cross_harness_handoff(
1967                            self,
1968                            session_id,
1969                            executor,
1970                            github_token.as_deref(),
1971                            config,
1972                            &canonical_session,
1973                            context_bytes,
1974                        )
1975                        .context("prepare the cross-harness destination")?,
1976                    )
1977                } else {
1978                    self.provision_session_with_failure_disposition(
1979                        session_id,
1980                        executor,
1981                        github_token.as_deref(),
1982                        ProvisioningFailureDisposition::Preserve,
1983                    )
1984                    .await?;
1985                    None
1986                }
1987            };
1988            if transferring_workspace {
1989                self.restore_move_workspace(session_id, executor)?;
1990            }
1991            let restore_repositories = !transferring_workspace && ((resumed_project_directory.is_none()
1992                && conversion.is_none())
1993                || plan == ResumePlan::RawToWorkspace
1994                || (recreated_managed_worktree && plan == ResumePlan::InPlace));
1995            // A conversion restores the archive it just wrote, not the raw one
1996            // the session was stopped with.
1997            let restored_archive = conversion_checkpoint_written
1998                .as_ref()
1999                .map_or(archive_path.as_path(), |checkpoint| {
2000                    checkpoint.archive_path.as_path()
2001                });
2002            self.restore_into_target(
2003                session_id,
2004                RestoreIntoTarget {
2005                    profile: &profile,
2006                    archive: &verified_archive,
2007                    restored_archive,
2008                    resumed_project_directory,
2009                    resumed_container_workspace,
2010                    restore_repositories,
2011                    native_continuity,
2012                    discard_queued_prompts,
2013                    replay_queue: !discard_queue,
2014                    utility_handoff,
2015                    projection_build,
2016                    resume_notices,
2017                    include_in_place_subagents: false,
2018                    install_attached_resources: true,
2019                    worker_root_reset: WorkerRootReset::FreshTarget,
2020                    retire_after_ready: conversion
2021                        .as_ref()
2022                        .filter(|_| !transferring_workspace)
2023                        .and_then(ResumeConversion::raw_to_workspace)
2024                        .and_then(|plan| plan.retire.as_ref()),
2025                },
2026                executor,
2027            )
2028            .await
2029        }
2030        .await;
2031        match result {
2032            Ok(materialized) => {
2033                // The session now runs from the conversion archive, so the raw
2034                // one it replaced can go.
2035                if let Some(written) = &conversion_checkpoint_written {
2036                    super::checkpoint::prune_replaced_checkpoint(
2037                        previous.checkpoint.as_ref(),
2038                        written,
2039                    );
2040                }
2041                Ok(materialized)
2042            }
2043            Err(error) => {
2044                // The rollback puts back a record that names the previous
2045                // archive, so the conversion's archive is nothing but litter.
2046                if let Some(written) = &conversion_checkpoint_written
2047                    && let Err(remove_error) = std::fs::remove_file(&written.archive_path)
2048                    && remove_error.kind() != std::io::ErrorKind::NotFound
2049                {
2050                    tracing::warn!(
2051                        session_id,
2052                        path = %written.archive_path.display(),
2053                        "could not remove the conversion checkpoint after resume failed: {remove_error}"
2054                    );
2055                }
2056                // Put back whatever this resume wrote to the durable
2057                // projection, including the failed worker's own lines.
2058                restore_projection_after_failed_resume(
2059                    session_id,
2060                    &canonical_session,
2061                    discard_queued_prompts,
2062                );
2063                if let Some(operation) = move_operation {
2064                    return Err(self.rollback_move_destination(operation, error, executor)?);
2065                }
2066                Err(self.rollback_failed_resume(
2067                    session_id,
2068                    &previous,
2069                    recreated_managed_worktree,
2070                    error,
2071                    executor,
2072                )?)
2073            }
2074        }
2075
2076        }).await
2077    }
2078
2079    pub(super) fn rollback_failed_resume(
2080        &mut self,
2081        session_id: &str,
2082        previous: &SessionRecord,
2083        recreated_managed_worktree: bool,
2084        error: anyhow::Error,
2085        _executor: &impl CommandExecutor,
2086    ) -> Result<anyhow::Error> {
2087        let current = self
2088            .state
2089            .sessions
2090            .get(session_id)
2091            .with_context(|| format!("unknown session {session_id}"))?
2092            .clone();
2093        let cleanup = match current.target.as_ref() {
2094            Some(locator) => (|| -> Result<()> {
2095                let backend = backend_locator(locator, &current, &self.config)?;
2096                targets::close_plan(&backend, session_id)?
2097                    // Use a fresh executor: cancellation applies to the
2098                    // requested operation, not to its compensating cleanup.
2099                    .execute(&CancellableProcessExecutor::with_timeout(
2100                        Duration::from_secs(15),
2101                    ))
2102                    .map(|_| ())
2103            })(),
2104            None => Ok(()),
2105        };
2106        // A failed target teardown may leave its harness writing. Keep its
2107        // checkout intact until a later retry proves the process is stopped.
2108        let worktree_cleanup = if cleanup.is_err() {
2109            Ok(())
2110        } else {
2111            let current_worktree = self.state.checkout(session_id)?.managed_worktree().cloned();
2112            let previous_checkout = self.state.checkout_for_record(session_id, previous)?;
2113            let previous_worktree = previous_checkout.managed_worktree().cloned();
2114            match (current_worktree.as_ref(), previous_worktree.as_ref()) {
2115                (_, Some(previous)) if recreated_managed_worktree => retire_managed_worktree(
2116                    &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
2117                    previous,
2118                ),
2119                (Some(current), Some(previous)) if current == previous => Ok(()),
2120                // The failed resume created this worktree and its branch, and
2121                // no harness ever ran in it, so the rollback removes both.
2122                (Some(worktree), _) => cleanup_managed_worktree(
2123                    &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
2124                    worktree,
2125                    crate::controller::BranchDisposition::Delete,
2126                ),
2127                (None, _) => Ok(()),
2128            }
2129        };
2130        let cleanup_error = [cleanup, worktree_cleanup]
2131            .into_iter()
2132            .filter_map(Result::err)
2133            .map(|cleanup_error| format!("{cleanup_error:#}"))
2134            .collect::<Vec<_>>()
2135            .join("; ");
2136        if !cleanup_error.is_empty() {
2137            tracing::warn!(
2138                session_id,
2139                error = %cleanup_error,
2140                "resume rollback cleanup reported failures"
2141            );
2142        }
2143        // The session's error is one line; a worker's diagnostic dump goes to
2144        // the log (R8-2).
2145        let original = super::worker_binary::failure_line(&error);
2146        let detail = format!("{error:#}");
2147        if detail != original {
2148            tracing::warn!(session_id, error = %detail, "resume failed");
2149        }
2150        let current_owns_managed_checkout = self
2151            .state
2152            .checkout(session_id)?
2153            .managed_worktree()
2154            .is_some();
2155        let record = self.state.sessions.get_mut(session_id).unwrap();
2156        let failure = apply_failed_resume_rollback_with_ownership(
2157            record,
2158            previous,
2159            current_owns_managed_checkout,
2160            &original,
2161            (!cleanup_error.is_empty()).then_some(cleanup_error),
2162        );
2163        // Rollback restores only resources owned by this attempt, preserving
2164        // client edits committed while provisioning was in flight.
2165        crate::database::save_resumed_session(&self.state.sessions[session_id], None)?;
2166        Ok(failure)
2167    }
2168}
2169
2170fn worktree_cleanup_notice(worktree_root: &Path, error: &anyhow::Error) -> String {
2171    format!(
2172        "Mjolnir could not remove the worktree at {}: {error:#}. Remove it with `git worktree remove --force {}`.",
2173        worktree_root.display(),
2174        worktree_root.display()
2175    )
2176}
2177
2178#[cfg(test)]
2179pub(super) fn apply_failed_resume_rollback(
2180    current: &mut SessionRecord,
2181    previous: &SessionRecord,
2182    original_error: &str,
2183    cleanup_error: Option<String>,
2184) -> anyhow::Error {
2185    let current_owns_managed_checkout = current.checkout().managed_worktree().is_some();
2186    apply_failed_resume_rollback_with_ownership(
2187        current,
2188        previous,
2189        current_owns_managed_checkout,
2190        original_error,
2191        cleanup_error,
2192    )
2193}
2194
2195fn apply_failed_resume_rollback_with_ownership(
2196    current: &mut SessionRecord,
2197    previous: &SessionRecord,
2198    current_owns_managed_checkout: bool,
2199    original_error: &str,
2200    cleanup_error: Option<String>,
2201) -> anyhow::Error {
2202    match cleanup_error {
2203        None => {
2204            *current = previous.clone();
2205            current.state = SessionState::Stopped;
2206            current.target = None;
2207            current.updated_at = now();
2208            current.last_error = Some(format!("resume failed: {original_error}"));
2209            anyhow::anyhow!(original_error.to_owned())
2210        }
2211        Some(cleanup_error) => {
2212            let failure = format!(
2213                "{original_error}; cleanup of the partial resume target failed: {cleanup_error}"
2214            );
2215            // Keep the exact partial target and checkout ownership until
2216            // cleanup succeeds; the harness may still be writing there.
2217            // A container conversion has no new managed host checkout, so
2218            // retain the original host checkout that it has not retired yet.
2219            if !current_owns_managed_checkout {
2220                // Restore the persisted fields as a snapshot. The State-level
2221                // checkout derivation decides whether this record owns them.
2222                current
2223                    .project_directory
2224                    .clone_from(&previous.project_directory);
2225                current
2226                    .managed_worktree
2227                    .clone_from(&previous.managed_worktree);
2228                current.bundle_id.clone_from(&previous.bundle_id);
2229                current.project.clone_from(&previous.project);
2230            }
2231            current.state = SessionState::Error;
2232            current.updated_at = now();
2233            current.last_error = Some(format!("resume failed: {failure}"));
2234            anyhow::anyhow!(failure)
2235        }
2236    }
2237}
2238
2239/// What the conversation is told when a local session moves into a target.
2240fn conversion_notice(
2241    target_id: &str,
2242    checkout: &Path,
2243    branch: Option<&str>,
2244    retire: Option<&mj_core::state::ManagedWorktree>,
2245) -> String {
2246    let branch = branch.unwrap_or("a detached head");
2247    match retire {
2248        Some(worktree) => format!(
2249            "This session moved out of {} and into the {target_id} target, where its checkout is on {branch}. Its branch {} stays in {}.",
2250            checkout.display(),
2251            worktree.branch,
2252            worktree.source_repository.display()
2253        ),
2254        None => format!(
2255            "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.",
2256            checkout.display()
2257        ),
2258    }
2259}
2260
2261/// The archive a converting resume provisions and restores from: the previous
2262/// archive's session, conversation, and native state, with the host checkout's
2263/// snapshot as its only repository.
2264///
2265/// The snapshot carries network provenance, so this archive is what lets the
2266/// destination clone a real remote and later checkpoint like any other
2267/// isolated session.
2268fn conversion_checkpoint(
2269    previous_archive: &Path,
2270    snapshot: mj_checkpoint::archive::RepositorySnapshot,
2271    output: &Path,
2272) -> Result<mj_core::state::CheckpointMetadata> {
2273    let previous = mj_checkpoint::archive::read_archive_verified(previous_archive)
2274        .with_context(|| format!("read checkpoint archive {}", previous_archive.display()))?;
2275    // Native harness state and relay attachments travel byte for byte: a
2276    // conversion replaces repository content and nothing else.
2277    let native_artifacts = previous
2278        .manifest
2279        .payloads
2280        .iter()
2281        .filter_map(|descriptor| match &descriptor.role {
2282            mj_checkpoint::archive::PayloadRole::NativeArtifact { relative_path } => {
2283                Some((relative_path, descriptor))
2284            }
2285            _ => None,
2286        })
2287        .map(|(relative_path, descriptor)| {
2288            Ok(mj_checkpoint::archive::NativeArtifact {
2289                relative_path: relative_path.clone(),
2290                data: previous.payload(descriptor)?.to_vec(),
2291                mode: descriptor.mode,
2292            })
2293        })
2294        .collect::<Result<Vec<_>>>()?;
2295    let canonical_session = previous.canonical_session()?;
2296    let event_frontier = canonical_session.event_frontier;
2297    let written = mj_checkpoint::archive::write_archive_atomic(
2298        output,
2299        &mj_checkpoint::archive::ArchiveInput {
2300            session: previous.manifest.session.clone(),
2301            // Provenance for a person reading the archive; a restore reads
2302            // nothing from it, so the target it was captured on stands.
2303            target: previous.manifest.target.clone(),
2304            bundle: mj_checkpoint::archive::BundleManifest {
2305                id: previous.manifest.bundle.id.clone(),
2306                // A restore finds the primary repository by this id, and with
2307                // it the working directory the native transcript is rewritten
2308                // to, so it has to name the snapshot.
2309                primary_repository: snapshot.metadata.id.clone(),
2310            },
2311            canonical_session,
2312            native_artifacts,
2313            repositories: vec![snapshot],
2314        },
2315    )
2316    .with_context(|| format!("write the conversion archive {}", output.display()))?;
2317    Ok(mj_core::state::CheckpointMetadata {
2318        archive_path: output.to_path_buf(),
2319        sha256: written.archive_sha256,
2320        created_at: now(),
2321        event_frontier,
2322    })
2323}
2324
2325/// Whether the native session a restored worker opened may stand in for the
2326/// archived one.
2327///
2328/// The archived session is the one the conversation lives in, so a different
2329/// one is refused: resuming in it would silently drop that history. The one
2330/// exception is a worker that says it replaced exactly the archived session
2331/// because the harness had no record of it and this session never used it.
2332/// Claude Code and Codex write nothing for a native session until its first
2333/// prompt, so a session suspended before one (or right after `/clear`) can
2334/// only ever come back this way (R7-5).
2335fn restored_native_session_accepted(
2336    archived: &str,
2337    opened: &str,
2338    replaced_unused: Option<&str>,
2339) -> bool {
2340    opened == archived || replaced_unused == Some(archived)
2341}
2342
2343/// Put the durable projection back to the archived one after a failed resume
2344/// or in-place swap.
2345///
2346/// The attempt can have changed it two ways: a rebuild from the archive, and
2347/// the events of the worker it started, which the controller projects while it
2348/// waits for the harness. Either leaves the stored frontier somewhere other
2349/// than the archive's, so the frontier decides, and the failed attempt leaves
2350/// no lines in the transcript for the next one to add to. A projection still
2351/// at the archived frontier only needs its queue back when the attempt
2352/// discarded it.
2353fn restore_projection_after_failed_resume(
2354    session_id: &str,
2355    canonical_session: &mj_checkpoint::archive::CanonicalSessionSnapshot,
2356    discard_queued_prompts: bool,
2357) {
2358    let stored_frontier = crate::database::materialized_event_frontier(session_id)
2359        .unwrap_or_else(|error| {
2360            tracing::warn!(
2361                session_id,
2362                error = format!("{error:#}"),
2363                "could not read the stored projection frontier after a failed resume; rebuilding it from the archive"
2364            );
2365            None
2366        });
2367    if projection_rebuild_required(
2368        stored_frontier
2369            .as_ref()
2370            .map(|(ordinal, digest)| (*ordinal, digest.as_str())),
2371        canonical_session.event_frontier,
2372        &canonical_session.event_frontier_digest,
2373    ) {
2374        match materialized_session_from_canonical(session_id, canonical_session) {
2375            Ok(previous_projection) => {
2376                if let Err(restore_error) =
2377                    crate::database::save_materialized_session(&previous_projection)
2378                {
2379                    tracing::error!(
2380                        session_id,
2381                        error = format!("{restore_error:#}"),
2382                        "could not restore the durable projection after a failed resume"
2383                    );
2384                }
2385            }
2386            Err(restore_error) => tracing::error!(
2387                session_id,
2388                error = format!("{restore_error:#}"),
2389                "could not rebuild the durable projection after a failed resume"
2390            ),
2391        }
2392    } else if discard_queued_prompts
2393        && let Err(restore_error) = crate::database::replace_materialized_queued_prompts(
2394            session_id,
2395            &mj_transcript::projection::materialized_queued_prompts_from_canonical(
2396                &canonical_session.queued_prompts,
2397            ),
2398        )
2399    {
2400        tracing::error!(
2401            session_id,
2402            error = format!("{restore_error:#}"),
2403            "could not restore queued prompts after a failed resume"
2404        );
2405    }
2406}
2407
2408/// Whether a resume has to rebuild the durable projection from its archive.
2409///
2410/// The projection is a deterministic fold of the relay event chain, so a stored
2411/// projection standing at the archive's frontier *and* carrying the archive's
2412/// frontier digest already holds the archived content: same chain, same
2413/// ordinal, same result. Anything else - no stored row, a different ordinal, a
2414/// different digest, or a frontier that could not be read - rebuilds.
2415fn projection_rebuild_required(
2416    stored: Option<(u64, &str)>,
2417    archive_frontier: u64,
2418    archive_frontier_digest: &str,
2419) -> bool {
2420    stored != Some((archive_frontier, archive_frontier_digest))
2421}
2422
2423fn restore_archive_path(
2424    backend: &targets::TargetLocator,
2425    verified_archive: &Path,
2426    remote_archive: &Path,
2427) -> PathBuf {
2428    if matches!(backend, targets::TargetLocator::LocalBare { .. }) {
2429        verified_archive.to_path_buf()
2430    } else {
2431        remote_archive.to_path_buf()
2432    }
2433}
2434
2435fn should_upload_restore_archive(backend: &targets::TargetLocator) -> bool {
2436    !matches!(backend, targets::TargetLocator::LocalBare { .. })
2437}
2438
2439/// Provisioning currently performs its target plan synchronously inside an
2440/// async function. Run the network-bound cross-harness handoff on a joined
2441/// side runtime so it can make progress during that plan without borrowing
2442/// the mutable controller or leaving work behind on failure.
2443struct CrossHarnessProvisionExecutor<'a, E: CommandExecutor + ?Sized> {
2444    inner: &'a E,
2445    cancellation: CancellationToken,
2446}
2447
2448impl<E: CommandExecutor + ?Sized> CommandExecutor for CrossHarnessProvisionExecutor<'_, E> {
2449    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2450        if self.cancellation.is_cancelled() {
2451            bail!("operation cancelled while provisioning destination");
2452        }
2453        self.inner.execute(command)
2454    }
2455
2456    fn cancellation_requested(&self) -> bool {
2457        self.cancellation.is_cancelled() || self.inner.cancellation_requested()
2458    }
2459
2460    fn stage_started(&self, stage: ProvisionStage) {
2461        self.inner.stage_started(stage);
2462    }
2463
2464    fn stage_finished(&self, stage: ProvisionStage) {
2465        self.inner.stage_finished(stage);
2466    }
2467
2468    fn notify_notice(&self, notice: &str) {
2469        self.inner.notify_notice(notice);
2470    }
2471
2472    fn execute_with_stdin(
2473        &self,
2474        command: &CommandSpec,
2475        input: &mut (dyn std::io::Read + Send),
2476    ) -> Result<CommandOutput> {
2477        if self.cancellation.is_cancelled() {
2478            bail!("operation cancelled while provisioning destination");
2479        }
2480        self.inner.execute_with_stdin(command, input)
2481    }
2482}
2483
2484fn provision_with_cross_harness_handoff(
2485    controller: &mut Controller,
2486    session_id: &str,
2487    executor: &(impl CommandExecutor + Sync),
2488    github_token: Option<&str>,
2489    config: &Config,
2490    snapshot: &CanonicalSessionSnapshot,
2491    context_bytes: usize,
2492) -> Result<String> {
2493    let (_provision, handoff) = execute_joined_cross_harness_work(
2494        "cross-harness provisioning",
2495        move |cancellation| {
2496            let provision_executor = CrossHarnessProvisionExecutor {
2497                inner: executor,
2498                cancellation,
2499            };
2500            block_on_cross_harness_lane(
2501                "cross-harness provisioning",
2502                controller.provision_session_with_failure_disposition(
2503                    session_id,
2504                    &provision_executor,
2505                    github_token,
2506                    ProvisioningFailureDisposition::Preserve,
2507                ),
2508            )
2509        },
2510        "cross-harness handoff",
2511        move |cancellation| {
2512            block_on_cross_harness_lane(
2513                "cross-harness handoff",
2514                utility_handoff_while_cancellable(
2515                    session_id,
2516                    config,
2517                    snapshot,
2518                    context_bytes,
2519                    executor,
2520                    cancellation,
2521                ),
2522            )
2523        },
2524    )?;
2525    ensure!(
2526        !executor.cancellation_requested(),
2527        "operation cancelled while provisioning destination"
2528    );
2529    Ok(handoff)
2530}
2531
2532/// Each joined OS thread must own the Tokio reactor used by provisioning and
2533/// handoff I/O. A plain futures executor cannot drive Tokio sockets or timers.
2534fn block_on_cross_harness_lane<T>(
2535    name: &str,
2536    work: impl std::future::Future<Output = Result<T>>,
2537) -> Result<T> {
2538    let runtime = tokio::runtime::Builder::new_current_thread()
2539        .enable_all()
2540        .build()
2541        .with_context(|| format!("create {name} runtime"))?;
2542    runtime.block_on(work)
2543}
2544
2545/// Run the two independent cross-harness lanes together, cancelling and
2546/// joining the peer as soon as either lane fails. The lane order is stable so
2547/// diagnostics do not depend on which worker happened to finish first.
2548fn execute_joined_cross_harness_work<A: Send, B: Send>(
2549    first_name: &'static str,
2550    first: impl FnOnce(CancellationToken) -> Result<A> + Send,
2551    second_name: &'static str,
2552    second: impl FnOnce(CancellationToken) -> Result<B> + Send,
2553) -> Result<(A, B)> {
2554    let cancellation = CancellationToken::new();
2555    let owner = crate::worker_lifecycle::capture();
2556    std::thread::scope(|scope| {
2557        let first_cancel = cancellation.clone();
2558        let first_owner = owner.clone();
2559        let mut first_handle = Some(scope.spawn(move || match first_owner {
2560            Some(owner) => owner.scope_blocking(|| first(first_cancel)),
2561            None => first(first_cancel),
2562        }));
2563        let second_cancel = cancellation.clone();
2564        let mut second_handle = Some(scope.spawn(move || match owner {
2565            Some(owner) => owner.scope_blocking(|| second(second_cancel)),
2566            None => second(second_cancel),
2567        }));
2568        let mut first_result = None;
2569        let mut second_result = None;
2570
2571        while first_result.is_none() || second_result.is_none() {
2572            if first_result.is_none()
2573                && first_handle
2574                    .as_ref()
2575                    .is_some_and(|handle| handle.is_finished())
2576            {
2577                let handle = first_handle.take().expect("first lane handle present");
2578                first_result = Some(match handle.join() {
2579                    Ok(result) => result,
2580                    Err(panic) => {
2581                        cancellation.cancel();
2582                        Err(anyhow::anyhow!(
2583                            "{first_name} thread panicked: {}",
2584                            targets::command_thread_panic_message(panic.as_ref())
2585                        ))
2586                    }
2587                });
2588                if first_result.as_ref().is_some_and(Result::is_err) {
2589                    cancellation.cancel();
2590                }
2591            }
2592            if second_result.is_none()
2593                && second_handle
2594                    .as_ref()
2595                    .is_some_and(|handle| handle.is_finished())
2596            {
2597                let handle = second_handle.take().expect("second lane handle present");
2598                second_result = Some(match handle.join() {
2599                    Ok(result) => result,
2600                    Err(panic) => {
2601                        cancellation.cancel();
2602                        Err(anyhow::anyhow!(
2603                            "{second_name} thread panicked: {}",
2604                            targets::command_thread_panic_message(panic.as_ref())
2605                        ))
2606                    }
2607                });
2608                if second_result.as_ref().is_some_and(Result::is_err) {
2609                    cancellation.cancel();
2610                }
2611            }
2612            if first_result.is_none() || second_result.is_none() {
2613                std::thread::sleep(Duration::from_millis(10));
2614            }
2615        }
2616
2617        match (
2618            first_result.expect("first lane result received after joined handle"),
2619            second_result.expect("second lane result received after joined handle"),
2620        ) {
2621            (Err(first), Err(second)) => {
2622                Err(first.context(format!("{second_name} lane also failed: {second:#}")))
2623            }
2624            (Err(error), Ok(_)) => Err(error),
2625            (Ok(_), Err(error)) => Err(error),
2626            (Ok(first), Ok(second)) => Ok((first, second)),
2627        }
2628    })
2629}
2630
2631/// Whether the restored native session can carry the conversation into the
2632/// resumed session.
2633///
2634fn native_continuity_preserved(profile_kind: HarnessKind, archived_kind: HarnessKind) -> bool {
2635    profile_kind == archived_kind
2636}
2637
2638/// Discover a utility model and compact the cross-harness handoff while still
2639/// watching for cancellation. Discovery and compaction can both make several
2640/// network requests, so a cancelled resume must not wait them out.
2641async fn utility_handoff_while_cancellable(
2642    session_id: &str,
2643    config: &Config,
2644    snapshot: &CanonicalSessionSnapshot,
2645    context_bytes: usize,
2646    executor: &impl CommandExecutor,
2647    cancellation: CancellationToken,
2648) -> Result<String> {
2649    let _phase = ResumePhaseTimer::new(session_id, "cross-harness handoff");
2650    if executor.cancellation_requested() {
2651        bail!("operation cancelled while compacting the cross-harness handoff");
2652    }
2653    let _compacting = ProvisionStageGuard::new(executor, ProvisionStage::Compacting);
2654    let cancel = cancellation.child_token();
2655    let operation =
2656        crate::handoff::build_handoff_context(session_id, config, snapshot, context_bytes, &cancel);
2657    tokio::pin!(operation);
2658    loop {
2659        tokio::select! {
2660            context = &mut operation => return context,
2661            _ = cancellation.cancelled() => {
2662                cancel.cancel();
2663                bail!("operation cancelled while compacting the cross-harness handoff");
2664            }
2665            _ = tokio::time::sleep(super::readiness::CANCELLATION_POLL_INTERVAL) => {
2666                if executor.cancellation_requested() {
2667                    cancel.cancel();
2668                    bail!("operation cancelled while compacting the cross-harness handoff");
2669                }
2670            }
2671        }
2672    }
2673}
2674
2675mod in_place;
2676pub(crate) use in_place::InPlaceRestartError;
2677
2678#[cfg(test)]
2679mod tests;