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