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 serde::{Deserialize, Serialize};
11use tokio_util::sync::CancellationToken;
12
13use crate::hel_session_manager::new_command_id;
14use hel::hel_archive::{
15 CanonicalQueuedCommandKind, CanonicalSessionSnapshot, CheckpointRepositoryBundle, SystemGit,
16 checkpoint_bundle_prerequisites, read_checkpoint_repository_bundles, verify_archive_streaming,
17};
18use hel::hel_checkpoint::{CheckpointRestoreSpec, restore_command};
19use hel::hel_config::{HarnessKind, HelConfig, ProjectRepository, mount_history_host};
20use hel::hel_projection::materialized_session_from_canonical;
21use hel::hel_state::{MaterializedSession, SessionRecord, SessionResourceAllocation, SessionState};
22use hel::hel_targets::{
23 self, AdditionalMount, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec,
24 ProcessExecutor, ProvisionStage, ProvisionStageGuard,
25};
26use hel::hel_worker::RelayCommand;
27
28use super::backend::{backend_locator, controller_github_token, validate_resource_allocation};
29use super::checkpoint::upload_checkpoint_spec;
30use super::provisioning::{
31 ProvisioningFailureDisposition, StagedExecutor, execute_concurrent_lanes,
32 install_attached_resources,
33};
34use super::readiness::{connect_started_worker, wait_for_native_session_in_stage};
35use super::worker_binary::{bridge_readiness_stage, start_worker, worker_probe_diagnosis};
36use super::worktree::{
37 PrimaryCheckoutRequirement, ResumeConversion, ResumePlan, apply_raw_to_workspace,
38 apply_workspace_to_raw, cleanup_managed_worktree, create_managed_worktree,
39 managed_worktree_checkout_exists, plan_raw_to_workspace,
40 preserve_retained_managed_worktree_branch, raw_checkout_divergence_notice,
41 raw_checkout_position, restore_managed_worktree, resume_compatibility, retire_managed_worktree,
42};
43use super::{
44 Controller, SessionResumeOptions, execute_checked, now, selected_host_container_size,
45 target_profile_home,
46};
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ResumeRepositorySourceMismatch {
50 pub session_id: String,
51 pub bundle_id: String,
52 pub repository_id: String,
53 pub missing_commit: String,
54 pub archived_origin: String,
55 pub configured_origin: String,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct ResumeRepositorySourceReceipt {
61 session_id: String,
62 bundle_id: String,
63 checkpoint_sha256: String,
64 repositories: Vec<ProjectRepository>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum ResumeRepositorySourcePreflight {
69 Ready(ResumeRepositorySourceReceipt),
70 RepositoryMoved(ResumeRepositorySourceMismatch),
71}
72
73struct ResumeRepositoryBundles {
74 checkpoint_sha256: String,
75 repositories: Vec<CheckpointRepositoryBundle>,
76}
77
78struct ResumePhaseTimer<'a> {
82 session_id: &'a str,
83 phase: &'static str,
84 started: Instant,
85}
86
87impl<'a> ResumePhaseTimer<'a> {
88 fn new(session_id: &'a str, phase: &'static str) -> Self {
89 Self {
90 session_id,
91 phase,
92 started: Instant::now(),
93 }
94 }
95}
96
97impl Drop for ResumePhaseTimer<'_> {
98 fn drop(&mut self) {
99 tracing::debug!(
100 session_id = self.session_id,
101 phase = self.phase,
102 elapsed_ms = self.started.elapsed().as_millis(),
103 "resume phase completed"
104 );
105 }
106}
107
108impl Controller {
109 pub(super) fn validate_muse_resume_destination(
111 &self,
112 source: &SessionRecord,
113 destination_harness: HarnessKind,
114 _target_id: &str,
115 ) -> Result<()> {
116 if destination_harness != HarnessKind::Muse {
117 return Ok(());
118 }
119 ensure!(
120 source.project_directory.is_some()
121 || self
122 .config
123 .bundles
124 .get(&source.bundle_id)
125 .is_none_or(|bundle| bundle.repositories.len() == 1),
126 "Muse Code ACP supports one workspace root; use a single-repository bundle"
127 );
128 Ok(())
129 }
130 pub fn preflight_resume_repository_sources(
133 &self,
134 session_id: &str,
135 target_id: &str,
136 executor: &(impl CommandExecutor + Sync),
137 ) -> Result<ResumeRepositorySourcePreflight> {
138 let session = self
139 .state
140 .sessions
141 .get(session_id)
142 .with_context(|| format!("unknown session {session_id}"))?;
143 let checkpoint = session
144 .checkpoint
145 .as_ref()
146 .context("session has no checkpoint")?;
147 let plan = resume_compatibility(session, &self.config, target_id)
148 .map_err(|reason| anyhow::anyhow!(reason))?;
149 if session.project_directory.is_some() {
150 debug_assert!(matches!(
151 plan,
152 ResumePlan::InPlace | ResumePlan::RawToWorkspace
153 ));
154 return Ok(ResumeRepositorySourcePreflight::Ready(
159 ResumeRepositorySourceReceipt {
160 session_id: session_id.to_owned(),
161 bundle_id: session.bundle_id.clone(),
162 checkpoint_sha256: checkpoint.sha256.clone(),
163 repositories: Vec::new(),
164 },
165 ));
166 }
167 let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
168 self.preflight_verified_repository_sources(
169 session_id,
170 ResumeRepositoryBundles {
171 checkpoint_sha256: checkpoint.sha256.clone(),
172 repositories,
173 },
174 None,
175 plan != ResumePlan::WorkspaceToRaw,
176 executor,
177 )
178 }
179
180 fn preflight_verified_repository_sources(
181 &self,
182 session_id: &str,
183 verified: ResumeRepositoryBundles,
184 skip_repository_id: Option<&str>,
185 use_archived_network_sources: bool,
186 executor: &(impl CommandExecutor + Sync),
187 ) -> Result<ResumeRepositorySourcePreflight> {
188 let session = self
189 .state
190 .sessions
191 .get(session_id)
192 .with_context(|| format!("unknown session {session_id}"))?;
193 ensure!(
194 verified.repositories.iter().all(|repository| {
195 !repository.metadata.origin.starts_with("mj-local:")
196 && !repository.metadata.origin.starts_with("ext::")
197 }),
198 "resuming legacy host-bridge sessions is not supported; start a new network-backed session"
199 );
200 if use_archived_network_sources
203 && verified
204 .repositories
205 .iter()
206 .all(|repository| repository.metadata.remote_workspace)
207 {
208 return Ok(ResumeRepositorySourcePreflight::Ready(
209 ResumeRepositorySourceReceipt {
210 session_id: session_id.to_owned(),
211 bundle_id: session.bundle_id.clone(),
212 checkpoint_sha256: verified.checkpoint_sha256,
213 repositories: Vec::new(),
214 },
215 ));
216 }
217 if verified.repositories.is_empty() {
218 return Ok(ResumeRepositorySourcePreflight::Ready(
219 ResumeRepositorySourceReceipt {
220 session_id: session_id.to_owned(),
221 bundle_id: session.bundle_id.clone(),
222 checkpoint_sha256: verified.checkpoint_sha256,
223 repositories: Vec::new(),
224 },
225 ));
226 }
227 let bundle = self
228 .config
229 .bundles
230 .get(&session.bundle_id)
231 .with_context(|| format!("session bundle {:?} is missing", session.bundle_id))?;
232 let configured = verified
233 .repositories
234 .iter()
235 .map(|archived| {
236 bundle
237 .repositories
238 .iter()
239 .find(|repository| repository.id == archived.metadata.id)
240 .cloned()
241 .with_context(|| {
242 format!(
243 "session bundle {:?} no longer contains repository {:?}",
244 session.bundle_id, archived.metadata.id
245 )
246 })
247 })
248 .collect::<Result<Vec<_>>>()?;
249 let github_token = configured
250 .iter()
251 .any(|repository| repository.github.is_some())
252 .then(controller_github_token)
253 .flatten();
254 let outcomes = verified
255 .repositories
256 .par_iter()
257 .zip(configured.par_iter())
258 .map(|(archived, configured)| {
259 if skip_repository_id == Some(configured.id.as_str()) {
260 return Ok(None);
261 }
262 checkpoint_source_missing_commit(
263 configured,
264 archived,
265 executor,
266 github_token.as_deref(),
267 )
268 .map(|missing_commit| {
269 missing_commit.map(|missing_commit| ResumeRepositorySourceMismatch {
270 session_id: session_id.to_owned(),
271 bundle_id: session.bundle_id.clone(),
272 repository_id: configured.id.clone(),
273 missing_commit,
274 archived_origin: archived.metadata.origin.clone(),
275 configured_origin: configured.source_label(),
276 })
277 })
278 })
279 .collect::<Vec<Result<Option<ResumeRepositorySourceMismatch>>>>();
280 for outcome in outcomes {
281 if let Some(mismatch) = outcome? {
282 return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(mismatch));
283 }
284 }
285 Ok(ResumeRepositorySourcePreflight::Ready(
286 ResumeRepositorySourceReceipt {
287 session_id: session_id.to_owned(),
288 bundle_id: session.bundle_id.clone(),
289 checkpoint_sha256: verified.checkpoint_sha256,
290 repositories: configured,
291 },
292 ))
293 }
294
295 fn repository_source_receipt_is_current(
296 &self,
297 session_id: &str,
298 receipt: &ResumeRepositorySourceReceipt,
299 ) -> bool {
300 let Some(session) = self.state.sessions.get(session_id) else {
301 return false;
302 };
303 if receipt.session_id != session_id
304 || receipt.bundle_id != session.bundle_id
305 || session
306 .checkpoint
307 .as_ref()
308 .map(|checkpoint| &checkpoint.sha256)
309 != Some(&receipt.checkpoint_sha256)
310 {
311 return false;
312 }
313 if receipt.repositories.is_empty() {
314 return true;
315 }
316 let Some(bundle) = self.config.bundles.get(&session.bundle_id) else {
317 return false;
318 };
319 receipt.repositories.iter().all(|expected| {
320 bundle
321 .repositories
322 .iter()
323 .any(|configured| configured == expected)
324 })
325 }
326
327 pub fn replace_resume_repository_origin(
331 &mut self,
332 session_id: &str,
333 repository_id: &str,
334 replacement: &str,
335 executor: &(impl CommandExecutor + Sync),
336 ) -> Result<ResumeRepositorySourcePreflight> {
337 let session = self
338 .state
339 .sessions
340 .get(session_id)
341 .with_context(|| format!("unknown session {session_id}"))?;
342 let bundle_id = session.bundle_id.clone();
343 let checkpoint = session
344 .checkpoint
345 .as_ref()
346 .context("session has no checkpoint")?;
347 let replacement = replacement_repository_source(repository_id, replacement)?;
348 let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
349 let verified = ResumeRepositoryBundles {
350 checkpoint_sha256: checkpoint.sha256.clone(),
351 repositories,
352 };
353 let archived = verified
354 .repositories
355 .iter()
356 .find(|repository| repository.metadata.id == repository_id)
357 .with_context(|| format!("checkpoint does not contain repository {repository_id:?}"))?;
358 if let Some(missing_commit) = checkpoint_source_missing_commit(
359 &replacement,
360 archived,
361 executor,
362 controller_github_token().as_deref(),
363 )? {
364 return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(
365 ResumeRepositorySourceMismatch {
366 session_id: session_id.to_owned(),
367 bundle_id,
368 repository_id: repository_id.to_owned(),
369 missing_commit,
370 archived_origin: archived.metadata.origin.clone(),
371 configured_origin: replacement.source_label(),
372 },
373 ));
374 }
375 let (config, ()) = HelConfig::update(|config| {
376 let bundle = config
377 .bundles
378 .get_mut(&bundle_id)
379 .with_context(|| format!("session bundle {bundle_id:?} is missing"))?;
380 let repository = bundle
381 .repositories
382 .iter_mut()
383 .find(|repository| repository.id == repository_id)
384 .with_context(|| {
385 format!(
386 "session bundle {:?} no longer contains repository {repository_id:?}",
387 bundle_id
388 )
389 })?;
390 repository.github = replacement.github.clone();
391 repository.local = replacement.local.clone();
392 Ok(())
393 })?;
394 self.config = config;
395 self.preflight_verified_repository_sources(
396 session_id,
397 verified,
398 Some(repository_id),
399 false,
400 executor,
401 )
402 }
403}
404
405fn replacement_repository_source(id: &str, replacement: &str) -> Result<ProjectRepository> {
406 let replacement = replacement.trim();
407 ensure!(!replacement.is_empty(), "enter the repository's new origin");
408 let path = Path::new(replacement);
409 let (github, local) = if path.is_absolute() {
410 ensure!(
411 path.is_dir(),
412 "local repository {replacement:?} is not a directory"
413 );
414 (None, Some(hel::hel_local_git::canonical_repository(path)?))
415 } else {
416 let github = crate::hel_setup::github_repository_from_origin(replacement)
417 .context("origin must be a GitHub repository or an absolute local repository path")?;
418 (
419 Some(format!("{}/{}", github.owner, github.repository)),
420 None,
421 )
422 };
423 Ok(ProjectRepository {
424 id: id.to_owned(),
425 github,
426 local,
427 destination: PathBuf::from(id),
428 git_ref: None,
429 })
430}
431
432fn checkpoint_source_missing_commit(
433 configured: &ProjectRepository,
434 archived: &CheckpointRepositoryBundle,
435 executor: &impl CommandExecutor,
436 github_token: Option<&str>,
437) -> Result<Option<String>> {
438 let staging = tempfile::tempdir().context("create repository source preflight")?;
439 let repository = staging.path().join("repository.git");
440 checked_preflight_git(
441 executor,
442 CommandSpec::new(
443 "git",
444 [
445 "init".to_owned(),
446 "--bare".to_owned(),
447 "--quiet".to_owned(),
448 repository.to_string_lossy().into_owned(),
449 ],
450 )
451 .purpose("initialize repository source preflight"),
452 )?;
453 let missing = checkpoint_bundle_prerequisites(archived)?;
454 if missing.is_empty() {
455 let bundle = staging.path().join("checkpoint.bundle");
456 std::fs::write(&bundle, &archived.committed_bundle)
457 .context("write self-contained checkpoint bundle for source preflight")?;
458 checked_preflight_git(
459 executor,
460 checkpoint_bundle_import_command(&repository, &bundle),
461 )?;
462 return Ok(None);
463 }
464 for commit in missing {
468 let output = fetch_source_commit(executor, &repository, configured, &commit, github_token)?;
469 if output.status != 0 {
470 let stderr = String::from_utf8_lossy(&output.stderr);
471 if source_does_not_have_commit(&stderr) {
472 return Ok(Some(commit));
473 }
474 bail!(
475 "could not check configured source {:?}: {}",
476 configured.source_label(),
477 stderr.trim()
478 );
479 }
480 }
481 Ok(None)
485}
486
487fn checkpoint_bundle_import_command(repository: &Path, bundle: &Path) -> CommandSpec {
488 let mut command = CommandSpec::new(
489 "git",
490 [
491 "-C".to_owned(),
492 repository.to_string_lossy().into_owned(),
493 "fetch".to_owned(),
494 "--no-tags".to_owned(),
495 bundle.to_string_lossy().into_owned(),
496 "HEAD".to_owned(),
497 ],
498 )
499 .purpose("validate self-contained checkpoint bundle");
500 command
501 .env
502 .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
503 command
504 .env
505 .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
506 command
507}
508
509fn fetch_source_commit(
510 executor: &impl CommandExecutor,
511 repository: &Path,
512 configured: &ProjectRepository,
513 commit: &str,
514 github_token: Option<&str>,
515) -> Result<CommandOutput> {
516 let mut arguments = Vec::new();
517 let mut token_auth = false;
518 let mut ssh_transport = false;
519 let source = if let Some(local) = &configured.local {
520 local.to_string_lossy().into_owned()
521 } else {
522 let source = configured
523 .github
524 .as_deref()
525 .context("repository source is missing")?;
526 let github = crate::hel_setup::github_repository_from_origin(source)
527 .context("configured repository is not a GitHub source")?;
528 if github_token.is_some() {
529 token_auth = true;
530 arguments.extend([
531 "-c".to_owned(),
532 "credential.helper=".to_owned(),
533 "-c".to_owned(),
534 "credential.helper=!f() { if [ \"$1\" = get ]; then echo username=x-access-token; echo \"password=$GH_TOKEN\"; fi; }; f".to_owned(),
535 ]);
536 format!(
537 "https://github.com/{}/{}.git",
538 github.owner, github.repository
539 )
540 } else {
541 ssh_transport = true;
542 format!("git@github.com:{}/{}.git", github.owner, github.repository)
543 }
544 };
545 arguments.extend([
546 "-C".to_owned(),
547 repository.to_string_lossy().into_owned(),
548 "fetch".to_owned(),
549 "--no-tags".to_owned(),
550 "--depth=1".to_owned(),
551 "--filter=blob:none".to_owned(),
552 source,
553 commit.to_owned(),
554 ]);
555 let mut command = CommandSpec::new("git", arguments).purpose("check checkpoint base commit");
556 command
557 .env
558 .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
559 command
560 .env
561 .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
562 if token_auth {
563 let token = github_token.expect("token authentication requires a GitHub token");
564 command.env.insert("GH_TOKEN".to_owned(), token.to_owned());
565 }
566 if ssh_transport {
567 command.env.insert(
568 "GIT_SSH_COMMAND".to_owned(),
569 "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15"
570 .to_owned(),
571 );
572 }
573 executor.execute(&command)
574}
575
576fn source_does_not_have_commit(stderr: &str) -> bool {
577 let stderr = stderr.to_ascii_lowercase();
578 [
579 "not our ref",
580 "couldn't find remote ref",
581 "not a valid object name",
582 "no such ref was fetched",
583 ]
584 .iter()
585 .any(|needle| stderr.contains(needle))
586}
587
588fn checked_preflight_git(
589 executor: &impl CommandExecutor,
590 command: CommandSpec,
591) -> Result<CommandOutput> {
592 let output = executor.execute(&command)?;
593 ensure!(
594 output.status == 0,
595 "{}: {}",
596 command.purpose,
597 String::from_utf8_lossy(&output.stderr).trim()
598 );
599 Ok(output)
600}
601
602impl Controller {
603 pub async fn resume_session_with_options(
608 &mut self,
609 session_id: &str,
610 profile_id: &str,
611 target_id: &str,
612 additional_mounts: Option<Vec<AdditionalMount>>,
613 resource_allocation: Option<SessionResourceAllocation>,
614 ) -> Result<MaterializedSession> {
615 self.resume_session_with_options_and_queue_disposition(
616 session_id,
617 profile_id,
618 target_id,
619 additional_mounts,
620 resource_allocation,
621 false,
622 )
623 .await
624 }
625
626 pub async fn resume_session_with_options_and_queue_disposition(
627 &mut self,
628 session_id: &str,
629 profile_id: &str,
630 target_id: &str,
631 additional_mounts: Option<Vec<AdditionalMount>>,
632 resource_allocation: Option<SessionResourceAllocation>,
633 discard_queue: bool,
634 ) -> Result<MaterializedSession> {
635 self.resume_session_controlled(
636 session_id,
637 profile_id,
638 target_id,
639 SessionResumeOptions {
640 additional_mounts,
641 resource_allocation,
642 discard_queue,
643 },
644 &ProcessExecutor,
645 )
646 .await
647 }
648
649 pub async fn resume_session_controlled(
650 &mut self,
651 session_id: &str,
652 profile_id: &str,
653 target_id: &str,
654 options: SessionResumeOptions,
655 executor: &(impl CommandExecutor + Sync),
656 ) -> Result<MaterializedSession> {
657 self.resume_session_controlled_with_repository_preflight(
658 session_id, profile_id, target_id, options, None, executor,
659 )
660 .await
661 }
662
663 pub async fn resume_session_controlled_with_repository_preflight(
664 &mut self,
665 session_id: &str,
666 profile_id: &str,
667 target_id: &str,
668 options: SessionResumeOptions,
669 repository_preflight: Option<ResumeRepositorySourceReceipt>,
670 executor: &(impl CommandExecutor + Sync),
671 ) -> Result<MaterializedSession> {
672 let SessionResumeOptions {
673 additional_mounts,
674 resource_allocation,
675 discard_queue,
676 } = options;
677 let previous = self
678 .state
679 .sessions
680 .get(session_id)
681 .with_context(|| format!("unknown session {session_id}"))?
682 .clone();
683 if !matches!(
684 previous.state,
685 SessionState::Stopped | SessionState::Lost | SessionState::Error
686 ) {
687 bail!("session {session_id} is not stopped, lost, or retryable");
688 }
689 let checkpoint = previous
690 .checkpoint
691 .as_ref()
692 .context("session has no checkpoint")?;
693 let moving_to_raw = previous.project_directory.is_none()
696 && self
697 .config
698 .targets
699 .get(target_id)
700 .is_some_and(hel::hel_config::is_bare_project_target);
701 if moving_to_raw
702 || !repository_preflight.as_ref().is_some_and(|receipt| {
703 self.repository_source_receipt_is_current(session_id, receipt)
704 })
705 {
706 let _phase = ResumePhaseTimer::new(session_id, "preflight repository sources");
707 if let ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) =
708 self.preflight_resume_repository_sources(session_id, target_id, executor)?
709 {
710 bail!(
711 "checkpoint base commit {} is missing from configured source {:?} for repository {:?}; the repository may have moved (archived origin: {:?})",
712 mismatch.missing_commit,
713 mismatch.configured_origin,
714 mismatch.repository_id,
715 mismatch.archived_origin,
716 );
717 }
718 }
719 let archive_path = {
724 let _phase = ResumePhaseTimer::new(session_id, "verify checkpoint archive");
725 checkpoint.archive_path.canonicalize().with_context(|| {
726 format!(
727 "resolve checkpoint archive {}",
728 checkpoint.archive_path.display()
729 )
730 })?
731 };
732 ensure!(
733 archive_path.is_absolute() && archive_path.is_file(),
734 "checkpoint archive path is not an absolute regular file: {}",
735 archive_path.display()
736 );
737 let hel::hel_archive::VerifiedArchiveMetadata {
741 manifest: archive_manifest,
742 canonical_session,
743 archive_sha256,
744 } = {
745 let _phase = ResumePhaseTimer::new(session_id, "verify checkpoint archive contents");
746 verify_archive_streaming(&archive_path)?
747 };
748 if archive_sha256 != checkpoint.sha256 || archive_manifest.session.id != session_id {
749 bail!("persisted checkpoint verification failed");
750 }
751 ensure!(
752 archive_manifest.repositories.iter().all(|repository| {
753 !repository.metadata.origin.starts_with("mj-local:")
754 && !repository.metadata.origin.starts_with("ext::")
755 }),
756 "resuming legacy host-bridge sessions is not supported; start a new network-backed session"
757 );
758 let canonical_session = Arc::new(canonical_session);
759 let profile = self
760 .config
761 .profiles
762 .get(profile_id)
763 .with_context(|| format!("unknown profile {profile_id:?}"))?
764 .clone();
765 let target_template = self
766 .config
767 .targets
768 .get(target_id)
769 .with_context(|| format!("unknown target template {target_id:?}"))?
770 .clone();
771 if !hel::hel_config::is_bare_project_target(&target_template) {
772 super::network_git::bundle_from_manifest(&archive_manifest)?;
773 }
774 self.validate_muse_resume_destination(&previous, profile.kind, target_id)?;
777 ensure!(
778 profile.kind != HarnessKind::Muse || previous.additional_mounts.is_empty(),
779 "Muse Code ACP supports one workspace root; attached directories are unsupported"
780 );
781 let plan = resume_compatibility(&previous, &self.config, target_id)
782 .map_err(|reason| anyhow::anyhow!("{reason}"))?;
783 if plan == ResumePlan::InPlace
784 && previous.managed_worktree.is_none()
785 && let Some(project_directory) = &previous.project_directory
786 {
787 self.validate_project_directory(target_id, project_directory, executor)
788 .context("raw project is unavailable for resume")?;
789 }
790 let conversion = match plan {
791 ResumePlan::InPlace => None,
792 ResumePlan::RawToWorkspace => Some(ResumeConversion::RawToWorkspace(
793 plan_raw_to_workspace(&previous, &self.config, executor)
794 .context("prepare the raw checkout for its new target")?,
795 )),
796 ResumePlan::WorkspaceToRaw => Some(ResumeConversion::WorkspaceToRaw(
797 self.plan_workspace_to_raw(&previous, target_id, executor)
798 .context("prepare a checkout for this session")?,
799 )),
800 };
801 let resource_allocation =
802 resource_allocation.or_else(|| previous.resource_allocation.clone());
803 let additional_mounts =
804 additional_mounts.unwrap_or_else(|| previous.additional_mounts.clone());
805 validate_resource_allocation(&target_template, resource_allocation.as_ref())?;
806 let selected_container_size =
807 selected_host_container_size(&target_template, resource_allocation.as_ref());
808 if !additional_mounts.is_empty() && mount_history_host(&target_template).is_none() {
809 bail!("attached resources are unsupported for this target");
810 }
811 hel_targets::validate_additional_mounts(&additional_mounts)?;
812 let history_host = mount_history_host(&target_template);
813 let history_mounts = additional_mounts.clone();
814 if previous.state == SessionState::Error
815 && let Some(locator) = &previous.target
816 {
817 let backend = backend_locator(locator, &previous, &self.config)?;
818 hel_targets::close_plan(&backend, session_id)?
819 .execute(executor)
820 .context("clean up target from failed resume")?;
821 }
822 let mut resume_notices = Vec::new();
823 if let Some(conversion) = conversion
824 .as_ref()
825 .and_then(ResumeConversion::raw_to_workspace)
826 && let Some(project_directory) = &previous.project_directory
827 {
828 resume_notices.push(match &conversion.retire {
829 Some(worktree) => format!(
830 "This session moved out of {} and into the {target_id} target. Its branch {} stays in {}.",
831 project_directory.display(),
832 worktree.branch,
833 worktree.source_repository.display()
834 ),
835 None => format!(
836 "This session moved out of {} and into the {target_id} target.",
837 project_directory.display()
838 ),
839 });
840 }
841 if let Some(conversion) = conversion
842 .as_ref()
843 .and_then(ResumeConversion::workspace_to_raw)
844 {
845 resume_notices.push(format!(
846 "This session moved out of its {} target and into {}. Its branch {} is now {}.",
847 previous.target_template_id,
848 conversion.worktree.worktree_root.display(),
849 archive_manifest
850 .repositories
851 .first()
852 .and_then(|repository| repository.metadata.branch.as_deref())
853 .unwrap_or("a detached head"),
854 conversion.worktree.branch,
855 ));
856 }
857 let managed_checkout_present = previous
858 .managed_worktree
859 .as_ref()
860 .map(|worktree| managed_worktree_checkout_exists(executor, worktree))
861 .transpose()?
862 .unwrap_or(true);
863 if managed_checkout_present && let Some(project_directory) = &previous.project_directory {
866 match raw_checkout_position(&previous, &self.config, project_directory, executor) {
867 Ok(live) => resume_notices.extend(raw_checkout_divergence_notice(
868 project_directory,
869 archive_manifest
870 .repositories
871 .first()
872 .map(|repository| &repository.metadata),
873 &live,
874 )),
875 Err(error) => tracing::warn!(
878 session_id,
879 error = format!("{error:#}"),
880 "could not read the raw checkout position for a resume notice"
881 ),
882 }
883 }
884 super::worker_binary::preflight_worker_binary(&target_template)?;
889 let same_harness = profile.kind == archive_manifest.session.harness_kind;
890 let context_bytes = profile
891 .context_window_bytes
892 .unwrap_or(crate::hel_compaction::DEFAULT_CONTEXT_BYTES);
893 let utility_config = (!same_harness).then(|| self.config.clone());
897 let discard_queued_prompts = discard_queue || !same_harness;
898 let stored_frontier = hel::hel_database::materialized_event_frontier(session_id)
902 .unwrap_or_else(|error| {
903 tracing::warn!(
904 session_id,
905 error = format!("{error:#}"),
906 "could not read the stored projection frontier; rebuilding it from the archive"
907 );
908 None
909 });
910 let rebuild_projection = projection_rebuild_required(
911 stored_frontier
912 .as_ref()
913 .map(|(ordinal, digest)| (*ordinal, digest.as_str())),
914 canonical_session.event_frontier,
915 &canonical_session.event_frontier_digest,
916 );
917 let projection_build = rebuild_projection.then(|| {
927 let canonical = Arc::clone(&canonical_session);
928 let session_id = session_id.to_owned();
929 tokio::task::spawn_blocking(move || {
930 materialized_session_from_canonical(session_id, &canonical)
931 })
932 });
933 let github_token = controller_github_token();
934
935 if let Some(conversion) = conversion
938 .as_ref()
939 .and_then(ResumeConversion::raw_to_workspace)
940 && let Some(bundle) = &conversion.new_bundle
941 {
942 let (config, ()) = HelConfig::update(|config| {
943 if let Some(existing) = config.bundles.get(&conversion.bundle_id) {
944 ensure!(
945 existing == bundle,
946 "bundle {:?} was configured concurrently with a different definition; retry the resume",
947 conversion.bundle_id
948 );
949 } else {
950 config
951 .bundles
952 .insert(conversion.bundle_id.clone(), bundle.clone());
953 }
954 Ok(())
955 })
956 .context("save the bundle for a converted raw session")?;
957 self.config = config;
958 }
959
960 let record = self.state.sessions.get_mut(session_id).unwrap();
961 record.harness_kind = profile.kind;
962 record.last_profile = profile_id.to_string();
963 record.target_template_id = target_id.to_string();
964 record.resource_allocation = resource_allocation;
965 record.additional_mounts = additional_mounts;
966 record.target = None;
967 record.native_session_id =
968 same_harness.then(|| archive_manifest.session.native_session_id.clone());
969 record.state = SessionState::Provisioning;
970 record.updated_at = now();
971 record.last_error = None;
972 match &conversion {
973 Some(ResumeConversion::RawToWorkspace(conversion)) => {
974 apply_raw_to_workspace(record, conversion);
975 }
976 Some(ResumeConversion::WorkspaceToRaw(conversion)) => {
977 apply_workspace_to_raw(record, conversion);
978 }
979 None => {}
980 }
981 let resumed_project_directory = record.project_directory.clone();
982 if let Some(host) = history_host {
983 self.state.remember_mount_sources(host, &history_mounts);
984 hel::hel_database::remember_mount_sources(host, &history_mounts)?;
985 }
986 if let Some(conversion) = conversion
989 .as_ref()
990 .and_then(ResumeConversion::raw_to_workspace)
991 {
992 hel::hel_database::rebind_session_bundle(session_id, &conversion.bundle_id)?;
993 }
994 if let Some((host, size)) = selected_container_size.as_ref() {
997 hel::hel_database::save_session_with_container_size(
998 &self.state.sessions[session_id],
999 host,
1000 *size,
1001 )?;
1002 } else {
1003 hel::hel_database::save_session(&self.state.sessions[session_id])?;
1004 }
1005 if let Some((host, size)) = selected_container_size {
1006 self.state.remember_container_size(&host, size);
1007 }
1008
1009 let mut recreated_managed_worktree = false;
1010 let result = async {
1011 if let Some(worktree) = previous.managed_worktree.as_ref() {
1012 recreated_managed_worktree = restore_managed_worktree(executor, worktree)?;
1013 if recreated_managed_worktree && plan == ResumePlan::RawToWorkspace {
1014 hel::hel_checkpoint::restore_single_repository_onto_branch(
1015 &archive_path,
1016 &worktree.worktree_root,
1017 &worktree.branch,
1018 &SystemGit,
1019 )
1020 .context("restore the retired checkout before moving it into a target")?;
1021 }
1022 }
1023 if let Some(conversion) = conversion
1026 .as_ref()
1027 .and_then(ResumeConversion::workspace_to_raw)
1028 {
1029 if conversion.reuse_existing_branch {
1030 let recovery_ref =
1031 preserve_retained_managed_worktree_branch(executor, &conversion.worktree)?;
1032 restore_managed_worktree(executor, &conversion.worktree)?;
1033 resume_notices.push(format!(
1034 "Before restoring this session's retained branch, Mjolnir preserved its tip at {recovery_ref}."
1035 ));
1036 } else {
1037 create_managed_worktree(
1038 executor,
1039 &conversion.worktree,
1040 None,
1041 PrimaryCheckoutRequirement::Any,
1042 )?;
1043 }
1044 hel::hel_checkpoint::restore_single_repository_onto_branch(
1045 &archive_path,
1046 &conversion.worktree.worktree_root,
1047 &conversion.worktree.branch,
1048 &SystemGit,
1049 )
1050 .context("restore this session's checkout")?;
1051 }
1052 let utility_handoff = {
1053 let _provisioning = ResumePhaseTimer::new(session_id, "provision destination");
1054 if let Some(config) = utility_config.as_ref() {
1055 Some(
1056 provision_with_cross_harness_handoff(
1057 self,
1058 session_id,
1059 executor,
1060 github_token.as_deref(),
1061 config,
1062 &canonical_session,
1063 context_bytes,
1064 )
1065 .context("prepare the cross-harness destination")?,
1066 )
1067 } else {
1068 self.provision_session_with_failure_disposition(
1069 session_id,
1070 executor,
1071 github_token.as_deref(),
1072 ProvisioningFailureDisposition::Preserve,
1073 )
1074 .await?;
1075 None
1076 }
1077 };
1078 let (backend, worker_root) = self.worker_placement(session_id)?;
1079 let harness_home = target_profile_home(&backend, session_id, &profile);
1080 let workspace_root = if let Some(project_directory) = &resumed_project_directory {
1081 project_directory
1082 .parent()
1083 .context("bare project directory has no parent")?
1084 .to_string_lossy()
1085 .into_owned()
1086 } else {
1087 match &backend {
1088 hel_targets::TargetLocator::LocalPodman { .. }
1089 | hel_targets::TargetLocator::LocalDocker { .. }
1090 | hel_targets::TargetLocator::AppleContainer { .. }
1091 | hel_targets::TargetLocator::SshPodman { .. }
1092 | hel_targets::TargetLocator::SshDocker { .. } => "/workspace".to_string(),
1093 hel_targets::TargetLocator::AwsEc2 { workspace, .. }
1094 | hel_targets::TargetLocator::SshBare { workspace, .. } => workspace.clone(),
1095 hel_targets::TargetLocator::LocalBare { worker_root } => worker_root.clone(),
1096 }
1097 };
1098 let target_path = |path: &str| match &backend {
1099 hel_targets::TargetLocator::AwsEc2 { .. }
1100 | hel_targets::TargetLocator::SshBare { .. }
1101 if !path.starts_with('/') =>
1102 {
1103 PathBuf::from(format!("~/{path}"))
1104 }
1105 _ => PathBuf::from(path),
1106 };
1107 let remote_archive = format!("{worker_root}/restore.hel.zip");
1108 let remote_spec = format!("{worker_root}/restore-spec.json");
1109 let restore = CheckpointRestoreSpec {
1110 archive_path: restore_archive_path(
1111 &backend,
1112 &archive_path,
1113 &target_path(&remote_archive),
1114 ),
1115 workspace_root: target_path(&workspace_root),
1116 relay_root: target_path(&worker_root),
1117 harness_home: target_path(&harness_home),
1118 restore_repositories: (resumed_project_directory.is_none() && conversion.is_none())
1122 || (recreated_managed_worktree && plan == ResumePlan::InPlace),
1123 restore_native: same_harness,
1124 primary_repository_root: conversion
1128 .is_some()
1129 .then(|| resumed_project_directory.clone())
1130 .flatten()
1131 .map(|directory| target_path(&directory.to_string_lossy())),
1132 discard_queued_prompts,
1133 };
1134 {
1141 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1142 if let Some(command) = hel_targets::clear_relay_state_plan(&backend, session_id)? {
1143 execute_checked(syncing, command)?;
1144 }
1145 execute_checked(
1147 syncing,
1148 hel_targets::command_on_locator(
1149 &backend,
1150 session_id,
1151 vec!["mkdir".into(), "-p".into(), worker_root.clone()],
1152 "create the session worker root",
1153 )?,
1154 )?;
1155 }
1156 let staging = tempfile::tempdir().context("create restore staging")?;
1157 let local_spec = staging.path().join("restore-spec.json");
1158 std::fs::write(&local_spec, serde_json::to_vec_pretty(&restore)?)?;
1159 let controller = &*self;
1163 let backend_ref = &backend;
1164 let worker_root_ref = worker_root.as_str();
1165 let local_spec_ref = local_spec.as_path();
1166 execute_concurrent_lanes(
1167 || {
1168 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1169 controller.prepare_worker_files(
1170 session_id,
1171 backend_ref,
1172 worker_root_ref,
1173 syncing,
1174 )?;
1175 super::provisioning::install_inherited_git_settings(
1176 syncing,
1177 backend_ref,
1178 session_id,
1179 )?;
1180 Ok(())
1181 },
1182 || {
1183 let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1184 if should_upload_restore_archive(&backend) {
1185 upload_checkpoint_spec(
1186 restoring,
1187 backend_ref,
1188 session_id,
1189 &archive_path,
1190 &remote_archive,
1191 )?;
1192 }
1193 upload_checkpoint_spec(
1194 restoring,
1195 backend_ref,
1196 session_id,
1197 local_spec_ref,
1198 &remote_spec,
1199 )
1200 },
1201 )?;
1202 {
1203 let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1204 execute_checked(
1205 restoring,
1206 restore_command(&backend, session_id, &remote_spec)?,
1207 )?;
1208 }
1209 {
1210 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1211 install_attached_resources(
1212 &self.state,
1213 session_id,
1214 &backend,
1215 &worker_root,
1216 syncing,
1217 )?;
1218
1219 }
1220 match projection_build {
1221 Some(build) => {
1222 let mut restored_projection = build
1223 .await
1224 .context("rebuild the restored projection")?
1225 .context("rebuild the restored projection")?;
1226 if discard_queued_prompts {
1227 restored_projection.queued_prompts.clear();
1228 }
1229 hel::hel_database::save_materialized_session(&restored_projection)?;
1230 }
1231 None if discard_queued_prompts => {
1234 hel::hel_database::replace_materialized_queued_prompts(session_id, &[])?;
1235 }
1236 None => {}
1237 }
1238 let readiness_stage = bridge_readiness_stage(&profile);
1239 let spec = self.reconnect_command(session_id)?;
1240 let readiness = async {
1241 let mut relay = {
1242 let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
1243 start_worker(executor, &backend, &worker_root)?;
1244 connect_started_worker(&spec, session_id, executor, &backend, &worker_root)
1245 .await?
1246 };
1247 let native_session_id =
1248 wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
1249 Ok::<_, anyhow::Error>((relay, native_session_id))
1250 }
1251 .await;
1252 let (mut relay, native_session_id) = readiness
1253 .map_err(|error| worker_probe_diagnosis(executor, &backend, &worker_root, error))?;
1254 if same_harness {
1255 if native_session_id != archive_manifest.session.native_session_id {
1256 bail!(
1257 "ACP loaded native session {native_session_id}, expected {}",
1258 archive_manifest.session.native_session_id
1259 );
1260 }
1261 } else {
1262 relay
1263 .install_prompt_context(
1264 utility_handoff
1265 .clone()
1266 .context("cross-harness resume has no utility-model handoff")?,
1267 )
1268 .await?;
1269 if !discard_queue {
1270 for prompt in &canonical_session.queued_prompts {
1271 let command = match &prompt.kind {
1275 CanonicalQueuedCommandKind::Prompt => RelayCommand::Prompt {
1276 prompt: prompt
1277 .content
1278 .iter()
1279 .cloned()
1280 .map(serde_json::from_value)
1281 .collect::<serde_json::Result<Vec<ContentBlock>>>()?,
1282 },
1283 CanonicalQueuedCommandKind::SetConfig { key, value } => {
1284 RelayCommand::SetConfig {
1285 key: key.clone(),
1286 value: value.clone(),
1287 }
1288 }
1289 };
1290 relay.submit(prompt.command_id.clone(), command).await?;
1291 }
1292 }
1293 }
1294 if let Some(worktree) = conversion
1298 .as_ref()
1299 .and_then(ResumeConversion::raw_to_workspace)
1300 .and_then(|plan| plan.retire.as_ref())
1301 && let Err(error) = retire_managed_worktree(executor, worktree)
1302 {
1303 tracing::warn!(
1304 session_id,
1305 worktree = %worktree.worktree_root.display(),
1306 error = format!("{error:#}"),
1307 "could not retire the old managed worktree after resume"
1308 );
1309 resume_notices.push(worktree_cleanup_notice(&worktree.worktree_root, &error));
1310 }
1311 for notice in &resume_notices {
1312 let submitted = async {
1313 let command_id = new_command_id("resume-notice")?;
1314 relay
1315 .submit(
1316 command_id,
1317 RelayCommand::RecordNotice {
1318 text: notice.clone(),
1319 },
1320 )
1321 .await
1322 }
1323 .await;
1324 if let Err(error) = submitted {
1327 tracing::warn!(
1328 session_id,
1329 error = format!("{error:#}"),
1330 "could not record a resume notice in the conversation"
1331 );
1332 }
1333 }
1334 self.mark_worker_connected(session_id, Some(native_session_id))?;
1335 Ok::<_, anyhow::Error>(relay.sync().await?.materialized)
1336 }
1337 .await;
1338 match result {
1339 Ok(materialized) => Ok(materialized),
1340 Err(error) => {
1341 if rebuild_projection {
1346 match materialized_session_from_canonical(session_id, &canonical_session) {
1347 Ok(previous_projection) => {
1348 if let Err(restore_error) =
1349 hel::hel_database::save_materialized_session(&previous_projection)
1350 {
1351 tracing::error!(
1352 session_id,
1353 error = format!("{restore_error:#}"),
1354 "could not restore the durable projection after resume failed"
1355 );
1356 }
1357 }
1358 Err(restore_error) => {
1359 tracing::error!(
1360 session_id,
1361 error = format!("{restore_error:#}"),
1362 "could not rebuild the durable projection after resume failed"
1363 );
1364 }
1365 }
1366 } else if discard_queued_prompts
1367 && let Err(restore_error) =
1368 hel::hel_database::replace_materialized_queued_prompts(
1369 session_id,
1370 &hel::hel_projection::materialized_queued_prompts_from_canonical(
1371 &canonical_session.queued_prompts,
1372 ),
1373 )
1374 {
1375 tracing::error!(
1376 session_id,
1377 error = format!("{restore_error:#}"),
1378 "could not restore queued prompts after resume failed"
1379 );
1380 }
1381 Err(self.rollback_failed_resume(
1382 session_id,
1383 &previous,
1384 recreated_managed_worktree,
1385 error,
1386 executor,
1387 )?)
1388 }
1389 }
1390 }
1391
1392 pub(super) fn rollback_failed_resume(
1393 &mut self,
1394 session_id: &str,
1395 previous: &SessionRecord,
1396 recreated_managed_worktree: bool,
1397 error: anyhow::Error,
1398 _executor: &impl CommandExecutor,
1399 ) -> Result<anyhow::Error> {
1400 let current = self
1401 .state
1402 .sessions
1403 .get(session_id)
1404 .with_context(|| format!("unknown session {session_id}"))?
1405 .clone();
1406 let cleanup = match current.target.as_ref() {
1407 Some(locator) => (|| -> Result<()> {
1408 let backend = backend_locator(locator, ¤t, &self.config)?;
1409 hel_targets::close_plan(&backend, session_id)?
1410 .execute(&CancellableProcessExecutor::with_timeout(
1413 Duration::from_secs(15),
1414 ))
1415 .map(|_| ())
1416 })(),
1417 None => Ok(()),
1418 };
1419 let worktree_cleanup = if cleanup.is_err() {
1422 Ok(())
1423 } else {
1424 match (
1425 current.managed_worktree.as_ref(),
1426 previous.managed_worktree.as_ref(),
1427 ) {
1428 (_, Some(previous)) if recreated_managed_worktree => retire_managed_worktree(
1429 &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1430 previous,
1431 ),
1432 (Some(current), Some(previous)) if current == previous => Ok(()),
1433 (Some(worktree), _) => cleanup_managed_worktree(
1434 &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1435 worktree,
1436 ),
1437 (None, _) => Ok(()),
1438 }
1439 };
1440 let cleanup_error = [cleanup, worktree_cleanup]
1441 .into_iter()
1442 .filter_map(Result::err)
1443 .map(|cleanup_error| format!("{cleanup_error:#}"))
1444 .collect::<Vec<_>>()
1445 .join("; ");
1446 if !cleanup_error.is_empty() {
1447 tracing::warn!(
1448 session_id,
1449 error = %cleanup_error,
1450 "resume rollback cleanup reported failures"
1451 );
1452 }
1453 let original = format!("{error:#}");
1454 let record = self.state.sessions.get_mut(session_id).unwrap();
1455 let failure = apply_failed_resume_rollback(
1456 record,
1457 previous,
1458 &original,
1459 (!cleanup_error.is_empty()).then_some(cleanup_error),
1460 );
1461 if record.bundle_id != current.bundle_id {
1464 let bundle_id = record.bundle_id.clone();
1465 hel::hel_database::rebind_session_bundle(session_id, &bundle_id)?;
1466 }
1467 hel::hel_database::save_session(&self.state.sessions[session_id])?;
1470 Ok(failure)
1471 }
1472}
1473
1474fn worktree_cleanup_notice(worktree_root: &Path, error: &anyhow::Error) -> String {
1475 format!(
1476 "Mjolnir could not remove the worktree at {}: {error:#}. Remove it with `git worktree remove --force {}`.",
1477 worktree_root.display(),
1478 worktree_root.display()
1479 )
1480}
1481
1482pub(super) fn apply_failed_resume_rollback(
1483 current: &mut SessionRecord,
1484 previous: &SessionRecord,
1485 original_error: &str,
1486 cleanup_error: Option<String>,
1487) -> anyhow::Error {
1488 match cleanup_error {
1489 None => {
1490 *current = previous.clone();
1491 current.state = SessionState::Stopped;
1492 current.target = None;
1493 current.updated_at = now();
1494 current.last_error = Some(format!("resume failed: {original_error}"));
1495 anyhow::anyhow!(original_error.to_owned())
1496 }
1497 Some(cleanup_error) => {
1498 let failure = format!(
1499 "{original_error}; cleanup of the partial resume target failed: {cleanup_error}"
1500 );
1501 if current.managed_worktree.is_none() {
1506 current
1507 .project_directory
1508 .clone_from(&previous.project_directory);
1509 current
1510 .managed_worktree
1511 .clone_from(&previous.managed_worktree);
1512 current.bundle_id.clone_from(&previous.bundle_id);
1513 }
1514 current.state = SessionState::Error;
1515 current.updated_at = now();
1516 current.last_error = Some(format!("resume failed: {failure}"));
1517 anyhow::anyhow!(failure)
1518 }
1519 }
1520}
1521
1522fn projection_rebuild_required(
1530 stored: Option<(u64, &str)>,
1531 archive_frontier: u64,
1532 archive_frontier_digest: &str,
1533) -> bool {
1534 stored != Some((archive_frontier, archive_frontier_digest))
1535}
1536
1537fn restore_archive_path(
1538 backend: &hel_targets::TargetLocator,
1539 verified_archive: &Path,
1540 remote_archive: &Path,
1541) -> PathBuf {
1542 if matches!(backend, hel_targets::TargetLocator::LocalBare { .. }) {
1543 verified_archive.to_path_buf()
1544 } else {
1545 remote_archive.to_path_buf()
1546 }
1547}
1548
1549fn should_upload_restore_archive(backend: &hel_targets::TargetLocator) -> bool {
1550 !matches!(backend, hel_targets::TargetLocator::LocalBare { .. })
1551}
1552
1553struct CrossHarnessProvisionExecutor<'a, E: CommandExecutor + ?Sized> {
1558 inner: &'a E,
1559 cancellation: CancellationToken,
1560}
1561
1562impl<E: CommandExecutor + ?Sized> CommandExecutor for CrossHarnessProvisionExecutor<'_, E> {
1563 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1564 if self.cancellation.is_cancelled() {
1565 bail!("operation cancelled while provisioning destination");
1566 }
1567 self.inner.execute(command)
1568 }
1569
1570 fn cancellation_requested(&self) -> bool {
1571 self.cancellation.is_cancelled() || self.inner.cancellation_requested()
1572 }
1573
1574 fn stage_started(&self, stage: ProvisionStage) {
1575 self.inner.stage_started(stage);
1576 }
1577
1578 fn stage_finished(&self, stage: ProvisionStage) {
1579 self.inner.stage_finished(stage);
1580 }
1581
1582 fn notify_notice(&self, notice: &str) {
1583 self.inner.notify_notice(notice);
1584 }
1585
1586 fn execute_with_stdin(
1587 &self,
1588 command: &CommandSpec,
1589 input: &mut (dyn std::io::Read + Send),
1590 ) -> Result<CommandOutput> {
1591 if self.cancellation.is_cancelled() {
1592 bail!("operation cancelled while provisioning destination");
1593 }
1594 self.inner.execute_with_stdin(command, input)
1595 }
1596}
1597
1598fn provision_with_cross_harness_handoff(
1599 controller: &mut Controller,
1600 session_id: &str,
1601 executor: &(impl CommandExecutor + Sync),
1602 github_token: Option<&str>,
1603 config: &HelConfig,
1604 snapshot: &CanonicalSessionSnapshot,
1605 context_bytes: usize,
1606) -> Result<String> {
1607 let (_provision, handoff) = execute_joined_cross_harness_work(
1608 "cross-harness provisioning",
1609 move |cancellation| {
1610 let provision_executor = CrossHarnessProvisionExecutor {
1611 inner: executor,
1612 cancellation,
1613 };
1614 futures::executor::block_on(controller.provision_session_with_failure_disposition(
1615 session_id,
1616 &provision_executor,
1617 github_token,
1618 ProvisioningFailureDisposition::Preserve,
1619 ))
1620 },
1621 "cross-harness handoff",
1622 move |cancellation| {
1623 let runtime = tokio::runtime::Builder::new_current_thread()
1624 .enable_all()
1625 .build()
1626 .context("create cross-harness handoff runtime")?;
1627 runtime.block_on(utility_handoff_while_cancellable(
1628 session_id,
1629 config,
1630 snapshot,
1631 context_bytes,
1632 executor,
1633 cancellation,
1634 ))
1635 },
1636 )?;
1637 ensure!(
1638 !executor.cancellation_requested(),
1639 "operation cancelled while provisioning destination"
1640 );
1641 Ok(handoff)
1642}
1643
1644fn execute_joined_cross_harness_work<A: Send, B: Send>(
1648 first_name: &'static str,
1649 first: impl FnOnce(CancellationToken) -> Result<A> + Send,
1650 second_name: &'static str,
1651 second: impl FnOnce(CancellationToken) -> Result<B> + Send,
1652) -> Result<(A, B)> {
1653 let cancellation = CancellationToken::new();
1654 std::thread::scope(|scope| {
1655 let first_cancel = cancellation.clone();
1656 let mut first_handle = Some(scope.spawn(move || first(first_cancel)));
1657 let second_cancel = cancellation.clone();
1658 let mut second_handle = Some(scope.spawn(move || second(second_cancel)));
1659 let mut first_result = None;
1660 let mut second_result = None;
1661
1662 while first_result.is_none() || second_result.is_none() {
1663 if first_result.is_none()
1664 && first_handle
1665 .as_ref()
1666 .is_some_and(|handle| handle.is_finished())
1667 {
1668 let handle = first_handle.take().expect("first lane handle present");
1669 first_result = Some(match handle.join() {
1670 Ok(result) => result,
1671 Err(panic) => {
1672 cancellation.cancel();
1673 Err(anyhow::anyhow!(
1674 "{first_name} thread panicked: {}",
1675 hel_targets::command_thread_panic_message(panic.as_ref())
1676 ))
1677 }
1678 });
1679 if first_result.as_ref().is_some_and(Result::is_err) {
1680 cancellation.cancel();
1681 }
1682 }
1683 if second_result.is_none()
1684 && second_handle
1685 .as_ref()
1686 .is_some_and(|handle| handle.is_finished())
1687 {
1688 let handle = second_handle.take().expect("second lane handle present");
1689 second_result = Some(match handle.join() {
1690 Ok(result) => result,
1691 Err(panic) => {
1692 cancellation.cancel();
1693 Err(anyhow::anyhow!(
1694 "{second_name} thread panicked: {}",
1695 hel_targets::command_thread_panic_message(panic.as_ref())
1696 ))
1697 }
1698 });
1699 if second_result.as_ref().is_some_and(Result::is_err) {
1700 cancellation.cancel();
1701 }
1702 }
1703 if first_result.is_none() || second_result.is_none() {
1704 std::thread::sleep(Duration::from_millis(10));
1705 }
1706 }
1707
1708 match (
1709 first_result.expect("first lane result received after joined handle"),
1710 second_result.expect("second lane result received after joined handle"),
1711 ) {
1712 (Err(first), Err(second)) => {
1713 Err(first.context(format!("{second_name} lane also failed: {second:#}")))
1714 }
1715 (Err(error), Ok(_)) => Err(error),
1716 (Ok(_), Err(error)) => Err(error),
1717 (Ok(first), Ok(second)) => Ok((first, second)),
1718 }
1719 })
1720}
1721
1722async fn utility_handoff_while_cancellable(
1726 session_id: &str,
1727 config: &HelConfig,
1728 snapshot: &CanonicalSessionSnapshot,
1729 context_bytes: usize,
1730 executor: &impl CommandExecutor,
1731 cancellation: CancellationToken,
1732) -> Result<String> {
1733 let _phase = ResumePhaseTimer::new(session_id, "cross-harness handoff");
1734 if executor.cancellation_requested() {
1735 bail!("operation cancelled while compacting the cross-harness handoff");
1736 }
1737 let _compacting = ProvisionStageGuard::new(executor, ProvisionStage::Compacting);
1738 let cancel = cancellation.child_token();
1739 let operation = async {
1740 let candidates = crate::hel_utility_llm::UtilityLlmRuntime::shared()
1741 .resolve(config, &cancel)
1742 .await?;
1743 let backend =
1744 crate::hel_utility_llm::UtilityCompactionBackend::new(candidates, cancel.clone());
1745 let budget = crate::hel_compaction::CompactionBudget {
1750 page_bytes: backend.page_bytes(),
1751 handoff_bytes: context_bytes,
1752 };
1753 crate::hel_compaction::compact_snapshot(snapshot, budget, &backend).await
1754 };
1755 tokio::pin!(operation);
1756 loop {
1757 tokio::select! {
1758 context = &mut operation => return context,
1759 _ = cancellation.cancelled() => {
1760 cancel.cancel();
1761 bail!("operation cancelled while compacting the cross-harness handoff");
1762 }
1763 _ = tokio::time::sleep(super::readiness::CANCELLATION_POLL_INTERVAL) => {
1764 if executor.cancellation_requested() {
1765 cancel.cancel();
1766 bail!("operation cancelled while compacting the cross-harness handoff");
1767 }
1768 }
1769 }
1770 }
1771}
1772
1773#[cfg(test)]
1774mod tests {
1775 use std::cell::RefCell;
1776 use std::collections::BTreeMap;
1777 use std::path::{Path, PathBuf};
1778 use std::process::Command;
1779 use std::sync::{Barrier, Mutex};
1780
1781 use anyhow::Result;
1782
1783 use crate::hel_controller::test_support::{
1784 checkpoint_test_session, committed_repository, managed_worktree_session,
1785 resume_compatibility_config, write_checkpoint_gate_archive,
1786 };
1787 use crate::hel_controller::{Controller, SessionResumeOptions};
1788 use hel::hel_archive::{GitCommandRunner, verify_archive_streaming};
1789 use hel::hel_config::{
1790 ContainerTemplate as ConfigContainer, HarnessProfile, HelConfig, ProjectBundle,
1791 ProjectRepository, TargetTemplate,
1792 };
1793 use hel::hel_projection::materialized_session_from_canonical;
1794 use hel::hel_state::{HelState, SessionRecord, SessionState, TargetLocator};
1795 use hel::hel_targets::{CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
1796
1797 use super::*;
1798
1799 const RESUME_ROLLBACK_TEST_CHILD: &str = "MJ_RESUME_ROLLBACK_TEST_CHILD";
1800 const RETIRED_WORKTREE_RESUME_TEST_CHILD: &str = "MJ_RETIRED_WORKTREE_RESUME_TEST_CHILD";
1801 const WORKER_PREFLIGHT_TEST_CHILD: &str = "MJ_WORKER_PREFLIGHT_TEST_CHILD";
1802
1803 #[test]
1804 fn muse_resume_allows_workspace_relocation_before_provisioning() {
1805 let mut config = resume_compatibility_config();
1806 config
1807 .targets
1808 .insert("other-container".into(), config.targets["podman"].clone());
1809 let controller = Controller {
1810 config,
1811 state: HelState::default(),
1812 };
1813 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1814 session.harness_kind = HarnessKind::Muse;
1815 assert!(
1816 controller
1817 .validate_muse_resume_destination(&session, HarnessKind::Muse, "podman")
1818 .is_ok()
1819 );
1820 assert!(
1821 controller
1822 .validate_muse_resume_destination(&session, HarnessKind::Muse, "other-container")
1823 .is_ok()
1824 );
1825 controller
1826 .validate_muse_resume_destination(&session, HarnessKind::Muse, "ssh-bare")
1827 .unwrap();
1828 assert!(
1829 controller
1830 .validate_muse_resume_destination(&session, HarnessKind::Codex, "ssh-bare")
1831 .is_ok()
1832 );
1833 assert_eq!(session.state, SessionState::Running);
1834 }
1835
1836 #[test]
1840 fn a_resume_preflights_the_worker_binary_before_compacting() {
1841 if std::env::var_os(WORKER_PREFLIGHT_TEST_CHILD).is_none() {
1844 let directory = tempfile::tempdir().unwrap();
1845 let test_name = format!(
1846 "{}::a_resume_preflights_the_worker_binary_before_compacting",
1847 module_path!()
1848 .strip_prefix("mj_controller::")
1849 .unwrap_or(module_path!())
1850 );
1851 let output = Command::new(std::env::current_exe().unwrap())
1852 .args(["--exact", &test_name, "--nocapture"])
1853 .env(WORKER_PREFLIGHT_TEST_CHILD, "1")
1854 .env("MJ_DATA_DIR", directory.path().join("data"))
1855 .env("MJ_CONFIG_DIR", directory.path().join("config"))
1856 .env("MJ_WORKER_BINARY", directory.path().join("absent-worker"))
1859 .output()
1860 .unwrap();
1861 assert!(
1862 output.status.success(),
1863 "isolated worker preflight test failed\nstdout:\n{}\nstderr:\n{}",
1864 String::from_utf8_lossy(&output.stdout),
1865 String::from_utf8_lossy(&output.stderr)
1866 );
1867 return;
1868 }
1869 let _writer = hel::hel_database::install_isolated_test_writer();
1871
1872 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
1873 let archive_directory = data_directory.join("archives");
1874 std::fs::create_dir_all(&archive_directory).unwrap();
1875 let session_id = "0123456789abcdef0123456789abcdef";
1876 let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
1877 let repository = committed_repository();
1878 let mut session = managed_worktree_session(repository.path(), session_id);
1879 session.checkpoint = Some(checkpoint);
1880
1881 let profile_home = data_directory.join("profile");
1882 std::fs::create_dir_all(&profile_home).unwrap();
1883 let mut config = resume_compatibility_config();
1884 config.profiles.insert(
1887 "claude".into(),
1888 HarnessProfile {
1889 kind: hel::hel_config::HarnessKind::Claude,
1890 home: profile_home,
1891 environment: BTreeMap::new(),
1892 context_window_bytes: None,
1893 },
1894 );
1895 let mut controller = Controller {
1896 config,
1897 state: HelState {
1898 sessions: BTreeMap::from([(session_id.into(), session)]),
1899 ..HelState::default()
1900 },
1901 };
1902 hel::hel_database::save_state(&controller.state).unwrap();
1903
1904 let error = tokio::runtime::Builder::new_current_thread()
1905 .enable_all()
1906 .build()
1907 .unwrap()
1908 .block_on(controller.resume_session_controlled(
1909 session_id,
1910 "claude",
1911 "local-bare",
1912 SessionResumeOptions {
1913 additional_mounts: None,
1914 resource_allocation: None,
1915 discard_queue: false,
1916 },
1917 &ProcessExecutor,
1918 ))
1919 .unwrap_err();
1920
1921 let detail = format!("{error:#}");
1922 assert!(
1923 detail.contains("preflight the worker binary before resuming"),
1924 "{detail}"
1925 );
1926 assert!(detail.contains("absent-worker"), "{detail}");
1927 assert!(
1928 !detail.contains("compact the cross-harness handoff transcript"),
1929 "compaction must not run for a resume that cannot install a worker: {detail}"
1930 );
1931 assert_eq!(
1932 controller.state.sessions[session_id].state,
1933 SessionState::Stopped
1934 );
1935 }
1936
1937 #[test]
1938 fn network_resume_ignores_host_history_but_an_explicit_raw_move_checks_it() {
1939 let directory = tempfile::tempdir().unwrap();
1940 let repository = committed_repository();
1941 let session_id = "0123456789abcdef0123456789abcdef";
1942 let mut session = checkpoint_test_session(session_id);
1943 session.state = SessionState::Stopped;
1944 session.checkpoint = Some(
1945 super::super::test_support::write_network_checkpoint_archive(
1946 directory.path(),
1947 session_id,
1948 0,
1949 ),
1950 );
1951 let mut config = resume_compatibility_config();
1952 config.bundles.insert(
1953 "project".into(),
1954 super::super::test_support::local_bundle(repository.path()),
1955 );
1956 let controller = Controller {
1957 config,
1958 state: HelState {
1959 sessions: BTreeMap::from([(session_id.into(), session)]),
1960 ..HelState::default()
1961 },
1962 };
1963 assert!(matches!(
1964 controller
1965 .preflight_resume_repository_sources(session_id, "podman", &ProcessExecutor,)
1966 .unwrap(),
1967 ResumeRepositorySourcePreflight::Ready(_)
1968 ));
1969 let result = controller
1970 .preflight_resume_repository_sources(session_id, "local-bare", &ProcessExecutor)
1971 .unwrap();
1972 let ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) = result else {
1973 panic!("moving into a host checkout must detect its missing archive base");
1974 };
1975 assert_eq!(mismatch.missing_commit, "a".repeat(40));
1976 assert!(!repository.path().join(".mj/worktrees").exists());
1977 }
1978
1979 #[test]
1980 fn raw_in_place_preflight_does_not_require_its_synthetic_bundle() {
1981 struct UnusedExecutor;
1982
1983 impl CommandExecutor for UnusedExecutor {
1984 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1985 panic!("raw in-place preflight ran {}", command.purpose);
1986 }
1987 }
1988
1989 let directory = tempfile::tempdir().unwrap();
1990 let session_id = "0123456789abcdef0123456789abcdef";
1991 let mut session = checkpoint_test_session(session_id);
1992 session.checkpoint = Some(write_checkpoint_gate_archive(
1993 directory.path(),
1994 session_id,
1995 3,
1996 ));
1997 session.bundle_id = "remote-project-a66373eef659f856".into();
1998 session.target_template_id = "localhost".into();
1999 session.project_directory = Some("/mnt/optane/bifrost-fird".into());
2000 let controller = Controller {
2001 config: HelConfig {
2002 targets: BTreeMap::from([("localhost".into(), TargetTemplate::LocalBare)]),
2003 bundles: BTreeMap::new(),
2006 ..HelConfig::default()
2007 },
2008 state: HelState {
2009 sessions: BTreeMap::from([(session_id.into(), session)]),
2010 ..HelState::default()
2011 },
2012 };
2013
2014 let preflight = controller
2015 .preflight_resume_repository_sources(session_id, "localhost", &UnusedExecutor)
2016 .unwrap();
2017 let ResumeRepositorySourcePreflight::Ready(receipt) = preflight else {
2018 panic!("raw in-place resume unexpectedly needs a repository replacement");
2019 };
2020 assert!(controller.repository_source_receipt_is_current(session_id, &receipt));
2021 }
2022
2023 #[test]
2024 fn repository_preflight_distinguishes_the_original_source_from_a_reused_name() {
2025 fn git(repository: &Path, arguments: &[&str]) {
2026 let output = SystemGit
2027 .run(
2028 repository,
2029 &hel::hel_archive::GitCommand {
2030 arguments: arguments.iter().map(std::ffi::OsString::from).collect(),
2031 stdin: Vec::new(),
2032 env: Vec::new(),
2033 },
2034 )
2035 .unwrap();
2036 assert_eq!(
2037 output.status,
2038 0,
2039 "git {arguments:?}: {}",
2040 String::from_utf8_lossy(&output.stderr)
2041 );
2042 }
2043
2044 let directory = tempfile::tempdir().unwrap();
2045 let origin = directory.path().join("original");
2046 std::fs::create_dir(&origin).unwrap();
2047 git(&origin, &["init", "-q", "-b", "main"]);
2048 git(&origin, &["config", "user.name", "Hel Test"]);
2049 git(&origin, &["config", "user.email", "hel@example.test"]);
2050 git(&origin, &["commit", "--allow-empty", "-qm", "base"]);
2051 let source = directory.path().join("source");
2052 git(
2053 directory.path(),
2054 &["clone", "-q", origin.to_str().unwrap(), "source"],
2055 );
2056 git(&source, &["config", "user.name", "Hel Test"]);
2057 git(&source, &["config", "user.email", "hel@example.test"]);
2058 git(&source, &["commit", "--allow-empty", "-qm", "session"]);
2059 let snapshot = hel::hel_archive::collect_git_snapshot(
2060 &SystemGit,
2061 &source,
2062 &hel::hel_archive::GitCollectionSpec {
2063 id: "project".into(),
2064 relative_destination: "project".into(),
2065 history: hel::hel_archive::GitHistoryMode::SessionDelta,
2066 origin_override: None,
2067 },
2068 )
2069 .unwrap();
2070 let configured = ProjectRepository {
2071 id: "project".into(),
2072 github: None,
2073 local: Some(origin.clone()),
2074 destination: "project".into(),
2075 git_ref: None,
2076 };
2077 assert_eq!(
2078 checkpoint_source_missing_commit(
2079 &configured,
2080 &CheckpointRepositoryBundle {
2081 metadata: snapshot.metadata.clone(),
2082 committed_bundle: snapshot.committed_bundle.clone(),
2083 },
2084 &ProcessExecutor,
2085 None,
2086 )
2087 .unwrap(),
2088 None
2089 );
2090
2091 let replacement = directory.path().join("replacement");
2092 std::fs::create_dir(&replacement).unwrap();
2093 git(&replacement, &["init", "-q", "-b", "main"]);
2094 git(&replacement, &["config", "user.name", "Hel Test"]);
2095 git(&replacement, &["config", "user.email", "hel@example.test"]);
2096 git(
2097 &replacement,
2098 &["commit", "--allow-empty", "-qm", "different history"],
2099 );
2100 let configured = ProjectRepository {
2101 local: Some(replacement),
2102 ..configured
2103 };
2104 assert!(
2105 checkpoint_source_missing_commit(
2106 &configured,
2107 &CheckpointRepositoryBundle {
2108 metadata: snapshot.metadata,
2109 committed_bundle: snapshot.committed_bundle,
2110 },
2111 &ProcessExecutor,
2112 None,
2113 )
2114 .unwrap()
2115 .is_some()
2116 );
2117 }
2118
2119 #[test]
2120 fn repository_preflight_checks_independent_sources_concurrently_and_receipts_are_scoped() {
2121 struct ConcurrentSourceExecutor {
2122 source_checks: Barrier,
2123 }
2124
2125 impl CommandExecutor for ConcurrentSourceExecutor {
2126 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2127 if command.purpose == "check checkpoint base commit" {
2128 self.source_checks.wait();
2129 }
2130 Ok(CommandOutput {
2131 status: 0,
2132 stdout: Vec::new(),
2133 stderr: Vec::new(),
2134 })
2135 }
2136 }
2137
2138 let directory = tempfile::tempdir().unwrap();
2139 let session_id = "0123456789abcdef0123456789abcdef";
2140 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 3);
2141 let repositories = ["one", "two"]
2142 .map(|id| ProjectRepository {
2143 id: id.into(),
2144 github: None,
2145 local: Some(PathBuf::from(format!("/origin/{id}"))),
2146 destination: id.into(),
2147 git_ref: None,
2148 })
2149 .to_vec();
2150 let mut session = checkpoint_test_session(session_id);
2151 session.checkpoint = Some(checkpoint.clone());
2152 let mut controller = Controller {
2153 config: HelConfig {
2154 bundles: BTreeMap::from([(
2155 session.bundle_id.clone(),
2156 ProjectBundle {
2157 primary_repo: "one".into(),
2158 repositories: repositories.clone(),
2159 },
2160 )]),
2161 ..HelConfig::default()
2162 },
2163 state: HelState {
2164 sessions: BTreeMap::from([(session_id.into(), session)]),
2165 ..HelState::default()
2166 },
2167 };
2168 let verified = ResumeRepositoryBundles {
2169 checkpoint_sha256: checkpoint.sha256,
2170 repositories: repositories
2171 .iter()
2172 .map(|repository| CheckpointRepositoryBundle {
2173 metadata: hel::hel_archive::RepositoryMetadata {
2174 push_urls: Vec::new(),
2175 remote_workspace: false,
2176 id: repository.id.clone(),
2177 relative_destination: repository.destination.clone(),
2178 origin: repository.source_label(),
2179 base_commit: String::new(),
2180 head_commit: if repository.id == "one" {
2181 "a".repeat(40)
2182 } else {
2183 "b".repeat(40)
2184 },
2185 branch: Some("main".into()),
2186 },
2187 committed_bundle: Vec::new(),
2188 })
2189 .collect(),
2190 };
2191 let executor = ConcurrentSourceExecutor {
2192 source_checks: Barrier::new(2),
2193 };
2194 let pool = rayon::ThreadPoolBuilder::new()
2195 .num_threads(2)
2196 .build()
2197 .unwrap();
2198 let preflight = pool
2199 .install(|| {
2200 controller.preflight_verified_repository_sources(
2201 session_id, verified, None, false, &executor,
2202 )
2203 })
2204 .unwrap();
2205 let ResumeRepositorySourcePreflight::Ready(receipt) = preflight else {
2206 panic!("expected repository source receipt");
2207 };
2208 assert!(controller.repository_source_receipt_is_current(session_id, &receipt));
2209
2210 controller
2211 .config
2212 .bundles
2213 .values_mut()
2214 .next()
2215 .unwrap()
2216 .repositories[0]
2217 .local = Some(PathBuf::from("/different-origin"));
2218 assert!(!controller.repository_source_receipt_is_current(session_id, &receipt));
2219 }
2220
2221 #[test]
2222 fn repository_preflight_checks_declared_boundary_without_importing_delta_bundle() {
2223 struct RecordingExecutor {
2224 commands: Mutex<Vec<CommandSpec>>,
2225 }
2226
2227 impl CommandExecutor for RecordingExecutor {
2228 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2229 self.commands.lock().unwrap().push(command.clone());
2230 Ok(CommandOutput {
2231 status: 0,
2232 stdout: Vec::new(),
2233 stderr: Vec::new(),
2234 })
2235 }
2236 }
2237
2238 let prerequisite = "a".repeat(40);
2239 let head = "b".repeat(40);
2240 let archived = CheckpointRepositoryBundle {
2241 metadata: hel::hel_archive::RepositoryMetadata {
2242 push_urls: Vec::new(),
2243 remote_workspace: false,
2244 id: "project".into(),
2245 relative_destination: "project".into(),
2246 origin: "https://github.com/archived/should-not-be-contacted.git".into(),
2247 base_commit: prerequisite.clone(),
2248 head_commit: head.clone(),
2249 branch: Some("main".into()),
2250 },
2251 committed_bundle: format!(
2252 "# v2 git bundle\n-{prerequisite} base\n{head} HEAD\n\nPACKnot-read"
2253 )
2254 .into_bytes(),
2255 };
2256 let configured = ProjectRepository {
2257 id: "project".into(),
2258 github: Some("configured/project".into()),
2259 local: None,
2260 destination: "project".into(),
2261 git_ref: None,
2262 };
2263 let executor = RecordingExecutor {
2264 commands: Mutex::new(Vec::new()),
2265 };
2266
2267 assert_eq!(
2268 checkpoint_source_missing_commit(
2269 &configured,
2270 &archived,
2271 &executor,
2272 Some("secret-token")
2273 )
2274 .unwrap(),
2275 None
2276 );
2277
2278 let commands = executor.commands.into_inner().unwrap();
2279 assert_eq!(commands.len(), 2, "commands: {commands:?}");
2280 assert_eq!(
2281 commands
2282 .iter()
2283 .map(|command| command.purpose.as_str())
2284 .collect::<Vec<_>>(),
2285 [
2286 "initialize repository source preflight",
2287 "check checkpoint base commit"
2288 ]
2289 );
2290 let source_check = &commands[1];
2291 assert!(
2292 source_check
2293 .args
2294 .iter()
2295 .any(|argument| argument == "credential.helper=")
2296 );
2297 assert_eq!(
2298 source_check
2299 .env
2300 .get("GIT_NO_LAZY_FETCH")
2301 .map(String::as_str),
2302 Some("1")
2303 );
2304 assert_eq!(
2305 source_check
2306 .env
2307 .get("GIT_TERMINAL_PROMPT")
2308 .map(String::as_str),
2309 Some("0")
2310 );
2311 assert_eq!(
2312 source_check.args.last().map(String::as_str),
2313 Some(prerequisite.as_str())
2314 );
2315 assert!(
2316 !source_check
2317 .args
2318 .iter()
2319 .any(|argument| argument.contains("archived"))
2320 );
2321 }
2322
2323 #[test]
2324 fn self_contained_bundle_validation_cannot_lazy_fetch_or_prompt() {
2325 let command = checkpoint_bundle_import_command(
2326 Path::new("/tmp/repository.git"),
2327 Path::new("/tmp/checkpoint.bundle"),
2328 );
2329 assert_eq!(
2330 command.env.get("GIT_NO_LAZY_FETCH").map(String::as_str),
2331 Some("1")
2332 );
2333 assert_eq!(
2334 command.env.get("GIT_TERMINAL_PROMPT").map(String::as_str),
2335 Some("0")
2336 );
2337 }
2338
2339 #[test]
2340 fn lost_bundle_sessions_reach_resume_compatibility_before_the_record_changes() {
2341 struct UnusedExecutor;
2342
2343 impl CommandExecutor for UnusedExecutor {
2344 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2345 panic!("resume ran {} before rejecting the target", command.program);
2346 }
2347 }
2348
2349 let directory = tempfile::tempdir().unwrap();
2350 let session_id = "0123456789abcdef0123456789abcdef";
2351 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 3);
2352 let mut session = checkpoint_test_session(session_id);
2353 session.state = SessionState::Lost;
2354 session.checkpoint = Some(checkpoint);
2355 let previous = session.clone();
2356 let profile_home = directory.path().join("profile");
2357 std::fs::create_dir_all(&profile_home).unwrap();
2358 let mut config = HelConfig::default();
2359 config.profiles.insert(
2360 "codex".into(),
2361 HarnessProfile {
2362 kind: hel::hel_config::HarnessKind::Codex,
2363 home: profile_home,
2364 environment: BTreeMap::new(),
2365 context_window_bytes: None,
2366 },
2367 );
2368 config
2369 .targets
2370 .insert("localhost".into(), TargetTemplate::LocalBare);
2371 let mut controller = Controller {
2372 config,
2373 state: HelState {
2374 sessions: BTreeMap::from([(session_id.into(), session)]),
2375 ..HelState::default()
2376 },
2377 };
2378
2379 let error = tokio::runtime::Builder::new_current_thread()
2380 .enable_all()
2381 .build()
2382 .unwrap()
2383 .block_on(controller.resume_session_controlled(
2384 session_id,
2385 "codex",
2386 "localhost",
2387 SessionResumeOptions {
2388 additional_mounts: None,
2389 resource_allocation: None,
2390 discard_queue: false,
2391 },
2392 &UnusedExecutor,
2393 ))
2394 .unwrap_err();
2395
2396 let detail = format!("{error:#}");
2397 assert!(detail.contains("created from a project bundle"), "{detail}");
2398 assert!(
2399 detail.contains("resume it on a container, SSH, or EC2 target"),
2400 "{detail}"
2401 );
2402 assert_eq!(controller.state.sessions[session_id], previous);
2403 }
2404 struct BarrierExecutor {
2408 seen: Mutex<Vec<String>>,
2409 barrier: Barrier,
2410 }
2411
2412 impl CommandExecutor for BarrierExecutor {
2413 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2414 self.seen.lock().unwrap().push(command.purpose.clone());
2415 self.barrier.wait();
2416 Ok(CommandOutput {
2417 status: 0,
2418 stdout: Vec::new(),
2419 stderr: Vec::new(),
2420 })
2421 }
2422 }
2423
2424 fn lane_command(purpose: &str) -> CommandSpec {
2425 CommandSpec::new("hel", ["worker"]).purpose(purpose)
2426 }
2427
2428 #[test]
2433 fn start_begins_at_the_worker_launch_not_at_the_transfers_before_it() {
2434 struct RecordingExecutor {
2435 commands: RefCell<Vec<CommandSpec>>,
2436 }
2437 impl CommandExecutor for RecordingExecutor {
2438 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2439 self.commands.borrow_mut().push(command.clone());
2440 Ok(CommandOutput {
2441 status: 0,
2442 stdout: Vec::new(),
2443 stderr: Vec::new(),
2444 })
2445 }
2446 }
2447
2448 let session_id = "0123456789abcdef0123456789abcdef";
2449 let worker_root = format!("/var/lib/hel/workers/{session_id}");
2450 let executor = RecordingExecutor {
2451 commands: RefCell::new(Vec::new()),
2452 };
2453 let syncing = StagedExecutor::new(&executor, ProvisionStage::Syncing);
2454 let backend = hel_targets::TargetLocator::LocalPodman {
2455 container_id: "abcdef0123456789".into(),
2456 workspace_storage: Default::default(),
2457 };
2458
2459 upload_checkpoint_spec(
2460 &syncing,
2461 &backend,
2462 session_id,
2463 Path::new("/archives/session.hel.zip"),
2464 &format!("{worker_root}/restore.hel.zip"),
2465 )
2466 .unwrap();
2467 execute_checked(
2468 &syncing,
2469 restore_command(
2470 &backend,
2471 session_id,
2472 &format!("{worker_root}/restore-spec.json"),
2473 )
2474 .unwrap(),
2475 )
2476 .unwrap();
2477 start_worker(&syncing, &backend, &worker_root).unwrap();
2480
2481 let stages = executor
2482 .commands
2483 .borrow()
2484 .iter()
2485 .map(|command| (command.purpose.clone(), command.stage))
2486 .collect::<Vec<_>>();
2487 assert_eq!(
2488 stages,
2489 vec![
2490 (
2491 "upload checkpoint specification".to_owned(),
2492 Some(ProvisionStage::Syncing)
2493 ),
2494 (
2495 "restore target checkpoint".to_owned(),
2496 Some(ProvisionStage::Syncing)
2497 ),
2498 (
2499 "start detached Mjolnir worker".to_owned(),
2500 Some(ProvisionStage::Starting)
2501 ),
2502 ]
2503 );
2504 }
2505 #[test]
2506 fn independent_target_lanes_run_at_the_same_time() {
2507 let executor = BarrierExecutor {
2508 seen: Mutex::new(Vec::new()),
2509 barrier: Barrier::new(2),
2510 };
2511
2512 execute_concurrent_lanes(
2513 || execute_checked(&executor, lane_command("install the worker")).map(|_| ()),
2514 || execute_checked(&executor, lane_command("upload the checkpoint")).map(|_| ()),
2515 )
2516 .unwrap();
2517
2518 let mut seen = executor.seen.into_inner().unwrap();
2519 seen.sort();
2520 assert_eq!(seen, ["install the worker", "upload the checkpoint"]);
2521 }
2522 #[test]
2523 fn a_lane_failure_is_reported_in_lane_order_and_never_abandons_the_other_lane() {
2524 let reached = Mutex::new(Vec::new());
2525
2526 let error = execute_concurrent_lanes(
2529 || -> Result<()> {
2530 std::thread::sleep(Duration::from_millis(50));
2531 bail!("worker install failed")
2532 },
2533 || -> Result<()> {
2534 reached.lock().unwrap().push("second");
2535 bail!("checkpoint upload failed")
2536 },
2537 )
2538 .unwrap_err();
2539
2540 assert_eq!(error.to_string(), "worker install failed");
2541 assert_eq!(
2542 *reached.lock().unwrap(),
2543 ["second"],
2544 "a failing first lane must not cut the second one short"
2545 );
2546
2547 let error = execute_concurrent_lanes(
2548 || Ok(()),
2549 || -> Result<()> { bail!("checkpoint upload failed") },
2550 )
2551 .unwrap_err();
2552 assert_eq!(error.to_string(), "checkpoint upload failed");
2553 }
2554
2555 #[test]
2556 fn cross_harness_lanes_prove_overlap_with_handshake_channels() {
2557 let (provision_started_tx, provision_started_rx) = std::sync::mpsc::sync_channel::<()>(1);
2558 let (handoff_started_tx, handoff_started_rx) = std::sync::mpsc::sync_channel::<()>(1);
2559 let (provision_seen_handoff_tx, provision_seen_handoff_rx) =
2560 std::sync::mpsc::sync_channel::<()>(1);
2561 let (handoff_seen_provision_tx, handoff_seen_provision_rx) =
2562 std::sync::mpsc::sync_channel::<()>(1);
2563
2564 execute_joined_cross_harness_work(
2565 "provision",
2566 move |_cancellation| -> Result<()> {
2567 provision_started_tx
2568 .send(())
2569 .map_err(|error| anyhow::anyhow!("signal provisioning start: {error}"))?;
2570 handoff_started_rx
2571 .recv_timeout(Duration::from_secs(2))
2572 .map_err(|error| anyhow::anyhow!("wait for handoff start: {error}"))?;
2573 provision_seen_handoff_tx
2574 .send(())
2575 .map_err(|error| anyhow::anyhow!("signal provisioning overlap: {error}"))?;
2576 Ok(())
2577 },
2578 "handoff",
2579 move |_cancellation| -> Result<()> {
2580 handoff_started_tx
2581 .send(())
2582 .map_err(|error| anyhow::anyhow!("signal handoff start: {error}"))?;
2583 provision_started_rx
2584 .recv_timeout(Duration::from_secs(2))
2585 .map_err(|error| anyhow::anyhow!("wait for provisioning start: {error}"))?;
2586 handoff_seen_provision_tx
2587 .send(())
2588 .map_err(|error| anyhow::anyhow!("signal handoff overlap: {error}"))?;
2589 Ok(())
2590 },
2591 )
2592 .unwrap();
2593
2594 assert!(provision_seen_handoff_rx.recv().is_ok());
2595 assert!(handoff_seen_provision_rx.recv().is_ok());
2596 }
2597
2598 #[test]
2599 fn cross_harness_lane_failure_cancels_and_joins_the_peer() {
2600 let (handoff_started_tx, handoff_started_rx) = std::sync::mpsc::sync_channel::<()>(1);
2601 let (handoff_joined_tx, handoff_joined_rx) = std::sync::mpsc::sync_channel::<()>(1);
2602
2603 let error = execute_joined_cross_harness_work(
2604 "provision",
2605 move |_cancellation| -> Result<()> {
2606 handoff_started_rx
2607 .recv_timeout(Duration::from_secs(2))
2608 .map_err(|error| anyhow::anyhow!("wait for handoff start: {error}"))?;
2609 bail!("provisioning failed after handoff started");
2610 },
2611 "handoff",
2612 move |cancellation| -> Result<()> {
2613 handoff_started_tx
2614 .send(())
2615 .map_err(|error| anyhow::anyhow!("signal handoff start: {error}"))?;
2616 let runtime = tokio::runtime::Builder::new_current_thread()
2617 .enable_all()
2618 .build()
2619 .map_err(|error| {
2620 anyhow::anyhow!("create cancellation test runtime: {error}")
2621 })?;
2622 runtime
2623 .block_on(async {
2624 tokio::time::timeout(Duration::from_secs(2), cancellation.cancelled()).await
2625 })
2626 .map_err(|error| anyhow::anyhow!("peer was not cancelled: {error}"))?;
2627 handoff_joined_tx
2628 .send(())
2629 .map_err(|error| anyhow::anyhow!("signal handoff join: {error}"))?;
2630 Ok(())
2631 },
2632 )
2633 .unwrap_err();
2634
2635 assert_eq!(
2636 error.to_string(),
2637 "provisioning failed after handoff started"
2638 );
2639 assert!(handoff_joined_rx.recv().is_ok());
2640 }
2641
2642 #[test]
2643 fn a_projection_standing_at_the_archived_frontier_is_reused() {
2644 let digest = "a".repeat(64);
2645 let other = "b".repeat(64);
2646
2647 assert!(!projection_rebuild_required(
2648 Some((82_000, &digest)),
2649 82_000,
2650 &digest
2651 ));
2652
2653 for stored in [
2654 Some((82_000, other.as_str())),
2656 Some((81_999, digest.as_str())),
2658 Some((82_001, digest.as_str())),
2659 None,
2661 ] {
2662 assert!(
2663 projection_rebuild_required(stored, 82_000, &digest),
2664 "{stored:?} must not be mistaken for the archived projection"
2665 );
2666 }
2667 }
2668
2669 #[test]
2670 fn local_bare_restore_reuses_verified_absolute_archive_without_upload() {
2671 let archive = Path::new("/var/lib/hel/archives/session.hel.zip");
2672 let remote = Path::new("/var/lib/hel/workers/session/restore.hel.zip");
2673 let local = hel_targets::TargetLocator::LocalBare {
2674 worker_root: "/var/lib/hel/workers/session".into(),
2675 };
2676 let container = hel_targets::TargetLocator::LocalPodman {
2677 container_id: "container".into(),
2678 workspace_storage: Default::default(),
2679 };
2680
2681 assert_eq!(restore_archive_path(&local, archive, remote), archive);
2682 assert!(!should_upload_restore_archive(&local));
2683 assert_eq!(restore_archive_path(&container, archive, remote), remote);
2684 assert!(should_upload_restore_archive(&container));
2685 }
2686
2687 #[test]
2688 fn cross_harness_provision_cancellation_stops_the_next_command() {
2689 struct RecordingExecutor {
2690 commands: Mutex<Vec<String>>,
2691 }
2692
2693 impl CommandExecutor for RecordingExecutor {
2694 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2695 self.commands.lock().unwrap().push(command.purpose.clone());
2696 Ok(CommandOutput {
2697 status: 0,
2698 stdout: Vec::new(),
2699 stderr: Vec::new(),
2700 })
2701 }
2702 }
2703
2704 let inner = RecordingExecutor {
2705 commands: Mutex::new(Vec::new()),
2706 };
2707 let cancellation = CancellationToken::new();
2708 let provision = CrossHarnessProvisionExecutor {
2709 inner: &inner,
2710 cancellation: cancellation.clone(),
2711 };
2712 let command = CommandSpec::new("hel", ["worker"]).purpose("provision target");
2713 provision.execute(&command).unwrap();
2714 cancellation.cancel();
2715
2716 let error = provision.execute(&command).unwrap_err();
2717 assert!(error.to_string().contains("cancelled while provisioning"));
2718 assert_eq!(
2719 inner.commands.lock().unwrap().as_slice(),
2720 ["provision target"]
2721 );
2722 }
2723
2724 #[test]
2725 fn failed_resume_rolls_back_only_after_target_cleanup() {
2726 let previous = SessionRecord {
2727 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2728 archived: false,
2729 container_cpus: None,
2730 container_memory: None,
2731 id: "0123456789abcdef0123456789abcdef".into(),
2732 title: "imported session".into(),
2733 harness_kind: hel::hel_config::HarnessKind::Codex,
2734 last_profile: "codex-old".into(),
2735 bundle_id: "project".into(),
2736 project_directory: None,
2737 managed_worktree: None,
2738 target_template_id: "podman-old".into(),
2739 resource_allocation: None,
2740 additional_mounts: Vec::new(),
2741 state: SessionState::Stopped,
2742 target: None,
2743 native_session_id: Some("native-session".into()),
2744 acp_session_title: None,
2745 session_title_override: None,
2746 created_at: "2026-08-12T00:00:00Z".into(),
2747 updated_at: "2026-08-12T00:00:00Z".into(),
2748 viewed_through_event_ordinal: 0,
2749 draft_input: String::new(),
2750 last_error: None,
2751 last_checkpoint_error: None,
2752 checkpoint: None,
2753 };
2754 let partial_target = TargetLocator::LocalPodman {
2755 container_id: "partial-container".into(),
2756 workspace_storage: Default::default(),
2757 };
2758 let mut cleaned = previous.clone();
2759 cleaned.state = SessionState::Error;
2760 cleaned.last_profile = "codex-new".into();
2761 cleaned.target = Some(partial_target.clone());
2762
2763 let failure =
2764 apply_failed_resume_rollback(&mut cleaned, &previous, "worker upload failed", None);
2765
2766 assert_eq!(cleaned.state, SessionState::Stopped);
2767 assert_eq!(cleaned.last_profile, "codex-old");
2768 assert_eq!(cleaned.target, None);
2769 assert_eq!(failure.to_string(), "worker upload failed");
2770 assert_eq!(
2771 cleaned.last_error.as_deref(),
2772 Some("resume failed: worker upload failed")
2773 );
2774
2775 let mut cleanup_failed = previous.clone();
2776 cleanup_failed.state = SessionState::Error;
2777 cleanup_failed.last_profile = "codex-new".into();
2778 cleanup_failed.target = Some(partial_target.clone());
2779 let partial_checkout = crate::hel_controller::test_support::managed_raw_session(
2780 hel::hel_state::ManagedWorktreeTarget::Local,
2781 );
2782 cleanup_failed.project_directory = partial_checkout.project_directory.clone();
2783 cleanup_failed.managed_worktree = partial_checkout.managed_worktree.clone();
2784
2785 let failure = apply_failed_resume_rollback(
2786 &mut cleanup_failed,
2787 &previous,
2788 "worker upload failed",
2789 Some("podman rm failed".into()),
2790 );
2791
2792 assert_eq!(cleanup_failed.state, SessionState::Error);
2793 assert_eq!(cleanup_failed.last_profile, "codex-new");
2794 assert_eq!(cleanup_failed.target, Some(partial_target));
2795 assert_eq!(
2796 cleanup_failed.project_directory,
2797 partial_checkout.project_directory
2798 );
2799 assert_eq!(
2800 cleanup_failed.managed_worktree,
2801 partial_checkout.managed_worktree
2802 );
2803 assert!(failure.to_string().contains("cleanup"));
2804 }
2805 #[test]
2806 fn failed_worktree_cleanup_notice_names_mjolnir_and_the_recovery_command() {
2807 let notice = worktree_cleanup_notice(
2808 Path::new("/workspace/project"),
2809 &anyhow::anyhow!("permission denied"),
2810 );
2811
2812 assert!(
2813 notice.starts_with(
2814 "Mjolnir could not remove the worktree at /workspace/project: permission denied."
2815 ),
2816 "{notice}"
2817 );
2818 assert!(
2819 notice.contains("`git worktree remove --force /workspace/project`"),
2820 "{notice}"
2821 );
2822 assert!(!notice.contains("Hel"), "{notice}");
2823 }
2824 #[test]
2825 fn failed_resume_provisioning_preserves_checkpoint_and_projection_lineage() {
2826 if std::env::var_os(RESUME_ROLLBACK_TEST_CHILD).is_none() {
2829 let directory = tempfile::tempdir().unwrap();
2830 let test_name = format!(
2831 "{}::failed_resume_provisioning_preserves_checkpoint_and_projection_lineage",
2832 module_path!()
2833 .strip_prefix("mj_controller::")
2834 .unwrap_or(module_path!())
2835 );
2836 let output = Command::new(std::env::current_exe().unwrap())
2837 .args(["--exact", &test_name, "--nocapture"])
2838 .env(RESUME_ROLLBACK_TEST_CHILD, "1")
2839 .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
2842 .env("MJ_DATA_DIR", directory.path())
2843 .env("GH_TOKEN", "test-token")
2844 .output()
2845 .unwrap();
2846 assert!(
2847 output.status.success(),
2848 "isolated resume rollback test failed\nstdout:\n{}\nstderr:\n{}",
2849 String::from_utf8_lossy(&output.stdout),
2850 String::from_utf8_lossy(&output.stderr)
2851 );
2852 return;
2853 }
2854 let _writer = hel::hel_database::install_isolated_test_writer();
2856
2857 #[derive(Default)]
2860 struct FailingPreflightExecutor {
2861 mounts_during_provisioning: Mutex<Option<Vec<AdditionalMount>>>,
2862 }
2863
2864 impl CommandExecutor for FailingPreflightExecutor {
2865 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2866 if command.program == "stat" {
2869 return Ok(CommandOutput {
2870 status: 0,
2871 stdout: b"ext4\n".to_vec(),
2872 stderr: Vec::new(),
2873 });
2874 }
2875 assert_eq!(command.program, "podman");
2876 let mut observed = self.mounts_during_provisioning.lock().unwrap();
2877 if observed.is_none() {
2878 let durable = hel::hel_database::load_state().unwrap();
2879 *observed = Some(
2880 durable.sessions["0123456789abcdef0123456789abcdef"]
2881 .additional_mounts
2882 .clone(),
2883 );
2884 }
2885 Ok(CommandOutput {
2886 status: 1,
2887 stdout: Vec::new(),
2888 stderr: b"podman is temporarily unavailable".to_vec(),
2889 })
2890 }
2891 }
2892
2893 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
2894 let archive_directory = data_directory.join("archives");
2895 std::fs::create_dir_all(&archive_directory).unwrap();
2896 let session_id = "0123456789abcdef0123456789abcdef";
2897 let checkpoint = super::super::test_support::write_network_checkpoint_archive(
2898 &archive_directory,
2899 session_id,
2900 7,
2901 );
2902 let archive = verify_archive_streaming(&checkpoint.archive_path).unwrap();
2903 let expected_projection =
2904 materialized_session_from_canonical(session_id, &archive.canonical_session).unwrap();
2905
2906 let mut session = checkpoint_test_session(session_id);
2907 session.state = SessionState::Stopped;
2908 session.checkpoint = Some(checkpoint.clone());
2909 session.additional_mounts = vec![AdditionalMount {
2910 source: PathBuf::from("/host/old"),
2911 destination: PathBuf::from("/mnt/old"),
2912 read_only: false,
2913 }];
2914 let previous = session.clone();
2915 let resumed_mounts = vec![AdditionalMount {
2916 source: PathBuf::from("/host/new"),
2917 destination: PathBuf::from("/mnt/new"),
2918 read_only: false,
2919 }];
2920 let profile_home = data_directory.join("profile");
2921 std::fs::create_dir_all(&profile_home).unwrap();
2922 let mut config = HelConfig::default();
2923 config.profiles.insert(
2924 "codex".into(),
2925 HarnessProfile {
2926 kind: hel::hel_config::HarnessKind::Codex,
2927 home: profile_home,
2928 environment: BTreeMap::new(),
2929 context_window_bytes: None,
2930 },
2931 );
2932 config.bundles.insert(
2933 "project".into(),
2934 ProjectBundle {
2935 primary_repo: "project".into(),
2936 repositories: vec![ProjectRepository {
2937 id: "project".into(),
2938 github: None,
2939 local: Some(data_directory.join("host-clone-that-no-longer-exists")),
2940 destination: "project".into(),
2941 git_ref: None,
2942 }],
2943 },
2944 );
2945 config.targets.insert(
2946 "podman".into(),
2947 TargetTemplate::LocalPodman {
2948 container: ConfigContainer {
2949 image: "example.invalid/hel-test:latest".into(),
2950 pull_policy: Default::default(),
2951 platform: None,
2952 cpus: None,
2953 memory: None,
2954 environment: BTreeMap::new(),
2955 workspace_storage: Default::default(),
2956 },
2957 },
2958 );
2959 let mut controller = Controller {
2960 config,
2961 state: HelState {
2962 sessions: BTreeMap::from([(session_id.into(), session)]),
2963 ..HelState::default()
2964 },
2965 };
2966 hel::hel_database::save_state(&controller.state).unwrap();
2967 hel::hel_database::save_materialized_session(&expected_projection).unwrap();
2968
2969 let runtime = tokio::runtime::Builder::new_current_thread()
2970 .enable_all()
2971 .build()
2972 .unwrap();
2973 let executor = FailingPreflightExecutor::default();
2974 let error = runtime
2975 .block_on(controller.resume_session_controlled(
2976 session_id,
2977 "codex",
2978 "podman",
2979 SessionResumeOptions {
2980 additional_mounts: Some(resumed_mounts.clone()),
2981 resource_allocation: None,
2982 discard_queue: false,
2983 },
2984 &executor,
2985 ))
2986 .unwrap_err();
2987 let detail = format!("{error:#}");
2988 assert!(
2989 detail.contains("podman is temporarily unavailable"),
2990 "{detail}"
2991 );
2992 assert!(!detail.contains("returned to stopped"), "{detail}");
2993 assert!(!detail.contains("unknown session"), "{detail}");
2994 assert_eq!(
2995 executor.mounts_during_provisioning.into_inner().unwrap(),
2996 Some(resumed_mounts)
2997 );
2998
2999 let retained = controller.state.sessions.get(session_id).unwrap();
3000 assert_eq!(retained.state, SessionState::Stopped);
3001 assert_eq!(retained.checkpoint, previous.checkpoint);
3002 assert_eq!(retained.managed_worktree, previous.managed_worktree);
3003 assert!(checkpoint.archive_path.is_file());
3004
3005 let durable = hel::hel_database::load_state().unwrap();
3006 let durable_session = durable.sessions.get(session_id).unwrap();
3007 assert_eq!(durable_session.state, SessionState::Stopped);
3008 assert_eq!(durable_session.checkpoint, previous.checkpoint);
3009 assert_eq!(
3010 durable_session.additional_mounts,
3011 previous.additional_mounts
3012 );
3013 assert_eq!(
3014 hel::hel_database::load_materialized_session(session_id).unwrap(),
3015 Some(expected_projection)
3016 );
3017 }
3018 #[test]
3019 fn failed_resume_retires_a_checkout_it_recreated() {
3020 if std::env::var_os(RETIRED_WORKTREE_RESUME_TEST_CHILD).is_none() {
3021 let directory = tempfile::tempdir().unwrap();
3022 let test_name = format!(
3023 "{}::failed_resume_retires_a_checkout_it_recreated",
3024 module_path!()
3025 .strip_prefix("mj_controller::")
3026 .unwrap_or(module_path!())
3027 );
3028 let output = Command::new(std::env::current_exe().unwrap())
3029 .args(["--exact", &test_name, "--nocapture"])
3030 .env(RETIRED_WORKTREE_RESUME_TEST_CHILD, "1")
3031 .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
3034 .env("MJ_DATA_DIR", directory.path().join("data"))
3035 .env("MJ_CONFIG_DIR", directory.path().join("config"))
3036 .output()
3037 .unwrap();
3038 assert!(
3039 output.status.success(),
3040 "isolated retired-worktree resume test failed\nstdout:\n{}\nstderr:\n{}",
3041 String::from_utf8_lossy(&output.stdout),
3042 String::from_utf8_lossy(&output.stderr)
3043 );
3044 return;
3045 }
3046 let _writer = hel::hel_database::install_isolated_test_writer();
3048
3049 struct FailAfterWorktreeRestore;
3050
3051 impl CommandExecutor for FailAfterWorktreeRestore {
3052 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3053 if matches!(command.program.as_str(), "git" | "mkdir") {
3054 return ProcessExecutor.execute(command);
3055 }
3056 Ok(CommandOutput {
3057 status: 1,
3058 stdout: Vec::new(),
3059 stderr: b"stop after recreating the checkout".to_vec(),
3060 })
3061 }
3062 }
3063
3064 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
3065 let archive_directory = data_directory.join("archives");
3066 std::fs::create_dir_all(&archive_directory).unwrap();
3067 let session_id = "0123456789abcdef0123456789abcdef";
3068 let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
3069 let repository = committed_repository();
3070 let mut session = managed_worktree_session(repository.path(), session_id);
3071 session.checkpoint = Some(checkpoint);
3072 let worktree = session.managed_worktree.clone().unwrap();
3073 retire_managed_worktree(&ProcessExecutor, &worktree).unwrap();
3074 assert!(!worktree.worktree_root.exists());
3075
3076 let profile_home = data_directory.join("profile");
3077 std::fs::create_dir_all(&profile_home).unwrap();
3078 let mut config = resume_compatibility_config();
3079 config.profiles.insert(
3080 "codex".into(),
3081 HarnessProfile {
3082 kind: hel::hel_config::HarnessKind::Codex,
3083 home: profile_home,
3084 environment: BTreeMap::new(),
3085 context_window_bytes: None,
3086 },
3087 );
3088 let mut controller = Controller {
3089 config,
3090 state: HelState {
3091 sessions: BTreeMap::from([(session_id.into(), session)]),
3092 ..HelState::default()
3093 },
3094 };
3095 hel::hel_database::save_state(&controller.state).unwrap();
3096
3097 let error = tokio::runtime::Builder::new_current_thread()
3098 .enable_all()
3099 .build()
3100 .unwrap()
3101 .block_on(controller.resume_session_controlled(
3102 session_id,
3103 "codex",
3104 "local-bare",
3105 SessionResumeOptions {
3106 additional_mounts: None,
3107 resource_allocation: None,
3108 discard_queue: false,
3109 },
3110 &FailAfterWorktreeRestore,
3111 ))
3112 .unwrap_err();
3113
3114 assert!(
3115 format!("{error:#}").contains("stop after recreating the checkout"),
3116 "{error:#}"
3117 );
3118 assert!(!worktree.worktree_root.exists());
3119 assert_eq!(
3120 controller.state.sessions[session_id].state,
3121 SessionState::Stopped
3122 );
3123 let branch = Command::new("git")
3124 .arg("-C")
3125 .arg(repository.path())
3126 .args([
3127 "show-ref",
3128 "--verify",
3129 &format!("refs/heads/{}", worktree.branch),
3130 ])
3131 .status()
3132 .unwrap();
3133 assert!(branch.success(), "resume rollback must retain the branch");
3134 }
3135 const RAW_CONVERSION_TEST_CHILD: &str = "MJ_RAW_CONVERSION_TEST_CHILD";
3136 #[test]
3137 fn unsupported_raw_conversion_leaves_config_and_worktree_unchanged() {
3138 if std::env::var_os(RAW_CONVERSION_TEST_CHILD).is_none() {
3141 let directory = tempfile::tempdir().unwrap();
3142 let test_name = format!(
3143 "{}::unsupported_raw_conversion_leaves_config_and_worktree_unchanged",
3144 module_path!()
3145 .strip_prefix("mj_controller::")
3146 .unwrap_or(module_path!())
3147 );
3148 let output = Command::new(std::env::current_exe().unwrap())
3149 .args(["--exact", &test_name, "--nocapture"])
3150 .env(RAW_CONVERSION_TEST_CHILD, "1")
3151 .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
3154 .env("MJ_DATA_DIR", directory.path().join("data"))
3155 .env("MJ_CONFIG_DIR", directory.path().join("config"))
3156 .env("GH_TOKEN", "test-token")
3157 .output()
3158 .unwrap();
3159 assert!(
3160 output.status.success(),
3161 "isolated raw conversion test failed\nstdout:\n{}\nstderr:\n{}",
3162 String::from_utf8_lossy(&output.stdout),
3163 String::from_utf8_lossy(&output.stderr)
3164 );
3165 return;
3166 }
3167 let _writer = hel::hel_database::install_isolated_test_writer();
3169
3170 struct GitWithoutPodmanExecutor;
3173
3174 impl CommandExecutor for GitWithoutPodmanExecutor {
3175 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3176 if command.program == "git" {
3177 return ProcessExecutor.execute(command);
3178 }
3179 Ok(CommandOutput {
3180 status: 1,
3181 stdout: Vec::new(),
3182 stderr: b"podman is temporarily unavailable".to_vec(),
3183 })
3184 }
3185 }
3186
3187 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
3188 let archive_directory = data_directory.join("archives");
3189 std::fs::create_dir_all(&archive_directory).unwrap();
3190 std::fs::create_dir_all(hel::hel_config::config_dir()).unwrap();
3191 let session_id = "0123456789abcdef0123456789abcdef";
3192 let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
3193
3194 let repository = committed_repository();
3195 let mut session = managed_worktree_session(repository.path(), session_id);
3196 session.checkpoint = Some(checkpoint);
3197 let worktree = session.managed_worktree.clone().unwrap();
3198 let previous = session.clone();
3199
3200 let profile_home = data_directory.join("profile");
3201 std::fs::create_dir_all(&profile_home).unwrap();
3202 let mut config = resume_compatibility_config();
3203 config.profiles.insert(
3204 "codex".into(),
3205 HarnessProfile {
3206 kind: hel::hel_config::HarnessKind::Codex,
3207 home: profile_home,
3208 environment: BTreeMap::new(),
3209 context_window_bytes: None,
3210 },
3211 );
3212 config.save().unwrap();
3215 let original_config = config.clone();
3216 let mut controller = Controller {
3217 config,
3218 state: HelState {
3219 sessions: BTreeMap::from([(session_id.into(), session)]),
3220 ..HelState::default()
3221 },
3222 };
3223 hel::hel_database::save_state(&controller.state).unwrap();
3224
3225 let error = tokio::runtime::Builder::new_current_thread()
3226 .enable_all()
3227 .build()
3228 .unwrap()
3229 .block_on(controller.resume_session_controlled(
3230 session_id,
3231 "codex",
3232 "podman",
3233 SessionResumeOptions {
3234 additional_mounts: None,
3235 resource_allocation: None,
3236 discard_queue: false,
3237 },
3238 &GitWithoutPodmanExecutor,
3239 ))
3240 .unwrap_err();
3241 assert!(
3242 format!("{error:#}").contains("network repository provenance"),
3243 "{error:#}"
3244 );
3245 assert!(!format!("{error:#}").contains("returned to stopped"));
3246
3247 assert_eq!(controller.config, original_config);
3248 assert_eq!(hel::hel_config::HelConfig::load().unwrap(), original_config);
3249
3250 let retained = controller.state.sessions.get(session_id).unwrap();
3251 assert_eq!(retained.state, SessionState::Stopped);
3252 assert_eq!(retained.project_directory, previous.project_directory);
3253 assert_eq!(retained.managed_worktree, previous.managed_worktree);
3254 assert_eq!(retained.bundle_id, previous.bundle_id);
3255 assert!(worktree.worktree_root.is_dir(), "the checkout stays put");
3256 }
3257}