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 fn raw_conversion_preview_for(
440 session: &SessionRecord,
441 config: &Config,
442 executor: &(impl CommandExecutor + Sync),
443) -> Result<mj_core::state::RawConversionPreview> {
444 let conversion = plan_raw_to_workspace(session, config, executor)?;
445 raw_conversion_preview(session, &conversion, executor)
446}
447
448fn replacement_repository_source(id: &str, replacement: &str) -> Result<ProjectRepository> {
449 let replacement = replacement.trim();
450 ensure!(!replacement.is_empty(), "enter the repository's new origin");
451 let expanded = mj_core::path_input::expand_local(Path::new(replacement))?;
452 let path = expanded.as_path();
453 let (github, local) = if path.is_absolute() {
454 ensure!(
455 path.is_dir(),
456 "local repository {replacement:?} is not a directory"
457 );
458 (None, Some(mj_core::local_git::canonical_repository(path)?))
459 } else {
460 let github = crate::setup::github_repository_from_origin(replacement)
461 .context("origin must be a GitHub repository or an absolute local repository path")?;
462 (
463 Some(format!("{}/{}", github.owner, github.repository)),
464 None,
465 )
466 };
467 Ok(ProjectRepository {
468 id: id.to_owned(),
469 github,
470 local,
471 destination: PathBuf::from(id),
472 git_ref: None,
473 })
474}
475
476fn checkpoint_source_missing_commit(
477 configured: &ProjectRepository,
478 archived: &CheckpointRepositoryBundle,
479 executor: &impl CommandExecutor,
480 github_token: Option<&str>,
481) -> Result<Option<String>> {
482 let staging = tempfile::tempdir().context("create repository source preflight")?;
483 let repository = staging.path().join("repository.git");
484 checked_preflight_git(
485 executor,
486 CommandSpec::new(
487 "git",
488 [
489 "init".to_owned(),
490 "--bare".to_owned(),
491 "--quiet".to_owned(),
492 repository.to_string_lossy().into_owned(),
493 ],
494 )
495 .purpose("initialize repository source preflight"),
496 )?;
497 let missing = checkpoint_bundle_prerequisites(archived)?;
498 if missing.is_empty() {
499 let bundle = staging.path().join("checkpoint.bundle");
500 std::fs::write(&bundle, &archived.committed_bundle)
501 .context("write self-contained checkpoint bundle for source preflight")?;
502 checked_preflight_git(
503 executor,
504 checkpoint_bundle_import_command(&repository, &bundle),
505 )?;
506 return Ok(None);
507 }
508 for commit in missing {
512 let output = fetch_source_commit(executor, &repository, configured, &commit, github_token)?;
513 if output.status != 0 {
514 let stderr = String::from_utf8_lossy(&output.stderr);
515 if source_does_not_have_commit(&stderr) {
516 return Ok(Some(commit));
517 }
518 bail!(
519 "could not check configured source {:?}: {}",
520 configured.source_label(),
521 stderr.trim()
522 );
523 }
524 }
525 Ok(None)
529}
530
531fn checkpoint_bundle_import_command(repository: &Path, bundle: &Path) -> CommandSpec {
532 let mut command = CommandSpec::new(
533 "git",
534 [
535 "-C".to_owned(),
536 repository.to_string_lossy().into_owned(),
537 "fetch".to_owned(),
538 "--no-tags".to_owned(),
539 bundle.to_string_lossy().into_owned(),
540 "HEAD".to_owned(),
541 ],
542 )
543 .purpose("validate self-contained checkpoint bundle");
544 command
545 .env
546 .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
547 command
548 .env
549 .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
550 command
551}
552
553fn fetch_source_commit(
554 executor: &impl CommandExecutor,
555 repository: &Path,
556 configured: &ProjectRepository,
557 commit: &str,
558 github_token: Option<&str>,
559) -> Result<CommandOutput> {
560 let mut arguments = Vec::new();
561 let mut token_auth = false;
562 let mut ssh_transport = false;
563 let source = if let Some(local) = &configured.local {
564 local.to_string_lossy().into_owned()
565 } else {
566 let source = configured
567 .github
568 .as_deref()
569 .context("repository source is missing")?;
570 let github = crate::setup::github_repository_from_origin(source)
571 .context("configured repository is not a GitHub source")?;
572 if github_token.is_some() {
573 token_auth = true;
574 arguments.extend([
575 "-c".to_owned(),
576 "credential.helper=".to_owned(),
577 "-c".to_owned(),
578 "credential.helper=!f() { if [ \"$1\" = get ]; then echo username=x-access-token; echo \"password=$GH_TOKEN\"; fi; }; f".to_owned(),
579 ]);
580 format!(
581 "https://github.com/{}/{}.git",
582 github.owner, github.repository
583 )
584 } else {
585 ssh_transport = true;
586 format!("git@github.com:{}/{}.git", github.owner, github.repository)
587 }
588 };
589 arguments.extend([
590 "-C".to_owned(),
591 repository.to_string_lossy().into_owned(),
592 "fetch".to_owned(),
593 "--no-tags".to_owned(),
594 "--depth=1".to_owned(),
595 "--filter=blob:none".to_owned(),
596 source,
597 commit.to_owned(),
598 ]);
599 let mut command = CommandSpec::new("git", arguments).purpose("check checkpoint base commit");
600 command
601 .env
602 .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
603 command
604 .env
605 .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
606 if token_auth {
607 let token = github_token.expect("token authentication requires a GitHub token");
608 command.env.insert("GH_TOKEN".to_owned(), token.to_owned());
609 }
610 if ssh_transport {
611 command.env.insert(
612 "GIT_SSH_COMMAND".to_owned(),
613 "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15"
614 .to_owned(),
615 );
616 }
617 executor.execute(&command)
618}
619
620fn source_does_not_have_commit(stderr: &str) -> bool {
621 let stderr = stderr.to_ascii_lowercase();
622 [
623 "not our ref",
624 "couldn't find remote ref",
625 "not a valid object name",
626 "no such ref was fetched",
627 ]
628 .iter()
629 .any(|needle| stderr.contains(needle))
630}
631
632fn checked_preflight_git(
633 executor: &impl CommandExecutor,
634 command: CommandSpec,
635) -> Result<CommandOutput> {
636 let output = executor.execute(&command)?;
637 ensure!(
638 output.status == 0,
639 "{}: {}",
640 command.purpose,
641 String::from_utf8_lossy(&output.stderr).trim()
642 );
643 Ok(output)
644}
645
646impl Controller {
647 pub async fn resume_session_with_options(
652 &mut self,
653 session_id: &str,
654 profile_id: &str,
655 target_id: &str,
656 additional_mounts: Option<Vec<AdditionalMount>>,
657 resource_allocation: Option<SessionResourceAllocation>,
658 ) -> Result<MaterializedSession> {
659 self.resume_session_with_options_and_queue_disposition(
660 session_id,
661 profile_id,
662 target_id,
663 additional_mounts,
664 resource_allocation,
665 false,
666 )
667 .await
668 }
669
670 pub async fn resume_session_with_options_and_queue_disposition(
671 &mut self,
672 session_id: &str,
673 profile_id: &str,
674 target_id: &str,
675 additional_mounts: Option<Vec<AdditionalMount>>,
676 resource_allocation: Option<SessionResourceAllocation>,
677 discard_queue: bool,
678 ) -> Result<MaterializedSession> {
679 self.resume_session_controlled(
680 session_id,
681 profile_id,
682 target_id,
683 SessionResumeOptions {
684 additional_mounts,
685 resource_allocation,
686 discard_queue,
687 },
688 &ProcessExecutor,
689 )
690 .await
691 }
692
693 pub async fn resume_session_controlled(
694 &mut self,
695 session_id: &str,
696 profile_id: &str,
697 target_id: &str,
698 options: SessionResumeOptions,
699 executor: &(impl CommandExecutor + Sync),
700 ) -> Result<MaterializedSession> {
701 self.resume_session_controlled_with_repository_preflight(
702 session_id, profile_id, target_id, options, None, executor,
703 )
704 .await
705 }
706
707 pub async fn resume_session_controlled_with_repository_preflight(
708 &mut self,
709 session_id: &str,
710 profile_id: &str,
711 target_id: &str,
712 options: SessionResumeOptions,
713 repository_preflight: Option<ResumeRepositorySourceReceipt>,
714 executor: &(impl CommandExecutor + Sync),
715 ) -> Result<MaterializedSession> {
716 let SessionResumeOptions {
717 additional_mounts,
718 resource_allocation,
719 discard_queue,
720 } = options;
721 let previous = self
722 .state
723 .sessions
724 .get(session_id)
725 .with_context(|| format!("unknown session {session_id}"))?
726 .clone();
727 if !matches!(
728 previous.state,
729 SessionState::Stopped | SessionState::Lost | SessionState::Error
730 ) {
731 bail!("session {session_id} is not stopped, lost, or retryable");
732 }
733 let checkpoint = previous
734 .checkpoint
735 .as_ref()
736 .context("session has no checkpoint")?;
737 let moving_to_raw = previous.project_directory.is_none()
740 && self
741 .config
742 .targets
743 .get(target_id)
744 .is_some_and(mj_core::config::is_bare_project_target);
745 if moving_to_raw
746 || !repository_preflight.as_ref().is_some_and(|receipt| {
747 self.repository_source_receipt_is_current(session_id, receipt)
748 })
749 {
750 let _phase = ResumePhaseTimer::new(session_id, "preflight repository sources");
751 if let ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) =
752 self.preflight_repository_sources(session_id, target_id, false, executor)?
753 {
754 bail!(
755 "checkpoint base commit {} is missing from configured source {:?} for repository {:?}; the repository may have moved (archived origin: {:?})",
756 mismatch.missing_commit,
757 mismatch.configured_origin,
758 mismatch.repository_id,
759 mismatch.archived_origin,
760 );
761 }
762 }
763 let archive_path = {
768 let _phase = ResumePhaseTimer::new(session_id, "verify checkpoint archive");
769 checkpoint.archive_path.canonicalize().with_context(|| {
770 format!(
771 "resolve checkpoint archive {}",
772 checkpoint.archive_path.display()
773 )
774 })?
775 };
776 ensure!(
777 archive_path.is_absolute() && archive_path.is_file(),
778 "checkpoint archive path is not an absolute regular file: {}",
779 archive_path.display()
780 );
781 let mj_checkpoint::archive::VerifiedArchiveMetadata {
785 manifest: archive_manifest,
786 canonical_session,
787 archive_sha256,
788 } = {
789 let _phase = ResumePhaseTimer::new(session_id, "verify checkpoint archive contents");
790 verify_archive_streaming(&archive_path)?
791 };
792 if archive_sha256 != checkpoint.sha256 || archive_manifest.session.id != session_id {
793 bail!("persisted checkpoint verification failed");
794 }
795 ensure!(
796 archive_manifest.repositories.iter().all(|repository| {
797 !repository.metadata.origin.starts_with("mj-local:")
798 && !repository.metadata.origin.starts_with("ext::")
799 }),
800 "resuming legacy host-bridge sessions is not supported; start a new network-backed session"
801 );
802 let canonical_session = Arc::new(canonical_session);
803 let profile = self
804 .config
805 .profiles
806 .get(profile_id)
807 .with_context(|| format!("unknown profile {profile_id:?}"))?
808 .clone();
809 ensure!(profile.enabled, "profile {profile_id:?} is disabled");
810 let target_template = self
811 .config
812 .targets
813 .get(target_id)
814 .with_context(|| format!("unknown target template {target_id:?}"))?
815 .clone();
816 self.validate_muse_resume_destination(&previous, profile.kind, target_id)?;
819 ensure!(
820 profile.kind != HarnessKind::Muse || previous.additional_mounts.is_empty(),
821 "Muse Code ACP supports one workspace root; attached directories are unsupported"
822 );
823 let plan = resume_compatibility(&previous, &self.config, target_id)
824 .map_err(|reason| anyhow::anyhow!("{reason}"))?;
825 if !mj_core::config::is_bare_project_target(&target_template)
829 && plan != ResumePlan::RawToWorkspace
830 {
831 super::network_git::bundle_from_manifest(&archive_manifest)?;
832 }
833 if plan == ResumePlan::InPlace
834 && previous.managed_worktree.is_none()
835 && let Some(project_directory) = &previous.project_directory
836 {
837 self.validate_project_directory(target_id, project_directory, executor)
838 .context("raw project is unavailable for resume")?;
839 }
840 let conversion = match plan {
841 ResumePlan::InPlace => None,
842 ResumePlan::RawToWorkspace => Some(ResumeConversion::RawToWorkspace(
843 plan_raw_to_workspace(&previous, &self.config, executor)
844 .context("prepare the raw checkout for its new target")?,
845 )),
846 ResumePlan::WorkspaceToRaw => Some(ResumeConversion::WorkspaceToRaw(
847 self.plan_workspace_to_raw(&previous, target_id, executor)
848 .context("prepare a checkout for this session")?,
849 )),
850 };
851 let resource_allocation =
852 resource_allocation.or_else(|| previous.resource_allocation.clone());
853 let additional_mounts =
854 additional_mounts.unwrap_or_else(|| previous.additional_mounts.clone());
855 validate_resource_allocation(&target_template, resource_allocation.as_ref())?;
856 let selected_container_size =
857 selected_host_container_size(&target_template, resource_allocation.as_ref());
858 if !additional_mounts.is_empty() && mount_history_host(&target_template).is_none() {
859 bail!("attached resources are unsupported for this target");
860 }
861 targets::validate_additional_mounts(&additional_mounts)?;
862 let history_host = mount_history_host(&target_template);
863 let history_mounts = additional_mounts.clone();
864 if previous.state == SessionState::Error
865 && let Some(locator) = &previous.target
866 {
867 let backend = backend_locator(locator, &previous, &self.config)?;
868 targets::close_plan(&backend, session_id)?
869 .execute(executor)
870 .context("clean up target from failed resume")?;
871 }
872 let mut resume_notices = Vec::new();
873 if let Some(conversion) = conversion
876 .as_ref()
877 .and_then(ResumeConversion::workspace_to_raw)
878 {
879 resume_notices.push(format!(
880 "This session moved out of its {} target and into {}. Its branch {} is now {}.",
881 previous.target_template_id,
882 conversion.worktree.worktree_root.display(),
883 archive_manifest
884 .repositories
885 .first()
886 .and_then(|repository| repository.metadata.branch.as_deref())
887 .unwrap_or("a detached head"),
888 conversion.worktree.branch,
889 ));
890 }
891 let managed_checkout_present = previous
892 .managed_worktree
893 .as_ref()
894 .map(|worktree| managed_worktree_checkout_exists(executor, worktree))
895 .transpose()?
896 .unwrap_or(true);
897 if managed_checkout_present && let Some(project_directory) = &previous.project_directory {
900 match raw_checkout_position(&previous, &self.config, project_directory, executor) {
901 Ok(live) => resume_notices.extend(raw_checkout_divergence_notice(
902 project_directory,
903 archive_manifest
904 .repositories
905 .first()
906 .map(|repository| &repository.metadata),
907 &live,
908 )),
909 Err(error) => tracing::warn!(
912 session_id,
913 error = format!("{error:#}"),
914 "could not read the raw checkout position for a resume notice"
915 ),
916 }
917 }
918 super::worker_binary::preflight_worker_binary(&target_template)?;
923 let same_harness = profile.kind == archive_manifest.session.harness_kind;
924 let native_continuity =
925 native_continuity_preserved(profile.kind, archive_manifest.session.harness_kind);
926 let context_bytes = crate::handoff::profile_handoff_bytes(Some(&profile));
927 let utility_config = (!native_continuity).then(|| self.config.clone());
931 let discard_queued_prompts = discard_queue || !same_harness;
932 let stored_frontier = crate::database::materialized_event_frontier(session_id)
936 .unwrap_or_else(|error| {
937 tracing::warn!(
938 session_id,
939 error = format!("{error:#}"),
940 "could not read the stored projection frontier; rebuilding it from the archive"
941 );
942 None
943 });
944 let rebuild_projection = projection_rebuild_required(
945 stored_frontier
946 .as_ref()
947 .map(|(ordinal, digest)| (*ordinal, digest.as_str())),
948 canonical_session.event_frontier,
949 &canonical_session.event_frontier_digest,
950 );
951 let projection_build = rebuild_projection.then(|| {
961 let canonical = Arc::clone(&canonical_session);
962 let session_id = session_id.to_owned();
963 tokio::task::spawn_blocking(move || {
964 materialized_session_from_canonical(session_id, &canonical)
965 })
966 });
967 let github_token = controller_github_token();
968
969 if let Some(conversion) = conversion
972 .as_ref()
973 .and_then(ResumeConversion::raw_to_workspace)
974 && let Some(bundle) = &conversion.new_bundle
975 {
976 let (config, ()) = Config::update(|config| {
977 if let Some(existing) = config.bundles.get(&conversion.bundle_id) {
978 ensure!(
979 existing == bundle,
980 "bundle {:?} was configured concurrently with a different definition; retry the resume",
981 conversion.bundle_id
982 );
983 } else {
984 config
985 .bundles
986 .insert(conversion.bundle_id.clone(), bundle.clone());
987 }
988 Ok(())
989 })
990 .context("save the bundle for a converted raw session")?;
991 self.config = config;
992 }
993
994 let moving_into_first_container = self
1000 .config
1001 .targets
1002 .get(target_id)
1003 .is_some_and(mj_core::config::is_container_target)
1004 && !self
1005 .config
1006 .targets
1007 .get(&previous.target_template_id)
1008 .is_some_and(mj_core::config::is_container_target);
1009 let record = self.state.sessions.get_mut(session_id).unwrap();
1010 if record.container_workspace.is_none() && moving_into_first_container {
1011 record.container_workspace = Some(targets::new_container_workspace(session_id)?);
1012 }
1013 record.harness_kind = profile.kind;
1014 record.last_profile = profile_id.to_string();
1015 record.target_template_id = target_id.to_string();
1016 record.resource_allocation = resource_allocation;
1017 record.additional_mounts = additional_mounts;
1018 record.target = None;
1019 record.native_session_id =
1020 native_continuity.then(|| archive_manifest.session.native_session_id.clone());
1021 record.state = SessionState::Provisioning;
1022 record.updated_at = now();
1023 record.last_error = None;
1024 match &conversion {
1025 Some(ResumeConversion::RawToWorkspace(conversion)) => {
1026 apply_raw_to_workspace(record, conversion);
1027 }
1028 Some(ResumeConversion::WorkspaceToRaw(conversion)) => {
1029 apply_workspace_to_raw(record, conversion);
1030 }
1031 None => {}
1032 }
1033 let resumed_project_directory = record.project_directory.clone();
1034 let resumed_container_workspace = record.container_workspace.clone();
1035 if let Some(host) = history_host {
1036 self.state.remember_mount_sources(host, &history_mounts);
1037 crate::database::remember_mount_sources(host, &history_mounts)?;
1038 }
1039 if let Some(conversion) = conversion
1042 .as_ref()
1043 .and_then(ResumeConversion::raw_to_workspace)
1044 {
1045 crate::database::rebind_session_bundle(session_id, &conversion.bundle_id)?;
1046 }
1047 if let Some((host, size)) = selected_container_size.as_ref() {
1050 crate::database::save_session_with_container_size(
1051 &self.state.sessions[session_id],
1052 host,
1053 *size,
1054 )?;
1055 } else {
1056 crate::database::save_session(&self.state.sessions[session_id])?;
1057 }
1058 if let Some((host, size)) = selected_container_size.as_ref() {
1059 self.state.remember_container_size(host, *size);
1060 }
1061
1062 let mut recreated_managed_worktree = false;
1063 let mut conversion_checkpoint_written: Option<mj_core::state::CheckpointMetadata> = None;
1066 let result = async {
1067 if let Some(worktree) = previous.managed_worktree.as_ref() {
1068 recreated_managed_worktree = restore_managed_worktree(executor, worktree)?;
1069 if recreated_managed_worktree && plan == ResumePlan::RawToWorkspace {
1070 mj_checkpoint::checkpoint::restore_single_repository_onto_branch(
1071 &archive_path,
1072 &worktree.worktree_root,
1073 &worktree.branch,
1074 &SystemGit,
1075 )
1076 .context("restore the retired checkout before moving it into a target")?;
1077 }
1078 }
1079 if let Some(conversion) = conversion
1082 .as_ref()
1083 .and_then(ResumeConversion::workspace_to_raw)
1084 {
1085 if conversion.reuse_existing_branch {
1086 let recovery_ref =
1087 preserve_retained_managed_worktree_branch(executor, &conversion.worktree)?;
1088 restore_managed_worktree(executor, &conversion.worktree)?;
1089 resume_notices.push(format!(
1090 "Before restoring this session's retained branch, Mjolnir preserved its tip at {recovery_ref}."
1091 ));
1092 } else {
1093 create_managed_worktree(
1094 executor,
1095 &conversion.worktree,
1096 None,
1097 PrimaryCheckoutRequirement::Any,
1098 )?;
1099 }
1100 mj_checkpoint::checkpoint::restore_single_repository_onto_branch(
1101 &archive_path,
1102 &conversion.worktree.worktree_root,
1103 &conversion.worktree.branch,
1104 &SystemGit,
1105 )
1106 .context("restore this session's checkout")?;
1107 }
1108 if let Some(conversion) = conversion
1113 .as_ref()
1114 .and_then(ResumeConversion::raw_to_workspace)
1115 {
1116 let destination = PathBuf::from(
1117 previous
1118 .project_directory
1119 .as_deref()
1120 .context("a raw session has no project directory")?
1121 .file_name()
1122 .context("a raw project directory cannot be the filesystem root")?,
1123 );
1124 let snapshot = raw_checkout_snapshot(
1125 &conversion.checkout,
1126 &conversion.source,
1127 &destination,
1128 &SystemGit,
1129 )
1130 .context("snapshot the host checkout for its new target")?;
1131 resume_notices.push(conversion_notice(
1132 target_id,
1133 previous
1134 .project_directory
1135 .as_deref()
1136 .unwrap_or(&conversion.checkout),
1137 snapshot.metadata.branch.as_deref(),
1138 conversion.retire.as_ref(),
1139 ));
1140 let archives = mj_core::config::sessions_dir();
1141 std::fs::create_dir_all(&archives).with_context(|| {
1142 format!("create the checkpoint directory {}", archives.display())
1143 })?;
1144 let output = archives.join(format!(
1148 "{session_id}-converted-{}-{}.hel.zip",
1149 previous
1150 .checkpoint
1151 .as_ref()
1152 .map_or(0, |checkpoint| checkpoint.event_frontier),
1153 new_command_id("archive")?
1154 ));
1155 let written = conversion_checkpoint(&archive_path, snapshot, &output)?;
1156 conversion_checkpoint_written = Some(written.clone());
1157 let record = self.state.sessions.get_mut(session_id).unwrap();
1158 record.checkpoint = Some(written);
1159 record.updated_at = now();
1160 if let Some((host, size)) = selected_container_size.as_ref() {
1163 crate::database::save_session_with_container_size(
1164 &self.state.sessions[session_id],
1165 host,
1166 *size,
1167 )?;
1168 } else {
1169 crate::database::save_session(&self.state.sessions[session_id])?;
1170 }
1171 }
1172 let utility_handoff = {
1173 let _provisioning = ResumePhaseTimer::new(session_id, "provision destination");
1174 if let Some(config) = utility_config.as_ref() {
1175 Some(
1176 provision_with_cross_harness_handoff(
1177 self,
1178 session_id,
1179 executor,
1180 github_token.as_deref(),
1181 config,
1182 &canonical_session,
1183 context_bytes,
1184 )
1185 .context("prepare the cross-harness destination")?,
1186 )
1187 } else {
1188 self.provision_session_with_failure_disposition(
1189 session_id,
1190 executor,
1191 github_token.as_deref(),
1192 ProvisioningFailureDisposition::Preserve,
1193 )
1194 .await?;
1195 None
1196 }
1197 };
1198 let (backend, worker_root) = self.worker_placement(session_id)?;
1199 let harness_home = target_profile_home(&backend, session_id, &profile);
1200 let workspace_root = if let Some(project_directory) = &resumed_project_directory {
1201 project_directory
1202 .parent()
1203 .context("bare project directory has no parent")?
1204 .to_string_lossy()
1205 .into_owned()
1206 } else {
1207 super::network_git::workspace_root(&backend, resumed_container_workspace.as_deref())
1208 };
1209 let target_path = |path: &str| match &backend {
1210 targets::TargetLocator::AwsEc2 { .. }
1211 | targets::TargetLocator::SshBare { .. }
1212 if !path.starts_with('/') =>
1213 {
1214 PathBuf::from(format!("~/{path}"))
1215 }
1216 _ => PathBuf::from(path),
1217 };
1218 let remote_archive = format!("{worker_root}/restore.hel.zip");
1219 let remote_spec = format!("{worker_root}/restore-spec.json");
1220 let restored_archive = conversion_checkpoint_written
1223 .as_ref()
1224 .map_or(archive_path.as_path(), |checkpoint| {
1225 checkpoint.archive_path.as_path()
1226 });
1227 let restore = CheckpointRestoreSpec {
1228 archive_path: restore_archive_path(
1229 &backend,
1230 restored_archive,
1231 &target_path(&remote_archive),
1232 ),
1233 workspace_root: target_path(&workspace_root),
1234 relay_root: target_path(&worker_root),
1235 harness_home: target_path(&harness_home),
1236 restore_repositories: (resumed_project_directory.is_none()
1242 && conversion.is_none())
1243 || plan == ResumePlan::RawToWorkspace
1244 || (recreated_managed_worktree && plan == ResumePlan::InPlace),
1245 restore_native: native_continuity,
1246 primary_repository_root: conversion
1253 .is_some()
1254 .then(|| resumed_project_directory.clone())
1255 .flatten()
1256 .map(|directory| target_path(&directory.to_string_lossy())),
1257 discard_queued_prompts,
1258 };
1259 {
1266 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1267 if let Some(command) = targets::clear_relay_state_plan(&backend, session_id)? {
1268 execute_checked(syncing, command)?;
1269 }
1270 execute_checked(
1272 syncing,
1273 targets::command_on_locator(
1274 &backend,
1275 session_id,
1276 vec!["mkdir".into(), "-p".into(), worker_root.clone()],
1277 "create the session worker root",
1278 )?,
1279 )?;
1280 }
1281 let staging = tempfile::tempdir().context("create restore staging")?;
1282 let local_spec = staging.path().join("restore-spec.json");
1283 std::fs::write(&local_spec, serde_json::to_vec_pretty(&restore)?)?;
1284 let controller = &*self;
1288 let backend_ref = &backend;
1289 let worker_root_ref = worker_root.as_str();
1290 let local_spec_ref = local_spec.as_path();
1291 execute_concurrent_lanes(
1292 || {
1293 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1294 controller.prepare_worker_files(
1295 session_id,
1296 backend_ref,
1297 worker_root_ref,
1298 syncing,
1299 )?;
1300 super::provisioning::install_inherited_git_settings(
1301 syncing,
1302 backend_ref,
1303 session_id,
1304 )?;
1305 Ok(())
1306 },
1307 || {
1308 let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1309 if should_upload_restore_archive(&backend) {
1310 upload_checkpoint_spec(
1311 restoring,
1312 backend_ref,
1313 session_id,
1314 restored_archive,
1315 &remote_archive,
1316 )?;
1317 }
1318 upload_checkpoint_spec(
1319 restoring,
1320 backend_ref,
1321 session_id,
1322 local_spec_ref,
1323 &remote_spec,
1324 )
1325 },
1326 )?;
1327 {
1328 let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1329 execute_checked(
1330 restoring,
1331 restore_command(&backend, session_id, &remote_spec)?,
1332 )?;
1333 }
1334 {
1335 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1336 install_attached_resources(
1337 &self.state,
1338 session_id,
1339 &backend,
1340 &worker_root,
1341 syncing,
1342 )?;
1343
1344 }
1345 match projection_build {
1346 Some(build) => {
1347 let mut restored_projection = build
1348 .await
1349 .context("rebuild the restored projection")?
1350 .context("rebuild the restored projection")?;
1351 if discard_queued_prompts {
1352 restored_projection.queued_prompts.clear();
1353 }
1354 crate::database::save_materialized_session(&restored_projection)?;
1355 }
1356 None if discard_queued_prompts => {
1359 crate::database::replace_materialized_queued_prompts(session_id, &[])?;
1360 }
1361 None => {}
1362 }
1363 let readiness_stage = bridge_readiness_stage(&profile);
1364 let spec = self.reconnect_command(session_id)?;
1365 let readiness = async {
1366 let mut relay = {
1367 let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
1368 start_worker(executor, &backend, &worker_root)?;
1369 connect_started_worker(&spec, session_id, executor, &backend, &worker_root)
1370 .await?
1371 };
1372 let native_session_id =
1373 wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
1374 Ok::<_, anyhow::Error>((relay, native_session_id))
1375 }
1376 .await;
1377 let (mut relay, native_session_id) = readiness
1378 .map_err(|error| worker_probe_diagnosis(executor, &backend, &worker_root, error))?;
1379 if native_continuity {
1380 if native_session_id != archive_manifest.session.native_session_id {
1381 bail!(
1382 "ACP loaded native session {native_session_id}, expected {}",
1383 archive_manifest.session.native_session_id
1384 );
1385 }
1386 } else {
1387 relay
1388 .install_prompt_context(
1389 utility_handoff
1390 .clone()
1391 .context("a resume into a fresh native session has no handoff")?,
1392 )
1393 .await?;
1394 if !discard_queue {
1395 for prompt in &canonical_session.queued_prompts {
1396 let command = match &prompt.kind {
1400 CanonicalQueuedCommandKind::Prompt => RelayCommand::Prompt {
1401 prompt: prompt
1402 .content
1403 .iter()
1404 .cloned()
1405 .map(serde_json::from_value)
1406 .collect::<serde_json::Result<Vec<ContentBlock>>>()?,
1407 },
1408 CanonicalQueuedCommandKind::SetConfig { key, value } => {
1409 RelayCommand::SetConfig {
1410 key: key.clone(),
1411 value: value.clone(),
1412 }
1413 }
1414 };
1415 relay.submit(prompt.command_id.clone(), command).await?;
1416 }
1417 }
1418 }
1419 if let Some(worktree) = conversion
1423 .as_ref()
1424 .and_then(ResumeConversion::raw_to_workspace)
1425 .and_then(|plan| plan.retire.as_ref())
1426 && let Err(error) = retire_managed_worktree(executor, worktree)
1427 {
1428 tracing::warn!(
1429 session_id,
1430 worktree = %worktree.worktree_root.display(),
1431 error = format!("{error:#}"),
1432 "could not retire the old managed worktree after resume"
1433 );
1434 resume_notices.push(worktree_cleanup_notice(&worktree.worktree_root, &error));
1435 }
1436 for notice in &resume_notices {
1437 let submitted = async {
1438 let command_id = new_command_id("resume-notice")?;
1439 relay
1440 .submit(
1441 command_id,
1442 RelayCommand::RecordNotice {
1443 text: notice.clone(),
1444 },
1445 )
1446 .await
1447 }
1448 .await;
1449 if let Err(error) = submitted {
1452 tracing::warn!(
1453 session_id,
1454 error = format!("{error:#}"),
1455 "could not record a resume notice in the conversation"
1456 );
1457 }
1458 }
1459 self.mark_worker_connected(session_id, Some(native_session_id))?;
1460 Ok::<_, anyhow::Error>(relay.sync().await?.materialized)
1461 }
1462 .await;
1463 match result {
1464 Ok(materialized) => {
1465 if let Some(written) = &conversion_checkpoint_written {
1468 super::checkpoint::prune_replaced_checkpoint(
1469 previous.checkpoint.as_ref(),
1470 written,
1471 );
1472 }
1473 Ok(materialized)
1474 }
1475 Err(error) => {
1476 if let Some(written) = &conversion_checkpoint_written
1479 && let Err(remove_error) = std::fs::remove_file(&written.archive_path)
1480 && remove_error.kind() != std::io::ErrorKind::NotFound
1481 {
1482 tracing::warn!(
1483 session_id,
1484 path = %written.archive_path.display(),
1485 "could not remove the conversion checkpoint after resume failed: {remove_error}"
1486 );
1487 }
1488 if rebuild_projection {
1493 match materialized_session_from_canonical(session_id, &canonical_session) {
1494 Ok(previous_projection) => {
1495 if let Err(restore_error) =
1496 crate::database::save_materialized_session(&previous_projection)
1497 {
1498 tracing::error!(
1499 session_id,
1500 error = format!("{restore_error:#}"),
1501 "could not restore the durable projection after resume failed"
1502 );
1503 }
1504 }
1505 Err(restore_error) => {
1506 tracing::error!(
1507 session_id,
1508 error = format!("{restore_error:#}"),
1509 "could not rebuild the durable projection after resume failed"
1510 );
1511 }
1512 }
1513 } else if discard_queued_prompts
1514 && let Err(restore_error) = crate::database::replace_materialized_queued_prompts(
1515 session_id,
1516 &mj_transcript::projection::materialized_queued_prompts_from_canonical(
1517 &canonical_session.queued_prompts,
1518 ),
1519 )
1520 {
1521 tracing::error!(
1522 session_id,
1523 error = format!("{restore_error:#}"),
1524 "could not restore queued prompts after resume failed"
1525 );
1526 }
1527 Err(self.rollback_failed_resume(
1528 session_id,
1529 &previous,
1530 recreated_managed_worktree,
1531 error,
1532 executor,
1533 )?)
1534 }
1535 }
1536 }
1537
1538 pub(super) fn rollback_failed_resume(
1539 &mut self,
1540 session_id: &str,
1541 previous: &SessionRecord,
1542 recreated_managed_worktree: bool,
1543 error: anyhow::Error,
1544 _executor: &impl CommandExecutor,
1545 ) -> Result<anyhow::Error> {
1546 let current = self
1547 .state
1548 .sessions
1549 .get(session_id)
1550 .with_context(|| format!("unknown session {session_id}"))?
1551 .clone();
1552 let cleanup = match current.target.as_ref() {
1553 Some(locator) => (|| -> Result<()> {
1554 let backend = backend_locator(locator, ¤t, &self.config)?;
1555 targets::close_plan(&backend, session_id)?
1556 .execute(&CancellableProcessExecutor::with_timeout(
1559 Duration::from_secs(15),
1560 ))
1561 .map(|_| ())
1562 })(),
1563 None => Ok(()),
1564 };
1565 let worktree_cleanup = if cleanup.is_err() {
1568 Ok(())
1569 } else {
1570 match (
1571 current.managed_worktree.as_ref(),
1572 previous.managed_worktree.as_ref(),
1573 ) {
1574 (_, Some(previous)) if recreated_managed_worktree => retire_managed_worktree(
1575 &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1576 previous,
1577 ),
1578 (Some(current), Some(previous)) if current == previous => Ok(()),
1579 (Some(worktree), _) => cleanup_managed_worktree(
1580 &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1581 worktree,
1582 ),
1583 (None, _) => Ok(()),
1584 }
1585 };
1586 let cleanup_error = [cleanup, worktree_cleanup]
1587 .into_iter()
1588 .filter_map(Result::err)
1589 .map(|cleanup_error| format!("{cleanup_error:#}"))
1590 .collect::<Vec<_>>()
1591 .join("; ");
1592 if !cleanup_error.is_empty() {
1593 tracing::warn!(
1594 session_id,
1595 error = %cleanup_error,
1596 "resume rollback cleanup reported failures"
1597 );
1598 }
1599 let original = format!("{error:#}");
1600 let record = self.state.sessions.get_mut(session_id).unwrap();
1601 let failure = apply_failed_resume_rollback(
1602 record,
1603 previous,
1604 &original,
1605 (!cleanup_error.is_empty()).then_some(cleanup_error),
1606 );
1607 if record.bundle_id != current.bundle_id {
1610 let bundle_id = record.bundle_id.clone();
1611 crate::database::rebind_session_bundle(session_id, &bundle_id)?;
1612 }
1613 crate::database::save_session(&self.state.sessions[session_id])?;
1616 Ok(failure)
1617 }
1618}
1619
1620fn worktree_cleanup_notice(worktree_root: &Path, error: &anyhow::Error) -> String {
1621 format!(
1622 "Mjolnir could not remove the worktree at {}: {error:#}. Remove it with `git worktree remove --force {}`.",
1623 worktree_root.display(),
1624 worktree_root.display()
1625 )
1626}
1627
1628pub(super) fn apply_failed_resume_rollback(
1629 current: &mut SessionRecord,
1630 previous: &SessionRecord,
1631 original_error: &str,
1632 cleanup_error: Option<String>,
1633) -> anyhow::Error {
1634 match cleanup_error {
1635 None => {
1636 *current = previous.clone();
1637 current.state = SessionState::Stopped;
1638 current.target = None;
1639 current.updated_at = now();
1640 current.last_error = Some(format!("resume failed: {original_error}"));
1641 anyhow::anyhow!(original_error.to_owned())
1642 }
1643 Some(cleanup_error) => {
1644 let failure = format!(
1645 "{original_error}; cleanup of the partial resume target failed: {cleanup_error}"
1646 );
1647 if current.managed_worktree.is_none() {
1652 current
1653 .project_directory
1654 .clone_from(&previous.project_directory);
1655 current
1656 .managed_worktree
1657 .clone_from(&previous.managed_worktree);
1658 current.bundle_id.clone_from(&previous.bundle_id);
1659 }
1660 current.state = SessionState::Error;
1661 current.updated_at = now();
1662 current.last_error = Some(format!("resume failed: {failure}"));
1663 anyhow::anyhow!(failure)
1664 }
1665 }
1666}
1667
1668fn conversion_notice(
1670 target_id: &str,
1671 checkout: &Path,
1672 branch: Option<&str>,
1673 retire: Option<&mj_core::state::ManagedWorktree>,
1674) -> String {
1675 let branch = branch.unwrap_or("a detached head");
1676 match retire {
1677 Some(worktree) => format!(
1678 "This session moved out of {} and into the {target_id} target, where its checkout is on {branch}. Its branch {} stays in {}.",
1679 checkout.display(),
1680 worktree.branch,
1681 worktree.source_repository.display()
1682 ),
1683 None => format!(
1684 "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.",
1685 checkout.display()
1686 ),
1687 }
1688}
1689
1690fn conversion_checkpoint(
1698 previous_archive: &Path,
1699 snapshot: mj_checkpoint::archive::RepositorySnapshot,
1700 output: &Path,
1701) -> Result<mj_core::state::CheckpointMetadata> {
1702 let previous = mj_checkpoint::archive::read_archive_verified(previous_archive)
1703 .with_context(|| format!("read checkpoint archive {}", previous_archive.display()))?;
1704 let native_artifacts = previous
1707 .manifest
1708 .payloads
1709 .iter()
1710 .filter_map(|descriptor| match &descriptor.role {
1711 mj_checkpoint::archive::PayloadRole::NativeArtifact { relative_path } => {
1712 Some((relative_path, descriptor))
1713 }
1714 _ => None,
1715 })
1716 .map(|(relative_path, descriptor)| {
1717 Ok(mj_checkpoint::archive::NativeArtifact {
1718 relative_path: relative_path.clone(),
1719 data: previous.payload(descriptor)?.to_vec(),
1720 mode: descriptor.mode,
1721 })
1722 })
1723 .collect::<Result<Vec<_>>>()?;
1724 let canonical_session = previous.canonical_session()?;
1725 let event_frontier = canonical_session.event_frontier;
1726 let written = mj_checkpoint::archive::write_archive_atomic(
1727 output,
1728 &mj_checkpoint::archive::ArchiveInput {
1729 session: previous.manifest.session.clone(),
1730 target: previous.manifest.target.clone(),
1733 bundle: mj_checkpoint::archive::BundleManifest {
1734 id: previous.manifest.bundle.id.clone(),
1735 primary_repository: snapshot.metadata.id.clone(),
1739 },
1740 canonical_session,
1741 native_artifacts,
1742 repositories: vec![snapshot],
1743 },
1744 )
1745 .with_context(|| format!("write the conversion archive {}", output.display()))?;
1746 Ok(mj_core::state::CheckpointMetadata {
1747 archive_path: output.to_path_buf(),
1748 sha256: written.archive_sha256,
1749 created_at: now(),
1750 event_frontier,
1751 })
1752}
1753
1754fn projection_rebuild_required(
1762 stored: Option<(u64, &str)>,
1763 archive_frontier: u64,
1764 archive_frontier_digest: &str,
1765) -> bool {
1766 stored != Some((archive_frontier, archive_frontier_digest))
1767}
1768
1769fn restore_archive_path(
1770 backend: &targets::TargetLocator,
1771 verified_archive: &Path,
1772 remote_archive: &Path,
1773) -> PathBuf {
1774 if matches!(backend, targets::TargetLocator::LocalBare { .. }) {
1775 verified_archive.to_path_buf()
1776 } else {
1777 remote_archive.to_path_buf()
1778 }
1779}
1780
1781fn should_upload_restore_archive(backend: &targets::TargetLocator) -> bool {
1782 !matches!(backend, targets::TargetLocator::LocalBare { .. })
1783}
1784
1785struct CrossHarnessProvisionExecutor<'a, E: CommandExecutor + ?Sized> {
1790 inner: &'a E,
1791 cancellation: CancellationToken,
1792}
1793
1794impl<E: CommandExecutor + ?Sized> CommandExecutor for CrossHarnessProvisionExecutor<'_, E> {
1795 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1796 if self.cancellation.is_cancelled() {
1797 bail!("operation cancelled while provisioning destination");
1798 }
1799 self.inner.execute(command)
1800 }
1801
1802 fn cancellation_requested(&self) -> bool {
1803 self.cancellation.is_cancelled() || self.inner.cancellation_requested()
1804 }
1805
1806 fn stage_started(&self, stage: ProvisionStage) {
1807 self.inner.stage_started(stage);
1808 }
1809
1810 fn stage_finished(&self, stage: ProvisionStage) {
1811 self.inner.stage_finished(stage);
1812 }
1813
1814 fn notify_notice(&self, notice: &str) {
1815 self.inner.notify_notice(notice);
1816 }
1817
1818 fn execute_with_stdin(
1819 &self,
1820 command: &CommandSpec,
1821 input: &mut (dyn std::io::Read + Send),
1822 ) -> Result<CommandOutput> {
1823 if self.cancellation.is_cancelled() {
1824 bail!("operation cancelled while provisioning destination");
1825 }
1826 self.inner.execute_with_stdin(command, input)
1827 }
1828}
1829
1830fn provision_with_cross_harness_handoff(
1831 controller: &mut Controller,
1832 session_id: &str,
1833 executor: &(impl CommandExecutor + Sync),
1834 github_token: Option<&str>,
1835 config: &Config,
1836 snapshot: &CanonicalSessionSnapshot,
1837 context_bytes: usize,
1838) -> Result<String> {
1839 let (_provision, handoff) = execute_joined_cross_harness_work(
1840 "cross-harness provisioning",
1841 move |cancellation| {
1842 let provision_executor = CrossHarnessProvisionExecutor {
1843 inner: executor,
1844 cancellation,
1845 };
1846 futures::executor::block_on(controller.provision_session_with_failure_disposition(
1847 session_id,
1848 &provision_executor,
1849 github_token,
1850 ProvisioningFailureDisposition::Preserve,
1851 ))
1852 },
1853 "cross-harness handoff",
1854 move |cancellation| {
1855 let runtime = tokio::runtime::Builder::new_current_thread()
1856 .enable_all()
1857 .build()
1858 .context("create cross-harness handoff runtime")?;
1859 runtime.block_on(utility_handoff_while_cancellable(
1860 session_id,
1861 config,
1862 snapshot,
1863 context_bytes,
1864 executor,
1865 cancellation,
1866 ))
1867 },
1868 )?;
1869 ensure!(
1870 !executor.cancellation_requested(),
1871 "operation cancelled while provisioning destination"
1872 );
1873 Ok(handoff)
1874}
1875
1876fn execute_joined_cross_harness_work<A: Send, B: Send>(
1880 first_name: &'static str,
1881 first: impl FnOnce(CancellationToken) -> Result<A> + Send,
1882 second_name: &'static str,
1883 second: impl FnOnce(CancellationToken) -> Result<B> + Send,
1884) -> Result<(A, B)> {
1885 let cancellation = CancellationToken::new();
1886 std::thread::scope(|scope| {
1887 let first_cancel = cancellation.clone();
1888 let mut first_handle = Some(scope.spawn(move || first(first_cancel)));
1889 let second_cancel = cancellation.clone();
1890 let mut second_handle = Some(scope.spawn(move || second(second_cancel)));
1891 let mut first_result = None;
1892 let mut second_result = None;
1893
1894 while first_result.is_none() || second_result.is_none() {
1895 if first_result.is_none()
1896 && first_handle
1897 .as_ref()
1898 .is_some_and(|handle| handle.is_finished())
1899 {
1900 let handle = first_handle.take().expect("first lane handle present");
1901 first_result = Some(match handle.join() {
1902 Ok(result) => result,
1903 Err(panic) => {
1904 cancellation.cancel();
1905 Err(anyhow::anyhow!(
1906 "{first_name} thread panicked: {}",
1907 targets::command_thread_panic_message(panic.as_ref())
1908 ))
1909 }
1910 });
1911 if first_result.as_ref().is_some_and(Result::is_err) {
1912 cancellation.cancel();
1913 }
1914 }
1915 if second_result.is_none()
1916 && second_handle
1917 .as_ref()
1918 .is_some_and(|handle| handle.is_finished())
1919 {
1920 let handle = second_handle.take().expect("second lane handle present");
1921 second_result = Some(match handle.join() {
1922 Ok(result) => result,
1923 Err(panic) => {
1924 cancellation.cancel();
1925 Err(anyhow::anyhow!(
1926 "{second_name} thread panicked: {}",
1927 targets::command_thread_panic_message(panic.as_ref())
1928 ))
1929 }
1930 });
1931 if second_result.as_ref().is_some_and(Result::is_err) {
1932 cancellation.cancel();
1933 }
1934 }
1935 if first_result.is_none() || second_result.is_none() {
1936 std::thread::sleep(Duration::from_millis(10));
1937 }
1938 }
1939
1940 match (
1941 first_result.expect("first lane result received after joined handle"),
1942 second_result.expect("second lane result received after joined handle"),
1943 ) {
1944 (Err(first), Err(second)) => {
1945 Err(first.context(format!("{second_name} lane also failed: {second:#}")))
1946 }
1947 (Err(error), Ok(_)) => Err(error),
1948 (Ok(_), Err(error)) => Err(error),
1949 (Ok(first), Ok(second)) => Ok((first, second)),
1950 }
1951 })
1952}
1953
1954fn native_continuity_preserved(profile_kind: HarnessKind, archived_kind: HarnessKind) -> bool {
1958 profile_kind == archived_kind
1959}
1960
1961async fn utility_handoff_while_cancellable(
1965 session_id: &str,
1966 config: &Config,
1967 snapshot: &CanonicalSessionSnapshot,
1968 context_bytes: usize,
1969 executor: &impl CommandExecutor,
1970 cancellation: CancellationToken,
1971) -> Result<String> {
1972 let _phase = ResumePhaseTimer::new(session_id, "cross-harness handoff");
1973 if executor.cancellation_requested() {
1974 bail!("operation cancelled while compacting the cross-harness handoff");
1975 }
1976 let _compacting = ProvisionStageGuard::new(executor, ProvisionStage::Compacting);
1977 let cancel = cancellation.child_token();
1978 let operation =
1979 crate::handoff::build_handoff_context(session_id, config, snapshot, context_bytes, &cancel);
1980 tokio::pin!(operation);
1981 loop {
1982 tokio::select! {
1983 context = &mut operation => return context,
1984 _ = cancellation.cancelled() => {
1985 cancel.cancel();
1986 bail!("operation cancelled while compacting the cross-harness handoff");
1987 }
1988 _ = tokio::time::sleep(super::readiness::CANCELLATION_POLL_INTERVAL) => {
1989 if executor.cancellation_requested() {
1990 cancel.cancel();
1991 bail!("operation cancelled while compacting the cross-harness handoff");
1992 }
1993 }
1994 }
1995 }
1996}
1997
1998#[cfg(test)]
1999mod tests;