1use std::collections::BTreeSet;
4use std::ffi::OsStr;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, bail, ensure};
9
10use crate::hel_session_manager::{
11 ManagedSessionHandle, ManagedSessionLease, SessionManagerControl, StandaloneSession,
12 new_command_id, worker_connect_needs_restart,
13};
14use crate::hel_worker_client::RelayRejected;
15use hel::hel_archive::{
16 BundleManifest, CanonicalSessionSnapshot, SessionManifest, TargetManifest,
17 verify_archive_streaming,
18};
19use hel::hel_checkpoint::{
20 CHECKPOINT_EXPORT_PROTOCOL_VERSION, CHECKPOINT_STAGING_PROTOCOL_VERSION, CapturedCheckpoint,
21 CheckpointCaptureSpec, CheckpointExportSpec, CheckpointPackSpec, CheckpointRepositoryCapture,
22 CheckpointRepositorySpec, CheckpointTransfer, canonical_session_contains_prompt,
23 capture_stdin_command, checkpoint_sha256, export_command, export_stdin_command,
24 pack_stdin_command,
25};
26use hel::hel_config::sessions_dir;
27use hel::hel_projection::canonical_session_from_materialized;
28use hel::hel_state::{
29 CheckpointMetadata, HelState, ManagedSessionSnapshot, SessionRecord, SessionState,
30};
31use hel::hel_targets::{self, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
32use hel::hel_worker::{RelayCommand, RelayCursor, RelayExecutionState};
33
34use super::backend::backend_locator;
35use super::readiness::wait_for_native_session_in_stage;
36use super::worker_restart::{InstalledWorkerRestart, RESTART_FOR_CHECKPOINT};
37use super::{
38 Controller, execute_checked, now, persist_session_record_transition_or_restore,
39 scp_command_spec, ssh_command_spec, target_kind, target_profile_home,
40};
41
42const CHECKPOINT_BARRIER_TIMEOUT: Duration = Duration::from_secs(30);
47const CHECKPOINT_CANCEL_TIMEOUT: Duration = Duration::from_secs(30);
51const CHECKPOINT_BARRIER_TIMEOUT_AFTER_RESTART: Duration = Duration::from_secs(300);
54
55pub fn reconcile_managed_checkpoint_archives() -> Result<usize> {
59 let mut state = HelState::load()?;
60 for operation in hel::hel_database::load_move_operations()? {
63 if operation.retains_checkpoint()
64 && let Some(checkpoint) = operation.checkpoint
65 && let Some(mut session) = state.sessions.get(&operation.selection.session_id).cloned()
66 {
67 session.checkpoint = Some(checkpoint);
68 state
69 .sessions
70 .insert(format!("move:{}", operation.operation_id), session);
71 }
72 }
73 reconcile_managed_checkpoint_archives_in(&sessions_dir(), &state)
74}
75
76fn reconcile_managed_checkpoint_archives_in(directory: &Path, state: &HelState) -> Result<usize> {
77 if !directory.exists() {
78 return Ok(0);
79 }
80 let referenced_names = state
81 .sessions
82 .values()
83 .filter_map(|session| session.checkpoint.as_ref())
84 .filter_map(|checkpoint| checkpoint.archive_path.file_name())
85 .map(ToOwned::to_owned)
86 .collect::<BTreeSet<_>>();
87 let mut removed = 0;
88 for entry in std::fs::read_dir(directory)
89 .with_context(|| format!("scan checkpoint directory {}", directory.display()))?
90 {
91 let entry = entry?;
92 let file_type = entry.file_type()?;
93 if !file_type.is_file()
94 || !is_managed_checkpoint_archive_name(&entry.file_name())
95 || referenced_names.contains(&entry.file_name())
96 {
97 continue;
98 }
99 std::fs::remove_file(entry.path()).with_context(|| {
100 format!(
101 "remove unreferenced managed checkpoint {}",
102 entry.path().display()
103 )
104 })?;
105 removed += 1;
106 }
107 Ok(removed)
108}
109
110fn is_managed_checkpoint_archive_name(name: &OsStr) -> bool {
111 let Some(stem) = name.to_str().and_then(|name| name.strip_suffix(".hel.zip")) else {
112 return false;
113 };
114 let Some((frontier_prefix, nonce)) = stem.rsplit_once("-archive-") else {
115 return false;
116 };
117 if nonce.len() != 32
118 || !nonce
119 .bytes()
120 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
121 {
122 return false;
123 }
124 let Some((session_id, frontier)) = frontier_prefix.rsplit_once('-') else {
125 return false;
126 };
127 !session_id.is_empty()
128 && frontier.parse::<u64>().is_ok()
129 && hel::hel_config::validate_id("session", session_id).is_ok()
130}
131
132#[derive(Debug, Clone)]
133pub struct CheckpointArtifact {
134 pub metadata: CheckpointMetadata,
135 pub native_session_id: String,
136 pub event_frontier_digest: String,
138}
139
140pub(super) enum ControllerRelayLease {
148 Managed {
149 handle: ManagedSessionHandle,
150 lease: Option<ManagedSessionLease>,
151 },
152 Standalone(StandaloneSession),
153}
154
155impl ControllerRelayLease {
156 pub(super) fn connection_mut(&mut self) -> &mut StandaloneSession {
159 match self {
160 Self::Managed { lease, .. } => lease
161 .as_mut()
162 .expect("checkpoint latch has already returned its connection")
163 .connection_mut(),
164 Self::Standalone(connection) => connection,
165 }
166 }
167
168 async fn submit(&mut self, command_id: String, command: RelayCommand) -> Result<u64> {
169 match self {
170 Self::Managed {
171 lease: Some(lease), ..
172 } => lease.connection_mut().submit(command_id, command).await,
173 Self::Managed { handle, .. } => handle.submit(command_id, command).await,
174 Self::Standalone(connection) => connection.submit(command_id, command).await,
175 }
176 }
177
178 async fn sync_snapshot(&mut self) -> Result<ManagedSessionSnapshot> {
179 match self {
180 Self::Managed {
181 lease: Some(lease), ..
182 } => lease.connection_mut().sync().await,
183 Self::Managed { handle, .. } => {
184 handle.sync_now().await?;
185 handle
186 .view()
187 .snapshot
188 .context("managed session has no snapshot")
189 }
190 Self::Standalone(connection) => connection.sync().await,
191 }
192 }
193
194 fn replace_connection(&mut self, connection: StandaloneSession) {
196 match self {
197 Self::Managed {
198 lease: Some(lease), ..
199 } => lease.replace_connection(connection),
200 Self::Standalone(existing) => *existing = connection,
201 Self::Managed { lease: None, .. } => {
202 *self = Self::Standalone(connection);
203 }
204 }
205 }
206
207 fn end_latch(&mut self) {
211 if let Self::Managed { lease, .. } = self
212 && let Some(lease) = lease.take()
213 {
214 lease.release();
215 }
216 }
217
218 async fn cancel_abandoned_barrier(&mut self) -> Result<()> {
226 let Self::Managed { handle, lease } = self else {
227 return Ok(());
230 };
231 match lease.take() {
232 Some(lease) => drop(lease),
233 None => drop(handle.lease_connection().await?),
234 }
235 Ok(())
236 }
237
238 pub(super) fn release(self) {
239 if let Self::Managed {
240 lease: Some(lease), ..
241 } = self
242 {
243 lease.release();
244 }
245 }
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub(super) enum LatchExclusivity {
251 ReleaseAfterLatch,
256 HoldThroughClose,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub(super) enum CheckpointExportPolicy {
264 Always,
266 ReuseUnchangedArchive,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub(super) enum CheckpointCompletion {
275 HeldBarrier,
279 ReleasedAfterCapture,
282}
283
284pub(super) struct LatchedCheckpoint {
285 pub(super) artifact: CheckpointArtifact,
286 pub(super) relay: ControllerRelayLease,
287 pub(super) barrier_command_id: String,
288 pub(super) cursor: RelayCursor,
289 pub(super) completion: CheckpointCompletion,
290}
291
292impl LatchedCheckpoint {
299 async fn complete(mut self) -> Result<()> {
301 let (prefix, command) = match self.completion {
302 CheckpointCompletion::HeldBarrier => (
303 "checkpoint-complete",
304 RelayCommand::CompleteCheckpoint {
305 barrier_command_id: self.barrier_command_id.clone(),
306 },
307 ),
308 CheckpointCompletion::ReleasedAfterCapture => (
311 "checkpoint-floor",
312 RelayCommand::AdvanceRecoveryFloor {
313 through: self.cursor.clone(),
314 },
315 ),
316 };
317 let command_id = new_command_id(prefix)?;
318 self.relay.submit(command_id, command).await.map(|_| ())
319 }
320
321 async fn abandon(mut self, session_id: &str) {
327 if self.completion == CheckpointCompletion::ReleasedAfterCapture {
328 return;
332 }
333 if let Err(error) = self.relay.cancel_abandoned_barrier().await {
334 tracing::warn!(
335 session_id,
336 "abandoned checkpoint could not cancel its relay barrier: {error:#}"
337 );
338 }
339 }
340}
341
342impl Controller {
343 pub(super) fn persist_checkpoint_transition_or_restore(
344 &mut self,
345 session_id: &str,
346 previous: &SessionRecord,
347 context: &'static str,
348 ) -> Result<()> {
349 persist_session_record_transition_or_restore(
350 &mut self.state,
351 session_id,
352 previous,
353 context,
354 &hel::hel_database::save_checkpointed_session,
355 )
356 }
357
358 pub(super) fn persist_failed_checkpoint_state_or_restore(
359 &mut self,
360 session_id: &str,
361 previous: &SessionRecord,
362 primary: anyhow::Error,
363 ) -> anyhow::Error {
364 match self.persist_session_state(session_id) {
365 Ok(()) => primary,
366 Err(error) => self.restore_prior_session_after_persistence_failure(
367 session_id,
368 previous,
369 primary.context(format!(
370 "failed to persist the checkpoint rollback state: {error:#}"
371 )),
372 ),
373 }
374 }
375
376 pub async fn checkpoint_session(&mut self, session_id: &str) -> Result<CheckpointMetadata> {
380 self.checkpoint_session_controlled(session_id, &ProcessExecutor)
381 .await
382 }
383
384 pub async fn checkpoint_session_controlled(
385 &mut self,
386 session_id: &str,
387 executor: &(impl CommandExecutor + Sync),
388 ) -> Result<CheckpointMetadata> {
389 self.checkpoint_session_controlled_with_manager(session_id, executor, None)
390 .await
391 }
392
393 async fn checkpoint_session_controlled_with_manager(
394 &mut self,
395 session_id: &str,
396 executor: &(impl CommandExecutor + Sync),
397 manager: Option<&SessionManagerControl>,
398 ) -> Result<CheckpointMetadata> {
399 let previous = self
400 .state
401 .sessions
402 .get(session_id)
403 .with_context(|| format!("unknown session {session_id}"))?
404 .clone();
405 ensure!(
406 !matches!(
407 previous.state,
408 SessionState::Closing | SessionState::Destroying
409 ),
410 "session {session_id} is already closing; resume that close instead of starting an ordinary checkpoint"
411 );
412 let record = self.state.sessions.get_mut(session_id).unwrap();
413 record.state = SessionState::Checkpointing;
414 record.updated_at = now();
415 record.last_checkpoint_error = None;
416 self.persist_session_transition_or_restore(
417 session_id,
418 &previous,
419 "persist checkpointing state before creating a checkpoint",
420 )?;
421
422 match self
423 .checkpoint_session_latched(
424 session_id,
425 executor,
426 manager,
427 LatchExclusivity::ReleaseAfterLatch,
428 CheckpointExportPolicy::Always,
429 )
430 .await
431 {
432 Ok(latched) => {
433 let artifact = latched.artifact.clone();
434 if let Err(error) = hel::hel_test_hooks::reach_test_hook(
435 "checkpoint_archive_before_database_publication",
436 ) {
437 latched.abandon(session_id).await;
438 return Err(remove_uninstalled_checkpoint(
439 &artifact.metadata.archive_path,
440 error,
441 ));
442 }
443 {
444 let record = self.state.sessions.get_mut(session_id).unwrap();
445 record.state = SessionState::Running;
446 record.native_session_id = Some(artifact.native_session_id.clone());
447 record.checkpoint = Some(artifact.metadata.clone());
448 record.updated_at = now();
449 record.last_error = None;
450 record.last_checkpoint_error = None;
451 }
452 let persist_started = Instant::now();
453 if let Err(error) = self.persist_checkpoint_transition_or_restore(
454 session_id,
455 &previous,
456 "persist verified checkpoint before releasing relay history",
457 ) {
458 latched.abandon(session_id).await;
459 return Err(error);
460 }
461 tracing::info!(
462 session_id,
463 persist_ms = persist_started.elapsed().as_millis() as u64,
464 "checkpoint metadata persisted"
465 );
466 prune_replaced_checkpoint(previous.checkpoint.as_ref(), &artifact.metadata);
467 release_projection_behind_checkpoint(session_id, &artifact.metadata);
468 if let Err(error) = latched.complete().await {
469 tracing::warn!(
475 session_id,
476 "verified checkpoint was saved, but the relay could not be told to release the history it covers: {error:#}"
477 );
478 }
479 Ok(artifact.metadata)
480 }
481 Err(error) => {
482 let deferred = checkpoint_was_deferred(&error);
487 if let Some(record) = self.state.sessions.get_mut(session_id) {
488 record.state = if previous.state == SessionState::Checkpointing {
489 SessionState::Running
490 } else {
491 previous.state
492 };
493 record.updated_at = now();
494 if !deferred {
495 record.last_checkpoint_error = Some(format!("{error:#}"));
496 }
497 }
498 Err(self.persist_failed_checkpoint_state_or_restore(session_id, &previous, error))
499 }
500 }
501 }
502
503 pub async fn create_recovery_checkpoint_managed_controlled(
506 &self,
507 session_id: &str,
508 manager: &SessionManagerControl,
509 executor: &(impl CommandExecutor + Sync),
510 ) -> Result<CheckpointArtifact> {
511 self.create_recovery_checkpoint_with_manager(session_id, Some(manager), executor)
512 .await
513 }
514
515 async fn create_recovery_checkpoint_with_manager(
516 &self,
517 session_id: &str,
518 manager: Option<&SessionManagerControl>,
519 executor: &(impl CommandExecutor + Sync),
520 ) -> Result<CheckpointArtifact> {
521 let previous_checkpoint = self
522 .state
523 .sessions
524 .get(session_id)
525 .with_context(|| format!("unknown session {session_id}"))?
526 .checkpoint
527 .clone();
528 let latched = self
529 .checkpoint_session_latched(
530 session_id,
531 executor,
532 manager,
533 LatchExclusivity::ReleaseAfterLatch,
534 CheckpointExportPolicy::Always,
535 )
536 .await?;
537 let artifact = latched.artifact.clone();
538 if let Err(error) = verify_checkpoint_artifact(session_id, &artifact) {
539 latched.abandon(session_id).await;
540 return Err(remove_uninstalled_checkpoint(
541 &artifact.metadata.archive_path,
542 error.context("final recovery checkpoint verification"),
543 ));
544 }
545 if let Err(error) =
546 hel::hel_test_hooks::reach_test_hook("checkpoint_archive_before_database_publication")
547 {
548 latched.abandon(session_id).await;
549 return Err(remove_uninstalled_checkpoint(
550 &artifact.metadata.archive_path,
551 error,
552 ));
553 }
554 let persist_started = Instant::now();
555 if let Err(error) = hel::hel_database::record_recovery_success(
556 session_id,
557 &artifact.native_session_id,
558 &artifact.metadata,
559 ) {
560 latched.abandon(session_id).await;
561 return Err(error
562 .context("persist verified recovery checkpoint before releasing relay history"));
563 }
564 tracing::info!(
565 session_id,
566 persist_ms = persist_started.elapsed().as_millis() as u64,
567 "recovery checkpoint metadata persisted"
568 );
569 if let Err(error) = latched.complete().await {
570 tracing::warn!(
575 session_id,
576 "recovery checkpoint was saved, but the relay could not be told to release the history it covers: {error:#}"
577 );
578 }
579 prune_replaced_checkpoint(previous_checkpoint.as_ref(), &artifact.metadata);
580 release_projection_behind_checkpoint(session_id, &artifact.metadata);
581 Ok(artifact)
582 }
583
584 pub(super) async fn checkpoint_session_latched(
585 &self,
586 session_id: &str,
587 executor: &(impl CommandExecutor + Sync),
588 manager: Option<&SessionManagerControl>,
589 exclusivity: LatchExclusivity,
590 export_policy: CheckpointExportPolicy,
591 ) -> Result<LatchedCheckpoint> {
592 if let Some(operation) = hel::hel_database::load_move_operation(session_id)?
593 && operation.queue_admission_started
594 && !operation.queue_admission_finished
595 {
596 bail!(
599 "move queue admission is incomplete; retry Move before checkpointing this destination"
600 );
601 }
602 let session = self
603 .state
604 .sessions
605 .get(session_id)
606 .with_context(|| format!("unknown session {session_id}"))?
607 .clone();
608 let locator = session
609 .target
610 .as_ref()
611 .context("session has no live target")?;
612 let backend = backend_locator(locator, &session, &self.config)?;
613 let profile = self
614 .config
615 .profiles
616 .get(&session.last_profile)
617 .context("session profile is missing")?;
618 let bundle = session
619 .project_directory
620 .is_none()
621 .then(|| self.config.bundles.get(&session.bundle_id))
622 .flatten();
623 let reconnect = hel_targets::reconnect_plan(&backend, session_id)?
624 .commands
625 .into_iter()
626 .next()
627 .context("reconnect plan is empty")?;
628 let worker_root = hel_targets::worker_root(&backend, session_id)?;
629 let harness_home = target_profile_home(&backend, session_id, profile);
630 let (workspace_root, primary_repository, repositories) =
631 if let Some(project_directory) = &session.project_directory {
632 let parent = project_directory
633 .parent()
634 .context("bare project directory has no parent")?;
635 let destination = project_directory
636 .file_name()
637 .context("bare project directory cannot be the filesystem root")?;
638 (
639 parent.to_string_lossy().into_owned(),
640 "project".to_owned(),
641 vec![CheckpointRepositorySpec {
642 id: "project".into(),
643 relative_destination: PathBuf::from(destination),
644 capture: if session.managed_worktree.is_some() {
650 CheckpointRepositoryCapture::DeltaFrom {
651 base_commit: super::worktree::raw_checkout_position(
652 &session,
653 &self.config,
654 project_directory,
655 executor,
656 )?
657 .head_commit,
658 }
659 } else {
660 CheckpointRepositoryCapture::MetadataOnly
661 },
662 origin_override: None,
663 }],
664 )
665 } else {
666 let bundle = bundle.context("session bundle is missing")?;
667 let workspace_root = match &backend {
668 hel_targets::TargetLocator::LocalPodman { .. }
669 | hel_targets::TargetLocator::LocalDocker { .. }
670 | hel_targets::TargetLocator::AppleContainer { .. }
671 | hel_targets::TargetLocator::SshPodman { .. }
672 | hel_targets::TargetLocator::SshDocker { .. } => "/workspace".to_string(),
673 hel_targets::TargetLocator::AwsEc2 { workspace, .. }
674 | hel_targets::TargetLocator::SshBare { workspace, .. } => workspace.clone(),
675 hel_targets::TargetLocator::LocalBare { worker_root } => worker_root.clone(),
676 };
677 let repositories = bundle
678 .repositories
679 .iter()
680 .map(|repository| CheckpointRepositorySpec {
681 id: repository.id.clone(),
682 relative_destination: repository.destination.clone(),
683 capture: CheckpointRepositoryCapture::SessionDelta,
684 origin_override: repository
685 .is_local()
686 .then(|| format!("mj-local:{}", repository.id)),
687 })
688 .collect();
689 (workspace_root, bundle.primary_repo.clone(), repositories)
690 };
691 let target_path = |path: &str| match &backend {
692 hel_targets::TargetLocator::AwsEc2 { .. }
693 | hel_targets::TargetLocator::SshBare { .. }
694 if !path.starts_with('/') =>
695 {
696 PathBuf::from(format!("~/{path}"))
697 }
698 _ => PathBuf::from(path),
699 };
700 let remote_spec = format!("{worker_root}/checkpoint-spec.json");
701 let remote_archive = format!("{worker_root}/checkpoint.hel.zip");
702 let remote_stage = format!(
703 "{worker_root}/checkpoint-stage-{}",
704 new_command_id("capture")?
705 );
706 let checkpointed_at = now();
707 let target_manifest = TargetManifest {
708 template_id: session.target_template_id.clone(),
709 target_kind: target_kind(&backend).into(),
710 details: Default::default(),
711 };
712 let bundle_manifest = BundleManifest {
713 id: session.bundle_id.clone(),
714 primary_repository,
715 };
716 let session_manifest = |native_session_id: &str| SessionManifest {
717 id: session.id.clone(),
718 title: session.title.clone(),
719 harness_kind: session.harness_kind,
720 profile_id: session.last_profile.clone(),
721 native_session_id: native_session_id.to_owned(),
722 created_at: session.created_at.clone(),
723 checkpointed_at: checkpointed_at.clone(),
724 hel_version: env!("CARGO_PKG_VERSION").into(),
725 relay_version: env!("CARGO_PKG_VERSION").into(),
726 adapter_version: "acp-v1".into(),
727 };
728 let releases_after_capture = exclusivity == LatchExclusivity::ReleaseAfterLatch;
729 if releases_after_capture
730 && let Some(native_session_id) = session.native_session_id.as_deref()
731 {
732 let prestage = CheckpointCaptureSpec {
733 protocol_version: CHECKPOINT_STAGING_PROTOCOL_VERSION,
734 session: session_manifest(native_session_id),
735 target: target_manifest.clone(),
736 bundle: bundle_manifest.clone(),
737 relay_root: target_path(&worker_root),
738 harness_home: target_path(&harness_home),
739 workspace_root: target_path(&workspace_root),
740 repositories: repositories.clone(),
741 allow_empty_native: false,
742 stage_path: target_path(&remote_stage),
743 refresh_existing: false,
744 };
745 let prestage_started = Instant::now();
746 match run_checkpoint_staging_command(
747 executor,
748 &backend,
749 session_id,
750 &prestage,
751 capture_stdin_command,
752 "prestage target checkpoint",
753 ) {
754 Ok(output) => match serde_json::from_slice::<CapturedCheckpoint>(&output.stdout) {
755 Ok(captured) => tracing::info!(
756 session_id,
757 prestage_ms = prestage_started.elapsed().as_millis() as u64,
758 native_bytes = captured.native_bytes,
759 repository_bytes = captured.repository_bytes,
760 reused_native = captured.reused_native,
761 "checkpoint target state prestaged while ACP dispatch remained active"
762 ),
763 Err(error) => tracing::warn!(
764 session_id,
765 error = format!("{error:#}"),
766 "checkpoint prestage returned an invalid result; barrier capture will replace it"
767 ),
768 },
769 Err(error) => {
770 if executor.cancellation_requested() {
771 return Err(error.context("checkpoint prestage was cancelled"));
772 }
773 tracing::warn!(
774 session_id,
775 error = format!("{error:#}"),
776 "checkpoint prestage failed; barrier capture will collect a fresh generation"
777 );
778 }
779 }
780 }
781 let (mut relay, mut restarted_worker) = self
782 .open_checkpoint_relay(
783 session_id,
784 executor,
785 manager,
786 &backend,
787 &worker_root,
788 &reconnect,
789 )
790 .await?;
791 let (barrier, barrier_command_id) = loop {
792 wait_for_native_session_in_stage(
796 relay.connection_mut(),
797 executor,
798 hel_targets::ProvisionStage::Starting,
799 )
800 .await?;
801 if exclusivity == LatchExclusivity::ReleaseAfterLatch
802 && relay.connection_mut().sync().await?.operational.execution
803 == RelayExecutionState::Running
804 {
805 relay.release();
808 return Err(CheckpointDeferred::harness_busy().into());
809 }
810 let barrier_command_id = new_command_id("checkpoint")?;
811 let timeout = if restarted_worker {
812 CHECKPOINT_BARRIER_TIMEOUT_AFTER_RESTART
813 } else {
814 CHECKPOINT_BARRIER_TIMEOUT
815 };
816 let result = {
817 let connection = relay.connection_mut();
818 connection
819 .submit(
820 barrier_command_id.clone(),
821 RelayCommand::BeginCheckpoint {
822 reason: Some("controller archive checkpoint".into()),
823 },
824 )
825 .await?;
826 wait_for_checkpoint_barrier(
827 connection,
828 session_id,
829 &barrier_command_id,
830 timeout,
831 BarrierBusyPolicy::of(exclusivity),
832 )
833 .await
834 };
835 match result {
836 Ok(barrier) => break (barrier, barrier_command_id),
837 Err(error)
838 if !restarted_worker && checkpoint_barrier_needs_worker_restart(&error) =>
839 {
840 tracing::warn!(
841 session_id,
842 "checkpoint requires a worker restart; restarting and retrying: {error:#}"
843 );
844 let connection = self
845 .restart_worker_for_checkpoint(
846 session_id,
847 executor,
848 &backend,
849 &worker_root,
850 &reconnect,
851 )
852 .await?;
853 relay.replace_connection(connection);
854 restarted_worker = true;
855 }
856 Err(error) => return Err(error),
857 }
858 };
859 let barrier_ready_at = Instant::now();
860 relay
864 .connection_mut()
865 .sync_project_memory()
866 .await
867 .context("synchronize project memory for checkpoint")?;
868 let cursor = barrier
869 .operational
870 .checkpoint_ready
871 .clone()
872 .context("relay reported a checkpoint barrier without its ready cursor")?;
873 let materialized = barrier.materialized;
874 let expected_ordinal = materialized.applied_event_ordinal;
875 let expected_digest = materialized.applied_event_digest.clone();
876 ensure!(
877 expected_ordinal == barrier.operational.latest_ordinal,
878 "checkpoint projection frontier {expected_ordinal} does not match relay frontier {}",
879 barrier.operational.latest_ordinal
880 );
881 ensure!(
882 expected_digest == barrier.operational.latest_digest,
883 "checkpoint projection digest does not match the relay frontier digest"
884 );
885 ensure_exact_checkpoint_cut(&cursor, expected_ordinal, &expected_digest)?;
886 let canonical_session = canonical_session_from_materialized(&materialized)?;
887 let native_session_id = barrier
888 .operational
889 .native_session_id
890 .or_else(|| session.native_session_id.clone())
891 .context("harness did not report its native session ID")?;
892
893 if exclusivity == LatchExclusivity::ReleaseAfterLatch {
898 relay.end_latch();
899 }
900
901 if export_policy == CheckpointExportPolicy::ReuseUnchangedArchive
907 && session.managed_worktree.is_none()
911 && let Some(artifact) = reusable_installed_checkpoint(
912 session_id,
913 session.checkpoint.as_ref(),
914 &native_session_id,
915 cursor.ordinal,
916 &canonical_session,
917 )
918 {
919 return Ok(LatchedCheckpoint {
920 artifact,
921 relay,
922 barrier_command_id,
923 cursor,
924 completion: CheckpointCompletion::HeldBarrier,
925 });
926 }
927
928 let mut completion = CheckpointCompletion::HeldBarrier;
933
934 let exported: Result<CheckpointArtifact> = async {
935 let spec = CheckpointExportSpec {
936 protocol_version: CHECKPOINT_EXPORT_PROTOCOL_VERSION,
937 session: session_manifest(&native_session_id),
938 target: target_manifest,
939 bundle: bundle_manifest,
940 relay_root: target_path(&worker_root),
941 harness_home: target_path(&harness_home),
942 workspace_root: target_path(&workspace_root),
943 repositories,
944 canonical_session,
945 output_path: target_path(&remote_archive),
946 };
947 let mut export_ms: Option<u64> = None;
950 let exported = if releases_after_capture {
951 let capture_spec = CheckpointCaptureSpec {
952 protocol_version: CHECKPOINT_STAGING_PROTOCOL_VERSION,
953 session: spec.session.clone(),
954 target: spec.target.clone(),
955 bundle: spec.bundle.clone(),
956 relay_root: spec.relay_root.clone(),
957 harness_home: spec.harness_home.clone(),
958 workspace_root: spec.workspace_root.clone(),
959 repositories: spec.repositories.clone(),
960 allow_empty_native: !canonical_session_contains_prompt(&spec.canonical_session),
961 stage_path: target_path(&remote_stage),
962 refresh_existing: true,
963 };
964 let capture_started = Instant::now();
965 let captured = run_checkpoint_staging_command(
966 executor,
967 &backend,
968 session_id,
969 &capture_spec,
970 capture_stdin_command,
971 "capture target checkpoint",
972 )?;
973 let captured: CapturedCheckpoint = serde_json::from_slice(&captured.stdout)
974 .context("decode captured checkpoint result")?;
975 tracing::info!(
976 session_id,
977 capture_ms = capture_started.elapsed().as_millis() as u64,
978 barrier_held_ms = barrier_ready_at.elapsed().as_millis() as u64,
979 native_bytes = captured.native_bytes,
980 repository_bytes = captured.repository_bytes,
981 reused_native = captured.reused_native,
982 "checkpoint target state captured; releasing ACP dispatch"
983 );
984 completion = release_checkpoint_after_capture(
985 &mut relay,
986 session_id,
987 &barrier_command_id,
988 &cursor,
989 )
990 .await?;
991 let pack_spec = CheckpointPackSpec {
992 protocol_version: CHECKPOINT_STAGING_PROTOCOL_VERSION,
993 relay_root: spec.relay_root.clone(),
994 stage_path: target_path(&remote_stage),
995 canonical_session: spec.canonical_session.clone(),
996 output_path: spec.output_path.clone(),
997 };
998 let pack_started = Instant::now();
999 let output = run_checkpoint_staging_command(
1000 executor,
1001 &backend,
1002 session_id,
1003 &pack_spec,
1004 pack_stdin_command,
1005 "pack target checkpoint",
1006 )?;
1007 tracing::info!(
1008 session_id,
1009 pack_ms = pack_started.elapsed().as_millis() as u64,
1010 "checkpoint archive packaged after ACP dispatch resumed"
1011 );
1012 output
1013 } else {
1014 let export_started = Instant::now();
1015 let output =
1016 export_target_checkpoint(executor, &backend, session_id, &spec, &remote_spec)?;
1017 export_ms = Some(export_started.elapsed().as_millis() as u64);
1018 output
1019 };
1020 let target_checkpoint: hel::hel_checkpoint::TargetCheckpoint =
1021 serde_json::from_slice(&exported.stdout)
1022 .context("decode target checkpoint result")?;
1023 if let Some(export_ms) = export_ms {
1024 let timings = target_checkpoint.timings.unwrap_or_default();
1027 tracing::info!(
1028 session_id,
1029 export_ms,
1030 timings_reported = target_checkpoint.timings.is_some(),
1031 native_ms = timings.native_ms,
1032 repositories_ms = timings.repositories_ms,
1033 archive_ms = timings.archive_ms,
1034 worker_total_ms = timings.total_ms,
1035 "checkpoint archive exported on the target"
1036 );
1037 }
1038 if target_checkpoint.event_frontier != expected_ordinal {
1039 bail!(
1040 "target checkpoint event frontier changed: expected {expected_ordinal}, found {}",
1041 target_checkpoint.event_frontier
1042 );
1043 }
1044 if target_checkpoint.event_frontier_digest != expected_digest {
1045 bail!("target checkpoint event frontier digest changed");
1046 }
1047
1048 let archive_id = new_command_id("archive")?;
1053 let destination = sessions_dir().join(format!(
1054 "{session_id}-{}-{archive_id}.hel.zip",
1055 target_checkpoint.event_frontier
1056 ));
1057 let transfer = CheckpointTransfer {
1058 locator: &backend,
1059 session_id,
1060 remote_archive: &remote_archive,
1061 destination: &destination,
1062 expected_sha256: &target_checkpoint.sha256,
1063 expected_event_frontier: target_checkpoint.event_frontier,
1064 expected_event_frontier_digest: &target_checkpoint.event_frontier_digest,
1065 };
1066 let transfer_started = Instant::now();
1067 let verified = transfer.execute(executor)?;
1068 tracing::info!(
1069 session_id,
1070 transfer_and_checksum_ms = transfer_started.elapsed().as_millis() as u64,
1071 "checkpoint archive transferred and checksum-verified"
1072 );
1073 let installed_archive = verified.archive_path().to_path_buf();
1074 let validate_transferred = || -> Result<()> {
1075 ensure!(
1076 verified.sha256() == target_checkpoint.sha256,
1077 "target and controller checkpoint checksums differ"
1078 );
1079 ensure!(
1080 verified.event_frontier_digest() == expected_digest,
1081 "verified checkpoint event frontier digest changed"
1082 );
1083 Ok(())
1084 };
1085 if let Err(error) = validate_transferred() {
1086 return Err(remove_uninstalled_checkpoint(&installed_archive, error));
1087 }
1088 if completion == CheckpointCompletion::HeldBarrier {
1092 let revalidated = relay.sync_snapshot().await.and_then(|snapshot| {
1093 validate_checkpoint_barrier_snapshot(&snapshot, &barrier_command_id, &cursor)
1094 });
1095 if let Err(error) = revalidated {
1096 return Err(remove_uninstalled_checkpoint(
1097 &installed_archive,
1098 error.context("checkpoint barrier changed while transferring its archive"),
1099 ));
1100 }
1101 }
1102 if let Err(error) = transfer
1103 .cleanup_plan(&verified)
1104 .and_then(|plan| plan.execute(executor).map(|_| ()))
1105 {
1106 return Err(remove_uninstalled_checkpoint(
1107 &installed_archive,
1108 error.context("clean target checkpoint staging"),
1109 ));
1110 }
1111 let metadata = CheckpointMetadata {
1112 archive_path: verified.archive_path().to_path_buf(),
1113 sha256: verified.sha256().to_string(),
1114 created_at: checkpointed_at.clone(),
1115 event_frontier: verified.event_frontier(),
1116 };
1117 Ok(CheckpointArtifact {
1118 metadata,
1119 native_session_id,
1120 event_frontier_digest: expected_digest,
1121 })
1122 }
1123 .await;
1124
1125 let artifact = match exported {
1126 Ok(artifact) => artifact,
1127 Err(error) => {
1128 if completion == CheckpointCompletion::HeldBarrier
1134 && let Err(cancel_error) = relay.cancel_abandoned_barrier().await
1135 {
1136 tracing::warn!(
1137 session_id,
1138 "failed checkpoint could not cancel its relay barrier: {cancel_error:#}"
1139 );
1140 }
1141 return Err(error);
1142 }
1143 };
1144 Ok(LatchedCheckpoint {
1145 artifact,
1146 relay,
1147 barrier_command_id,
1148 cursor,
1149 completion,
1150 })
1151 }
1152
1153 async fn open_checkpoint_relay(
1157 &self,
1158 session_id: &str,
1159 executor: &(impl CommandExecutor + Sync),
1160 manager: Option<&SessionManagerControl>,
1161 backend: &hel_targets::TargetLocator,
1162 worker_root: &str,
1163 reconnect: &hel_targets::CommandSpec,
1164 ) -> Result<(ControllerRelayLease, bool)> {
1165 let project_memory = match self.project_memory_sync_target(session_id) {
1166 Ok(target) => Some(target),
1167 Err(error) => {
1168 tracing::warn!(
1169 session_id,
1170 error = format!("{error:#}"),
1171 "project memory will not be synchronized during checkpoint reconnect"
1172 );
1173 None
1174 }
1175 };
1176 match connect_checkpoint_relay(session_id, manager, reconnect, project_memory.clone()).await
1177 {
1178 Ok(relay) => Ok((relay, false)),
1179 Err(error) if worker_connect_needs_restart(&error) => {
1180 tracing::warn!(
1181 session_id,
1182 "checkpoint could not reach the worker; restarting it: {error:#}"
1183 );
1184 let mut connection = self
1185 .restart_worker_for_checkpoint(
1186 session_id,
1187 executor,
1188 backend,
1189 worker_root,
1190 reconnect,
1191 )
1192 .await?;
1193 connection.set_project_memory_target(project_memory);
1194 let relay =
1195 adopt_restarted_checkpoint_relay(session_id, manager, connection).await?;
1196 Ok((relay, true))
1197 }
1198 Err(error) => Err(error).context("connect to the session worker for checkpoint"),
1199 }
1200 }
1201
1202 async fn restart_worker_for_checkpoint(
1206 &self,
1207 session_id: &str,
1208 executor: &(impl CommandExecutor + Sync),
1209 backend: &hel_targets::TargetLocator,
1210 worker_root: &str,
1211 reconnect: &hel_targets::CommandSpec,
1212 ) -> Result<StandaloneSession> {
1213 self.restart_worker_with_installed_binary(
1214 session_id,
1215 executor,
1216 InstalledWorkerRestart {
1217 backend,
1218 worker_root,
1219 reconnect,
1220 launch: None,
1221 messages: &RESTART_FOR_CHECKPOINT,
1222 },
1223 )
1224 .await
1225 }
1226}
1227
1228async fn connect_checkpoint_relay(
1229 session_id: &str,
1230 manager: Option<&SessionManagerControl>,
1231 reconnect: &hel_targets::CommandSpec,
1232 project_memory: Option<crate::hel_session_manager::ProjectMemorySyncTarget>,
1233) -> Result<ControllerRelayLease> {
1234 if let Some(manager) = manager {
1235 let handle = manager
1236 .wait_for_session(session_id, Duration::from_secs(5))
1237 .await?;
1238 let mut lease = handle.lease_connection().await?;
1239 lease
1240 .connection_mut()
1241 .set_project_memory_target(project_memory);
1242 Ok(ControllerRelayLease::Managed {
1243 handle,
1244 lease: Some(lease),
1245 })
1246 } else {
1247 let target = crate::hel_session_manager::RelaySessionTarget {
1248 session_id: session_id.to_owned(),
1249 spec: reconnect.clone(),
1250 worker_recovery: None,
1251 project_memory,
1252 };
1253 Ok(ControllerRelayLease::Standalone(
1254 StandaloneSession::connect(&target).await?,
1255 ))
1256 }
1257}
1258
1259async fn adopt_restarted_checkpoint_relay(
1260 session_id: &str,
1261 manager: Option<&SessionManagerControl>,
1262 connection: StandaloneSession,
1263) -> Result<ControllerRelayLease> {
1264 let Some(manager) = manager else {
1265 return Ok(ControllerRelayLease::Standalone(connection));
1266 };
1267 let handle = manager
1268 .wait_for_session(session_id, Duration::from_secs(5))
1269 .await?;
1270 match handle.lease_connection().await {
1271 Ok(mut lease) => {
1272 lease.replace_connection(connection);
1273 Ok(ControllerRelayLease::Managed {
1274 handle,
1275 lease: Some(lease),
1276 })
1277 }
1278 Err(error) => {
1279 tracing::warn!(
1280 session_id,
1281 "session actor could not lease after worker restart; using the restarted proxy: {error:#}"
1282 );
1283 Ok(ControllerRelayLease::Standalone(connection))
1284 }
1285 }
1286}
1287
1288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1290enum BarrierBusyPolicy {
1291 DeferWhileRunning,
1297 InterruptWhileRunning,
1301}
1302
1303impl BarrierBusyPolicy {
1304 fn of(exclusivity: LatchExclusivity) -> Self {
1305 match exclusivity {
1306 LatchExclusivity::ReleaseAfterLatch => Self::DeferWhileRunning,
1307 LatchExclusivity::HoldThroughClose => Self::InterruptWhileRunning,
1308 }
1309 }
1310}
1311
1312async fn wait_for_checkpoint_barrier(
1313 relay: &mut StandaloneSession,
1314 session_id: &str,
1315 command_id: &str,
1316 timeout: Duration,
1317 busy: BarrierBusyPolicy,
1318) -> Result<ManagedSessionSnapshot> {
1319 let deadline = tokio::time::Instant::now() + timeout;
1320 let mut cancel_submitted = false;
1321 let mut cancel_deadline = None;
1322 let mut cancel_started_at: Option<Instant> = None;
1323 loop {
1324 let snapshot = relay.sync().await?;
1325 if checkpoint_barrier_is_ready(&snapshot, command_id) {
1326 if let Some(started_at) = cancel_started_at {
1327 tracing::info!(
1328 session_id,
1329 barrier_command_id = command_id,
1330 cancellation_ms = started_at.elapsed().as_millis() as u64,
1331 "active turn cancellation settled before checkpoint barrier"
1332 );
1333 }
1334 return Ok(snapshot);
1335 }
1336 if busy == BarrierBusyPolicy::InterruptWhileRunning
1337 && snapshot.operational.execution == RelayExecutionState::Running
1338 && !cancel_submitted
1339 {
1340 let cancel_turn = RelayCommand::CancelTurn;
1341 if relay.protocol_version() < cancel_turn.minimum_protocol() {
1342 return Err(CheckpointBarrierUnreachable::cancel_turn_unavailable(
1343 command_id,
1344 relay.protocol_version(),
1345 )
1346 .into());
1347 }
1348 let cancel_command_id = new_command_id("checkpoint-cancel-turn")?;
1349 match relay.submit(cancel_command_id, cancel_turn).await {
1350 Ok(_) => {
1351 cancel_submitted = true;
1352 cancel_started_at = Some(Instant::now());
1353 cancel_deadline = Some(tokio::time::Instant::now() + CHECKPOINT_CANCEL_TIMEOUT);
1354 tracing::info!(
1355 session_id,
1356 barrier_command_id = command_id,
1357 "requested active turn cancellation before checkpoint barrier"
1358 );
1359 }
1360 Err(error) if checkpoint_cancel_turn_needs_worker_restart(&error) => {
1361 return Err(error.context(
1362 CheckpointBarrierUnreachable::cancel_turn_unavailable(
1363 command_id,
1364 relay.protocol_version(),
1365 ),
1366 ));
1367 }
1368 Err(error) if worker_connect_needs_restart(&error) => {
1369 return Err(error.context(
1370 CheckpointBarrierUnreachable::cancel_turn_unreachable(command_id),
1371 ));
1372 }
1373 Err(error) => {
1374 if let Ok(snapshot) = relay.sync().await
1378 && checkpoint_barrier_is_ready(&snapshot, command_id)
1379 {
1380 tracing::info!(
1381 session_id,
1382 barrier_command_id = command_id,
1383 "active turn settled while submitting checkpoint cancellation"
1384 );
1385 return Ok(snapshot);
1386 }
1387 return Err(error.context("cancel active ACP turn before checkpoint barrier"));
1388 }
1389 }
1390 continue;
1391 }
1392 let out_of_time = tokio::time::Instant::now() >= cancel_deadline.unwrap_or(deadline);
1393 if let Some(error) = checkpoint_barrier_wait_ended(
1394 &snapshot,
1395 command_id,
1396 busy,
1397 out_of_time,
1398 cancel_submitted,
1399 ) {
1400 return Err(error);
1401 }
1402 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1403 }
1404}
1405
1406fn checkpoint_barrier_wait_ended(
1413 snapshot: &ManagedSessionSnapshot,
1414 command_id: &str,
1415 busy: BarrierBusyPolicy,
1416 out_of_time: bool,
1417 cancel_submitted: bool,
1418) -> Option<anyhow::Error> {
1419 if snapshot.operational.execution == RelayExecutionState::Closed {
1420 return Some(CheckpointBarrierUnreachable::runtime_stopped().into());
1421 }
1422 if snapshot.operational.execution == RelayExecutionState::Running {
1423 return Some(match busy {
1424 BarrierBusyPolicy::DeferWhileRunning => CheckpointDeferred::harness_busy().into(),
1425 BarrierBusyPolicy::InterruptWhileRunning if out_of_time && cancel_submitted => {
1426 CheckpointBarrierUnreachable::cancel_timed_out(command_id).into()
1427 }
1428 BarrierBusyPolicy::InterruptWhileRunning => return None,
1429 });
1430 }
1431 out_of_time.then(|| CheckpointBarrierUnreachable::not_admitted(command_id).into())
1432}
1433
1434#[derive(Debug)]
1441struct CheckpointBarrierUnreachable(String);
1442
1443impl CheckpointBarrierUnreachable {
1444 fn runtime_stopped() -> Self {
1445 Self("ACP runtime stopped before reaching the checkpoint barrier".to_owned())
1446 }
1447
1448 fn not_admitted(command_id: &str) -> Self {
1449 Self(format!(
1450 "ACP relay did not reach checkpoint barrier {command_id}"
1451 ))
1452 }
1453
1454 fn cancel_timed_out(command_id: &str) -> Self {
1455 Self(format!(
1456 "active ACP turn did not settle after cancellation before checkpoint barrier {command_id}"
1457 ))
1458 }
1459
1460 fn cancel_turn_unavailable(command_id: &str, protocol_version: u32) -> Self {
1461 Self(format!(
1462 "worker protocol {protocol_version} cannot cancel the active ACP turn before checkpoint barrier {command_id} (requires protocol {})",
1463 RelayCommand::CancelTurn.minimum_protocol(),
1464 ))
1465 }
1466
1467 fn cancel_turn_unreachable(command_id: &str) -> Self {
1468 Self(format!(
1469 "worker transport became unavailable while cancelling the active ACP turn before checkpoint barrier {command_id}"
1470 ))
1471 }
1472}
1473
1474impl std::fmt::Display for CheckpointBarrierUnreachable {
1475 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1476 formatter.write_str(&self.0)
1477 }
1478}
1479
1480impl std::error::Error for CheckpointBarrierUnreachable {}
1481
1482fn checkpoint_barrier_needs_worker_restart(error: &anyhow::Error) -> bool {
1483 error
1484 .downcast_ref::<CheckpointBarrierUnreachable>()
1485 .is_some()
1486}
1487
1488fn checkpoint_cancel_turn_needs_worker_restart(error: &anyhow::Error) -> bool {
1493 error.chain().any(|cause| {
1494 let Some(rejected) = cause.downcast_ref::<RelayRejected>() else {
1495 return false;
1496 };
1497 rejected.0.code == hel::hel_worker::RelayErrorCode::IncompatibleProtocol
1498 })
1499}
1500
1501#[derive(Debug)]
1511pub struct CheckpointDeferred(String);
1512
1513impl CheckpointDeferred {
1514 pub(crate) fn harness_busy() -> Self {
1515 Self("the agent is working; try again when it is idle".to_owned())
1516 }
1517
1518 fn frontier_moved() -> Self {
1519 Self(
1520 "the session moved past the checkpoint-ready cursor before the barrier latched, so this checkpoint was deferred"
1521 .to_owned(),
1522 )
1523 }
1524
1525 fn harness_turn_during_capture() -> Self {
1526 Self(
1527 "the agent started a turn of its own while target state was captured, so this checkpoint was deferred"
1528 .to_owned(),
1529 )
1530 }
1531}
1532
1533impl std::fmt::Display for CheckpointDeferred {
1534 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1535 formatter.write_str(&self.0)
1536 }
1537}
1538
1539impl std::error::Error for CheckpointDeferred {}
1540
1541pub fn checkpoint_was_deferred(error: &anyhow::Error) -> bool {
1546 error
1547 .chain()
1548 .any(|cause| cause.downcast_ref::<CheckpointDeferred>().is_some())
1549}
1550
1551fn checkpoint_barrier_is_ready(snapshot: &ManagedSessionSnapshot, command_id: &str) -> bool {
1552 snapshot.operational.checkpoint_barrier.as_deref() == Some(command_id)
1553 && snapshot.operational.checkpoint_ready.is_some()
1554}
1555
1556fn ensure_exact_checkpoint_cut(
1564 cursor: &RelayCursor,
1565 expected_ordinal: u64,
1566 expected_digest: &str,
1567) -> Result<()> {
1568 if cursor.ordinal != expected_ordinal || cursor.digest != expected_digest {
1569 bail!(CheckpointDeferred::frontier_moved());
1570 }
1571 Ok(())
1572}
1573
1574fn validate_checkpoint_barrier_snapshot(
1589 snapshot: &ManagedSessionSnapshot,
1590 command_id: &str,
1591 expected: &RelayCursor,
1592) -> Result<()> {
1593 ensure!(
1594 snapshot.operational.checkpoint_barrier.as_deref() == Some(command_id),
1595 "checkpoint barrier {command_id} is no longer active"
1596 );
1597 ensure!(
1598 snapshot.operational.checkpoint_ready.as_ref() == Some(expected),
1599 "checkpoint barrier {command_id} has a different ready cursor"
1600 );
1601 if snapshot
1602 .operational
1603 .last_harness_turn_started_ordinal
1604 .is_some_and(|ordinal| ordinal > expected.ordinal)
1605 {
1606 bail!(CheckpointDeferred::harness_turn_during_capture());
1607 }
1608 Ok(())
1609}
1610
1611fn remove_uninstalled_checkpoint(path: &Path, error: anyhow::Error) -> anyhow::Error {
1612 match std::fs::remove_file(path) {
1613 Ok(()) => error,
1614 Err(remove_error) if remove_error.kind() == std::io::ErrorKind::NotFound => error,
1615 Err(remove_error) => error.context(format!(
1616 "also failed to remove uninstalled checkpoint {}: {remove_error}",
1617 path.display()
1618 )),
1619 }
1620}
1621
1622pub(super) async fn wait_for_relay_closed(relay: &mut StandaloneSession) -> Result<()> {
1623 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
1624 loop {
1625 if relay.sync().await?.operational.execution == RelayExecutionState::Closed {
1626 return Ok(());
1627 }
1628 if tokio::time::Instant::now() >= deadline {
1629 bail!("ACP runtime did not close within 30 seconds");
1630 }
1631 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1632 }
1633}
1634
1635async fn release_checkpoint_after_capture(
1647 relay: &mut ControllerRelayLease,
1648 session_id: &str,
1649 barrier_command_id: &str,
1650 cursor: &RelayCursor,
1651) -> Result<CheckpointCompletion> {
1652 relay
1653 .sync_snapshot()
1654 .await
1655 .and_then(|snapshot| {
1656 validate_checkpoint_barrier_snapshot(&snapshot, barrier_command_id, cursor)
1657 })
1658 .context("checkpoint barrier changed while capturing target state")?;
1659 match relay
1660 .submit(
1661 new_command_id("checkpoint-release")?,
1662 RelayCommand::ReleaseCheckpoint {
1663 barrier_command_id: barrier_command_id.to_owned(),
1664 },
1665 )
1666 .await
1667 {
1668 Ok(_) => Ok(CheckpointCompletion::ReleasedAfterCapture),
1669 Err(error) => {
1670 tracing::debug!(
1671 session_id,
1672 "relay kept the checkpoint barrier through the transfer: {error:#}"
1673 );
1674 Ok(CheckpointCompletion::HeldBarrier)
1675 }
1676 }
1677}
1678
1679fn run_checkpoint_staging_command<T: serde::Serialize>(
1680 executor: &impl CommandExecutor,
1681 locator: &hel_targets::TargetLocator,
1682 session_id: &str,
1683 spec: &T,
1684 command: fn(&hel_targets::TargetLocator, &str) -> Result<CommandSpec>,
1685 operation: &str,
1686) -> Result<CommandOutput> {
1687 let body = serde_json::to_vec(spec).with_context(|| format!("serialize {operation} spec"))?;
1688 let mut replaced_worker = false;
1689 loop {
1690 let command = command(locator, session_id)?;
1691 let output = executor.execute_with_stdin(&command, &mut body.as_slice())?;
1692 if output.status == 0 {
1693 return Ok(output);
1694 }
1695 let failure = String::from_utf8_lossy(&output.stderr).into_owned();
1696 if staging_protocol_unsupported(&failure)
1697 && replace_stale_export_worker(
1698 executor,
1699 locator,
1700 session_id,
1701 None,
1702 &failure,
1703 &mut replaced_worker,
1704 )?
1705 {
1706 continue;
1707 }
1708 bail!(
1709 "{operation} failed with status {}: {failure}",
1710 output.status
1711 );
1712 }
1713}
1714
1715fn export_target_checkpoint(
1720 executor: &impl CommandExecutor,
1721 locator: &hel_targets::TargetLocator,
1722 session_id: &str,
1723 spec: &CheckpointExportSpec,
1724 remote_spec: &str,
1725) -> Result<CommandOutput> {
1726 export_target_checkpoint_with_worker(executor, locator, session_id, spec, remote_spec, None)
1727}
1728
1729fn export_target_checkpoint_with_worker(
1730 executor: &impl CommandExecutor,
1731 locator: &hel_targets::TargetLocator,
1732 session_id: &str,
1733 spec: &CheckpointExportSpec,
1734 remote_spec: &str,
1735 worker_binary: Option<&Path>,
1736) -> Result<CommandOutput> {
1737 let body = serde_json::to_vec(spec).context("serialize checkpoint export spec")?;
1738 let mut replaced_worker = false;
1739 loop {
1740 let streamed = export_stdin_command(locator, session_id)?;
1741 let output = executor.execute_with_stdin(&streamed, &mut body.as_slice())?;
1742 if output.status == 0 {
1743 return Ok(output);
1744 }
1745 let failure = String::from_utf8_lossy(&output.stderr).into_owned();
1746 if export_spec_stdin_unsupported(&failure) {
1747 tracing::debug!(
1748 session_id,
1749 "target worker predates streamed checkpoint specs; uploading the spec file instead"
1750 );
1751 let output = export_uploaded_spec(executor, locator, session_id, spec, remote_spec)?;
1752 if output.status == 0 {
1753 return Ok(output);
1754 }
1755 let failure = String::from_utf8_lossy(&output.stderr).into_owned();
1756 if replace_stale_export_worker(
1757 executor,
1758 locator,
1759 session_id,
1760 worker_binary,
1761 &failure,
1762 &mut replaced_worker,
1763 )? {
1764 continue;
1765 }
1766 bail!(
1767 "export target checkpoint failed with status {}: {failure}",
1768 output.status
1769 );
1770 }
1771 if replace_stale_export_worker(
1772 executor,
1773 locator,
1774 session_id,
1775 worker_binary,
1776 &failure,
1777 &mut replaced_worker,
1778 )? {
1779 continue;
1780 }
1781 bail!(
1782 "{} failed with status {}: {failure}",
1783 streamed.purpose,
1784 output.status
1785 );
1786 }
1787}
1788
1789fn export_uploaded_spec(
1790 executor: &impl CommandExecutor,
1791 locator: &hel_targets::TargetLocator,
1792 session_id: &str,
1793 spec: &CheckpointExportSpec,
1794 remote_spec: &str,
1795) -> Result<CommandOutput> {
1796 let staging = tempfile::tempdir().context("create checkpoint staging")?;
1797 let local_spec = staging.path().join("checkpoint-spec.json");
1798 spec.write(&local_spec)?;
1799 upload_checkpoint_spec(executor, locator, session_id, &local_spec, remote_spec)?;
1800 executor.execute(&export_command(locator, session_id, remote_spec)?)
1801}
1802
1803fn replace_stale_export_worker(
1808 executor: &impl CommandExecutor,
1809 locator: &hel_targets::TargetLocator,
1810 session_id: &str,
1811 worker_binary: Option<&Path>,
1812 failure: &str,
1813 replaced_worker: &mut bool,
1814) -> Result<bool> {
1815 if *replaced_worker || !staging_protocol_unsupported(failure) {
1816 return Ok(false);
1817 }
1818 tracing::debug!(
1819 session_id,
1820 "target worker does not support this checkpoint export protocol; replacing the installed Mjolnir binary and retrying"
1821 );
1822 let owned_binary;
1823 let binary = if let Some(path) = worker_binary {
1824 path
1825 } else {
1826 owned_binary = super::worker_binary::worker_binary_for(locator, executor)?;
1827 owned_binary.as_path()
1828 };
1829 super::worker_binary::replace_installed_worker_binary(executor, locator, session_id, binary)?;
1830 *replaced_worker = true;
1831 Ok(true)
1832}
1833
1834fn export_spec_stdin_unsupported(failure: &str) -> bool {
1842 failure.contains("read checkpoint export spec -")
1843 || failure.contains("unexpected argument")
1844 || failure.contains("invalid value")
1845}
1846
1847fn export_spec_schema_unsupported(failure: &str) -> bool {
1852 failure.contains("parse checkpoint")
1853 && (failure.contains("unknown field") || failure.contains("unknown variant"))
1854}
1855
1856fn export_protocol_unsupported(failure: &str) -> bool {
1857 export_spec_schema_unsupported(failure)
1858 || failure.contains("unsupported checkpoint export protocol version")
1859}
1860
1861fn staging_protocol_unsupported(failure: &str) -> bool {
1862 export_protocol_unsupported(failure)
1863 || failure.contains("unrecognized subcommand")
1864 || failure.contains("unexpected argument")
1865}
1866
1867pub(super) fn upload_checkpoint_spec(
1868 executor: &impl CommandExecutor,
1869 locator: &hel_targets::TargetLocator,
1870 session_id: &str,
1871 local: &Path,
1872 remote: &str,
1873) -> Result<()> {
1874 match locator {
1875 hel_targets::TargetLocator::LocalBare { .. } => {
1876 std::fs::copy(local, remote)
1877 .with_context(|| format!("copy checkpoint specification to {remote}"))?;
1878 Ok(())
1879 }
1880 hel_targets::TargetLocator::LocalPodman { container_id, .. } => execute_checked(
1881 executor,
1882 CommandSpec::new(
1883 "podman",
1884 [
1885 "cp".into(),
1886 local.to_string_lossy().into_owned(),
1887 format!("{container_id}:{remote}"),
1888 ],
1889 )
1890 .purpose("upload checkpoint specification"),
1891 )
1892 .map(|_| ()),
1893 hel_targets::TargetLocator::LocalDocker { container_id } => execute_checked(
1894 executor,
1895 CommandSpec::new(
1896 "docker",
1897 [
1898 "cp".into(),
1899 local.to_string_lossy().into_owned(),
1900 format!("{container_id}:{remote}"),
1901 ],
1902 )
1903 .purpose("upload checkpoint specification"),
1904 )
1905 .map(|_| ()),
1906 hel_targets::TargetLocator::AppleContainer { container_id } => execute_checked(
1907 executor,
1908 CommandSpec::new(
1909 "container",
1910 [
1911 "cp".into(),
1912 local.to_string_lossy().into_owned(),
1913 format!("{container_id}:{remote}"),
1914 ],
1915 )
1916 .purpose("upload checkpoint specification"),
1917 )
1918 .map(|_| ()),
1919 hel_targets::TargetLocator::AwsEc2 { ssh, .. }
1920 | hel_targets::TargetLocator::SshBare { ssh, .. } => execute_checked(
1921 executor,
1922 scp_command_spec(ssh, local, remote, false).purpose("upload checkpoint specification"),
1923 )
1924 .map(|_| ()),
1925 hel_targets::TargetLocator::SshPodman {
1926 ssh, container_id, ..
1927 }
1928 | hel_targets::TargetLocator::SshDocker { ssh, container_id } => {
1929 let engine = match locator {
1930 hel_targets::TargetLocator::SshPodman { .. } => "podman",
1931 hel_targets::TargetLocator::SshDocker { .. } => "docker",
1932 _ => unreachable!("matched remote container target"),
1933 };
1934 let staging = format!(".local/share/hel/uploads/{session_id}-checkpoint.json");
1935 execute_checked(
1936 executor,
1937 ssh_command_spec(ssh, ["mkdir", "-p", ".local/share/hel/uploads"])
1938 .purpose("create remote checkpoint staging"),
1939 )?;
1940 execute_checked(
1941 executor,
1942 scp_command_spec(ssh, local, &staging, false)
1943 .purpose("upload remote container checkpoint specification"),
1944 )?;
1945 execute_checked(
1946 executor,
1947 ssh_command_spec(
1948 ssh,
1949 [engine, "cp", &staging, &format!("{container_id}:{remote}")],
1950 )
1951 .purpose("install remote container checkpoint specification"),
1952 )?;
1953 execute_checked(
1954 executor,
1955 ssh_command_spec(ssh, ["rm", "-f", "--", &staging])
1956 .purpose("remove remote checkpoint staging"),
1957 )?;
1958 Ok(())
1959 }
1960 }?;
1961 Ok(())
1962}
1963
1964fn reusable_installed_checkpoint(
1972 session_id: &str,
1973 installed: Option<&CheckpointMetadata>,
1974 native_session_id: &str,
1975 latched_ordinal: u64,
1976 latched_session: &CanonicalSessionSnapshot,
1977) -> Option<CheckpointArtifact> {
1978 let installed = installed?;
1979 if installed.event_frontier > latched_ordinal {
1980 tracing::warn!(
1981 session_id,
1982 installed_frontier = installed.event_frontier,
1983 latched_ordinal,
1984 "installed checkpoint is ahead of the latched cursor; exporting a fresh archive"
1985 );
1986 return None;
1987 }
1988 let verified = match verify_archive_streaming(&installed.archive_path) {
1989 Ok(verified) => verified,
1990 Err(error) => {
1991 tracing::warn!(
1992 session_id,
1993 path = %installed.archive_path.display(),
1994 "installed checkpoint could not be verified for reuse: {error:#}"
1995 );
1996 return None;
1997 }
1998 };
1999 if verified.archive_sha256 != installed.sha256
2000 || verified.manifest.session.id != session_id
2001 || verified.canonical_session.event_frontier != installed.event_frontier
2002 {
2003 tracing::warn!(
2004 session_id,
2005 path = %installed.archive_path.display(),
2006 "installed checkpoint no longer matches its controller metadata; exporting a fresh archive"
2007 );
2008 return None;
2009 }
2010 if !verified.canonical_session.content_matches(latched_session) {
2011 tracing::info!(
2012 session_id,
2013 archive_frontier = verified.canonical_session.event_frontier,
2014 latched_ordinal,
2015 "session content changed since the installed checkpoint; exporting a fresh archive"
2016 );
2017 return None;
2018 }
2019 tracing::info!(
2020 session_id,
2021 archive_frontier = verified.canonical_session.event_frontier,
2022 latched_ordinal,
2023 "reusing the installed checkpoint archive; only relay bookkeeping moved"
2024 );
2025 Some(CheckpointArtifact {
2026 metadata: installed.clone(),
2027 native_session_id: native_session_id.to_owned(),
2028 event_frontier_digest: verified.canonical_session.event_frontier_digest,
2029 })
2030}
2031
2032pub(super) fn verify_installed_checkpoint_gate(
2033 session_id: &str,
2034 checkpoint: &CheckpointMetadata,
2035) -> Result<()> {
2036 let sha256 = checkpoint_sha256(&checkpoint.archive_path).with_context(|| {
2037 format!(
2038 "hash installed checkpoint {} before target cleanup",
2039 checkpoint.archive_path.display()
2040 )
2041 })?;
2042 ensure!(
2043 sha256 == checkpoint.sha256,
2044 "refusing target cleanup for session {session_id}: installed checkpoint SHA changed"
2045 );
2046 Ok(())
2047}
2048
2049fn verify_checkpoint_artifact(session_id: &str, artifact: &CheckpointArtifact) -> Result<()> {
2050 let sha256 = checkpoint_sha256(&artifact.metadata.archive_path).with_context(|| {
2051 format!(
2052 "hash completed checkpoint {}",
2053 artifact.metadata.archive_path.display()
2054 )
2055 })?;
2056 ensure!(
2057 sha256 == artifact.metadata.sha256,
2058 "completed checkpoint SHA changed before persistence for session {session_id}"
2059 );
2060 Ok(())
2061}
2062
2063pub(super) fn release_projection_behind_checkpoint(session_id: &str, current: &CheckpointMetadata) {
2071 match hel::hel_database::compact_materialized_transcript_through(
2072 session_id,
2073 current.event_frontier,
2074 ) {
2075 Ok(retention) if retention.items == 0 => {}
2076 Ok(retention) => tracing::info!(
2077 session_id,
2078 items = retention.items,
2079 bytes = retention.bytes,
2080 remaining = retention.remaining,
2081 event_frontier = current.event_frontier,
2082 "released projection history the checkpoint covers"
2083 ),
2084 Err(error) => tracing::warn!(
2085 session_id,
2086 "checkpoint was saved, but the projection history it covers could not be released: {error:#}"
2087 ),
2088 }
2089}
2090
2091pub(super) fn prune_replaced_checkpoint(
2092 previous: Option<&CheckpointMetadata>,
2093 current: &CheckpointMetadata,
2094) {
2095 let Some(previous) = previous.filter(|old| old.archive_path != current.archive_path) else {
2096 return;
2097 };
2098 match hel::hel_database::move_checkpoint_is_retained(&previous.archive_path) {
2099 Ok(true) => return,
2100 Ok(false) => {}
2101 Err(error) => {
2102 tracing::warn!(%error, "could not check move retention; keeping superseded checkpoint");
2103 return;
2104 }
2105 }
2106 if let Err(error) = std::fs::remove_file(&previous.archive_path)
2107 && error.kind() != std::io::ErrorKind::NotFound
2108 {
2109 tracing::warn!(
2110 path = %previous.archive_path.display(),
2111 "could not remove superseded recovery copy: {error}"
2112 );
2113 }
2114}
2115
2116#[cfg(test)]
2117mod tests {
2118 use std::cell::{Cell, RefCell};
2119 use std::collections::BTreeMap;
2120 use std::fs::OpenOptions;
2121 use std::path::{Path, PathBuf};
2122 #[cfg(unix)]
2123 use std::process::Command;
2124 #[cfg(unix)]
2125 use std::time::Duration;
2126
2127 #[cfg(unix)]
2128 use agent_client_protocol::schema::v1::{ContentBlock, TextContent};
2129 use anyhow::Result;
2130
2131 #[cfg(unix)]
2132 use crate::hel_controller::now;
2133 use crate::hel_controller::restore_session_after_persistence_failure;
2134 use crate::hel_controller::test_support::{
2135 checkpoint_test_session, write_checkpoint_gate_archive,
2136 };
2137 #[cfg(unix)]
2138 use crate::hel_session_manager::{ManagedSessionHandle, new_command_id};
2139 use crate::hel_worker_client::RelayTransportDead;
2140 use hel::hel_archive::{
2141 BundleManifest, CanonicalTranscriptBody, CanonicalTranscriptItem, TargetManifest,
2142 };
2143 use hel::hel_checkpoint::CheckpointExportSpec;
2144 #[cfg(unix)]
2145 use hel::hel_config::{
2146 HarnessProfile, HelConfig, ProjectBundle, ProjectRepository, TargetTemplate,
2147 };
2148 use hel::hel_projection::canonical_session_from_materialized;
2149 #[cfg(unix)]
2150 use hel::hel_state::TargetLocator;
2151 use hel::hel_state::{
2152 CheckpointMetadata, HelState, ManagedSessionSnapshot, MaterializedSession, SessionState,
2153 };
2154 use hel::hel_targets::{self, CommandExecutor, CommandOutput, CommandSpec};
2155 #[cfg(unix)]
2156 use hel::hel_worker::RelayCommandOutcome;
2157 use hel::hel_worker::{RelayCommand, RelayCursor, RelayExecutionState};
2158
2159 use super::*;
2160
2161 #[test]
2162 fn startup_reconciliation_only_removes_unreferenced_controller_checkpoints() {
2163 let directory = tempfile::tempdir().unwrap();
2164 let session_id = "1123456789abcdef0123456789abcdef";
2165 let referenced_name =
2166 format!("{session_id}-7-archive-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.hel.zip");
2167 let orphan_name =
2168 format!("{session_id}-8-archive-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.hel.zip");
2169 let imported_name = format!("{session_id}.hel.zip");
2170 for name in [
2171 &referenced_name,
2172 &orphan_name,
2173 &imported_name,
2174 "notes.hel.zip",
2175 ] {
2176 std::fs::write(directory.path().join(name), b"test").unwrap();
2177 }
2178 let mut state = HelState::default();
2179 let mut session = checkpoint_test_session(session_id);
2180 session.checkpoint = Some(CheckpointMetadata {
2181 archive_path: directory.path().join(&referenced_name),
2182 sha256: "c".repeat(64),
2183 created_at: "2026-08-12T00:00:00Z".into(),
2184 event_frontier: 7,
2185 });
2186 state.sessions.insert(session_id.into(), session);
2187
2188 assert_eq!(
2189 reconcile_managed_checkpoint_archives_in(directory.path(), &state).unwrap(),
2190 1
2191 );
2192 assert!(directory.path().join(referenced_name).exists());
2193 assert!(!directory.path().join(orphan_name).exists());
2194 assert!(directory.path().join(imported_name).exists());
2195 assert!(directory.path().join("notes.hel.zip").exists());
2196 }
2197 #[test]
2198 fn recovery_artifact_final_verification_checks_the_archive_digest() {
2199 let directory = tempfile::tempdir().unwrap();
2200 let session_id = "1123456789abcdef0123456789abcdef";
2201 let metadata = write_checkpoint_gate_archive(directory.path(), session_id, 7);
2202 let mut artifact = CheckpointArtifact {
2203 metadata,
2204 native_session_id: "native-session".into(),
2205 event_frontier_digest: "a".repeat(64),
2206 };
2207
2208 verify_checkpoint_artifact(session_id, &artifact).unwrap();
2209 artifact.metadata.sha256 = "b".repeat(64);
2210 assert!(
2211 verify_checkpoint_artifact(session_id, &artifact)
2212 .unwrap_err()
2213 .to_string()
2214 .contains("checkpoint SHA changed")
2215 );
2216 }
2217 fn checkpoint_barrier_snapshot(cursor: &RelayCursor) -> ManagedSessionSnapshot {
2220 let mut materialized = MaterializedSession::empty("session-1");
2221 materialized.applied_event_ordinal = cursor.ordinal;
2222 materialized.applied_event_digest = cursor.digest.clone();
2223 ManagedSessionSnapshot {
2224 window: hel::hel_state::ProjectionWindow::of(&materialized),
2225 materialized,
2226 latest_credential_sync_signal: None,
2227 worker_build: None,
2228 operational: hel::hel_worker::RelayOperationalState {
2229 acp_ready: None,
2230 store_id: None,
2231 idle_since_ms: None,
2232 session_id: "session-1".into(),
2233 execution: RelayExecutionState::Idle,
2234 latest_ordinal: cursor.ordinal,
2235 latest_digest: cursor.digest.clone(),
2236 acknowledged_through: cursor.ordinal,
2237 acknowledged_digest: cursor.digest.clone(),
2238 recovery_floor_ordinal: 0,
2239 recovery_floor_digest: hel::hel_worker::RELAY_EVENT_GENESIS_DIGEST.into(),
2240 native_session_id: Some("native-session".into()),
2241 agent_capabilities: None,
2242 agent_info: None,
2243 config_options: Vec::new(),
2244 modes: None,
2245 available_commands: Vec::new(),
2246 config: BTreeMap::new(),
2247 active_prompt: None,
2248 queued_prompts: Vec::new(),
2249 active_user_shells: Vec::new(),
2250 active_agent_terminals: Vec::new(),
2251 checkpoint_barrier: Some("checkpoint-1".into()),
2252 checkpoint_ready: None,
2253 last_acp_activity_at_ms: None,
2254 current_step_started_at_ms: None,
2255 foreground_tool_started_at_ms: None,
2256 harness_turn: None,
2257 last_harness_turn_started_ordinal: None,
2258 background_commands: Vec::new(),
2259 },
2260 }
2261 }
2262 #[test]
2263 fn checkpoint_barrier_is_not_reached_until_its_ready_cursor_is_projected() {
2264 let cursor = RelayCursor {
2265 ordinal: 7,
2266 digest: "a".repeat(64),
2267 };
2268 let mut snapshot = checkpoint_barrier_snapshot(&cursor);
2269
2270 assert!(!checkpoint_barrier_is_ready(&snapshot, "checkpoint-1"));
2271 snapshot.operational.checkpoint_ready = Some(cursor.clone());
2272 assert!(checkpoint_barrier_is_ready(&snapshot, "checkpoint-1"));
2273 validate_checkpoint_barrier_snapshot(&snapshot, "checkpoint-1", &cursor).unwrap();
2274 }
2275 #[test]
2276 fn checkpoint_revalidation_accepts_a_frontier_that_moved_past_the_ready_cursor() {
2277 let cursor = RelayCursor {
2278 ordinal: 7,
2279 digest: "a".repeat(64),
2280 };
2281 let mut snapshot = checkpoint_barrier_snapshot(&cursor);
2282 snapshot.operational.checkpoint_ready = Some(cursor.clone());
2283
2284 snapshot.operational.latest_ordinal = cursor.ordinal + 2;
2288 snapshot.operational.latest_digest = "b".repeat(64);
2289 snapshot.materialized.applied_event_ordinal = cursor.ordinal + 2;
2290 snapshot.materialized.applied_event_digest = "b".repeat(64);
2291 validate_checkpoint_barrier_snapshot(&snapshot, "checkpoint-1", &cursor).unwrap();
2292
2293 snapshot.operational.checkpoint_ready = Some(RelayCursor {
2295 ordinal: cursor.ordinal + 1,
2296 digest: "c".repeat(64),
2297 });
2298 assert!(validate_checkpoint_barrier_snapshot(&snapshot, "checkpoint-1", &cursor).is_err());
2299 snapshot.operational.checkpoint_ready = Some(cursor.clone());
2300 snapshot.operational.checkpoint_barrier = None;
2301 assert!(validate_checkpoint_barrier_snapshot(&snapshot, "checkpoint-1", &cursor).is_err());
2302 }
2303 fn exported_checkpoint_json() -> Vec<u8> {
2305 serde_json::to_vec(&hel::hel_checkpoint::TargetCheckpoint {
2306 path: PathBuf::from("/var/lib/hel/workers/session/checkpoint.hel.zip"),
2307 sha256: "c".repeat(64),
2308 event_frontier: 7,
2309 event_frontier_digest: "d".repeat(64),
2310 timings: None,
2311 })
2312 .unwrap()
2313 }
2314 fn export_spec_fixture() -> CheckpointExportSpec {
2315 CheckpointExportSpec {
2316 protocol_version: CHECKPOINT_EXPORT_PROTOCOL_VERSION,
2317 session: hel::hel_archive::SessionManifest {
2318 id: LATCH_RELAY_SESSION.into(),
2319 title: "streamed spec".into(),
2320 harness_kind: hel::hel_config::HarnessKind::Codex,
2321 profile_id: "codex".into(),
2322 native_session_id: "native-session".into(),
2323 created_at: "2026-08-12T00:00:00Z".into(),
2324 checkpointed_at: "2026-08-16T00:00:00Z".into(),
2325 hel_version: "test".into(),
2326 relay_version: "test".into(),
2327 adapter_version: "acp-v1".into(),
2328 },
2329 target: TargetManifest {
2330 template_id: "podman".into(),
2331 target_kind: "local-podman".into(),
2332 details: BTreeMap::new(),
2333 },
2334 bundle: BundleManifest {
2335 id: "project".into(),
2336 primary_repository: "app".into(),
2337 },
2338 relay_root: PathBuf::from("/var/lib/hel/workers/session"),
2339 harness_home: PathBuf::from("/var/lib/hel/profiles/codex"),
2340 workspace_root: PathBuf::from("/workspace"),
2341 repositories: Vec::new(),
2342 canonical_session: canonical_session_from_materialized(&MaterializedSession::empty(
2343 LATCH_RELAY_SESSION.to_owned(),
2344 ))
2345 .unwrap(),
2346 output_path: PathBuf::from("/var/lib/hel/workers/session/checkpoint.hel.zip"),
2347 }
2348 }
2349 struct ExportExecutor {
2352 streamed_status: i32,
2353 streamed_stderr: String,
2354 retry_stdin_after_failure: bool,
2355 stdin_calls: Cell<usize>,
2356 purposes: RefCell<Vec<String>>,
2357 streamed_spec: RefCell<Vec<u8>>,
2358 }
2359 impl ExportExecutor {
2360 fn new(streamed_status: i32, streamed_stderr: &str) -> Self {
2361 Self {
2362 streamed_status,
2363 streamed_stderr: streamed_stderr.to_owned(),
2364 retry_stdin_after_failure: false,
2365 stdin_calls: Cell::new(0),
2366 purposes: RefCell::new(Vec::new()),
2367 streamed_spec: RefCell::new(Vec::new()),
2368 }
2369 }
2370
2371 fn retry_stdin_after_failure(mut self) -> Self {
2372 self.retry_stdin_after_failure = true;
2373 self
2374 }
2375 }
2376 impl CommandExecutor for ExportExecutor {
2377 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2378 self.purposes.borrow_mut().push(command.purpose.clone());
2379 Ok(CommandOutput {
2380 status: 0,
2381 stdout: exported_checkpoint_json(),
2382 stderr: Vec::new(),
2383 })
2384 }
2385
2386 fn execute_with_stdin(
2387 &self,
2388 command: &CommandSpec,
2389 input: &mut (dyn std::io::Read + Send),
2390 ) -> Result<CommandOutput> {
2391 self.purposes.borrow_mut().push(command.purpose.clone());
2392 let mut spec = Vec::new();
2393 input.read_to_end(&mut spec)?;
2394 *self.streamed_spec.borrow_mut() = spec;
2395 let attempt = self.stdin_calls.get();
2396 self.stdin_calls.set(attempt + 1);
2397 let failed =
2398 self.streamed_status != 0 && (attempt == 0 || !self.retry_stdin_after_failure);
2399 Ok(CommandOutput {
2400 status: if failed { self.streamed_status } else { 0 },
2401 stdout: if failed {
2402 Vec::new()
2403 } else {
2404 exported_checkpoint_json()
2405 },
2406 stderr: if failed {
2407 self.streamed_stderr.clone().into_bytes()
2408 } else {
2409 Vec::new()
2410 },
2411 })
2412 }
2413 }
2414 #[test]
2415 fn docker_checkpoint_fallback_upload_uses_docker_cp() {
2416 struct RecordingExecutor {
2417 commands: RefCell<Vec<CommandSpec>>,
2418 }
2419 impl CommandExecutor for RecordingExecutor {
2420 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2421 self.commands.borrow_mut().push(command.clone());
2422 Ok(CommandOutput {
2423 status: 0,
2424 stdout: Vec::new(),
2425 stderr: Vec::new(),
2426 })
2427 }
2428 }
2429
2430 let executor = RecordingExecutor {
2431 commands: RefCell::new(Vec::new()),
2432 };
2433 let locator = hel_targets::TargetLocator::LocalDocker {
2434 container_id: "hel-session-12345678".to_owned(),
2435 };
2436 upload_checkpoint_spec(
2437 &executor,
2438 &locator,
2439 LATCH_RELAY_SESSION,
2440 Path::new("checkpoint-spec.json"),
2441 "/var/lib/hel/workers/session/checkpoint-spec.json",
2442 )
2443 .unwrap();
2444
2445 let commands = executor.commands.borrow();
2446 assert_eq!(commands.len(), 1);
2447 assert_eq!(commands[0].program, "docker");
2448 assert_eq!(
2449 commands[0].args,
2450 [
2451 "cp",
2452 "checkpoint-spec.json",
2453 "hel-session-12345678:/var/lib/hel/workers/session/checkpoint-spec.json"
2454 ]
2455 );
2456 assert_eq!(commands[0].purpose, "upload checkpoint specification");
2457 }
2458 #[test]
2459 fn checkpoint_export_streams_its_spec_instead_of_uploading_it() {
2460 let locator = hel_targets::TargetLocator::LocalPodman {
2461 container_id: hel_targets::resource_name(LATCH_RELAY_SESSION).unwrap(),
2462 workspace_storage: Default::default(),
2463 };
2464 let spec = export_spec_fixture();
2465 let executor = ExportExecutor::new(0, "");
2466
2467 let output = export_target_checkpoint(
2468 &executor,
2469 &locator,
2470 LATCH_RELAY_SESSION,
2471 &spec,
2472 "/var/lib/hel/workers/session/checkpoint-spec.json",
2473 )
2474 .unwrap();
2475
2476 assert_eq!(output.stdout, exported_checkpoint_json());
2477 assert_eq!(
2478 serde_json::from_slice::<CheckpointExportSpec>(&executor.streamed_spec.borrow())
2479 .unwrap(),
2480 spec
2481 );
2482 assert_eq!(
2483 executor.purposes.into_inner(),
2484 vec!["export target checkpoint".to_owned()]
2485 );
2486 }
2487 #[test]
2490 fn an_export_that_cannot_read_stdin_falls_back_to_uploading_the_spec() {
2491 let locator = hel_targets::TargetLocator::LocalPodman {
2492 container_id: hel_targets::resource_name(LATCH_RELAY_SESSION).unwrap(),
2493 workspace_storage: Default::default(),
2494 };
2495 let executor = ExportExecutor::new(
2496 1,
2497 "Error: read checkpoint export spec -\n\nCaused by:\n \
2498 No such file or directory (os error 2)\n",
2499 );
2500
2501 let output = export_target_checkpoint(
2502 &executor,
2503 &locator,
2504 LATCH_RELAY_SESSION,
2505 &export_spec_fixture(),
2506 "/var/lib/hel/workers/session/checkpoint-spec.json",
2507 )
2508 .unwrap();
2509
2510 assert_eq!(output.stdout, exported_checkpoint_json());
2511 assert_eq!(
2512 executor.purposes.into_inner(),
2513 vec![
2514 "export target checkpoint".to_owned(),
2515 "upload checkpoint specification".to_owned(),
2516 "export target checkpoint".to_owned(),
2517 ]
2518 );
2519 }
2520 #[test]
2521 fn a_failing_export_is_not_retried_as_an_old_worker() {
2522 let locator = hel_targets::TargetLocator::LocalPodman {
2523 container_id: hel_targets::resource_name(LATCH_RELAY_SESSION).unwrap(),
2524 workspace_storage: Default::default(),
2525 };
2526 let executor = ExportExecutor::new(1, "Error: repository 'app' is missing\n");
2527
2528 let error = export_target_checkpoint(
2529 &executor,
2530 &locator,
2531 LATCH_RELAY_SESSION,
2532 &export_spec_fixture(),
2533 "/var/lib/hel/workers/session/checkpoint-spec.json",
2534 )
2535 .unwrap_err();
2536
2537 assert!(
2538 format!("{error:#}").contains("repository 'app' is missing"),
2539 "{error:#}"
2540 );
2541 assert_eq!(
2542 executor.purposes.into_inner(),
2543 vec!["export target checkpoint".to_owned()]
2544 );
2545 }
2546 #[test]
2549 fn a_legacy_export_worker_is_replaced_before_it_runs_obsolete_behavior() {
2550 let locator = hel_targets::TargetLocator::LocalPodman {
2551 container_id: hel_targets::resource_name(LATCH_RELAY_SESSION).unwrap(),
2552 workspace_storage: Default::default(),
2553 };
2554 let spec = export_spec_fixture();
2555 let executor = ExportExecutor::new(
2556 1,
2557 "Error: parse checkpoint export spec from standard input\n\nCaused by:\n \
2558 unknown field `protocol_version`, expected `session` at line 1 column 20\n",
2559 )
2560 .retry_stdin_after_failure();
2561 let worker_binary = Path::new("/hel-test-worker");
2562
2563 let output = export_target_checkpoint_with_worker(
2564 &executor,
2565 &locator,
2566 LATCH_RELAY_SESSION,
2567 &spec,
2568 "/var/lib/hel/workers/session/checkpoint-spec.json",
2569 Some(worker_binary),
2570 )
2571 .unwrap();
2572
2573 assert_eq!(output.stdout, exported_checkpoint_json());
2574 assert_eq!(
2575 serde_json::from_slice::<CheckpointExportSpec>(&executor.streamed_spec.borrow())
2576 .unwrap(),
2577 spec
2578 );
2579 assert_eq!(
2580 executor.purposes.into_inner(),
2581 vec![
2582 "export target checkpoint".to_owned(),
2583 "stage replacement Mjolnir worker".to_owned(),
2584 "replace installed Mjolnir worker".to_owned(),
2585 "make replaced Mjolnir worker executable".to_owned(),
2586 "export target checkpoint".to_owned(),
2587 ]
2588 );
2589 }
2590 #[test]
2591 fn a_schema_mismatch_after_uploading_the_spec_still_replaces_the_worker_binary() {
2592 let locator = hel_targets::TargetLocator::LocalPodman {
2593 container_id: hel_targets::resource_name(LATCH_RELAY_SESSION).unwrap(),
2594 workspace_storage: Default::default(),
2595 };
2596 struct FileThenRefreshExecutor {
2597 purposes: RefCell<Vec<String>>,
2598 file_export_calls: Cell<usize>,
2599 }
2600 impl CommandExecutor for FileThenRefreshExecutor {
2601 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2602 self.purposes.borrow_mut().push(command.purpose.clone());
2603 if command.purpose == "export target checkpoint" {
2604 let attempt = self.file_export_calls.get();
2605 self.file_export_calls.set(attempt + 1);
2606 if attempt == 0 {
2607 return Ok(CommandOutput {
2608 status: 1,
2609 stdout: Vec::new(),
2610 stderr: b"Error: parse checkpoint export spec /spec.json\n\nCaused by:\n unknown variant `terminal_output`, expected one of `user`, `agent`, `thought`, `tool`, `plan`, `system`\n".to_vec(),
2611 });
2612 }
2613 }
2614 Ok(CommandOutput {
2615 status: 0,
2616 stdout: exported_checkpoint_json(),
2617 stderr: Vec::new(),
2618 })
2619 }
2620
2621 fn execute_with_stdin(
2622 &self,
2623 command: &CommandSpec,
2624 input: &mut (dyn std::io::Read + Send),
2625 ) -> Result<CommandOutput> {
2626 self.purposes.borrow_mut().push(command.purpose.clone());
2627 let mut discarded = Vec::new();
2628 input.read_to_end(&mut discarded)?;
2629 let stdin_calls = self
2630 .purposes
2631 .borrow()
2632 .iter()
2633 .filter(|purpose| *purpose == "export target checkpoint")
2634 .count();
2635 if stdin_calls == 1 {
2636 return Ok(CommandOutput {
2637 status: 1,
2638 stdout: Vec::new(),
2639 stderr: b"Error: read checkpoint export spec -\n\nCaused by:\n No such file or directory (os error 2)\n".to_vec(),
2640 });
2641 }
2642 Ok(CommandOutput {
2643 status: 0,
2644 stdout: exported_checkpoint_json(),
2645 stderr: Vec::new(),
2646 })
2647 }
2648 }
2649
2650 let executor = FileThenRefreshExecutor {
2651 purposes: RefCell::new(Vec::new()),
2652 file_export_calls: Cell::new(0),
2653 };
2654 let output = export_target_checkpoint_with_worker(
2655 &executor,
2656 &locator,
2657 LATCH_RELAY_SESSION,
2658 &export_spec_fixture(),
2659 "/var/lib/hel/workers/session/checkpoint-spec.json",
2660 Some(Path::new("/hel-test-worker")),
2661 )
2662 .unwrap();
2663
2664 assert_eq!(output.stdout, exported_checkpoint_json());
2665 assert_eq!(
2666 executor.purposes.into_inner(),
2667 vec![
2668 "export target checkpoint".to_owned(),
2669 "upload checkpoint specification".to_owned(),
2670 "export target checkpoint".to_owned(),
2671 "stage replacement Mjolnir worker".to_owned(),
2672 "replace installed Mjolnir worker".to_owned(),
2673 "make replaced Mjolnir worker executable".to_owned(),
2674 "export target checkpoint".to_owned(),
2675 ]
2676 );
2677 }
2678 #[test]
2682 fn a_working_session_defers_but_close_waits_for_cancellation_before_recovery() {
2683 let cursor = RelayCursor {
2684 ordinal: 7,
2685 digest: "a".repeat(64),
2686 };
2687 let mut snapshot = checkpoint_barrier_snapshot(&cursor);
2688 snapshot.operational.execution = RelayExecutionState::Running;
2689
2690 let deferred = checkpoint_barrier_wait_ended(
2691 &snapshot,
2692 "checkpoint-1",
2693 BarrierBusyPolicy::DeferWhileRunning,
2694 false,
2695 false,
2696 )
2697 .expect("a working session ends the wait at once");
2698 assert!(checkpoint_was_deferred(&deferred), "{deferred:#}");
2699 assert!(
2700 !checkpoint_barrier_needs_worker_restart(&deferred),
2701 "a deferred copy must never restart the worker: {deferred:#}"
2702 );
2703 assert_eq!(
2704 BarrierBusyPolicy::of(LatchExclusivity::HoldThroughClose),
2705 BarrierBusyPolicy::InterruptWhileRunning
2706 );
2707
2708 assert!(
2711 checkpoint_barrier_wait_ended(
2712 &snapshot,
2713 "checkpoint-1",
2714 BarrierBusyPolicy::InterruptWhileRunning,
2715 false,
2716 false,
2717 )
2718 .is_none()
2719 );
2720 let interrupted = checkpoint_barrier_wait_ended(
2721 &snapshot,
2722 "checkpoint-1",
2723 BarrierBusyPolicy::InterruptWhileRunning,
2724 true,
2725 true,
2726 )
2727 .expect("an unresponsive cancellation ends the wait at the deadline");
2728 assert!(
2729 checkpoint_barrier_needs_worker_restart(&interrupted),
2730 "{interrupted:#}"
2731 );
2732 assert!(!checkpoint_was_deferred(&interrupted), "{interrupted:#}");
2733
2734 snapshot.operational.execution = RelayExecutionState::Idle;
2737 let wedged = checkpoint_barrier_wait_ended(
2738 &snapshot,
2739 "checkpoint-1",
2740 BarrierBusyPolicy::DeferWhileRunning,
2741 true,
2742 false,
2743 )
2744 .expect("the deadline ends the wait");
2745 assert!(
2746 checkpoint_barrier_needs_worker_restart(&wedged),
2747 "{wedged:#}"
2748 );
2749 assert!(!checkpoint_was_deferred(&wedged), "{wedged:#}");
2750 }
2751
2752 #[test]
2755 fn a_frontier_that_moved_before_the_latch_defers_the_checkpoint() {
2756 let cursor = RelayCursor {
2757 ordinal: 220,
2758 digest: "a".repeat(64),
2759 };
2760 ensure_exact_checkpoint_cut(&cursor, cursor.ordinal, &cursor.digest)
2761 .expect("a projection latched at the ready cursor is an exact cut");
2762
2763 for (ordinal, digest) in [(223, "a".repeat(64)), (220, "b".repeat(64))] {
2764 let error = ensure_exact_checkpoint_cut(&cursor, ordinal, &digest)
2765 .expect_err("a projection past the ready cursor is not an exact cut");
2766 assert!(checkpoint_was_deferred(&error), "{error:#}");
2767 assert!(
2768 !checkpoint_barrier_needs_worker_restart(&error),
2769 "{error:#}"
2770 );
2771 }
2772 }
2773
2774 #[test]
2778 fn a_harness_turn_started_during_capture_abandons_the_archive() {
2779 let cursor = RelayCursor {
2780 ordinal: 220,
2781 digest: "a".repeat(64),
2782 };
2783 let mut snapshot = checkpoint_barrier_snapshot(&cursor);
2784 snapshot.operational.checkpoint_ready = Some(cursor.clone());
2785
2786 snapshot.operational.last_harness_turn_started_ordinal = Some(cursor.ordinal);
2787 validate_checkpoint_barrier_snapshot(&snapshot, "checkpoint-1", &cursor)
2788 .expect("a turn that started at or before the cursor is covered by the archive");
2789
2790 snapshot.operational.last_harness_turn_started_ordinal = Some(cursor.ordinal + 1);
2791 let error = validate_checkpoint_barrier_snapshot(&snapshot, "checkpoint-1", &cursor)
2792 .expect_err("a turn that started after the cursor invalidates the capture");
2793 assert!(checkpoint_was_deferred(&error), "{error:#}");
2794 }
2795
2796 #[test]
2797 fn a_stuck_checkpoint_barrier_is_retried_by_restarting_the_worker() {
2798 for failure in [
2801 CheckpointBarrierUnreachable::not_admitted(
2802 "checkpoint-976f6746887c5ccd93b9d8bbe120ef06",
2803 ),
2804 CheckpointBarrierUnreachable::runtime_stopped(),
2805 ] {
2806 let error = anyhow::Error::new(failure).context("latch a session checkpoint");
2807 assert!(checkpoint_barrier_needs_worker_restart(&error), "{error:#}");
2808 }
2809 assert!(!checkpoint_barrier_needs_worker_restart(&anyhow::anyhow!(
2810 "export target checkpoint failed with status 1"
2811 )));
2812 assert!(!checkpoint_barrier_needs_worker_restart(&anyhow::anyhow!(
2815 "ACP relay did not reach checkpoint barrier checkpoint-1"
2816 )));
2817 }
2818
2819 #[test]
2820 fn an_incompatible_cancel_turn_requests_worker_recovery() {
2821 let error = anyhow::Error::new(RelayRejected(hel::hel_worker::RelayProtocolError {
2822 code: hel::hel_worker::RelayErrorCode::IncompatibleProtocol,
2823 message: "request uses protocol 6".into(),
2824 retryable: false,
2825 detail: None,
2826 }))
2827 .context("cancel active ACP turn before checkpoint barrier");
2828 assert!(
2829 checkpoint_cancel_turn_needs_worker_restart(&error),
2830 "{error:#}"
2831 );
2832 assert!(checkpoint_barrier_needs_worker_restart(&error.context(
2833 CheckpointBarrierUnreachable::cancel_turn_unavailable("checkpoint-1", 6,)
2834 )));
2835 }
2836 #[test]
2837 fn a_dead_worker_hello_failure_is_retried_by_restarting_the_worker() {
2838 let dead = anyhow::Error::new(RelayTransportDead::new("the proxy is gone"))
2839 .context("connect to the session worker for checkpoint");
2840 assert!(worker_connect_needs_restart(&dead), "{dead:#}");
2841 assert!(!worker_connect_needs_restart(&anyhow::anyhow!(
2842 "unknown session"
2843 )));
2844 }
2845 #[cfg(unix)]
2846 #[tokio::test]
2847 async fn checkpoint_restart_stop_failure_names_mjolnir() {
2848 struct FailingStop;
2849
2850 impl CommandExecutor for FailingStop {
2851 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
2852 Ok(CommandOutput {
2853 status: 1,
2854 stdout: Vec::new(),
2855 stderr: b"permission denied".to_vec(),
2856 })
2857 }
2858 }
2859
2860 let session_id = "0123456789abcdef0123456789abcdef";
2861 let worker_root = format!("/tmp/mjolnir-checkpoint-test/{session_id}");
2862 let backend = hel_targets::TargetLocator::LocalBare {
2863 worker_root: worker_root.clone(),
2864 };
2865 let controller = Controller {
2866 config: HelConfig::default(),
2867 state: HelState::default(),
2868 };
2869 let reconnect = CommandSpec::new("unused", std::iter::empty::<&str>());
2870
2871 let result = controller
2872 .restart_worker_for_checkpoint(
2873 session_id,
2874 &FailingStop,
2875 &backend,
2876 &worker_root,
2877 &reconnect,
2878 )
2879 .await;
2880 let error = match result {
2881 Ok(_) => panic!("a failed worker stop unexpectedly restarted the checkpoint worker"),
2882 Err(error) => error,
2883 };
2884 let detail = format!("{error:#}");
2885 assert!(
2886 detail.starts_with("stop wedged Mjolnir worker before retrying checkpoint"),
2887 "{detail}"
2888 );
2889 assert!(detail.contains("permission denied"), "{detail}");
2890 }
2891 #[test]
2892 fn export_spec_schema_mismatch_is_detected_from_the_parse_error() {
2893 assert!(export_spec_schema_unsupported(
2894 "Error: parse checkpoint export spec from standard input\n\nCaused by:\n \
2895 unknown field `terminal_refs`, expected `call` at line 1 column 7276552\n"
2896 ));
2897 assert!(export_spec_schema_unsupported(
2898 "Error: parse checkpoint export spec /spec.json\n\nCaused by:\n \
2899 unknown variant `terminal_output`, expected one of `user`, `agent`\n"
2900 ));
2901 assert!(!export_spec_schema_unsupported(
2902 "Error: repository 'app' is missing\n"
2903 ));
2904 assert!(!export_spec_schema_unsupported(
2905 "Error: parse checkpoint export spec from standard input\n\nCaused by:\n \
2906 missing field `relay_root`\n"
2907 ));
2908 assert!(export_protocol_unsupported(
2909 "Error: unsupported checkpoint export protocol version 2; worker supports 1\n"
2910 ));
2911 }
2912 const LATCH_RELAY_ROOT: &str = "MJ_TEST_LATCH_RELAY_ROOT";
2913 const LATCH_RELAY_STARTS: &str = "MJ_TEST_LATCH_RELAY_STARTS";
2914 const LATCH_RELAY_REJECT_RELEASE: &str = "MJ_TEST_LATCH_REJECT_RELEASE";
2915 #[cfg(unix)]
2916 const LATCH_RELAY_RUNNING: &str = "MJ_TEST_LATCH_RELAY_RUNNING";
2917 #[cfg(unix)]
2918 const LATCH_TEST_CHILD: &str = "MJ_TEST_LATCH_CHILD";
2919 #[cfg(unix)]
2920 const ABANDON_TEST_CHILD: &str = "MJ_TEST_ABANDON_LATCH_CHILD";
2921 #[cfg(unix)]
2922 const RELEASE_TEST_CHILD: &str = "MJ_TEST_RELEASE_LATCH_CHILD";
2923 #[cfg(unix)]
2924 const LEGACY_RELEASE_TEST_CHILD: &str = "MJ_TEST_LEGACY_RELEASE_LATCH_CHILD";
2925 #[cfg(unix)]
2926 const REUSE_TEST_CHILD: &str = "MJ_TEST_REUSE_LATCH_CHILD";
2927 const LATCH_RELAY_STARTUP_DELAY_MS: &str = "MJ_TEST_LATCH_STARTUP_DELAY_MS";
2928 const LATCH_RELAY_SESSION: &str = "018f9dd2-a3b4-7c8d-9000-0123456789ab";
2929 #[cfg(unix)]
2931 #[derive(Clone, Copy, PartialEq, Eq)]
2932 enum ReleaseSupport {
2933 Supported,
2934 Rejected,
2937 }
2938 #[test]
2945 fn latch_relay_child_serves_stdio() {
2946 let Some(root) = std::env::var_os(LATCH_RELAY_ROOT) else {
2947 return;
2948 };
2949 println!();
2953 if let Some(starts) = std::env::var_os(LATCH_RELAY_STARTS) {
2956 use std::io::Write;
2957 let mut log = OpenOptions::new()
2958 .create(true)
2959 .append(true)
2960 .open(starts)
2961 .expect("open the relay start log");
2962 writeln!(log, "{}", std::process::id()).expect("record this relay start");
2963 }
2964 let mut relay =
2965 hel::hel_worker::DurableRelay::open(Path::new(&root), LATCH_RELAY_SESSION, "1.0.0")
2966 .expect("open the test relay journal");
2967 if relay.operational_state().native_session_id.is_none() {
2968 relay
2969 .record_observation(hel::hel_worker::RelayObservation::SessionOpened {
2970 native_session_id: "native-session".into(),
2971 resumed: true,
2972 })
2973 .unwrap();
2974 }
2975 let ready_at = Instant::now()
2976 + Duration::from_millis(
2977 std::env::var(LATCH_RELAY_STARTUP_DELAY_MS)
2978 .ok()
2979 .map(|value| value.parse::<u64>().unwrap())
2980 .unwrap_or(0),
2981 );
2982 let reject_release = std::env::var_os(LATCH_RELAY_REJECT_RELEASE).is_some();
2983 #[cfg(unix)]
2984 let running = std::env::var_os(LATCH_RELAY_RUNNING).is_some();
2985 #[cfg(unix)]
2986 if running && relay.operational_state().active_prompt.is_none() {
2987 let response = relay.handle(hel::hel_worker::RelayRequestEnvelope {
2988 request_id: "seed-running-request".into(),
2989 protocol_version: hel::hel_worker::RELAY_PROTOCOL_VERSION,
2990 request: hel::hel_worker::RelayRequest::Submit {
2991 command_id: "seed-running-prompt".into(),
2992 command: RelayCommand::Prompt {
2993 prompt: vec![ContentBlock::Text(TextContent::new("running"))],
2994 },
2995 },
2996 });
2997 assert!(matches!(
2998 response.body,
2999 hel::hel_worker::RelayResponseBody::Ok {
3000 payload: hel::hel_worker::RelayResponsePayload::Accepted { .. }
3001 }
3002 ));
3003 let claimed = relay
3004 .claim_pending_commands(true)
3005 .expect("seed the running prompt");
3006 assert_eq!(claimed.len(), 1);
3007 assert_eq!(claimed[0].command_id, "seed-running-prompt");
3008 }
3009 let mut reader = std::io::stdin().lock();
3010 let mut writer = std::io::stdout().lock();
3011 let mut configured = false;
3012 while let Some(request) =
3013 hel::hel_worker::read_relay_frame(&mut reader).expect("read a relay request")
3014 {
3015 if !configured && Instant::now() >= ready_at {
3016 relay
3017 .record_observation(hel::hel_worker::RelayObservation::SessionConfigured {
3018 config_options: Vec::new(),
3019 })
3020 .unwrap();
3021 configured = true;
3022 }
3023 if matches!(
3024 &request.request,
3025 hel::hel_worker::RelayRequest::Submit {
3026 command: RelayCommand::BeginCheckpoint { .. },
3027 ..
3028 }
3029 ) {
3030 assert!(
3031 relay.operational_state().native_session_is_ready(),
3032 "checkpoint submitted before current ACP startup finished"
3033 );
3034 }
3035 let response = if reject_release && requests_checkpoint_release(&request) {
3036 unparseable_request_response(&request)
3037 } else {
3038 relay.handle(request)
3039 };
3040 hel::hel_worker::write_relay_frame(&mut writer, &response)
3041 .expect("answer a relay request");
3042 for claimed in relay
3043 .claim_pending_commands(true)
3044 .expect("claim relay commands")
3045 {
3046 match claimed.command {
3047 RelayCommand::BeginCheckpoint { .. } => {
3048 relay
3049 .record_checkpoint_ready(&claimed.command_id)
3050 .expect("report the checkpoint barrier ready");
3051 }
3052 #[cfg(unix)]
3053 RelayCommand::CancelTurn => {
3054 let prompt_id = relay
3055 .operational_state()
3056 .active_prompt
3057 .as_ref()
3058 .map(|prompt| prompt.command_id.clone())
3059 .expect("a prompt to cancel");
3060 relay
3061 .record_command_completed(
3062 &claimed.command_id,
3063 RelayCommandOutcome::Cancelled,
3064 )
3065 .expect("complete the cancellation");
3066 relay
3067 .record_command_completed(
3068 &prompt_id,
3069 RelayCommandOutcome::Prompt {
3070 stop_reason: "cancelled".into(),
3071 },
3072 )
3073 .expect("complete the cancelled prompt");
3074 }
3075 _ => {}
3076 }
3077 }
3078 }
3079 }
3080 fn requests_checkpoint_release(request: &hel::hel_worker::RelayRequestEnvelope) -> bool {
3081 matches!(
3082 &request.request,
3083 hel::hel_worker::RelayRequest::Submit {
3084 command: RelayCommand::ReleaseCheckpoint { .. },
3085 ..
3086 }
3087 )
3088 }
3089 fn unparseable_request_response(
3093 request: &hel::hel_worker::RelayRequestEnvelope,
3094 ) -> hel::hel_worker::RelayResponseEnvelope {
3095 hel::hel_worker::RelayResponseEnvelope {
3096 request_id: request.request_id.clone(),
3097 protocol_version: request.protocol_version,
3098 body: hel::hel_worker::RelayResponseBody::Error {
3099 error: hel::hel_worker::RelayProtocolError {
3100 code: hel::hel_worker::RelayErrorCode::InvalidRequest,
3101 message: "unknown variant `release_checkpoint`".into(),
3102 retryable: false,
3103 detail: None,
3104 },
3105 },
3106 }
3107 }
3108 #[cfg(unix)]
3111 fn latch_relay_target(
3112 relay_root: &Path,
3113 starts: Option<&Path>,
3114 release: ReleaseSupport,
3115 running: bool,
3116 ) -> crate::hel_session_manager::RelaySessionTarget {
3117 let script = format!(
3120 "\"$0\" --exact {}::latch_relay_child_serves_stdio --nocapture | \
3121 grep --line-buffered '^{{'",
3122 module_path!()
3123 .strip_prefix("mj_controller::")
3124 .unwrap_or(module_path!())
3125 );
3126 let mut spec = CommandSpec::new(
3127 "sh",
3128 [
3129 "-c".to_owned(),
3130 script,
3131 std::env::current_exe()
3132 .unwrap()
3133 .to_string_lossy()
3134 .into_owned(),
3135 ],
3136 )
3137 .purpose("test latch relay");
3138 spec.env.insert(
3139 LATCH_RELAY_ROOT.to_owned(),
3140 relay_root.to_string_lossy().into_owned(),
3141 );
3142 if let Some(starts) = starts {
3143 spec.env.insert(
3144 LATCH_RELAY_STARTS.to_owned(),
3145 starts.to_string_lossy().into_owned(),
3146 );
3147 }
3148 if release == ReleaseSupport::Rejected {
3149 spec.env
3150 .insert(LATCH_RELAY_REJECT_RELEASE.to_owned(), "1".to_owned());
3151 }
3152 if running {
3153 spec.env
3154 .insert(LATCH_RELAY_RUNNING.to_owned(), "1".to_owned());
3155 }
3156 crate::hel_session_manager::RelaySessionTarget {
3157 session_id: LATCH_RELAY_SESSION.to_owned(),
3158 spec,
3159 worker_recovery: None,
3160 project_memory: None,
3161 }
3162 }
3163 #[cfg(unix)]
3166 async fn latch_a_live_checkpoint(
3167 relay_root: &Path,
3168 starts: Option<&Path>,
3169 release: ReleaseSupport,
3170 running: bool,
3171 ) -> (
3172 crate::hel_session_manager::SessionManagerChannels,
3173 ManagedSessionHandle,
3174 ControllerRelayLease,
3175 String,
3176 RelayCursor,
3177 ) {
3178 hel::hel_database::save_session(&checkpoint_test_session(LATCH_RELAY_SESSION)).unwrap();
3181 let channels = crate::hel_session_manager::spawn_session_manager().unwrap();
3182 channels
3183 .targets
3184 .send(vec![latch_relay_target(
3185 relay_root, starts, release, running,
3186 )])
3187 .unwrap();
3188 let handle = channels
3189 .control
3190 .wait_for_session(LATCH_RELAY_SESSION, Duration::from_secs(10))
3191 .await
3192 .unwrap();
3193
3194 let lease = handle.lease_connection().await.unwrap();
3195 let mut relay = ControllerRelayLease::Managed {
3196 handle: handle.clone(),
3197 lease: Some(lease),
3198 };
3199 let barrier_command_id = new_command_id("checkpoint").unwrap();
3200 let connection = relay.connection_mut();
3201 connection
3202 .submit(
3203 barrier_command_id.clone(),
3204 RelayCommand::BeginCheckpoint { reason: None },
3205 )
3206 .await
3207 .unwrap();
3208 let barrier = wait_for_checkpoint_barrier(
3209 connection,
3210 LATCH_RELAY_SESSION,
3211 &barrier_command_id,
3212 CHECKPOINT_BARRIER_TIMEOUT,
3213 BarrierBusyPolicy::InterruptWhileRunning,
3214 )
3215 .await
3216 .unwrap();
3217 assert_eq!(
3218 barrier.materialized.applied_event_ordinal,
3219 barrier.operational.latest_ordinal
3220 );
3221 let cursor = barrier.operational.checkpoint_ready.clone().unwrap();
3222 (channels, handle, relay, barrier_command_id, cursor)
3223 }
3224
3225 #[cfg(unix)]
3230 #[tokio::test]
3231 async fn a_close_checkpoint_cancels_a_running_turn_without_restarting_the_worker() {
3232 if std::env::var_os(LATCH_TEST_CHILD).is_none() {
3235 let directory = tempfile::tempdir().unwrap();
3236 let test_name = format!(
3237 "{}::a_close_checkpoint_cancels_a_running_turn_without_restarting_the_worker",
3238 module_path!()
3239 .strip_prefix("mj_controller::")
3240 .unwrap_or(module_path!())
3241 );
3242 let output = Command::new(std::env::current_exe().unwrap())
3243 .args(["--exact", &test_name, "--nocapture"])
3244 .env(LATCH_TEST_CHILD, "1")
3245 .env("MJ_DATA_DIR", directory.path())
3246 .output()
3247 .unwrap();
3248 assert!(
3249 output.status.success(),
3250 "isolated cancellation checkpoint test failed\nstdout:\n{}\nstderr:\n{}",
3251 String::from_utf8_lossy(&output.stdout),
3252 String::from_utf8_lossy(&output.stderr)
3253 );
3254 return;
3255 }
3256 let _writer = hel::hel_database::install_isolated_test_writer();
3257 let relay_root = tempfile::tempdir().unwrap();
3258 let start_log_directory = tempfile::tempdir().unwrap();
3259 let start_log = start_log_directory.path().join("relay-starts");
3260 let (_channels, _handle, mut relay, _barrier_command_id, _cursor) =
3261 latch_a_live_checkpoint(
3262 relay_root.path(),
3263 Some(&start_log),
3264 ReleaseSupport::Supported,
3265 true,
3266 )
3267 .await;
3268 let snapshot = relay.sync_snapshot().await.unwrap();
3269 assert_eq!(
3270 snapshot.operational.execution,
3271 RelayExecutionState::Idle,
3272 "the close wait returned before the cancelled turn became idle"
3273 );
3274 assert!(
3275 snapshot.operational.active_prompt.is_none(),
3276 "the close wait returned before the cancelled prompt settled"
3277 );
3278 assert_eq!(
3279 relay_starts(&start_log),
3280 1,
3281 "responsive cancellation restarted worker"
3282 );
3283 }
3284 #[cfg(unix)]
3287 async fn wait_until_the_actor_serves_again(handle: &ManagedSessionHandle) {
3288 for attempt in 0.. {
3289 if handle.sync_now().await.is_ok() {
3290 return;
3291 }
3292 assert!(attempt < 200, "the actor never took its connection back");
3293 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3294 }
3295 }
3296 #[cfg(unix)]
3300 #[tokio::test]
3301 async fn ending_the_checkpoint_latch_returns_the_connection_to_its_actor() {
3302 if std::env::var_os(LATCH_TEST_CHILD).is_none() {
3305 let directory = tempfile::tempdir().unwrap();
3306 let test_name = format!(
3307 "{}::ending_the_checkpoint_latch_returns_the_connection_to_its_actor",
3308 module_path!()
3309 .strip_prefix("mj_controller::")
3310 .unwrap_or(module_path!())
3311 );
3312 let output = Command::new(std::env::current_exe().unwrap())
3313 .args(["--exact", &test_name, "--nocapture"])
3314 .env(LATCH_TEST_CHILD, "1")
3315 .env("MJ_DATA_DIR", directory.path())
3316 .output()
3317 .unwrap();
3318 assert!(
3319 output.status.success(),
3320 "isolated checkpoint latch test failed\nstdout:\n{}\nstderr:\n{}",
3321 String::from_utf8_lossy(&output.stdout),
3322 String::from_utf8_lossy(&output.stderr)
3323 );
3324 return;
3325 }
3326 let _writer = hel::hel_database::install_isolated_test_writer();
3328
3329 std::thread::spawn(|| {
3332 std::thread::sleep(std::time::Duration::from_secs(120));
3333 eprintln!("the checkpoint latch never returned its connection");
3334 std::process::exit(101);
3335 });
3336
3337 let relay_root = tempfile::tempdir().unwrap();
3338 let (_channels, handle, mut relay, barrier_command_id, cursor) =
3339 latch_a_live_checkpoint(relay_root.path(), None, ReleaseSupport::Supported, false)
3340 .await;
3341
3342 assert!(
3345 handle.sync_now().await.is_err(),
3346 "a latched projection must not be advanced by its own actor"
3347 );
3348
3349 relay.end_latch();
3350 wait_until_the_actor_serves_again(&handle).await;
3351
3352 let latched = relay.sync_snapshot().await.unwrap();
3356 validate_checkpoint_barrier_snapshot(&latched, &barrier_command_id, &cursor).unwrap();
3357
3358 let prompt_ordinal = relay
3361 .submit(
3362 new_command_id("prompt").unwrap(),
3363 RelayCommand::Prompt {
3364 prompt: vec![ContentBlock::Text(TextContent::new("hello"))],
3365 },
3366 )
3367 .await
3368 .unwrap();
3369 assert!(prompt_ordinal > cursor.ordinal);
3370 let snapshot = relay.sync_snapshot().await.unwrap();
3371 assert!(snapshot.operational.latest_ordinal > cursor.ordinal);
3372 validate_checkpoint_barrier_snapshot(&snapshot, &barrier_command_id, &cursor).unwrap();
3373
3374 latched_checkpoint(
3375 relay,
3376 barrier_command_id,
3377 cursor,
3378 CheckpointCompletion::HeldBarrier,
3379 )
3380 .complete()
3381 .await
3382 .unwrap();
3383 handle.sync_now().await.unwrap();
3384 assert_eq!(
3385 handle
3386 .view()
3387 .snapshot
3388 .expect("the actor published the completed barrier")
3389 .operational
3390 .checkpoint_barrier,
3391 None
3392 );
3393 }
3394 #[cfg(unix)]
3398 #[tokio::test]
3399 async fn releasing_a_checkpoint_after_capture_defers_only_the_recovery_floor() {
3400 if std::env::var_os(RELEASE_TEST_CHILD).is_none() {
3403 let directory = tempfile::tempdir().unwrap();
3404 let test_name = format!(
3405 "{}::releasing_a_checkpoint_after_capture_defers_only_the_recovery_floor",
3406 module_path!()
3407 .strip_prefix("mj_controller::")
3408 .unwrap_or(module_path!())
3409 );
3410 let output = Command::new(std::env::current_exe().unwrap())
3411 .args(["--exact", &test_name, "--nocapture"])
3412 .env(RELEASE_TEST_CHILD, "1")
3413 .env("MJ_DATA_DIR", directory.path())
3414 .output()
3415 .unwrap();
3416 assert!(
3417 output.status.success(),
3418 "isolated checkpoint release test failed\nstdout:\n{}\nstderr:\n{}",
3419 String::from_utf8_lossy(&output.stdout),
3420 String::from_utf8_lossy(&output.stderr)
3421 );
3422 return;
3423 }
3424 let _writer = hel::hel_database::install_isolated_test_writer();
3426
3427 std::thread::spawn(|| {
3430 std::thread::sleep(std::time::Duration::from_secs(120));
3431 eprintln!("the captured checkpoint never released its barrier");
3432 std::process::exit(101);
3433 });
3434
3435 let relay_root = tempfile::tempdir().unwrap();
3436 let (_channels, handle, mut relay, barrier_command_id, cursor) =
3437 latch_a_live_checkpoint(relay_root.path(), None, ReleaseSupport::Supported, false)
3438 .await;
3439 relay.end_latch();
3440 wait_until_the_actor_serves_again(&handle).await;
3441
3442 let completion = release_checkpoint_after_capture(
3445 &mut relay,
3446 LATCH_RELAY_SESSION,
3447 &barrier_command_id,
3448 &cursor,
3449 )
3450 .await
3451 .unwrap();
3452 assert_eq!(completion, CheckpointCompletion::ReleasedAfterCapture);
3453 let released = relay.sync_snapshot().await.unwrap();
3454 assert_eq!(released.operational.checkpoint_barrier, None);
3455 assert_eq!(released.operational.checkpoint_ready, None);
3456 assert_eq!(
3457 released.operational.recovery_floor_ordinal, 0,
3458 "an exported archive that is not installed may not release journal history"
3459 );
3460
3461 relay
3464 .submit(
3465 new_command_id("prompt").unwrap(),
3466 RelayCommand::Prompt {
3467 prompt: vec![ContentBlock::Text(TextContent::new("during transfer"))],
3468 },
3469 )
3470 .await
3471 .unwrap();
3472 let mut dispatched = None;
3473 for attempt in 0.. {
3474 let snapshot = relay.sync_snapshot().await.unwrap();
3475 if let Some(active) = snapshot.operational.active_prompt {
3476 dispatched = Some(active);
3477 break;
3478 }
3479 assert!(attempt < 200, "a released barrier still froze ACP dispatch");
3480 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3481 }
3482 assert!(dispatched.is_some());
3483
3484 latched_checkpoint(
3487 relay,
3488 barrier_command_id,
3489 cursor.clone(),
3490 CheckpointCompletion::ReleasedAfterCapture,
3491 )
3492 .complete()
3493 .await
3494 .unwrap();
3495 handle.sync_now().await.unwrap();
3496 let installed = handle
3497 .view()
3498 .snapshot
3499 .expect("the actor published the advanced recovery floor");
3500 assert_eq!(installed.operational.recovery_floor_ordinal, cursor.ordinal);
3501 assert_eq!(installed.operational.recovery_floor_digest, cursor.digest);
3502 }
3503 #[cfg(unix)]
3506 #[tokio::test]
3507 async fn a_worker_that_rejects_the_release_keeps_its_barrier_through_the_transfer() {
3508 if std::env::var_os(LEGACY_RELEASE_TEST_CHILD).is_none() {
3511 let directory = tempfile::tempdir().unwrap();
3512 let test_name = format!(
3513 "{}::a_worker_that_rejects_the_release_keeps_its_barrier_through_the_transfer",
3514 module_path!()
3515 .strip_prefix("mj_controller::")
3516 .unwrap_or(module_path!())
3517 );
3518 let output = Command::new(std::env::current_exe().unwrap())
3519 .args(["--exact", &test_name, "--nocapture"])
3520 .env(LEGACY_RELEASE_TEST_CHILD, "1")
3521 .env("MJ_DATA_DIR", directory.path())
3522 .output()
3523 .unwrap();
3524 assert!(
3525 output.status.success(),
3526 "isolated legacy checkpoint release test failed\nstdout:\n{}\nstderr:\n{}",
3527 String::from_utf8_lossy(&output.stdout),
3528 String::from_utf8_lossy(&output.stderr)
3529 );
3530 return;
3531 }
3532 let _writer = hel::hel_database::install_isolated_test_writer();
3534
3535 std::thread::spawn(|| {
3538 std::thread::sleep(std::time::Duration::from_secs(120));
3539 eprintln!("the rejected release never finished its checkpoint");
3540 std::process::exit(101);
3541 });
3542
3543 let relay_root = tempfile::tempdir().unwrap();
3544 let start_log = tempfile::tempdir().unwrap();
3545 let start_log = start_log.path().join("relay-starts");
3546 let (_channels, handle, mut relay, barrier_command_id, cursor) = latch_a_live_checkpoint(
3547 relay_root.path(),
3548 Some(&start_log),
3549 ReleaseSupport::Rejected,
3550 false,
3551 )
3552 .await;
3553 relay.end_latch();
3554 wait_until_the_actor_serves_again(&handle).await;
3555
3556 let completion = release_checkpoint_after_capture(
3557 &mut relay,
3558 LATCH_RELAY_SESSION,
3559 &barrier_command_id,
3560 &cursor,
3561 )
3562 .await
3563 .unwrap();
3564 assert_eq!(completion, CheckpointCompletion::HeldBarrier);
3565 assert_eq!(relay_starts(&start_log), 1);
3568
3569 let transferring = relay.sync_snapshot().await.unwrap();
3573 validate_checkpoint_barrier_snapshot(&transferring, &barrier_command_id, &cursor).unwrap();
3574 latched_checkpoint(relay, barrier_command_id, cursor.clone(), completion)
3575 .complete()
3576 .await
3577 .unwrap();
3578 handle.sync_now().await.unwrap();
3579 let completed = handle
3580 .view()
3581 .snapshot
3582 .expect("the actor published the completed barrier");
3583 assert_eq!(completed.operational.checkpoint_barrier, None);
3584 assert_eq!(completed.operational.recovery_floor_ordinal, cursor.ordinal);
3585 }
3586 #[cfg(unix)]
3591 #[tokio::test]
3592 async fn abandoning_a_latched_checkpoint_drops_the_connection_that_opened_its_barrier() {
3593 if std::env::var_os(ABANDON_TEST_CHILD).is_none() {
3596 let directory = tempfile::tempdir().unwrap();
3597 let test_name = format!(
3598 "{}::abandoning_a_latched_checkpoint_drops_the_connection_that_opened_its_barrier",
3599 module_path!()
3600 .strip_prefix("mj_controller::")
3601 .unwrap_or(module_path!())
3602 );
3603 let output = Command::new(std::env::current_exe().unwrap())
3604 .args(["--exact", &test_name, "--nocapture"])
3605 .env(ABANDON_TEST_CHILD, "1")
3606 .env("MJ_DATA_DIR", directory.path())
3607 .output()
3608 .unwrap();
3609 assert!(
3610 output.status.success(),
3611 "isolated abandoned checkpoint test failed\nstdout:\n{}\nstderr:\n{}",
3612 String::from_utf8_lossy(&output.stdout),
3613 String::from_utf8_lossy(&output.stderr)
3614 );
3615 return;
3616 }
3617 let _writer = hel::hel_database::install_isolated_test_writer();
3619
3620 std::thread::spawn(|| {
3623 std::thread::sleep(std::time::Duration::from_secs(120));
3624 eprintln!("an abandoned checkpoint never released its relay connection");
3625 std::process::exit(101);
3626 });
3627
3628 let relay_root = tempfile::tempdir().unwrap();
3629 let start_log = tempfile::tempdir().unwrap();
3630 let start_log = start_log.path().join("relay-starts");
3631 let (_channels, handle, mut relay, barrier_command_id, cursor) = latch_a_live_checkpoint(
3632 relay_root.path(),
3633 Some(&start_log),
3634 ReleaseSupport::Supported,
3635 false,
3636 )
3637 .await;
3638 relay.end_latch();
3639 wait_until_the_actor_serves_again(&handle).await;
3640 assert_eq!(relay_starts(&start_log), 1);
3641
3642 latched_checkpoint(
3643 relay,
3644 barrier_command_id,
3645 cursor,
3646 CheckpointCompletion::HeldBarrier,
3647 )
3648 .abandon(LATCH_RELAY_SESSION)
3649 .await;
3650
3651 wait_until_the_actor_serves_again(&handle).await;
3656 assert_eq!(relay_starts(&start_log), 2);
3657 }
3658 #[cfg(unix)]
3663 #[tokio::test]
3664 async fn a_close_latch_reuses_an_unchanged_archive_and_exports_after_new_content() {
3665 if std::env::var_os(REUSE_TEST_CHILD).is_none() {
3668 let directory = tempfile::tempdir().unwrap();
3669 let test_name = format!(
3670 "{}::a_close_latch_reuses_an_unchanged_archive_and_exports_after_new_content",
3671 module_path!()
3672 .strip_prefix("mj_controller::")
3673 .unwrap_or(module_path!())
3674 );
3675 let output = Command::new(std::env::current_exe().unwrap())
3676 .args(["--exact", &test_name, "--nocapture"])
3677 .env(REUSE_TEST_CHILD, "1")
3678 .env(LATCH_RELAY_STARTUP_DELAY_MS, "31000")
3681 .env("MJ_DATA_DIR", directory.path())
3682 .output()
3683 .unwrap();
3684 assert!(
3685 output.status.success(),
3686 "isolated checkpoint reuse test failed\nstdout:\n{}\nstderr:\n{}",
3687 String::from_utf8_lossy(&output.stdout),
3688 String::from_utf8_lossy(&output.stderr)
3689 );
3690 return;
3691 }
3692 let _writer = hel::hel_database::install_isolated_test_writer();
3694
3695 std::thread::spawn(|| {
3698 std::thread::sleep(std::time::Duration::from_secs(120));
3699 eprintln!("the reuse checkpoint never finished its latch");
3700 std::process::exit(101);
3701 });
3702
3703 #[derive(Default)]
3704 struct RecordingExecutor {
3705 purposes: std::sync::Mutex<Vec<String>>,
3706 }
3707
3708 impl RecordingExecutor {
3709 fn refused(&self, command: &CommandSpec) -> Result<CommandOutput> {
3710 self.purposes.lock().unwrap().push(command.purpose.clone());
3711 Ok(CommandOutput {
3712 status: 1,
3713 stdout: Vec::new(),
3714 stderr: b"no target is provisioned for this test".to_vec(),
3715 })
3716 }
3717
3718 fn purposes(&self) -> Vec<String> {
3719 self.purposes.lock().unwrap().clone()
3720 }
3721 }
3722
3723 impl CommandExecutor for RecordingExecutor {
3724 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3725 self.refused(command)
3726 }
3727
3728 fn execute_with_stdin(
3729 &self,
3730 command: &CommandSpec,
3731 _input: &mut (dyn std::io::Read + Send),
3732 ) -> Result<CommandOutput> {
3733 self.refused(command)
3734 }
3735 }
3736
3737 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
3738 let relay_root = data_directory.join("relay");
3739 let profile_home = data_directory.join("profile");
3740 let archive_directory = data_directory.join("archives");
3741 for directory in [&relay_root, &profile_home, &archive_directory] {
3742 std::fs::create_dir_all(directory).unwrap();
3743 }
3744 let checkpoint = write_checkpoint_gate_archive(&archive_directory, LATCH_RELAY_SESSION, 2);
3747
3748 let mut session = checkpoint_test_session(LATCH_RELAY_SESSION);
3749 session.target_template_id = "local".into();
3750 session.target = Some(TargetLocator::LocalBare {
3751 worker_root: data_directory.join("workers").join(LATCH_RELAY_SESSION),
3752 });
3753 session.checkpoint = Some(checkpoint.clone());
3754 hel::hel_database::save_session(&session).unwrap();
3755
3756 let mut config = HelConfig::default();
3757 config.profiles.insert(
3758 "codex".into(),
3759 HarnessProfile {
3760 kind: hel::hel_config::HarnessKind::Codex,
3761 home: profile_home,
3762 environment: BTreeMap::new(),
3763 context_window_bytes: None,
3764 },
3765 );
3766 config
3767 .targets
3768 .insert("local".into(), TargetTemplate::LocalBare);
3769 config.bundles.insert(
3770 "project".into(),
3771 ProjectBundle {
3772 primary_repo: "project".into(),
3773 repositories: vec![ProjectRepository {
3774 id: "project".into(),
3775 github: Some("example/project".into()),
3776 local: None,
3777 destination: "project".into(),
3778 git_ref: None,
3779 }],
3780 },
3781 );
3782 let controller = Controller {
3783 config,
3784 state: HelState {
3785 sessions: BTreeMap::from([(LATCH_RELAY_SESSION.into(), session)]),
3786 ..HelState::default()
3787 },
3788 };
3789
3790 let channels = crate::hel_session_manager::spawn_session_manager().unwrap();
3791 channels
3792 .targets
3793 .send(vec![latch_relay_target(
3794 &relay_root,
3795 None,
3796 ReleaseSupport::Supported,
3797 false,
3798 )])
3799 .unwrap();
3800 let handle = channels
3801 .control
3802 .wait_for_session(LATCH_RELAY_SESSION, Duration::from_secs(10))
3803 .await
3804 .unwrap();
3805
3806 let executor = RecordingExecutor::default();
3807 let latched = controller
3808 .checkpoint_session_latched(
3809 LATCH_RELAY_SESSION,
3810 &executor,
3811 Some(&channels.control),
3812 LatchExclusivity::HoldThroughClose,
3813 CheckpointExportPolicy::ReuseUnchangedArchive,
3814 )
3815 .await
3816 .unwrap();
3817
3818 assert!(
3819 executor.purposes().is_empty(),
3820 "an unchanged session exported an archive anyway: {:?}",
3821 executor.purposes()
3822 );
3823 assert_eq!(latched.artifact.metadata, checkpoint);
3824 assert!(checkpoint.archive_path.exists());
3825
3826 assert!(latched.cursor.ordinal > checkpoint.event_frontier);
3829 let cursor = latched.cursor.clone();
3830 latched.complete().await.unwrap();
3831 wait_until_the_actor_serves_again(&handle).await;
3832
3833 handle
3837 .submit(
3838 new_command_id("busy-prompt").unwrap(),
3839 RelayCommand::Prompt {
3840 prompt: vec![ContentBlock::Text(TextContent::new("keep working"))],
3841 },
3842 )
3843 .await
3844 .unwrap();
3845 let mut connection = handle.lease_connection().await.unwrap();
3846 let before = connection.connection_mut().sync().await.unwrap();
3847 assert_eq!(before.operational.execution, RelayExecutionState::Running);
3848 connection.release();
3849 let deferred = controller
3850 .checkpoint_session_latched(
3851 LATCH_RELAY_SESSION,
3852 &executor,
3853 Some(&channels.control),
3854 LatchExclusivity::ReleaseAfterLatch,
3855 CheckpointExportPolicy::ReuseUnchangedArchive,
3856 )
3857 .await;
3858 assert!(
3859 matches!(deferred, Err(ref error) if error.downcast_ref::<CheckpointDeferred>().is_some())
3860 );
3861 wait_until_the_actor_serves_again(&handle).await;
3862 let mut connection = handle.lease_connection().await.unwrap();
3863 let after = connection.connection_mut().sync().await.unwrap();
3864 assert_eq!(after.operational.execution, RelayExecutionState::Running);
3865 assert!(after.operational.checkpoint_barrier.is_none());
3866 let journal =
3867 std::fs::read_to_string(relay_root.join("relay-journal/active.jsonl")).unwrap();
3868 for line in journal.lines() {
3869 let event: hel::hel_worker::RelayEvent = serde_json::from_str(line).unwrap();
3870 if event.ordinal > before.operational.latest_ordinal {
3871 assert!(
3872 !matches!(
3873 event.observation,
3874 hel::hel_worker::RelayObservation::CommandQueued {
3875 command: RelayCommand::BeginCheckpoint { .. },
3876 ..
3877 } | hel::hel_worker::RelayObservation::CommandInterrupted {
3878 command: hel::hel_worker::RelayCommandKind::BeginCheckpoint,
3879 ..
3880 }
3881 ),
3882 "busy deferral journaled checkpoint activity: {event:?}"
3883 );
3884 }
3885 }
3886 connection.release();
3887 handle
3888 .submit(
3889 new_command_id("finish-busy-prompt").unwrap(),
3890 RelayCommand::CancelTurn,
3891 )
3892 .await
3893 .unwrap();
3894 handle.sync_now().await.unwrap();
3895
3896 handle
3898 .submit(
3899 new_command_id("resume-notice").unwrap(),
3900 RelayCommand::RecordNotice {
3901 text: "the session changed".into(),
3902 },
3903 )
3904 .await
3905 .unwrap();
3906 for attempt in 0.. {
3907 handle.sync_now().await.unwrap();
3908 let materialized = handle.view().snapshot.map(|snapshot| snapshot.materialized);
3909 if materialized.is_some_and(|materialized| {
3910 materialized.applied_event_ordinal > cursor.ordinal
3911 && !materialized.transcript.is_empty()
3912 }) {
3913 break;
3914 }
3915 assert!(attempt < 200, "the notice never reached the projection");
3916 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3917 }
3918
3919 let changed = controller
3920 .checkpoint_session_latched(
3921 LATCH_RELAY_SESSION,
3922 &executor,
3923 Some(&channels.control),
3924 LatchExclusivity::HoldThroughClose,
3925 CheckpointExportPolicy::ReuseUnchangedArchive,
3926 )
3927 .await;
3928 let Err(error) = changed else {
3929 panic!("a changed session reused its installed archive");
3930 };
3931
3932 assert!(
3933 executor
3934 .purposes()
3935 .contains(&"export target checkpoint".to_owned()),
3936 "a changed session skipped its export: {:?}",
3937 executor.purposes()
3938 );
3939 assert!(
3940 format!("{error:#}").contains("no target is provisioned for this test"),
3941 "{error:#}"
3942 );
3943 assert!(checkpoint.archive_path.exists());
3944 }
3945 #[cfg(unix)]
3946 fn relay_starts(path: &Path) -> usize {
3947 std::fs::read_to_string(path)
3948 .unwrap_or_default()
3949 .lines()
3950 .count()
3951 }
3952 #[cfg(unix)]
3955 fn latched_checkpoint(
3956 relay: ControllerRelayLease,
3957 barrier_command_id: String,
3958 cursor: RelayCursor,
3959 completion: CheckpointCompletion,
3960 ) -> LatchedCheckpoint {
3961 LatchedCheckpoint {
3962 artifact: CheckpointArtifact {
3963 metadata: CheckpointMetadata {
3964 archive_path: PathBuf::from("checkpoint.hel.zip"),
3965 sha256: "a".repeat(64),
3966 created_at: now(),
3967 event_frontier: cursor.ordinal,
3968 },
3969 native_session_id: "native-session".into(),
3970 event_frontier_digest: cursor.digest.clone(),
3971 },
3972 relay,
3973 barrier_command_id,
3974 cursor,
3975 completion,
3976 }
3977 }
3978 #[test]
3979 fn checkpoint_persistence_rollback_restores_memory_and_reports_both_failures() {
3980 let session_id = "0123456789abcdef0123456789abcdef";
3981 let previous = checkpoint_test_session(session_id);
3982 let mut changed = previous.clone();
3983 changed.state = SessionState::Closing;
3984 changed.last_checkpoint_error = Some("partially installed checkpoint".into());
3985 let mut state = HelState::default();
3986 state.sessions.insert(session_id.into(), changed);
3987
3988 let error = restore_session_after_persistence_failure(
3989 &mut state,
3990 session_id,
3991 &previous,
3992 anyhow::anyhow!("verified checkpoint persistence failed"),
3993 |record| {
3994 assert_eq!(record, &previous);
3995 Err(anyhow::anyhow!("rollback database write failed"))
3996 },
3997 );
3998
3999 assert_eq!(state.sessions.get(session_id), Some(&previous));
4000 let detail = format!("{error:#}");
4001 assert!(detail.contains("verified checkpoint persistence failed"));
4002 assert!(detail.contains("rollback database write failed"));
4003 }
4004 #[test]
4005 fn installed_checkpoint_gate_reopens_and_checks_sha() {
4006 let directory = tempfile::tempdir().unwrap();
4007 let session_id = "0123456789abcdef0123456789abcdef";
4008 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
4009 verify_installed_checkpoint_gate(session_id, &checkpoint).unwrap();
4010
4011 let mut wrong_sha = checkpoint.clone();
4012 wrong_sha.sha256 = "b".repeat(64);
4013 assert!(
4014 verify_installed_checkpoint_gate(session_id, &wrong_sha)
4015 .unwrap_err()
4016 .to_string()
4017 .contains("SHA changed")
4018 );
4019 std::fs::write(
4020 &checkpoint.archive_path,
4021 b"changed after first verification",
4022 )
4023 .unwrap();
4024 assert!(
4025 format!(
4026 "{:#}",
4027 verify_installed_checkpoint_gate(session_id, &checkpoint).unwrap_err()
4028 )
4029 .contains("installed checkpoint SHA changed")
4030 );
4031 }
4032 #[test]
4033 fn an_installed_archive_is_reused_when_only_relay_bookkeeping_moved() {
4034 let directory = tempfile::tempdir().unwrap();
4035 let session_id = "0123456789abcdef0123456789abcdef";
4036 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
4037 let archived = verify_archive_streaming(&checkpoint.archive_path)
4038 .unwrap()
4039 .canonical_session;
4040
4041 let mut latched = archived.clone();
4044 latched.event_frontier += 6;
4045 latched.event_frontier_digest = "b".repeat(64);
4046 latched.session.last_activity_at_ms = Some(9_999);
4047
4048 let artifact = reusable_installed_checkpoint(
4049 session_id,
4050 Some(&checkpoint),
4051 "native-session",
4052 latched.event_frontier,
4053 &latched,
4054 )
4055 .expect("an unchanged session reuses its installed archive");
4056
4057 assert_eq!(artifact.metadata, checkpoint);
4058 assert_eq!(artifact.native_session_id, "native-session");
4059 assert_eq!(
4060 artifact.event_frontier_digest,
4061 archived.event_frontier_digest
4062 );
4063 verify_checkpoint_artifact(session_id, &artifact).unwrap();
4065 verify_installed_checkpoint_gate(session_id, &artifact.metadata).unwrap();
4066 }
4067 #[test]
4068 fn archive_reuse_falls_back_to_a_full_export_for_anything_but_bookkeeping() {
4069 let directory = tempfile::tempdir().unwrap();
4070 let session_id = "0123456789abcdef0123456789abcdef";
4071 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
4072 let archived = verify_archive_streaming(&checkpoint.archive_path)
4073 .unwrap()
4074 .canonical_session;
4075 let mut latched = archived.clone();
4076 latched.event_frontier += 6;
4077 let reuse = |installed: Option<&CheckpointMetadata>,
4078 ordinal: u64,
4079 session: &CanonicalSessionSnapshot| {
4080 reusable_installed_checkpoint(session_id, installed, "native-session", ordinal, session)
4081 };
4082
4083 assert!(reuse(None, latched.event_frontier, &latched).is_none());
4084
4085 let mut with_new_content = latched.clone();
4086 with_new_content.transcript.push(CanonicalTranscriptItem {
4087 stable_id: "system:notice:notice-1".into(),
4088 position: latched.event_frontier,
4089 latest_content_event_ordinal: None,
4090 created_at_ms: 2_000,
4091 last_changed_at_ms: 2_000,
4092 body: CanonicalTranscriptBody::System {
4093 text: "resumed".into(),
4094 },
4095 });
4096 assert!(reuse(Some(&checkpoint), latched.event_frontier, &with_new_content).is_none());
4097
4098 assert!(reuse(Some(&checkpoint), checkpoint.event_frontier - 1, &latched).is_none());
4100
4101 let mut wrong_sha = checkpoint.clone();
4102 wrong_sha.sha256 = "b".repeat(64);
4103 assert!(reuse(Some(&wrong_sha), latched.event_frontier, &latched).is_none());
4104
4105 let another_session =
4106 write_checkpoint_gate_archive(directory.path(), "1123456789abcdef0123456789abcdef", 7);
4107 assert!(reuse(Some(&another_session), latched.event_frontier, &latched).is_none());
4108
4109 std::fs::write(&checkpoint.archive_path, b"not an archive any more").unwrap();
4110 assert!(reuse(Some(&checkpoint), latched.event_frontier, &latched).is_none());
4111 }
4112}