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