Skip to main content

mj_controller/hel_controller/
checkpoint.rs

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