1use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::Duration;
6
7use agent_client_protocol::schema::v1::ContentBlock;
8use anyhow::{Context, Result, bail, ensure};
9use rayon::prelude::*;
10use serde::{Deserialize, Serialize};
11
12use crate::hel_session_manager::new_command_id;
13use hel::hel_archive::{
14 CanonicalQueuedCommandKind, CanonicalSessionSnapshot, CheckpointRepositoryBundle, SystemGit,
15 checkpoint_bundle_prerequisites, read_checkpoint_repository_bundles, verify_archive_streaming,
16};
17use hel::hel_checkpoint::{CheckpointRestoreSpec, restore_command};
18use hel::hel_config::{HelConfig, ProjectRepository, mount_history_host};
19use hel::hel_projection::materialized_session_from_canonical;
20use hel::hel_state::{MaterializedSession, SessionRecord, SessionResourceAllocation, SessionState};
21use hel::hel_targets::{
22 self, AdditionalMount, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec,
23 ProcessExecutor, ProvisionStage, ProvisionStageGuard,
24};
25use hel::hel_worker::RelayCommand;
26
27use super::backend::{backend_locator, controller_github_token, validate_resource_allocation};
28use super::checkpoint::upload_checkpoint_spec;
29use super::provisioning::{
30 LocalBootstrap, ProvisioningFailureDisposition, StagedExecutor, execute_concurrent_lanes,
31 install_attached_resources,
32};
33use super::readiness::{connect_started_worker, wait_for_native_session_in_stage};
34use super::worker_binary::{bridge_readiness_stage, start_worker, worker_probe_diagnosis};
35use super::worktree::{
36 PrimaryCheckoutRequirement, ResumeConversion, ResumePlan, apply_raw_to_workspace,
37 apply_workspace_to_raw, cleanup_managed_worktree, create_managed_worktree,
38 managed_worktree_checkout_exists, plan_raw_to_workspace, raw_checkout_divergence_notice,
39 raw_checkout_position, restore_managed_worktree, resume_compatibility, retire_managed_worktree,
40};
41use super::{
42 Controller, SessionResumeOptions, execute_checked, now, selected_host_container_size,
43 target_profile_home,
44};
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ResumeRepositorySourceMismatch {
48 pub session_id: String,
49 pub bundle_id: String,
50 pub repository_id: String,
51 pub missing_commit: String,
52 pub archived_origin: String,
53 pub configured_origin: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct ResumeRepositorySourceReceipt {
59 session_id: String,
60 bundle_id: String,
61 checkpoint_sha256: String,
62 repositories: Vec<ProjectRepository>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum ResumeRepositorySourcePreflight {
67 Ready(ResumeRepositorySourceReceipt),
68 RepositoryMoved(ResumeRepositorySourceMismatch),
69}
70
71struct ResumeRepositoryBundles {
72 checkpoint_sha256: String,
73 repositories: Vec<CheckpointRepositoryBundle>,
74}
75
76impl Controller {
77 pub fn preflight_resume_repository_sources(
80 &self,
81 session_id: &str,
82 target_id: &str,
83 executor: &(impl CommandExecutor + Sync),
84 ) -> Result<ResumeRepositorySourcePreflight> {
85 let session = self
86 .state
87 .sessions
88 .get(session_id)
89 .with_context(|| format!("unknown session {session_id}"))?;
90 let checkpoint = session
91 .checkpoint
92 .as_ref()
93 .context("session has no checkpoint")?;
94 let plan = resume_compatibility(session, &self.config, target_id)
95 .map_err(|reason| anyhow::anyhow!(reason))?;
96 if session.project_directory.is_some() {
97 debug_assert!(matches!(
98 plan,
99 ResumePlan::InPlace | ResumePlan::RawToWorkspace
100 ));
101 return Ok(ResumeRepositorySourcePreflight::Ready(
106 ResumeRepositorySourceReceipt {
107 session_id: session_id.to_owned(),
108 bundle_id: session.bundle_id.clone(),
109 checkpoint_sha256: checkpoint.sha256.clone(),
110 repositories: Vec::new(),
111 },
112 ));
113 }
114 let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
115 self.preflight_verified_repository_sources(
116 session_id,
117 ResumeRepositoryBundles {
118 checkpoint_sha256: checkpoint.sha256.clone(),
119 repositories,
120 },
121 None,
122 executor,
123 )
124 }
125
126 fn preflight_verified_repository_sources(
127 &self,
128 session_id: &str,
129 verified: ResumeRepositoryBundles,
130 skip_repository_id: Option<&str>,
131 executor: &(impl CommandExecutor + Sync),
132 ) -> Result<ResumeRepositorySourcePreflight> {
133 let session = self
134 .state
135 .sessions
136 .get(session_id)
137 .with_context(|| format!("unknown session {session_id}"))?;
138 if verified.repositories.is_empty() {
139 return Ok(ResumeRepositorySourcePreflight::Ready(
140 ResumeRepositorySourceReceipt {
141 session_id: session_id.to_owned(),
142 bundle_id: session.bundle_id.clone(),
143 checkpoint_sha256: verified.checkpoint_sha256,
144 repositories: Vec::new(),
145 },
146 ));
147 }
148 let bundle = self
149 .config
150 .bundles
151 .get(&session.bundle_id)
152 .with_context(|| format!("session bundle {:?} is missing", session.bundle_id))?;
153 let configured = verified
154 .repositories
155 .iter()
156 .map(|archived| {
157 bundle
158 .repositories
159 .iter()
160 .find(|repository| repository.id == archived.metadata.id)
161 .cloned()
162 .with_context(|| {
163 format!(
164 "session bundle {:?} no longer contains repository {:?}",
165 session.bundle_id, archived.metadata.id
166 )
167 })
168 })
169 .collect::<Result<Vec<_>>>()?;
170 let github_token = configured
171 .iter()
172 .any(|repository| repository.github.is_some())
173 .then(controller_github_token)
174 .flatten();
175 let outcomes = verified
176 .repositories
177 .par_iter()
178 .zip(configured.par_iter())
179 .map(|(archived, configured)| {
180 if skip_repository_id == Some(configured.id.as_str()) {
181 return Ok(None);
182 }
183 checkpoint_source_missing_commit(
184 configured,
185 archived,
186 executor,
187 github_token.as_deref(),
188 )
189 .map(|missing_commit| {
190 missing_commit.map(|missing_commit| ResumeRepositorySourceMismatch {
191 session_id: session_id.to_owned(),
192 bundle_id: session.bundle_id.clone(),
193 repository_id: configured.id.clone(),
194 missing_commit,
195 archived_origin: archived.metadata.origin.clone(),
196 configured_origin: configured.source_label(),
197 })
198 })
199 })
200 .collect::<Vec<Result<Option<ResumeRepositorySourceMismatch>>>>();
201 for outcome in outcomes {
202 if let Some(mismatch) = outcome? {
203 return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(mismatch));
204 }
205 }
206 Ok(ResumeRepositorySourcePreflight::Ready(
207 ResumeRepositorySourceReceipt {
208 session_id: session_id.to_owned(),
209 bundle_id: session.bundle_id.clone(),
210 checkpoint_sha256: verified.checkpoint_sha256,
211 repositories: configured,
212 },
213 ))
214 }
215
216 fn repository_source_receipt_is_current(
217 &self,
218 session_id: &str,
219 receipt: &ResumeRepositorySourceReceipt,
220 ) -> bool {
221 let Some(session) = self.state.sessions.get(session_id) else {
222 return false;
223 };
224 if receipt.session_id != session_id
225 || receipt.bundle_id != session.bundle_id
226 || session
227 .checkpoint
228 .as_ref()
229 .map(|checkpoint| &checkpoint.sha256)
230 != Some(&receipt.checkpoint_sha256)
231 {
232 return false;
233 }
234 if receipt.repositories.is_empty() {
235 return true;
236 }
237 let Some(bundle) = self.config.bundles.get(&session.bundle_id) else {
238 return false;
239 };
240 receipt.repositories.iter().all(|expected| {
241 bundle
242 .repositories
243 .iter()
244 .any(|configured| configured == expected)
245 })
246 }
247
248 pub fn replace_resume_repository_origin(
252 &mut self,
253 session_id: &str,
254 repository_id: &str,
255 replacement: &str,
256 executor: &(impl CommandExecutor + Sync),
257 ) -> Result<ResumeRepositorySourcePreflight> {
258 let session = self
259 .state
260 .sessions
261 .get(session_id)
262 .with_context(|| format!("unknown session {session_id}"))?;
263 let bundle_id = session.bundle_id.clone();
264 let checkpoint = session
265 .checkpoint
266 .as_ref()
267 .context("session has no checkpoint")?;
268 let replacement = replacement_repository_source(repository_id, replacement)?;
269 let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
270 let verified = ResumeRepositoryBundles {
271 checkpoint_sha256: checkpoint.sha256.clone(),
272 repositories,
273 };
274 let archived = verified
275 .repositories
276 .iter()
277 .find(|repository| repository.metadata.id == repository_id)
278 .with_context(|| format!("checkpoint does not contain repository {repository_id:?}"))?;
279 if let Some(missing_commit) = checkpoint_source_missing_commit(
280 &replacement,
281 archived,
282 executor,
283 controller_github_token().as_deref(),
284 )? {
285 return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(
286 ResumeRepositorySourceMismatch {
287 session_id: session_id.to_owned(),
288 bundle_id,
289 repository_id: repository_id.to_owned(),
290 missing_commit,
291 archived_origin: archived.metadata.origin.clone(),
292 configured_origin: replacement.source_label(),
293 },
294 ));
295 }
296 let bundle = self
297 .config
298 .bundles
299 .get_mut(&bundle_id)
300 .with_context(|| format!("session bundle {bundle_id:?} is missing"))?;
301 let repository = bundle
302 .repositories
303 .iter_mut()
304 .find(|repository| repository.id == repository_id)
305 .with_context(|| {
306 format!(
307 "session bundle {:?} no longer contains repository {repository_id:?}",
308 bundle_id
309 )
310 })?;
311 repository.github = replacement.github;
312 repository.local = replacement.local;
313 self.config.save()?;
314 self.preflight_verified_repository_sources(
315 session_id,
316 verified,
317 Some(repository_id),
318 executor,
319 )
320 }
321}
322
323fn replacement_repository_source(id: &str, replacement: &str) -> Result<ProjectRepository> {
324 let replacement = replacement.trim();
325 ensure!(!replacement.is_empty(), "enter the repository's new origin");
326 let path = Path::new(replacement);
327 let (github, local) = if path.is_absolute() {
328 ensure!(
329 path.is_dir(),
330 "local repository {replacement:?} is not a directory"
331 );
332 (None, Some(hel::hel_local_git::canonical_repository(path)?))
333 } else {
334 let github = crate::hel_setup::github_repository_from_origin(replacement)
335 .context("origin must be a GitHub repository or an absolute local repository path")?;
336 (
337 Some(format!("{}/{}", github.owner, github.repository)),
338 None,
339 )
340 };
341 Ok(ProjectRepository {
342 id: id.to_owned(),
343 github,
344 local,
345 destination: PathBuf::from(id),
346 git_ref: None,
347 })
348}
349
350fn checkpoint_source_missing_commit(
351 configured: &ProjectRepository,
352 archived: &CheckpointRepositoryBundle,
353 executor: &impl CommandExecutor,
354 github_token: Option<&str>,
355) -> Result<Option<String>> {
356 let staging = tempfile::tempdir().context("create repository source preflight")?;
357 let repository = staging.path().join("repository.git");
358 checked_preflight_git(
359 executor,
360 CommandSpec::new(
361 "git",
362 [
363 "init".to_owned(),
364 "--bare".to_owned(),
365 "--quiet".to_owned(),
366 repository.to_string_lossy().into_owned(),
367 ],
368 )
369 .purpose("initialize repository source preflight"),
370 )?;
371 let missing = checkpoint_bundle_prerequisites(archived)?;
372 if missing.is_empty() {
373 let bundle = staging.path().join("checkpoint.bundle");
374 std::fs::write(&bundle, &archived.committed_bundle)
375 .context("write self-contained checkpoint bundle for source preflight")?;
376 checked_preflight_git(
377 executor,
378 checkpoint_bundle_import_command(&repository, &bundle),
379 )?;
380 return Ok(None);
381 }
382 for commit in missing {
386 let output = fetch_source_commit(executor, &repository, configured, &commit, github_token)?;
387 if output.status != 0 {
388 let stderr = String::from_utf8_lossy(&output.stderr);
389 if source_does_not_have_commit(&stderr) {
390 return Ok(Some(commit));
391 }
392 bail!(
393 "could not check configured source {:?}: {}",
394 configured.source_label(),
395 stderr.trim()
396 );
397 }
398 }
399 Ok(None)
403}
404
405fn checkpoint_bundle_import_command(repository: &Path, bundle: &Path) -> CommandSpec {
406 let mut command = CommandSpec::new(
407 "git",
408 [
409 "-C".to_owned(),
410 repository.to_string_lossy().into_owned(),
411 "fetch".to_owned(),
412 "--no-tags".to_owned(),
413 bundle.to_string_lossy().into_owned(),
414 "HEAD".to_owned(),
415 ],
416 )
417 .purpose("validate self-contained checkpoint bundle");
418 command
419 .env
420 .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
421 command
422 .env
423 .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
424 command
425}
426
427fn fetch_source_commit(
428 executor: &impl CommandExecutor,
429 repository: &Path,
430 configured: &ProjectRepository,
431 commit: &str,
432 github_token: Option<&str>,
433) -> Result<CommandOutput> {
434 let mut arguments = Vec::new();
435 let mut token_auth = false;
436 let mut ssh_transport = false;
437 let source = if let Some(local) = &configured.local {
438 local.to_string_lossy().into_owned()
439 } else {
440 let source = configured
441 .github
442 .as_deref()
443 .context("repository source is missing")?;
444 let github = crate::hel_setup::github_repository_from_origin(source)
445 .context("configured repository is not a GitHub source")?;
446 if github_token.is_some() {
447 token_auth = true;
448 arguments.extend([
449 "-c".to_owned(),
450 "credential.helper=".to_owned(),
451 "-c".to_owned(),
452 "credential.helper=!f() { if [ \"$1\" = get ]; then echo username=x-access-token; echo \"password=$GH_TOKEN\"; fi; }; f".to_owned(),
453 ]);
454 format!(
455 "https://github.com/{}/{}.git",
456 github.owner, github.repository
457 )
458 } else {
459 ssh_transport = true;
460 format!("git@github.com:{}/{}.git", github.owner, github.repository)
461 }
462 };
463 arguments.extend([
464 "-C".to_owned(),
465 repository.to_string_lossy().into_owned(),
466 "fetch".to_owned(),
467 "--no-tags".to_owned(),
468 "--depth=1".to_owned(),
469 "--filter=blob:none".to_owned(),
470 source,
471 commit.to_owned(),
472 ]);
473 let mut command = CommandSpec::new("git", arguments).purpose("check checkpoint base commit");
474 command
475 .env
476 .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
477 command
478 .env
479 .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
480 if token_auth {
481 let token = github_token.expect("token authentication requires a GitHub token");
482 command.env.insert("GH_TOKEN".to_owned(), token.to_owned());
483 }
484 if ssh_transport {
485 command.env.insert(
486 "GIT_SSH_COMMAND".to_owned(),
487 "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15"
488 .to_owned(),
489 );
490 }
491 executor.execute(&command)
492}
493
494fn source_does_not_have_commit(stderr: &str) -> bool {
495 let stderr = stderr.to_ascii_lowercase();
496 [
497 "not our ref",
498 "couldn't find remote ref",
499 "not a valid object name",
500 "no such ref was fetched",
501 ]
502 .iter()
503 .any(|needle| stderr.contains(needle))
504}
505
506fn checked_preflight_git(
507 executor: &impl CommandExecutor,
508 command: CommandSpec,
509) -> Result<CommandOutput> {
510 let output = executor.execute(&command)?;
511 ensure!(
512 output.status == 0,
513 "{}: {}",
514 command.purpose,
515 String::from_utf8_lossy(&output.stderr).trim()
516 );
517 Ok(output)
518}
519
520impl Controller {
521 pub async fn resume_session_with_options(
526 &mut self,
527 session_id: &str,
528 profile_id: &str,
529 target_id: &str,
530 additional_mounts: Option<Vec<AdditionalMount>>,
531 resource_allocation: Option<SessionResourceAllocation>,
532 ) -> Result<MaterializedSession> {
533 self.resume_session_with_options_and_queue_disposition(
534 session_id,
535 profile_id,
536 target_id,
537 additional_mounts,
538 resource_allocation,
539 false,
540 )
541 .await
542 }
543
544 pub async fn resume_session_with_options_and_queue_disposition(
545 &mut self,
546 session_id: &str,
547 profile_id: &str,
548 target_id: &str,
549 additional_mounts: Option<Vec<AdditionalMount>>,
550 resource_allocation: Option<SessionResourceAllocation>,
551 discard_queue: bool,
552 ) -> Result<MaterializedSession> {
553 self.resume_session_controlled(
554 session_id,
555 profile_id,
556 target_id,
557 SessionResumeOptions {
558 additional_mounts,
559 resource_allocation,
560 discard_queue,
561 },
562 &ProcessExecutor,
563 )
564 .await
565 }
566
567 pub async fn resume_session_controlled(
568 &mut self,
569 session_id: &str,
570 profile_id: &str,
571 target_id: &str,
572 options: SessionResumeOptions,
573 executor: &(impl CommandExecutor + Sync),
574 ) -> Result<MaterializedSession> {
575 self.resume_session_controlled_with_repository_preflight(
576 session_id, profile_id, target_id, options, None, executor,
577 )
578 .await
579 }
580
581 pub async fn resume_session_controlled_with_repository_preflight(
582 &mut self,
583 session_id: &str,
584 profile_id: &str,
585 target_id: &str,
586 options: SessionResumeOptions,
587 repository_preflight: Option<ResumeRepositorySourceReceipt>,
588 executor: &(impl CommandExecutor + Sync),
589 ) -> Result<MaterializedSession> {
590 let SessionResumeOptions {
591 additional_mounts,
592 resource_allocation,
593 discard_queue,
594 } = options;
595 let previous = self
596 .state
597 .sessions
598 .get(session_id)
599 .with_context(|| format!("unknown session {session_id}"))?
600 .clone();
601 if !matches!(
602 previous.state,
603 SessionState::Stopped | SessionState::Lost | SessionState::Error
604 ) {
605 bail!("session {session_id} is not stopped, lost, or retryable");
606 }
607 let checkpoint = previous
608 .checkpoint
609 .as_ref()
610 .context("session has no checkpoint")?;
611 if !repository_preflight
612 .as_ref()
613 .is_some_and(|receipt| self.repository_source_receipt_is_current(session_id, receipt))
614 && let ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) =
615 self.preflight_resume_repository_sources(session_id, target_id, executor)?
616 {
617 bail!(
618 "checkpoint base commit {} is missing from configured source {:?} for repository {:?}; the repository may have moved (archived origin: {:?})",
619 mismatch.missing_commit,
620 mismatch.configured_origin,
621 mismatch.repository_id,
622 mismatch.archived_origin,
623 );
624 }
625 let hel::hel_archive::VerifiedArchiveMetadata {
629 manifest: archive_manifest,
630 canonical_session,
631 archive_sha256,
632 } = verify_archive_streaming(&checkpoint.archive_path)?;
633 if archive_sha256 != checkpoint.sha256 || archive_manifest.session.id != session_id {
634 bail!("persisted checkpoint verification failed");
635 }
636 let canonical_session = Arc::new(canonical_session);
637 let profile = self
638 .config
639 .profiles
640 .get(profile_id)
641 .with_context(|| format!("unknown profile {profile_id:?}"))?
642 .clone();
643 let target_template = self
644 .config
645 .targets
646 .get(target_id)
647 .with_context(|| format!("unknown target template {target_id:?}"))?;
648 let plan = resume_compatibility(&previous, &self.config, target_id)
651 .map_err(|reason| anyhow::anyhow!("{reason}"))?;
652 if plan == ResumePlan::InPlace
653 && previous.managed_worktree.is_none()
654 && let Some(project_directory) = &previous.project_directory
655 {
656 self.validate_project_directory(target_id, project_directory, executor)
657 .context("raw project is unavailable for resume")?;
658 }
659 let conversion = match plan {
660 ResumePlan::InPlace => None,
661 ResumePlan::RawToWorkspace => Some(ResumeConversion::RawToWorkspace(
662 plan_raw_to_workspace(&previous, &self.config, executor)
663 .context("prepare the raw checkout for its new target")?,
664 )),
665 ResumePlan::WorkspaceToRaw => Some(ResumeConversion::WorkspaceToRaw(
666 self.plan_workspace_to_raw(&previous, target_id, executor)
667 .context("prepare a checkout for this session")?,
668 )),
669 };
670 let resource_allocation =
671 resource_allocation.or_else(|| previous.resource_allocation.clone());
672 let additional_mounts =
673 additional_mounts.unwrap_or_else(|| previous.additional_mounts.clone());
674 validate_resource_allocation(target_template, resource_allocation.as_ref())?;
675 let selected_container_size =
676 selected_host_container_size(target_template, resource_allocation.as_ref());
677 if !additional_mounts.is_empty() && mount_history_host(target_template).is_none() {
678 bail!("attached resources are unsupported for this target");
679 }
680 hel_targets::validate_additional_mounts(&additional_mounts)?;
681 let history_host = mount_history_host(target_template);
682 let history_mounts = additional_mounts.clone();
683 if previous.state == SessionState::Error
684 && let Some(locator) = &previous.target
685 {
686 let backend = backend_locator(locator, &previous, &self.config)?;
687 hel_targets::close_plan(&backend, session_id)?
688 .execute(executor)
689 .context("clean up target from failed resume")?;
690 }
691 let mut resume_notices = Vec::new();
692 if let Some(conversion) = conversion
693 .as_ref()
694 .and_then(ResumeConversion::raw_to_workspace)
695 && let Some(project_directory) = &previous.project_directory
696 {
697 resume_notices.push(match &conversion.retire {
698 Some(worktree) => format!(
699 "This session moved out of {} and into the {target_id} target. Its branch {} stays in {}.",
700 project_directory.display(),
701 worktree.branch,
702 worktree.source_repository.display()
703 ),
704 None => format!(
705 "This session moved out of {} and into the {target_id} target.",
706 project_directory.display()
707 ),
708 });
709 }
710 if let Some(conversion) = conversion
711 .as_ref()
712 .and_then(ResumeConversion::workspace_to_raw)
713 {
714 resume_notices.push(format!(
715 "This session moved out of its {} target and into {}. Its branch {} is now {}.",
716 previous.target_template_id,
717 conversion.worktree.worktree_root.display(),
718 archive_manifest
719 .repositories
720 .first()
721 .and_then(|repository| repository.metadata.branch.as_deref())
722 .unwrap_or("a detached head"),
723 conversion.worktree.branch,
724 ));
725 }
726 let managed_checkout_present = previous
727 .managed_worktree
728 .as_ref()
729 .map(|worktree| managed_worktree_checkout_exists(executor, worktree))
730 .transpose()?
731 .unwrap_or(true);
732 if managed_checkout_present && let Some(project_directory) = &previous.project_directory {
735 match raw_checkout_position(&previous, &self.config, project_directory, executor) {
736 Ok(live) => resume_notices.extend(raw_checkout_divergence_notice(
737 project_directory,
738 archive_manifest
739 .repositories
740 .first()
741 .map(|repository| &repository.metadata),
742 &live,
743 )),
744 Err(error) => tracing::warn!(
747 session_id,
748 error = format!("{error:#}"),
749 "could not read the raw checkout position for a resume notice"
750 ),
751 }
752 }
753 super::worker_binary::preflight_worker_binary(target_template)?;
758 let same_harness = profile.kind == archive_manifest.session.harness_kind;
759 let context_bytes = profile
760 .context_window_bytes
761 .unwrap_or(crate::hel_compaction::DEFAULT_CONTEXT_BYTES);
762 let utility_handoff = if same_harness {
763 None
764 } else {
765 let _compacting = ProvisionStageGuard::new(executor, ProvisionStage::Compacting);
766 Some(
767 utility_handoff_while_cancellable(
768 &self.config,
769 &canonical_session,
770 context_bytes,
771 executor,
772 )
773 .await
774 .context("compact the cross-harness handoff transcript")?,
775 )
776 };
777 let discard_queued_prompts = discard_queue || !same_harness;
778 let stored_frontier = hel::hel_database::materialized_event_frontier(session_id)
782 .unwrap_or_else(|error| {
783 tracing::warn!(
784 session_id,
785 error = format!("{error:#}"),
786 "could not read the stored projection frontier; rebuilding it from the archive"
787 );
788 None
789 });
790 let rebuild_projection = projection_rebuild_required(
791 stored_frontier
792 .as_ref()
793 .map(|(ordinal, digest)| (*ordinal, digest.as_str())),
794 canonical_session.event_frontier,
795 &canonical_session.event_frontier_digest,
796 );
797 let projection_build = rebuild_projection.then(|| {
807 let canonical = Arc::clone(&canonical_session);
808 let session_id = session_id.to_owned();
809 tokio::task::spawn_blocking(move || {
810 materialized_session_from_canonical(session_id, &canonical)
811 })
812 });
813 let github_token = controller_github_token();
814
815 if let Some(conversion) = conversion
818 .as_ref()
819 .and_then(ResumeConversion::raw_to_workspace)
820 && let Some(bundle) = &conversion.new_bundle
821 {
822 self.config
823 .bundles
824 .insert(conversion.bundle_id.clone(), bundle.clone());
825 self.config
826 .save()
827 .context("save the bundle for a converted raw session")?;
828 }
829
830 let record = self.state.sessions.get_mut(session_id).unwrap();
831 record.harness_kind = profile.kind;
832 record.last_profile = profile_id.to_string();
833 record.target_template_id = target_id.to_string();
834 record.resource_allocation = resource_allocation;
835 record.additional_mounts = additional_mounts;
836 record.target = None;
837 record.native_session_id =
838 same_harness.then(|| archive_manifest.session.native_session_id.clone());
839 record.state = SessionState::Provisioning;
840 record.updated_at = now();
841 record.last_error = None;
842 match &conversion {
843 Some(ResumeConversion::RawToWorkspace(conversion)) => {
844 apply_raw_to_workspace(record, conversion);
845 }
846 Some(ResumeConversion::WorkspaceToRaw(conversion)) => {
847 apply_workspace_to_raw(record, conversion);
848 }
849 None => {}
850 }
851 let resumed_project_directory = record.project_directory.clone();
852 if let Some(host) = history_host {
853 self.state.remember_mount_sources(host, &history_mounts);
854 hel::hel_database::remember_mount_sources(host, &history_mounts)?;
855 }
856 if let Some(conversion) = conversion
859 .as_ref()
860 .and_then(ResumeConversion::raw_to_workspace)
861 {
862 hel::hel_database::rebind_session_bundle(session_id, &conversion.bundle_id)?;
863 }
864 if let Some((host, size)) = selected_container_size.as_ref() {
867 hel::hel_database::save_session_with_container_size(
868 &self.state.sessions[session_id],
869 host,
870 *size,
871 )?;
872 } else {
873 hel::hel_database::save_session(&self.state.sessions[session_id])?;
874 }
875 if let Some((host, size)) = selected_container_size {
876 self.state.remember_container_size(&host, size);
877 }
878
879 let mut recreated_managed_worktree = false;
880 let result = async {
881 if let Some(worktree) = previous.managed_worktree.as_ref() {
882 recreated_managed_worktree = restore_managed_worktree(executor, worktree)?;
883 if recreated_managed_worktree && plan == ResumePlan::RawToWorkspace {
884 hel::hel_checkpoint::restore_single_repository_onto_branch(
885 &checkpoint.archive_path,
886 &worktree.worktree_root,
887 &worktree.branch,
888 &SystemGit,
889 )
890 .context("restore the retired checkout before moving it into a target")?;
891 }
892 }
893 if let Some(conversion) = conversion
896 .as_ref()
897 .and_then(ResumeConversion::workspace_to_raw)
898 {
899 create_managed_worktree(
900 executor,
901 &conversion.worktree,
902 None,
903 PrimaryCheckoutRequirement::Any,
904 )?;
905 hel::hel_checkpoint::restore_single_repository_onto_branch(
906 &checkpoint.archive_path,
907 &conversion.worktree.worktree_root,
908 &conversion.worktree.branch,
909 &SystemGit,
910 )
911 .context("restore this session's checkout")?;
912 }
913 self.provision_session_with_failure_disposition(
914 session_id,
915 executor,
916 github_token.as_deref(),
917 ProvisioningFailureDisposition::Preserve,
918 )
919 .await?;
920 let (backend, worker_root) = self.worker_placement(session_id)?;
921 let harness_home = target_profile_home(&backend, session_id, &profile);
922 let workspace_root = if let Some(project_directory) = &resumed_project_directory {
923 project_directory
924 .parent()
925 .context("bare project directory has no parent")?
926 .to_string_lossy()
927 .into_owned()
928 } else {
929 match &backend {
930 hel_targets::TargetLocator::LocalPodman { .. }
931 | hel_targets::TargetLocator::LocalDocker { .. }
932 | hel_targets::TargetLocator::AppleContainer { .. }
933 | hel_targets::TargetLocator::SshPodman { .. } => "/workspace".to_string(),
934 hel_targets::TargetLocator::AwsEc2 { workspace, .. }
935 | hel_targets::TargetLocator::SshBare { workspace, .. } => workspace.clone(),
936 hel_targets::TargetLocator::LocalBare { worker_root } => worker_root.clone(),
937 }
938 };
939 let target_path = |path: &str| match &backend {
940 hel_targets::TargetLocator::AwsEc2 { .. }
941 | hel_targets::TargetLocator::SshBare { .. }
942 if !path.starts_with('/') =>
943 {
944 PathBuf::from(format!("~/{path}"))
945 }
946 _ => PathBuf::from(path),
947 };
948 let remote_archive = format!("{worker_root}/restore.hel.zip");
949 let remote_spec = format!("{worker_root}/restore-spec.json");
950 let restore = CheckpointRestoreSpec {
951 archive_path: target_path(&remote_archive),
952 workspace_root: target_path(&workspace_root),
953 relay_root: target_path(&worker_root),
954 harness_home: target_path(&harness_home),
955 restore_repositories: (resumed_project_directory.is_none() && conversion.is_none())
959 || (recreated_managed_worktree && plan == ResumePlan::InPlace),
960 restore_native: same_harness,
961 primary_repository_root: conversion
965 .is_some()
966 .then(|| resumed_project_directory.clone())
967 .flatten()
968 .map(|directory| target_path(&directory.to_string_lossy())),
969 discard_queued_prompts,
970 };
971 {
978 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
979 if let Some(command) = hel_targets::clear_relay_state_plan(&backend, session_id)? {
980 execute_checked(syncing, command)?;
981 }
982 execute_checked(
984 syncing,
985 hel_targets::command_on_locator(
986 &backend,
987 session_id,
988 vec!["mkdir".into(), "-p".into(), worker_root.clone()],
989 "create the session worker root",
990 )?,
991 )?;
992 }
993 let staging = tempfile::tempdir().context("create restore staging")?;
994 let local_spec = staging.path().join("restore-spec.json");
995 std::fs::write(&local_spec, serde_json::to_vec_pretty(&restore)?)?;
996 let controller = &*self;
1005 let backend_ref = &backend;
1006 let worker_root_ref = worker_root.as_str();
1007 let local_spec_ref = local_spec.as_path();
1008 execute_concurrent_lanes(
1009 || {
1010 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1011 controller.prepare_worker_files(
1012 session_id,
1013 backend_ref,
1014 worker_root_ref,
1015 syncing,
1016 )?;
1017 super::provisioning::install_inherited_git_settings(
1018 syncing,
1019 backend_ref,
1020 session_id,
1021 )?;
1022 controller.connect_local_repositories(
1028 session_id,
1029 backend_ref,
1030 worker_root_ref,
1031 syncing,
1032 LocalBootstrap::Skip,
1033 )
1034 },
1035 || {
1036 let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1037 upload_checkpoint_spec(
1038 restoring,
1039 backend_ref,
1040 session_id,
1041 &checkpoint.archive_path,
1042 &remote_archive,
1043 )?;
1044 upload_checkpoint_spec(
1045 restoring,
1046 backend_ref,
1047 session_id,
1048 local_spec_ref,
1049 &remote_spec,
1050 )
1051 },
1052 )?;
1053 {
1054 let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1055 execute_checked(
1056 restoring,
1057 restore_command(&backend, session_id, &remote_spec)?,
1058 )?;
1059 }
1060 {
1061 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1062 install_attached_resources(
1063 &self.state,
1064 session_id,
1065 &backend,
1066 &worker_root,
1067 syncing,
1068 )?;
1069 self.connect_local_repositories(
1070 session_id,
1071 &backend,
1072 &worker_root,
1073 syncing,
1074 match conversion
1075 .as_ref()
1076 .and_then(ResumeConversion::raw_to_workspace)
1077 {
1078 Some(conversion) => LocalBootstrap::SeedFrom(conversion.checkout.clone()),
1079 None => LocalBootstrap::Seed,
1080 },
1081 )?;
1082 }
1083 match projection_build {
1084 Some(build) => {
1085 let mut restored_projection = build
1086 .await
1087 .context("rebuild the restored projection")?
1088 .context("rebuild the restored projection")?;
1089 if discard_queued_prompts {
1090 restored_projection.queued_prompts.clear();
1091 }
1092 hel::hel_database::save_materialized_session(&restored_projection)?;
1093 }
1094 None if discard_queued_prompts => {
1097 hel::hel_database::replace_materialized_queued_prompts(session_id, &[])?;
1098 }
1099 None => {}
1100 }
1101 let readiness_stage = bridge_readiness_stage(&profile);
1102 let spec = self.reconnect_command(session_id)?;
1103 let readiness = async {
1104 let mut relay = {
1105 let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
1106 start_worker(executor, &backend, &worker_root)?;
1107 connect_started_worker(&spec, session_id, executor, &backend, &worker_root)
1108 .await?
1109 };
1110 let native_session_id =
1111 wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
1112 Ok::<_, anyhow::Error>((relay, native_session_id))
1113 }
1114 .await;
1115 let (mut relay, native_session_id) = readiness
1116 .map_err(|error| worker_probe_diagnosis(executor, &backend, &worker_root, error))?;
1117 if same_harness {
1118 if native_session_id != archive_manifest.session.native_session_id {
1119 bail!(
1120 "ACP loaded native session {native_session_id}, expected {}",
1121 archive_manifest.session.native_session_id
1122 );
1123 }
1124 } else {
1125 relay
1126 .install_prompt_context(
1127 utility_handoff
1128 .clone()
1129 .context("cross-harness resume has no utility-model handoff")?,
1130 )
1131 .await?;
1132 if !discard_queue {
1133 for prompt in &canonical_session.queued_prompts {
1134 let command = match &prompt.kind {
1138 CanonicalQueuedCommandKind::Prompt => RelayCommand::Prompt {
1139 prompt: prompt
1140 .content
1141 .iter()
1142 .cloned()
1143 .map(serde_json::from_value)
1144 .collect::<serde_json::Result<Vec<ContentBlock>>>()?,
1145 },
1146 CanonicalQueuedCommandKind::SetConfig { key, value } => {
1147 RelayCommand::SetConfig {
1148 key: key.clone(),
1149 value: value.clone(),
1150 }
1151 }
1152 };
1153 relay.submit(prompt.command_id.clone(), command).await?;
1154 }
1155 }
1156 }
1157 if let Some(worktree) = conversion
1161 .as_ref()
1162 .and_then(ResumeConversion::raw_to_workspace)
1163 .and_then(|plan| plan.retire.as_ref())
1164 && let Err(error) = retire_managed_worktree(executor, worktree)
1165 {
1166 tracing::warn!(
1167 session_id,
1168 worktree = %worktree.worktree_root.display(),
1169 error = format!("{error:#}"),
1170 "could not retire the old managed worktree after resume"
1171 );
1172 resume_notices.push(worktree_cleanup_notice(&worktree.worktree_root, &error));
1173 }
1174 for notice in &resume_notices {
1175 let submitted = async {
1176 let command_id = new_command_id("resume-notice")?;
1177 relay
1178 .submit(
1179 command_id,
1180 RelayCommand::RecordNotice {
1181 text: notice.clone(),
1182 },
1183 )
1184 .await
1185 }
1186 .await;
1187 if let Err(error) = submitted {
1190 tracing::warn!(
1191 session_id,
1192 error = format!("{error:#}"),
1193 "could not record a resume notice in the conversation"
1194 );
1195 }
1196 }
1197 self.mark_worker_connected(session_id, Some(native_session_id))?;
1198 Ok::<_, anyhow::Error>(relay.sync().await?.materialized)
1199 }
1200 .await;
1201 match result {
1202 Ok(materialized) => Ok(materialized),
1203 Err(error) => {
1204 if rebuild_projection {
1209 match materialized_session_from_canonical(session_id, &canonical_session) {
1210 Ok(previous_projection) => {
1211 if let Err(restore_error) =
1212 hel::hel_database::save_materialized_session(&previous_projection)
1213 {
1214 tracing::error!(
1215 session_id,
1216 error = format!("{restore_error:#}"),
1217 "could not restore the durable projection after resume failed"
1218 );
1219 }
1220 }
1221 Err(restore_error) => {
1222 tracing::error!(
1223 session_id,
1224 error = format!("{restore_error:#}"),
1225 "could not rebuild the durable projection after resume failed"
1226 );
1227 }
1228 }
1229 } else if discard_queued_prompts
1230 && let Err(restore_error) =
1231 hel::hel_database::replace_materialized_queued_prompts(
1232 session_id,
1233 &hel::hel_projection::materialized_queued_prompts_from_canonical(
1234 &canonical_session.queued_prompts,
1235 ),
1236 )
1237 {
1238 tracing::error!(
1239 session_id,
1240 error = format!("{restore_error:#}"),
1241 "could not restore queued prompts after resume failed"
1242 );
1243 }
1244 Err(self.rollback_failed_resume(
1245 session_id,
1246 &previous,
1247 recreated_managed_worktree,
1248 error,
1249 executor,
1250 )?)
1251 }
1252 }
1253 }
1254
1255 fn rollback_failed_resume(
1256 &mut self,
1257 session_id: &str,
1258 previous: &SessionRecord,
1259 recreated_managed_worktree: bool,
1260 error: anyhow::Error,
1261 _executor: &impl CommandExecutor,
1262 ) -> Result<anyhow::Error> {
1263 let current = self
1264 .state
1265 .sessions
1266 .get(session_id)
1267 .with_context(|| format!("unknown session {session_id}"))?
1268 .clone();
1269 let cleanup = match current.target.as_ref() {
1270 Some(locator) => (|| -> Result<()> {
1271 let backend = backend_locator(locator, ¤t, &self.config)?;
1272 hel_targets::close_plan(&backend, session_id)?
1273 .execute(&CancellableProcessExecutor::with_timeout(
1276 Duration::from_secs(15),
1277 ))
1278 .map(|_| ())
1279 })(),
1280 None => Ok(()),
1281 };
1282 let worktree_cleanup = match (
1283 current.managed_worktree.as_ref(),
1284 previous.managed_worktree.as_ref(),
1285 ) {
1286 (_, Some(previous)) if recreated_managed_worktree => retire_managed_worktree(
1287 &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1288 previous,
1289 ),
1290 (Some(current), Some(previous)) if current == previous => Ok(()),
1291 (Some(worktree), _) => cleanup_managed_worktree(
1292 &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1293 worktree,
1294 ),
1295 (None, _) => Ok(()),
1296 };
1297 let cleanup_error = [cleanup, worktree_cleanup]
1298 .into_iter()
1299 .filter_map(Result::err)
1300 .map(|cleanup_error| format!("{cleanup_error:#}"))
1301 .collect::<Vec<_>>()
1302 .join("; ");
1303 if !cleanup_error.is_empty() {
1304 tracing::warn!(
1305 session_id,
1306 error = %cleanup_error,
1307 "resume rollback cleanup reported failures"
1308 );
1309 }
1310 let original = format!("{error:#}");
1311 let record = self.state.sessions.get_mut(session_id).unwrap();
1312 let failure = apply_failed_resume_rollback(
1313 record,
1314 previous,
1315 &original,
1316 (!cleanup_error.is_empty()).then_some(cleanup_error),
1317 );
1318 if record.bundle_id != current.bundle_id {
1321 let bundle_id = record.bundle_id.clone();
1322 hel::hel_database::rebind_session_bundle(session_id, &bundle_id)?;
1323 }
1324 hel::hel_database::save_session(&self.state.sessions[session_id])?;
1327 Ok(failure)
1328 }
1329}
1330
1331fn worktree_cleanup_notice(worktree_root: &Path, error: &anyhow::Error) -> String {
1332 format!(
1333 "Mjolnir could not remove the worktree at {}: {error:#}. Remove it with `git worktree remove --force {}`.",
1334 worktree_root.display(),
1335 worktree_root.display()
1336 )
1337}
1338
1339pub(super) fn apply_failed_resume_rollback(
1340 current: &mut SessionRecord,
1341 previous: &SessionRecord,
1342 original_error: &str,
1343 cleanup_error: Option<String>,
1344) -> anyhow::Error {
1345 match cleanup_error {
1346 None => {
1347 *current = previous.clone();
1348 current.state = SessionState::Stopped;
1349 current.target = None;
1350 current.updated_at = now();
1351 current.last_error = Some(format!("resume failed: {original_error}"));
1352 anyhow::anyhow!(original_error.to_owned())
1353 }
1354 Some(cleanup_error) => {
1355 let failure = format!(
1356 "{original_error}; cleanup of the partial resume target failed: {cleanup_error}"
1357 );
1358 current
1362 .project_directory
1363 .clone_from(&previous.project_directory);
1364 current
1365 .managed_worktree
1366 .clone_from(&previous.managed_worktree);
1367 current.bundle_id.clone_from(&previous.bundle_id);
1368 current.state = SessionState::Error;
1369 current.updated_at = now();
1370 current.last_error = Some(format!("resume failed: {failure}"));
1371 anyhow::anyhow!(failure)
1372 }
1373 }
1374}
1375
1376fn projection_rebuild_required(
1384 stored: Option<(u64, &str)>,
1385 archive_frontier: u64,
1386 archive_frontier_digest: &str,
1387) -> bool {
1388 stored != Some((archive_frontier, archive_frontier_digest))
1389}
1390
1391async fn utility_handoff_while_cancellable(
1395 config: &HelConfig,
1396 snapshot: &CanonicalSessionSnapshot,
1397 context_bytes: usize,
1398 executor: &impl CommandExecutor,
1399) -> Result<String> {
1400 if executor.cancellation_requested() {
1401 bail!("operation cancelled while compacting the cross-harness handoff");
1402 }
1403 let cancel = tokio_util::sync::CancellationToken::new();
1404 let operation = async {
1405 let candidates = crate::hel_utility_llm::UtilityLlmRuntime::shared()
1406 .resolve(config, &cancel)
1407 .await?;
1408 let backend =
1409 crate::hel_utility_llm::UtilityCompactionBackend::new(candidates, cancel.clone());
1410 let budget = crate::hel_compaction::CompactionBudget {
1415 page_bytes: backend.page_bytes(),
1416 handoff_bytes: context_bytes,
1417 };
1418 crate::hel_compaction::compact_snapshot(snapshot, budget, &backend).await
1419 };
1420 tokio::pin!(operation);
1421 loop {
1422 tokio::select! {
1423 context = &mut operation => return context,
1424 _ = tokio::time::sleep(super::readiness::CANCELLATION_POLL_INTERVAL) => {
1425 if executor.cancellation_requested() {
1426 cancel.cancel();
1427 bail!("operation cancelled while compacting the cross-harness handoff");
1428 }
1429 }
1430 }
1431 }
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436 use std::cell::RefCell;
1437 use std::collections::BTreeMap;
1438 use std::path::{Path, PathBuf};
1439 use std::process::Command;
1440 use std::sync::{Barrier, Mutex};
1441
1442 use anyhow::Result;
1443
1444 use crate::hel_controller::test_support::{
1445 checkpoint_test_session, committed_repository, managed_worktree_session,
1446 resume_compatibility_config, write_checkpoint_gate_archive,
1447 };
1448 use crate::hel_controller::{Controller, SessionResumeOptions};
1449 use hel::hel_archive::{GitCommandRunner, verify_archive_streaming};
1450 use hel::hel_config::{
1451 ContainerTemplate as ConfigContainer, HarnessProfile, HelConfig, ProjectBundle,
1452 ProjectRepository, TargetTemplate,
1453 };
1454 use hel::hel_projection::materialized_session_from_canonical;
1455 use hel::hel_state::{HelState, SessionRecord, SessionState, TargetLocator};
1456 use hel::hel_targets::{CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
1457
1458 use super::*;
1459
1460 const RESUME_ROLLBACK_TEST_CHILD: &str = "MJ_RESUME_ROLLBACK_TEST_CHILD";
1461 const RETIRED_WORKTREE_RESUME_TEST_CHILD: &str = "MJ_RETIRED_WORKTREE_RESUME_TEST_CHILD";
1462 const WORKER_PREFLIGHT_TEST_CHILD: &str = "MJ_WORKER_PREFLIGHT_TEST_CHILD";
1463
1464 #[test]
1468 fn a_resume_preflights_the_worker_binary_before_compacting() {
1469 if std::env::var_os(WORKER_PREFLIGHT_TEST_CHILD).is_none() {
1472 let directory = tempfile::tempdir().unwrap();
1473 let test_name = format!(
1474 "{}::a_resume_preflights_the_worker_binary_before_compacting",
1475 module_path!()
1476 .strip_prefix("mj_controller::")
1477 .unwrap_or(module_path!())
1478 );
1479 let output = Command::new(std::env::current_exe().unwrap())
1480 .args(["--exact", &test_name, "--nocapture"])
1481 .env(WORKER_PREFLIGHT_TEST_CHILD, "1")
1482 .env("MJ_DATA_DIR", directory.path().join("data"))
1483 .env("MJ_CONFIG_DIR", directory.path().join("config"))
1484 .env("MJ_WORKER_BINARY", directory.path().join("absent-worker"))
1487 .output()
1488 .unwrap();
1489 assert!(
1490 output.status.success(),
1491 "isolated worker preflight test failed\nstdout:\n{}\nstderr:\n{}",
1492 String::from_utf8_lossy(&output.stdout),
1493 String::from_utf8_lossy(&output.stderr)
1494 );
1495 return;
1496 }
1497 let _writer = hel::hel_database::install_isolated_test_writer();
1499
1500 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
1501 let archive_directory = data_directory.join("archives");
1502 std::fs::create_dir_all(&archive_directory).unwrap();
1503 let session_id = "0123456789abcdef0123456789abcdef";
1504 let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
1505 let repository = committed_repository();
1506 let mut session = managed_worktree_session(repository.path(), session_id);
1507 session.checkpoint = Some(checkpoint);
1508
1509 let profile_home = data_directory.join("profile");
1510 std::fs::create_dir_all(&profile_home).unwrap();
1511 let mut config = resume_compatibility_config();
1512 config.profiles.insert(
1515 "claude".into(),
1516 HarnessProfile {
1517 kind: hel::hel_config::HarnessKind::Claude,
1518 home: profile_home,
1519 executable: None,
1520 environment: BTreeMap::new(),
1521 context_window_bytes: None,
1522 },
1523 );
1524 let mut controller = Controller {
1525 config,
1526 state: HelState {
1527 sessions: BTreeMap::from([(session_id.into(), session)]),
1528 ..HelState::default()
1529 },
1530 };
1531 hel::hel_database::save_state(&controller.state).unwrap();
1532
1533 let error = tokio::runtime::Builder::new_current_thread()
1534 .enable_all()
1535 .build()
1536 .unwrap()
1537 .block_on(controller.resume_session_controlled(
1538 session_id,
1539 "claude",
1540 "local-bare",
1541 SessionResumeOptions {
1542 additional_mounts: None,
1543 resource_allocation: None,
1544 discard_queue: false,
1545 },
1546 &ProcessExecutor,
1547 ))
1548 .unwrap_err();
1549
1550 let detail = format!("{error:#}");
1551 assert!(
1552 detail.contains("preflight the worker binary before resuming"),
1553 "{detail}"
1554 );
1555 assert!(detail.contains("absent-worker"), "{detail}");
1556 assert!(
1557 !detail.contains("compact the cross-harness handoff transcript"),
1558 "compaction must not run for a resume that cannot install a worker: {detail}"
1559 );
1560 assert_eq!(
1561 controller.state.sessions[session_id].state,
1562 SessionState::Stopped
1563 );
1564 }
1565
1566 #[test]
1567 fn raw_in_place_preflight_does_not_require_its_synthetic_bundle() {
1568 struct UnusedExecutor;
1569
1570 impl CommandExecutor for UnusedExecutor {
1571 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1572 panic!("raw in-place preflight ran {}", command.purpose);
1573 }
1574 }
1575
1576 let directory = tempfile::tempdir().unwrap();
1577 let session_id = "0123456789abcdef0123456789abcdef";
1578 let mut session = checkpoint_test_session(session_id);
1579 session.checkpoint = Some(write_checkpoint_gate_archive(
1580 directory.path(),
1581 session_id,
1582 3,
1583 ));
1584 session.bundle_id = "remote-project-a66373eef659f856".into();
1585 session.target_template_id = "localhost".into();
1586 session.project_directory = Some("/mnt/optane/bifrost-fird".into());
1587 let controller = Controller {
1588 config: HelConfig {
1589 targets: BTreeMap::from([("localhost".into(), TargetTemplate::LocalBare)]),
1590 bundles: BTreeMap::new(),
1593 ..HelConfig::default()
1594 },
1595 state: HelState {
1596 sessions: BTreeMap::from([(session_id.into(), session)]),
1597 ..HelState::default()
1598 },
1599 };
1600
1601 let preflight = controller
1602 .preflight_resume_repository_sources(session_id, "localhost", &UnusedExecutor)
1603 .unwrap();
1604 let ResumeRepositorySourcePreflight::Ready(receipt) = preflight else {
1605 panic!("raw in-place resume unexpectedly needs a repository replacement");
1606 };
1607 assert!(controller.repository_source_receipt_is_current(session_id, &receipt));
1608 }
1609
1610 #[test]
1611 fn repository_preflight_distinguishes_the_original_source_from_a_reused_name() {
1612 fn git(repository: &Path, arguments: &[&str]) {
1613 let output = SystemGit
1614 .run(
1615 repository,
1616 &hel::hel_archive::GitCommand {
1617 arguments: arguments.iter().map(std::ffi::OsString::from).collect(),
1618 stdin: Vec::new(),
1619 env: Vec::new(),
1620 },
1621 )
1622 .unwrap();
1623 assert_eq!(
1624 output.status,
1625 0,
1626 "git {arguments:?}: {}",
1627 String::from_utf8_lossy(&output.stderr)
1628 );
1629 }
1630
1631 let directory = tempfile::tempdir().unwrap();
1632 let origin = directory.path().join("original");
1633 std::fs::create_dir(&origin).unwrap();
1634 git(&origin, &["init", "-q", "-b", "main"]);
1635 git(&origin, &["config", "user.name", "Hel Test"]);
1636 git(&origin, &["config", "user.email", "hel@example.test"]);
1637 git(&origin, &["commit", "--allow-empty", "-qm", "base"]);
1638 let source = directory.path().join("source");
1639 git(
1640 directory.path(),
1641 &["clone", "-q", origin.to_str().unwrap(), "source"],
1642 );
1643 git(&source, &["config", "user.name", "Hel Test"]);
1644 git(&source, &["config", "user.email", "hel@example.test"]);
1645 git(&source, &["commit", "--allow-empty", "-qm", "session"]);
1646 let snapshot = hel::hel_archive::collect_git_snapshot(
1647 &SystemGit,
1648 &source,
1649 &hel::hel_archive::GitCollectionSpec {
1650 id: "project".into(),
1651 relative_destination: "project".into(),
1652 history: hel::hel_archive::GitHistoryMode::SessionDelta,
1653 origin_override: None,
1654 },
1655 )
1656 .unwrap();
1657 let configured = ProjectRepository {
1658 id: "project".into(),
1659 github: None,
1660 local: Some(origin.clone()),
1661 destination: "project".into(),
1662 git_ref: None,
1663 };
1664 assert_eq!(
1665 checkpoint_source_missing_commit(
1666 &configured,
1667 &CheckpointRepositoryBundle {
1668 metadata: snapshot.metadata.clone(),
1669 committed_bundle: snapshot.committed_bundle.clone(),
1670 },
1671 &ProcessExecutor,
1672 None,
1673 )
1674 .unwrap(),
1675 None
1676 );
1677
1678 let replacement = directory.path().join("replacement");
1679 std::fs::create_dir(&replacement).unwrap();
1680 git(&replacement, &["init", "-q", "-b", "main"]);
1681 git(&replacement, &["config", "user.name", "Hel Test"]);
1682 git(&replacement, &["config", "user.email", "hel@example.test"]);
1683 git(
1684 &replacement,
1685 &["commit", "--allow-empty", "-qm", "different history"],
1686 );
1687 let configured = ProjectRepository {
1688 local: Some(replacement),
1689 ..configured
1690 };
1691 assert!(
1692 checkpoint_source_missing_commit(
1693 &configured,
1694 &CheckpointRepositoryBundle {
1695 metadata: snapshot.metadata,
1696 committed_bundle: snapshot.committed_bundle,
1697 },
1698 &ProcessExecutor,
1699 None,
1700 )
1701 .unwrap()
1702 .is_some()
1703 );
1704 }
1705
1706 #[test]
1707 fn repository_preflight_checks_independent_sources_concurrently_and_receipts_are_scoped() {
1708 struct ConcurrentSourceExecutor {
1709 source_checks: Barrier,
1710 }
1711
1712 impl CommandExecutor for ConcurrentSourceExecutor {
1713 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1714 if command.purpose == "check checkpoint base commit" {
1715 self.source_checks.wait();
1716 }
1717 Ok(CommandOutput {
1718 status: 0,
1719 stdout: Vec::new(),
1720 stderr: Vec::new(),
1721 })
1722 }
1723 }
1724
1725 let directory = tempfile::tempdir().unwrap();
1726 let session_id = "0123456789abcdef0123456789abcdef";
1727 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 3);
1728 let repositories = ["one", "two"]
1729 .map(|id| ProjectRepository {
1730 id: id.into(),
1731 github: None,
1732 local: Some(PathBuf::from(format!("/origin/{id}"))),
1733 destination: id.into(),
1734 git_ref: None,
1735 })
1736 .to_vec();
1737 let mut session = checkpoint_test_session(session_id);
1738 session.checkpoint = Some(checkpoint.clone());
1739 let mut controller = Controller {
1740 config: HelConfig {
1741 bundles: BTreeMap::from([(
1742 session.bundle_id.clone(),
1743 ProjectBundle {
1744 primary_repo: "one".into(),
1745 repositories: repositories.clone(),
1746 },
1747 )]),
1748 ..HelConfig::default()
1749 },
1750 state: HelState {
1751 sessions: BTreeMap::from([(session_id.into(), session)]),
1752 ..HelState::default()
1753 },
1754 };
1755 let verified = ResumeRepositoryBundles {
1756 checkpoint_sha256: checkpoint.sha256,
1757 repositories: repositories
1758 .iter()
1759 .map(|repository| CheckpointRepositoryBundle {
1760 metadata: hel::hel_archive::RepositoryMetadata {
1761 id: repository.id.clone(),
1762 relative_destination: repository.destination.clone(),
1763 origin: repository.source_label(),
1764 base_commit: String::new(),
1765 head_commit: if repository.id == "one" {
1766 "a".repeat(40)
1767 } else {
1768 "b".repeat(40)
1769 },
1770 branch: Some("main".into()),
1771 },
1772 committed_bundle: Vec::new(),
1773 })
1774 .collect(),
1775 };
1776 let executor = ConcurrentSourceExecutor {
1777 source_checks: Barrier::new(2),
1778 };
1779 let pool = rayon::ThreadPoolBuilder::new()
1780 .num_threads(2)
1781 .build()
1782 .unwrap();
1783 let preflight = pool
1784 .install(|| {
1785 controller
1786 .preflight_verified_repository_sources(session_id, verified, None, &executor)
1787 })
1788 .unwrap();
1789 let ResumeRepositorySourcePreflight::Ready(receipt) = preflight else {
1790 panic!("expected repository source receipt");
1791 };
1792 assert!(controller.repository_source_receipt_is_current(session_id, &receipt));
1793
1794 controller
1795 .config
1796 .bundles
1797 .values_mut()
1798 .next()
1799 .unwrap()
1800 .repositories[0]
1801 .local = Some(PathBuf::from("/different-origin"));
1802 assert!(!controller.repository_source_receipt_is_current(session_id, &receipt));
1803 }
1804
1805 #[test]
1806 fn repository_preflight_checks_declared_boundary_without_importing_delta_bundle() {
1807 struct RecordingExecutor {
1808 commands: Mutex<Vec<CommandSpec>>,
1809 }
1810
1811 impl CommandExecutor for RecordingExecutor {
1812 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1813 self.commands.lock().unwrap().push(command.clone());
1814 Ok(CommandOutput {
1815 status: 0,
1816 stdout: Vec::new(),
1817 stderr: Vec::new(),
1818 })
1819 }
1820 }
1821
1822 let prerequisite = "a".repeat(40);
1823 let head = "b".repeat(40);
1824 let archived = CheckpointRepositoryBundle {
1825 metadata: hel::hel_archive::RepositoryMetadata {
1826 id: "project".into(),
1827 relative_destination: "project".into(),
1828 origin: "https://github.com/archived/should-not-be-contacted.git".into(),
1829 base_commit: prerequisite.clone(),
1830 head_commit: head.clone(),
1831 branch: Some("main".into()),
1832 },
1833 committed_bundle: format!(
1834 "# v2 git bundle\n-{prerequisite} base\n{head} HEAD\n\nPACKnot-read"
1835 )
1836 .into_bytes(),
1837 };
1838 let configured = ProjectRepository {
1839 id: "project".into(),
1840 github: Some("configured/project".into()),
1841 local: None,
1842 destination: "project".into(),
1843 git_ref: None,
1844 };
1845 let executor = RecordingExecutor {
1846 commands: Mutex::new(Vec::new()),
1847 };
1848
1849 assert_eq!(
1850 checkpoint_source_missing_commit(
1851 &configured,
1852 &archived,
1853 &executor,
1854 Some("secret-token")
1855 )
1856 .unwrap(),
1857 None
1858 );
1859
1860 let commands = executor.commands.into_inner().unwrap();
1861 assert_eq!(commands.len(), 2, "commands: {commands:?}");
1862 assert_eq!(
1863 commands
1864 .iter()
1865 .map(|command| command.purpose.as_str())
1866 .collect::<Vec<_>>(),
1867 [
1868 "initialize repository source preflight",
1869 "check checkpoint base commit"
1870 ]
1871 );
1872 let source_check = &commands[1];
1873 assert!(
1874 source_check
1875 .args
1876 .iter()
1877 .any(|argument| argument == "credential.helper=")
1878 );
1879 assert_eq!(
1880 source_check
1881 .env
1882 .get("GIT_NO_LAZY_FETCH")
1883 .map(String::as_str),
1884 Some("1")
1885 );
1886 assert_eq!(
1887 source_check
1888 .env
1889 .get("GIT_TERMINAL_PROMPT")
1890 .map(String::as_str),
1891 Some("0")
1892 );
1893 assert_eq!(
1894 source_check.args.last().map(String::as_str),
1895 Some(prerequisite.as_str())
1896 );
1897 assert!(
1898 !source_check
1899 .args
1900 .iter()
1901 .any(|argument| argument.contains("archived"))
1902 );
1903 }
1904
1905 #[test]
1906 fn self_contained_bundle_validation_cannot_lazy_fetch_or_prompt() {
1907 let command = checkpoint_bundle_import_command(
1908 Path::new("/tmp/repository.git"),
1909 Path::new("/tmp/checkpoint.bundle"),
1910 );
1911 assert_eq!(
1912 command.env.get("GIT_NO_LAZY_FETCH").map(String::as_str),
1913 Some("1")
1914 );
1915 assert_eq!(
1916 command.env.get("GIT_TERMINAL_PROMPT").map(String::as_str),
1917 Some("0")
1918 );
1919 }
1920
1921 #[test]
1922 fn lost_bundle_sessions_reach_resume_compatibility_before_the_record_changes() {
1923 struct UnusedExecutor;
1924
1925 impl CommandExecutor for UnusedExecutor {
1926 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1927 panic!("resume ran {} before rejecting the target", command.program);
1928 }
1929 }
1930
1931 let directory = tempfile::tempdir().unwrap();
1932 let session_id = "0123456789abcdef0123456789abcdef";
1933 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 3);
1934 let mut session = checkpoint_test_session(session_id);
1935 session.state = SessionState::Lost;
1936 session.checkpoint = Some(checkpoint);
1937 let previous = session.clone();
1938 let profile_home = directory.path().join("profile");
1939 std::fs::create_dir_all(&profile_home).unwrap();
1940 let mut config = HelConfig::default();
1941 config.profiles.insert(
1942 "codex".into(),
1943 HarnessProfile {
1944 kind: hel::hel_config::HarnessKind::Codex,
1945 home: profile_home,
1946 executable: None,
1947 environment: BTreeMap::new(),
1948 context_window_bytes: None,
1949 },
1950 );
1951 config
1952 .targets
1953 .insert("localhost".into(), TargetTemplate::LocalBare);
1954 let mut controller = Controller {
1955 config,
1956 state: HelState {
1957 sessions: BTreeMap::from([(session_id.into(), session)]),
1958 ..HelState::default()
1959 },
1960 };
1961
1962 let error = tokio::runtime::Builder::new_current_thread()
1963 .enable_all()
1964 .build()
1965 .unwrap()
1966 .block_on(controller.resume_session_controlled(
1967 session_id,
1968 "codex",
1969 "localhost",
1970 SessionResumeOptions {
1971 additional_mounts: None,
1972 resource_allocation: None,
1973 discard_queue: false,
1974 },
1975 &UnusedExecutor,
1976 ))
1977 .unwrap_err();
1978
1979 let detail = format!("{error:#}");
1980 assert!(detail.contains("created from a project bundle"), "{detail}");
1981 assert!(
1982 detail.contains("resume it on a container, SSH, or EC2 target"),
1983 "{detail}"
1984 );
1985 assert_eq!(controller.state.sessions[session_id], previous);
1986 }
1987 struct BarrierExecutor {
1991 seen: Mutex<Vec<String>>,
1992 barrier: Barrier,
1993 }
1994
1995 impl CommandExecutor for BarrierExecutor {
1996 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1997 self.seen.lock().unwrap().push(command.purpose.clone());
1998 self.barrier.wait();
1999 Ok(CommandOutput {
2000 status: 0,
2001 stdout: Vec::new(),
2002 stderr: Vec::new(),
2003 })
2004 }
2005 }
2006
2007 fn lane_command(purpose: &str) -> CommandSpec {
2008 CommandSpec::new("hel", ["worker"]).purpose(purpose)
2009 }
2010
2011 #[test]
2016 fn start_begins_at_the_worker_launch_not_at_the_transfers_before_it() {
2017 struct RecordingExecutor {
2018 commands: RefCell<Vec<CommandSpec>>,
2019 }
2020 impl CommandExecutor for RecordingExecutor {
2021 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2022 self.commands.borrow_mut().push(command.clone());
2023 Ok(CommandOutput {
2024 status: 0,
2025 stdout: Vec::new(),
2026 stderr: Vec::new(),
2027 })
2028 }
2029 }
2030
2031 let session_id = "0123456789abcdef0123456789abcdef";
2032 let worker_root = format!("/var/lib/hel/workers/{session_id}");
2033 let executor = RecordingExecutor {
2034 commands: RefCell::new(Vec::new()),
2035 };
2036 let syncing = StagedExecutor::new(&executor, ProvisionStage::Syncing);
2037 let backend = hel_targets::TargetLocator::LocalPodman {
2038 container_id: "abcdef0123456789".into(),
2039 workspace_storage: Default::default(),
2040 };
2041
2042 upload_checkpoint_spec(
2043 &syncing,
2044 &backend,
2045 session_id,
2046 Path::new("/archives/session.hel.zip"),
2047 &format!("{worker_root}/restore.hel.zip"),
2048 )
2049 .unwrap();
2050 execute_checked(
2051 &syncing,
2052 restore_command(
2053 &backend,
2054 session_id,
2055 &format!("{worker_root}/restore-spec.json"),
2056 )
2057 .unwrap(),
2058 )
2059 .unwrap();
2060 start_worker(&syncing, &backend, &worker_root).unwrap();
2063
2064 let stages = executor
2065 .commands
2066 .borrow()
2067 .iter()
2068 .map(|command| (command.purpose.clone(), command.stage))
2069 .collect::<Vec<_>>();
2070 assert_eq!(
2071 stages,
2072 vec![
2073 (
2074 "upload checkpoint specification".to_owned(),
2075 Some(ProvisionStage::Syncing)
2076 ),
2077 (
2078 "restore target checkpoint".to_owned(),
2079 Some(ProvisionStage::Syncing)
2080 ),
2081 (
2082 "start detached Mjolnir worker".to_owned(),
2083 Some(ProvisionStage::Starting)
2084 ),
2085 ]
2086 );
2087 }
2088 #[test]
2089 fn independent_target_lanes_run_at_the_same_time() {
2090 let executor = BarrierExecutor {
2091 seen: Mutex::new(Vec::new()),
2092 barrier: Barrier::new(2),
2093 };
2094
2095 execute_concurrent_lanes(
2096 || execute_checked(&executor, lane_command("install the worker")).map(|_| ()),
2097 || execute_checked(&executor, lane_command("upload the checkpoint")).map(|_| ()),
2098 )
2099 .unwrap();
2100
2101 let mut seen = executor.seen.into_inner().unwrap();
2102 seen.sort();
2103 assert_eq!(seen, ["install the worker", "upload the checkpoint"]);
2104 }
2105 #[test]
2106 fn a_lane_failure_is_reported_in_lane_order_and_never_abandons_the_other_lane() {
2107 let reached = Mutex::new(Vec::new());
2108
2109 let error = execute_concurrent_lanes(
2112 || -> Result<()> {
2113 std::thread::sleep(Duration::from_millis(50));
2114 bail!("worker install failed")
2115 },
2116 || -> Result<()> {
2117 reached.lock().unwrap().push("second");
2118 bail!("checkpoint upload failed")
2119 },
2120 )
2121 .unwrap_err();
2122
2123 assert_eq!(error.to_string(), "worker install failed");
2124 assert_eq!(
2125 *reached.lock().unwrap(),
2126 ["second"],
2127 "a failing first lane must not cut the second one short"
2128 );
2129
2130 let error = execute_concurrent_lanes(
2131 || Ok(()),
2132 || -> Result<()> { bail!("checkpoint upload failed") },
2133 )
2134 .unwrap_err();
2135 assert_eq!(error.to_string(), "checkpoint upload failed");
2136 }
2137 #[test]
2138 fn a_projection_standing_at_the_archived_frontier_is_reused() {
2139 let digest = "a".repeat(64);
2140 let other = "b".repeat(64);
2141
2142 assert!(!projection_rebuild_required(
2143 Some((82_000, &digest)),
2144 82_000,
2145 &digest
2146 ));
2147
2148 for stored in [
2149 Some((82_000, other.as_str())),
2151 Some((81_999, digest.as_str())),
2153 Some((82_001, digest.as_str())),
2154 None,
2156 ] {
2157 assert!(
2158 projection_rebuild_required(stored, 82_000, &digest),
2159 "{stored:?} must not be mistaken for the archived projection"
2160 );
2161 }
2162 }
2163 #[test]
2164 fn failed_resume_rolls_back_only_after_target_cleanup() {
2165 let previous = SessionRecord {
2166 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2167 archived: false,
2168 container_cpus: None,
2169 container_memory: None,
2170 id: "0123456789abcdef0123456789abcdef".into(),
2171 title: "imported session".into(),
2172 harness_kind: hel::hel_config::HarnessKind::Codex,
2173 last_profile: "codex-old".into(),
2174 bundle_id: "project".into(),
2175 project_directory: None,
2176 managed_worktree: None,
2177 target_template_id: "podman-old".into(),
2178 resource_allocation: None,
2179 additional_mounts: Vec::new(),
2180 state: SessionState::Stopped,
2181 target: None,
2182 native_session_id: Some("native-session".into()),
2183 acp_session_title: None,
2184 session_title_override: None,
2185 created_at: "2026-08-12T00:00:00Z".into(),
2186 updated_at: "2026-08-12T00:00:00Z".into(),
2187 viewed_through_event_ordinal: 0,
2188 draft_input: String::new(),
2189 last_error: None,
2190 last_checkpoint_error: None,
2191 checkpoint: None,
2192 };
2193 let partial_target = TargetLocator::LocalPodman {
2194 container_id: "partial-container".into(),
2195 workspace_storage: Default::default(),
2196 };
2197 let mut cleaned = previous.clone();
2198 cleaned.state = SessionState::Error;
2199 cleaned.last_profile = "codex-new".into();
2200 cleaned.target = Some(partial_target.clone());
2201
2202 let failure =
2203 apply_failed_resume_rollback(&mut cleaned, &previous, "worker upload failed", None);
2204
2205 assert_eq!(cleaned.state, SessionState::Stopped);
2206 assert_eq!(cleaned.last_profile, "codex-old");
2207 assert_eq!(cleaned.target, None);
2208 assert_eq!(failure.to_string(), "worker upload failed");
2209 assert_eq!(
2210 cleaned.last_error.as_deref(),
2211 Some("resume failed: worker upload failed")
2212 );
2213
2214 let mut cleanup_failed = previous.clone();
2215 cleanup_failed.state = SessionState::Error;
2216 cleanup_failed.last_profile = "codex-new".into();
2217 cleanup_failed.target = Some(partial_target.clone());
2218
2219 let failure = apply_failed_resume_rollback(
2220 &mut cleanup_failed,
2221 &previous,
2222 "worker upload failed",
2223 Some("podman rm failed".into()),
2224 );
2225
2226 assert_eq!(cleanup_failed.state, SessionState::Error);
2227 assert_eq!(cleanup_failed.last_profile, "codex-new");
2228 assert_eq!(cleanup_failed.target, Some(partial_target));
2229 assert!(failure.to_string().contains("cleanup"));
2230 }
2231 #[test]
2232 fn failed_worktree_cleanup_notice_names_mjolnir_and_the_recovery_command() {
2233 let notice = worktree_cleanup_notice(
2234 Path::new("/workspace/project"),
2235 &anyhow::anyhow!("permission denied"),
2236 );
2237
2238 assert!(
2239 notice.starts_with(
2240 "Mjolnir could not remove the worktree at /workspace/project: permission denied."
2241 ),
2242 "{notice}"
2243 );
2244 assert!(
2245 notice.contains("`git worktree remove --force /workspace/project`"),
2246 "{notice}"
2247 );
2248 assert!(!notice.contains("Hel"), "{notice}");
2249 }
2250 #[test]
2251 fn failed_resume_provisioning_preserves_checkpoint_and_projection_lineage() {
2252 if std::env::var_os(RESUME_ROLLBACK_TEST_CHILD).is_none() {
2255 let directory = tempfile::tempdir().unwrap();
2256 let test_name = format!(
2257 "{}::failed_resume_provisioning_preserves_checkpoint_and_projection_lineage",
2258 module_path!()
2259 .strip_prefix("mj_controller::")
2260 .unwrap_or(module_path!())
2261 );
2262 let output = Command::new(std::env::current_exe().unwrap())
2263 .args(["--exact", &test_name, "--nocapture"])
2264 .env(RESUME_ROLLBACK_TEST_CHILD, "1")
2265 .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
2268 .env("MJ_DATA_DIR", directory.path())
2269 .env("GH_TOKEN", "test-token")
2270 .output()
2271 .unwrap();
2272 assert!(
2273 output.status.success(),
2274 "isolated resume rollback test failed\nstdout:\n{}\nstderr:\n{}",
2275 String::from_utf8_lossy(&output.stdout),
2276 String::from_utf8_lossy(&output.stderr)
2277 );
2278 return;
2279 }
2280 let _writer = hel::hel_database::install_isolated_test_writer();
2282
2283 #[derive(Default)]
2286 struct FailingPreflightExecutor {
2287 mounts_during_provisioning: Mutex<Option<Vec<AdditionalMount>>>,
2288 }
2289
2290 impl CommandExecutor for FailingPreflightExecutor {
2291 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2292 if command.program == "stat" {
2295 return Ok(CommandOutput {
2296 status: 0,
2297 stdout: b"ext4\n".to_vec(),
2298 stderr: Vec::new(),
2299 });
2300 }
2301 assert_eq!(command.program, "podman");
2302 let mut observed = self.mounts_during_provisioning.lock().unwrap();
2303 if observed.is_none() {
2304 let durable = hel::hel_database::load_state().unwrap();
2305 *observed = Some(
2306 durable.sessions["0123456789abcdef0123456789abcdef"]
2307 .additional_mounts
2308 .clone(),
2309 );
2310 }
2311 Ok(CommandOutput {
2312 status: 1,
2313 stdout: Vec::new(),
2314 stderr: b"podman is temporarily unavailable".to_vec(),
2315 })
2316 }
2317 }
2318
2319 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
2320 let archive_directory = data_directory.join("archives");
2321 std::fs::create_dir_all(&archive_directory).unwrap();
2322 let session_id = "0123456789abcdef0123456789abcdef";
2323 let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
2324 let archive = verify_archive_streaming(&checkpoint.archive_path).unwrap();
2325 let expected_projection =
2326 materialized_session_from_canonical(session_id, &archive.canonical_session).unwrap();
2327
2328 let mut session = checkpoint_test_session(session_id);
2329 session.state = SessionState::Stopped;
2330 session.checkpoint = Some(checkpoint.clone());
2331 session.additional_mounts = vec![AdditionalMount {
2332 source: PathBuf::from("/host/old"),
2333 destination: PathBuf::from("/mnt/old"),
2334 read_only: false,
2335 }];
2336 let previous = session.clone();
2337 let resumed_mounts = vec![AdditionalMount {
2338 source: PathBuf::from("/host/new"),
2339 destination: PathBuf::from("/mnt/new"),
2340 read_only: false,
2341 }];
2342 let profile_home = data_directory.join("profile");
2343 std::fs::create_dir_all(&profile_home).unwrap();
2344 let mut config = HelConfig::default();
2345 config.profiles.insert(
2346 "codex".into(),
2347 HarnessProfile {
2348 kind: hel::hel_config::HarnessKind::Codex,
2349 home: profile_home,
2350 executable: None,
2351 environment: BTreeMap::new(),
2352 context_window_bytes: None,
2353 },
2354 );
2355 config.bundles.insert(
2356 "project".into(),
2357 ProjectBundle {
2358 primary_repo: "project".into(),
2359 repositories: vec![ProjectRepository {
2360 id: "project".into(),
2361 github: Some("example/project".into()),
2362 local: None,
2363 destination: "project".into(),
2364 git_ref: None,
2365 }],
2366 },
2367 );
2368 config.targets.insert(
2369 "podman".into(),
2370 TargetTemplate::LocalPodman {
2371 container: ConfigContainer {
2372 image: "example.invalid/hel-test:latest".into(),
2373 pull_policy: Default::default(),
2374 platform: None,
2375 cpus: None,
2376 memory: None,
2377 environment: BTreeMap::new(),
2378 workspace_storage: Default::default(),
2379 },
2380 },
2381 );
2382 let mut controller = Controller {
2383 config,
2384 state: HelState {
2385 sessions: BTreeMap::from([(session_id.into(), session)]),
2386 ..HelState::default()
2387 },
2388 };
2389 hel::hel_database::save_state(&controller.state).unwrap();
2390 hel::hel_database::save_materialized_session(&expected_projection).unwrap();
2391
2392 let runtime = tokio::runtime::Builder::new_current_thread()
2393 .enable_all()
2394 .build()
2395 .unwrap();
2396 let executor = FailingPreflightExecutor::default();
2397 let error = runtime
2398 .block_on(controller.resume_session_controlled(
2399 session_id,
2400 "codex",
2401 "podman",
2402 SessionResumeOptions {
2403 additional_mounts: Some(resumed_mounts.clone()),
2404 resource_allocation: None,
2405 discard_queue: false,
2406 },
2407 &executor,
2408 ))
2409 .unwrap_err();
2410 let detail = format!("{error:#}");
2411 assert!(
2412 detail.contains("podman is temporarily unavailable"),
2413 "{detail}"
2414 );
2415 assert!(!detail.contains("returned to stopped"), "{detail}");
2416 assert!(!detail.contains("unknown session"), "{detail}");
2417 assert_eq!(
2418 executor.mounts_during_provisioning.into_inner().unwrap(),
2419 Some(resumed_mounts)
2420 );
2421
2422 let retained = controller.state.sessions.get(session_id).unwrap();
2423 assert_eq!(retained.state, SessionState::Stopped);
2424 assert_eq!(retained.checkpoint, previous.checkpoint);
2425 assert_eq!(retained.managed_worktree, previous.managed_worktree);
2426 assert!(checkpoint.archive_path.is_file());
2427
2428 let durable = hel::hel_database::load_state().unwrap();
2429 let durable_session = durable.sessions.get(session_id).unwrap();
2430 assert_eq!(durable_session.state, SessionState::Stopped);
2431 assert_eq!(durable_session.checkpoint, previous.checkpoint);
2432 assert_eq!(
2433 durable_session.additional_mounts,
2434 previous.additional_mounts
2435 );
2436 assert_eq!(
2437 hel::hel_database::load_materialized_session(session_id).unwrap(),
2438 Some(expected_projection)
2439 );
2440 }
2441 #[test]
2442 fn failed_resume_retires_a_checkout_it_recreated() {
2443 if std::env::var_os(RETIRED_WORKTREE_RESUME_TEST_CHILD).is_none() {
2444 let directory = tempfile::tempdir().unwrap();
2445 let test_name = format!(
2446 "{}::failed_resume_retires_a_checkout_it_recreated",
2447 module_path!()
2448 .strip_prefix("mj_controller::")
2449 .unwrap_or(module_path!())
2450 );
2451 let output = Command::new(std::env::current_exe().unwrap())
2452 .args(["--exact", &test_name, "--nocapture"])
2453 .env(RETIRED_WORKTREE_RESUME_TEST_CHILD, "1")
2454 .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
2457 .env("MJ_DATA_DIR", directory.path().join("data"))
2458 .env("MJ_CONFIG_DIR", directory.path().join("config"))
2459 .output()
2460 .unwrap();
2461 assert!(
2462 output.status.success(),
2463 "isolated retired-worktree resume test failed\nstdout:\n{}\nstderr:\n{}",
2464 String::from_utf8_lossy(&output.stdout),
2465 String::from_utf8_lossy(&output.stderr)
2466 );
2467 return;
2468 }
2469 let _writer = hel::hel_database::install_isolated_test_writer();
2471
2472 struct FailAfterWorktreeRestore;
2473
2474 impl CommandExecutor for FailAfterWorktreeRestore {
2475 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2476 if matches!(command.program.as_str(), "git" | "mkdir") {
2477 return ProcessExecutor.execute(command);
2478 }
2479 Ok(CommandOutput {
2480 status: 1,
2481 stdout: Vec::new(),
2482 stderr: b"stop after recreating the checkout".to_vec(),
2483 })
2484 }
2485 }
2486
2487 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
2488 let archive_directory = data_directory.join("archives");
2489 std::fs::create_dir_all(&archive_directory).unwrap();
2490 let session_id = "0123456789abcdef0123456789abcdef";
2491 let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
2492 let repository = committed_repository();
2493 let mut session = managed_worktree_session(repository.path(), session_id);
2494 session.checkpoint = Some(checkpoint);
2495 let worktree = session.managed_worktree.clone().unwrap();
2496 retire_managed_worktree(&ProcessExecutor, &worktree).unwrap();
2497 assert!(!worktree.worktree_root.exists());
2498
2499 let profile_home = data_directory.join("profile");
2500 std::fs::create_dir_all(&profile_home).unwrap();
2501 let mut config = resume_compatibility_config();
2502 config.profiles.insert(
2503 "codex".into(),
2504 HarnessProfile {
2505 kind: hel::hel_config::HarnessKind::Codex,
2506 home: profile_home,
2507 executable: None,
2508 environment: BTreeMap::new(),
2509 context_window_bytes: None,
2510 },
2511 );
2512 let mut controller = Controller {
2513 config,
2514 state: HelState {
2515 sessions: BTreeMap::from([(session_id.into(), session)]),
2516 ..HelState::default()
2517 },
2518 };
2519 hel::hel_database::save_state(&controller.state).unwrap();
2520
2521 let error = tokio::runtime::Builder::new_current_thread()
2522 .enable_all()
2523 .build()
2524 .unwrap()
2525 .block_on(controller.resume_session_controlled(
2526 session_id,
2527 "codex",
2528 "local-bare",
2529 SessionResumeOptions {
2530 additional_mounts: None,
2531 resource_allocation: None,
2532 discard_queue: false,
2533 },
2534 &FailAfterWorktreeRestore,
2535 ))
2536 .unwrap_err();
2537
2538 assert!(
2539 format!("{error:#}").contains("stop after recreating the checkout"),
2540 "{error:#}"
2541 );
2542 assert!(!worktree.worktree_root.exists());
2543 assert_eq!(
2544 controller.state.sessions[session_id].state,
2545 SessionState::Stopped
2546 );
2547 let branch = Command::new("git")
2548 .arg("-C")
2549 .arg(repository.path())
2550 .args([
2551 "show-ref",
2552 "--verify",
2553 &format!("refs/heads/{}", worktree.branch),
2554 ])
2555 .status()
2556 .unwrap();
2557 assert!(branch.success(), "resume rollback must retain the branch");
2558 }
2559 const RAW_CONVERSION_TEST_CHILD: &str = "MJ_RAW_CONVERSION_TEST_CHILD";
2560 #[test]
2561 fn a_failed_raw_conversion_keeps_the_bundle_and_leaves_the_worktree_alone() {
2562 if std::env::var_os(RAW_CONVERSION_TEST_CHILD).is_none() {
2565 let directory = tempfile::tempdir().unwrap();
2566 let test_name = format!(
2567 "{}::a_failed_raw_conversion_keeps_the_bundle_and_leaves_the_worktree_alone",
2568 module_path!()
2569 .strip_prefix("mj_controller::")
2570 .unwrap_or(module_path!())
2571 );
2572 let output = Command::new(std::env::current_exe().unwrap())
2573 .args(["--exact", &test_name, "--nocapture"])
2574 .env(RAW_CONVERSION_TEST_CHILD, "1")
2575 .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
2578 .env("MJ_DATA_DIR", directory.path().join("data"))
2579 .env("MJ_CONFIG_DIR", directory.path().join("config"))
2580 .env("GH_TOKEN", "test-token")
2581 .output()
2582 .unwrap();
2583 assert!(
2584 output.status.success(),
2585 "isolated raw conversion test failed\nstdout:\n{}\nstderr:\n{}",
2586 String::from_utf8_lossy(&output.stdout),
2587 String::from_utf8_lossy(&output.stderr)
2588 );
2589 return;
2590 }
2591 let _writer = hel::hel_database::install_isolated_test_writer();
2593
2594 struct GitWithoutPodmanExecutor;
2597
2598 impl CommandExecutor for GitWithoutPodmanExecutor {
2599 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2600 if command.program == "git" {
2601 return ProcessExecutor.execute(command);
2602 }
2603 Ok(CommandOutput {
2604 status: 1,
2605 stdout: Vec::new(),
2606 stderr: b"podman is temporarily unavailable".to_vec(),
2607 })
2608 }
2609 }
2610
2611 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
2612 let archive_directory = data_directory.join("archives");
2613 std::fs::create_dir_all(&archive_directory).unwrap();
2614 std::fs::create_dir_all(hel::hel_config::config_dir()).unwrap();
2615 let session_id = "0123456789abcdef0123456789abcdef";
2616 let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
2617
2618 let repository = committed_repository();
2619 let mut session = managed_worktree_session(repository.path(), session_id);
2620 session.checkpoint = Some(checkpoint);
2621 let worktree = session.managed_worktree.clone().unwrap();
2622 let previous = session.clone();
2623
2624 let profile_home = data_directory.join("profile");
2625 std::fs::create_dir_all(&profile_home).unwrap();
2626 let mut config = resume_compatibility_config();
2627 config.profiles.insert(
2628 "codex".into(),
2629 HarnessProfile {
2630 kind: hel::hel_config::HarnessKind::Codex,
2631 home: profile_home,
2632 executable: None,
2633 environment: BTreeMap::new(),
2634 context_window_bytes: None,
2635 },
2636 );
2637 let mut controller = Controller {
2638 config,
2639 state: HelState {
2640 sessions: BTreeMap::from([(session_id.into(), session)]),
2641 ..HelState::default()
2642 },
2643 };
2644 hel::hel_database::save_state(&controller.state).unwrap();
2645
2646 let error = tokio::runtime::Builder::new_current_thread()
2647 .enable_all()
2648 .build()
2649 .unwrap()
2650 .block_on(controller.resume_session_controlled(
2651 session_id,
2652 "codex",
2653 "podman",
2654 SessionResumeOptions {
2655 additional_mounts: None,
2656 resource_allocation: None,
2657 discard_queue: false,
2658 },
2659 &GitWithoutPodmanExecutor,
2660 ))
2661 .unwrap_err();
2662 assert!(
2663 format!("{error:#}").contains("podman is temporarily unavailable"),
2664 "{error:#}"
2665 );
2666 assert!(!format!("{error:#}").contains("returned to stopped"));
2667
2668 let (_, bundle) = controller
2671 .config
2672 .bundles
2673 .iter()
2674 .find(|(_, bundle)| bundle.repositories[0].local.as_deref() == Some(repository.path()))
2675 .expect("the conversion added a bundle for the checkout");
2676 assert_eq!(
2677 bundle.repositories[0].destination,
2678 PathBuf::from(session_id)
2679 );
2680 let saved = hel::hel_config::HelConfig::load().unwrap();
2681 assert_eq!(saved.bundles, controller.config.bundles);
2682
2683 let retained = controller.state.sessions.get(session_id).unwrap();
2684 assert_eq!(retained.state, SessionState::Stopped);
2685 assert_eq!(retained.project_directory, previous.project_directory);
2686 assert_eq!(retained.managed_worktree, previous.managed_worktree);
2687 assert_eq!(retained.bundle_id, previous.bundle_id);
2688 assert!(worktree.worktree_root.is_dir(), "the checkout stays put");
2689 }
2690}