Skip to main content

mj_controller/hel_controller/
lifecycle.rs

1//! Session close, force-stop, and permanent-destruction transitions.
2
3use std::time::Duration;
4
5use anyhow::{Context, Result, bail, ensure};
6
7use crate::hel_session_manager::{SessionManagerControl, new_command_id};
8use hel::hel_state::{CheckpointMetadata, SessionRecord, SessionState};
9use hel::hel_targets::{self, CommandExecutor, ProcessExecutor};
10use hel::hel_worker::{RelayCommand, RelayExecutionState};
11
12use super::backend::backend_locator;
13use super::checkpoint::{
14    CheckpointExportPolicy, LatchExclusivity, prune_replaced_checkpoint,
15    release_projection_behind_checkpoint, verify_installed_checkpoint_gate, wait_for_relay_closed,
16};
17use super::provisioning::retire_git_broker;
18use super::worktree::{cleanup_managed_worktree, retire_managed_worktree};
19use super::{Controller, now, persist_session_record_transition_or_restore};
20
21impl Controller {
22    /// Checkpoint, ask the harness to close, and only then tear down the exact
23    /// provisioned target. Checkpoint failure is deliberately non-destructive.
24    pub async fn close_session(&mut self, session_id: &str) -> Result<()> {
25        self.close_session_controlled(session_id, &ProcessExecutor)
26            .await
27    }
28
29    pub async fn close_session_controlled(
30        &mut self,
31        session_id: &str,
32        executor: &(impl CommandExecutor + Sync),
33    ) -> Result<()> {
34        if self
35            .close_session_controlled_with_manager(session_id, executor, None)
36            .await?
37        {
38            self.cleanup_stopped_target(session_id, executor)?;
39        }
40        Ok(())
41    }
42
43    pub async fn close_session_managed_controlled(
44        &mut self,
45        session_id: &str,
46        executor: &(impl CommandExecutor + Sync),
47        manager: &SessionManagerControl,
48    ) -> Result<bool> {
49        self.close_session_controlled_with_manager(session_id, executor, Some(manager))
50            .await
51    }
52
53    async fn close_session_controlled_with_manager(
54        &mut self,
55        session_id: &str,
56        executor: &(impl CommandExecutor + Sync),
57        manager: Option<&SessionManagerControl>,
58    ) -> Result<bool> {
59        let previous = self
60            .state
61            .sessions
62            .get(session_id)
63            .with_context(|| format!("unknown session {session_id}"))?
64            .clone();
65        let record = self.state.sessions.get_mut(session_id).unwrap();
66        // Persist the close intent before beginning its checkpoint. A process
67        // exit anywhere below must leave enough state for the next controller
68        // to retry the close, even when no checkpoint has been installed yet.
69        apply_close_checkpoint_started(record, now());
70        self.persist_session_transition_or_restore(
71            session_id,
72            &previous,
73            "persist closing state before checkpointing the session",
74        )?;
75
76        // Close seals the relay at the exact latched cursor, so this checkpoint
77        // keeps its exclusive connection until the relay reports Closed.
78        let mut latched = match self
79            .checkpoint_session_latched(
80                session_id,
81                executor,
82                manager,
83                LatchExclusivity::HoldThroughClose,
84                CheckpointExportPolicy::ReuseUnchangedArchive,
85            )
86            .await
87        {
88            Ok(latched) => latched,
89            Err(error) => {
90                let record = self.state.sessions.get_mut(session_id).unwrap();
91                record.state = previous.state;
92                record.updated_at = now();
93                record.last_checkpoint_error = Some(format!("{error:#}"));
94                return Err(
95                    self.persist_failed_checkpoint_state_or_restore(session_id, &previous, error)
96                );
97            }
98        };
99
100        let artifact = latched.artifact.clone();
101        let record = self.state.sessions.get_mut(session_id).unwrap();
102        record.state = SessionState::Closing;
103        record.native_session_id = Some(artifact.native_session_id.clone());
104        record.checkpoint = Some(artifact.metadata.clone());
105        record.updated_at = now();
106        record.last_error = None;
107        record.last_checkpoint_error = None;
108        self.persist_checkpoint_transition_or_restore(
109            session_id,
110            &previous,
111            "persist verified checkpoint and closing state before sealing the relay",
112        )?;
113        prune_replaced_checkpoint(previous.checkpoint.as_ref(), &artifact.metadata);
114        // A stopping session will not checkpoint again, so this is its last
115        // chance to release what its checkpoint now covers.
116        release_projection_behind_checkpoint(session_id, &artifact.metadata);
117
118        let close_command_id = new_command_id("close")?;
119        let barrier_command_id = latched.barrier_command_id.clone();
120        if let Err(error) = latched
121            .relay
122            .connection_mut()
123            .submit(
124                close_command_id,
125                RelayCommand::Close {
126                    barrier_command_id: barrier_command_id.clone(),
127                    expected: latched.cursor.clone(),
128                },
129            )
130            .await
131        {
132            self.record_interrupted_close(session_id, &error)?;
133            return Err(error.context("seal verified checkpoint for close"));
134        }
135        if let Err(error) = latched
136            .relay
137            .connection_mut()
138            .submit(
139                new_command_id("checkpoint-complete")?,
140                RelayCommand::CompleteCheckpoint { barrier_command_id },
141            )
142            .await
143        {
144            self.record_interrupted_close(session_id, &error)?;
145            return Err(error.context("release verified close checkpoint"));
146        }
147        if let Err(error) = wait_for_relay_closed(latched.relay.connection_mut()).await {
148            self.record_interrupted_close(session_id, &error)?;
149            return Err(error);
150        }
151        latched.relay.release();
152
153        match self.destroy_after_verified_checkpoint(session_id, &artifact.metadata, executor) {
154            Ok(deferred) => Ok(deferred),
155            Err(error) => {
156                self.record_interrupted_close(session_id, &error)?;
157                Err(error)
158            }
159        }
160    }
161
162    /// Resume the durable closing state after a controller restart. If the
163    /// relay had accepted Close, wait for it and destroy through the exact
164    /// installed checkpoint gate. If it had not, take a fresh checkpoint;
165    /// the previously installed archive may have become stale after EOF
166    /// released its barrier.
167    pub async fn recover_interrupted_close_managed(
168        &mut self,
169        session_id: &str,
170        executor: &(impl CommandExecutor + Sync),
171        manager: &SessionManagerControl,
172    ) -> Result<bool> {
173        let (state, verified) = {
174            let session = self
175                .state
176                .sessions
177                .get(session_id)
178                .with_context(|| format!("unknown session {session_id}"))?;
179            ensure!(
180                matches!(
181                    session.state,
182                    SessionState::Closing | SessionState::Destroying
183                ),
184                "session {session_id} has no interrupted close to recover"
185            );
186            (session.state, session.checkpoint.clone())
187        };
188        if state == SessionState::Destroying {
189            let verified = verified.context("destroying session has no verified checkpoint")?;
190            return self.destroy_after_verified_checkpoint(session_id, &verified, executor);
191        }
192        ensure!(
193            state == SessionState::Closing,
194            "session {session_id} has no relay close to recover"
195        );
196        let handle = manager
197            .wait_for_session(session_id, Duration::from_secs(5))
198            .await?;
199        let mut lease = handle.lease_connection().await?;
200        let execution = lease.connection_mut().sync().await?.operational.execution;
201        match execution {
202            RelayExecutionState::Closed => {}
203            RelayExecutionState::Closing => {
204                wait_for_relay_closed(lease.connection_mut()).await?;
205            }
206            RelayExecutionState::Idle | RelayExecutionState::Running => {
207                lease.release();
208                return self
209                    .close_session_controlled_with_manager(session_id, executor, Some(manager))
210                    .await;
211            }
212        }
213        lease.release();
214        let verified = verified.context("closed relay has no verified checkpoint")?;
215        self.destroy_after_verified_checkpoint(session_id, &verified, executor)
216    }
217
218    fn record_interrupted_close(&mut self, session_id: &str, error: &anyhow::Error) -> Result<()> {
219        let record = self.state.sessions.get_mut(session_id).unwrap();
220        apply_interrupted_close_error(record, error, &now());
221        self.persist_session_state(session_id)
222    }
223
224    /// Execute cleanup only after the close state machine has installed a
225    /// verified checkpoint on the record.
226    fn destroy_after_verified_checkpoint(
227        &mut self,
228        session_id: &str,
229        verified: &CheckpointMetadata,
230        executor: &impl CommandExecutor,
231    ) -> Result<bool> {
232        self.destroy_after_verified_checkpoint_with(
233            session_id,
234            verified,
235            executor,
236            hel::hel_database::save_lifecycle_session,
237        )
238    }
239
240    fn destroy_after_verified_checkpoint_with(
241        &mut self,
242        session_id: &str,
243        verified: &CheckpointMetadata,
244        executor: &impl CommandExecutor,
245        persist: impl Fn(&SessionRecord) -> Result<()>,
246    ) -> Result<bool> {
247        let session = self
248            .state
249            .sessions
250            .get(session_id)
251            .with_context(|| format!("unknown session {session_id}"))?
252            .clone();
253        ensure!(
254            matches!(
255                session.state,
256                SessionState::Closing | SessionState::Destroying
257            ),
258            "refusing to destroy session {session_id}: it is not closing or destroying"
259        );
260        ensure!(
261            session.checkpoint.as_ref() == Some(verified),
262            "refusing to destroy session {session_id}: verified checkpoint gate is stale"
263        );
264        if session.state == SessionState::Closing {
265            let record = self.state.sessions.get_mut(session_id).unwrap();
266            record.state = SessionState::Destroying;
267            record.updated_at = now();
268            record.last_error = None;
269            persist_session_record_transition_or_restore(
270                &mut self.state,
271                session_id,
272                &session,
273                "persist destroying state before target cleanup",
274                &persist,
275            )?;
276        }
277
278        let destroying = self
279            .state
280            .sessions
281            .get(session_id)
282            .expect("destroying session disappeared")
283            .clone();
284        verify_installed_checkpoint_gate(session_id, verified)?;
285        // The reviewer's native session lives on the target that is about to
286        // go. Recording that now, before the target is torn down, is what
287        // stops a resumed session from trying to reload a conversation that no
288        // longer exists; its transcript is kept for reference either way.
289        if let Err(error) = hel::hel_database::lose_reviewer_continuity(session_id) {
290            tracing::warn!(
291                session_id,
292                error = format!("{error:#}"),
293                "could not record that the second-opinion conversation ends with this target"
294            );
295        }
296        // The session's local Git origin ends here. Stopping the broker before
297        // the target it bridges into disappears is what keeps a normal close
298        // from reading as an unexpected broker death.
299        retire_git_broker(session_id).context("stop the session's local Git broker")?;
300        let locator = destroying
301            .target
302            .as_ref()
303            .context("session has no target")?;
304        let backend = backend_locator(locator, &destroying, &self.config)?;
305        let deferred = if let Some(plan) = hel_targets::quiesce_plan(&backend, session_id)? {
306            plan.execute(executor)?;
307            true
308        } else {
309            execute_target_cleanup(&backend, session_id, executor)?;
310            false
311        };
312        if let Some(worktree) = &destroying.managed_worktree {
313            retire_managed_worktree(executor, worktree)
314                .context("retire managed raw-session worktree after verified close")?;
315        }
316        let record = self.state.sessions.get_mut(session_id).unwrap();
317        record.state = SessionState::Stopped;
318        if !deferred {
319            record.target = None;
320        }
321        record.updated_at = now();
322        record.last_error = None;
323        persist_session_record_transition_or_restore(
324            &mut self.state,
325            session_id,
326            &destroying,
327            "persist stopped state after target cleanup",
328            &persist,
329        )?;
330        Ok(deferred)
331    }
332
333    /// Finish storage cleanup for a stopped Podman target retained by the
334    /// quiescence transition. The locator stays durable until every command
335    /// succeeds, making daemon restart and explicit retry idempotent.
336    pub fn cleanup_stopped_target(
337        &mut self,
338        session_id: &str,
339        executor: &impl CommandExecutor,
340    ) -> Result<()> {
341        self.cleanup_stopped_target_with(
342            session_id,
343            executor,
344            hel::hel_database::save_lifecycle_session,
345        )
346    }
347
348    fn cleanup_stopped_target_with(
349        &mut self,
350        session_id: &str,
351        executor: &impl CommandExecutor,
352        persist: impl Fn(&SessionRecord) -> Result<()>,
353    ) -> Result<()> {
354        let previous = self
355            .state
356            .sessions
357            .get(session_id)
358            .with_context(|| format!("unknown session {session_id}"))?
359            .clone();
360        ensure!(
361            previous.state == SessionState::Stopped,
362            "refusing deferred cleanup for active session {session_id}"
363        );
364        let Some(locator) = previous.target.as_ref() else {
365            return Ok(());
366        };
367        let backend = backend_locator(locator, &previous, &self.config)?;
368        ensure!(
369            hel_targets::quiesce_plan(&backend, session_id)?.is_some(),
370            "session {session_id} retained a non-Podman target after stopping"
371        );
372        execute_target_cleanup(&backend, session_id, executor)?;
373        let record = self.state.sessions.get_mut(session_id).unwrap();
374        record.target = None;
375        record.updated_at = now();
376        record.last_error = None;
377        persist_session_record_transition_or_restore(
378            &mut self.state,
379            session_id,
380            &previous,
381            "persist completion of deferred Podman target cleanup",
382            &persist,
383        )
384    }
385
386    /// Tear down the current target without taking a fresh checkpoint, then
387    /// leave the logical session resumable from its latest verified archive.
388    pub fn force_stop(
389        &mut self,
390        session_id: &str,
391        executor: &impl CommandExecutor,
392    ) -> Result<bool> {
393        self.force_stop_with(
394            session_id,
395            executor,
396            hel::hel_database::save_lifecycle_session,
397        )
398    }
399
400    fn force_stop_with(
401        &mut self,
402        session_id: &str,
403        executor: &impl CommandExecutor,
404        persist: impl Fn(&SessionRecord) -> Result<()>,
405    ) -> Result<bool> {
406        let session = self
407            .state
408            .sessions
409            .get(session_id)
410            .with_context(|| format!("unknown session {session_id}"))?
411            .clone();
412        ensure!(
413            session.state.is_active(),
414            "session {session_id} is already inactive"
415        );
416        let checkpoint = session
417            .checkpoint
418            .as_ref()
419            .context("force stop requires an existing recovery archive")?;
420        // Force stop skips a new checkpoint, never verification of the archive
421        // that makes the logical session resumable afterwards.
422        verify_installed_checkpoint_gate(session_id, checkpoint)
423            .context("verify the recovery archive before force stopping")?;
424        retire_git_broker(session_id).context("stop the session's local Git broker")?;
425        let mut deferred = false;
426        if let Some(locator) = &session.target {
427            let backend = backend_locator(locator, &session, &self.config)?;
428            if let Some(plan) = hel_targets::quiesce_plan(&backend, session_id)? {
429                plan.execute(executor)?;
430                deferred = true;
431            } else {
432                execute_target_cleanup(&backend, session_id, executor)?;
433            }
434        }
435        if let Some(worktree) = &session.managed_worktree {
436            retire_managed_worktree(executor, worktree)
437                .context("retire managed raw-session worktree after force stop")?;
438        }
439        let record = self.state.sessions.get_mut(session_id).unwrap();
440        record.state = SessionState::Stopped;
441        if !deferred {
442            record.target = None;
443        }
444        record.updated_at = now();
445        record.last_error = None;
446        record.last_checkpoint_error = None;
447        persist_session_record_transition_or_restore(
448            &mut self.state,
449            session_id,
450            &session,
451            "persist stopped state after force stopping the current target",
452            &persist,
453        )?;
454        Ok(deferred)
455    }
456
457    /// Permanently destroy an inactive session and every artifact Hel owns for it.
458    /// External cleanup happens before the durable record is dropped so failures
459    /// remain visible and retryable.
460    pub fn destroy_session_controlled(
461        &mut self,
462        session_id: &str,
463        executor: &impl CommandExecutor,
464    ) -> Result<()> {
465        let session = self
466            .state
467            .sessions
468            .get(session_id)
469            .with_context(|| format!("unknown session {session_id}"))?
470            .clone();
471        if session.state.is_active() {
472            bail!("refusing to destroy active session {session_id}");
473        }
474        // A session destroyed for good keeps nothing, including a broker an
475        // earlier failure left running.
476        retire_git_broker(session_id).context("stop the session's local Git broker")?;
477        if let Some(worktree) = &session.managed_worktree {
478            cleanup_managed_worktree(executor, worktree)
479                .context("remove managed raw-session worktree")?;
480        }
481        if let Some(checkpoint) = &session.checkpoint
482            && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
483            && error.kind() != std::io::ErrorKind::NotFound
484        {
485            return Err(error).with_context(|| {
486                format!(
487                    "remove session recovery archive {}",
488                    checkpoint.archive_path.display()
489                )
490            });
491        }
492        hel::hel_database::delete_session(session_id)
493            .context("destroy stopped session in database")?;
494        self.state.destroy_stopped_session(session_id)?;
495        Ok(())
496    }
497}
498
499fn execute_target_cleanup(
500    backend: &hel_targets::TargetLocator,
501    session_id: &str,
502    executor: &impl CommandExecutor,
503) -> Result<()> {
504    if let Err(cleanup_error) = hel_targets::close_plan(backend, session_id)?.execute(executor) {
505        match hel_targets::cleanup_target_is_confirmed_absent(backend, session_id, executor) {
506            Ok(true) => {
507                tracing::warn!(
508                    session_id,
509                    error = format!("{cleanup_error:#}"),
510                    "target cleanup command failed, but the target was confirmed absent"
511                );
512            }
513            Ok(false) => {
514                tracing::error!(
515                    session_id,
516                    error = format!("{cleanup_error:#}"),
517                    "target cleanup failed and the target is still present"
518                );
519                return Err(cleanup_error);
520            }
521            Err(probe_error) => {
522                tracing::error!(
523                    session_id,
524                    cleanup_error = format!("{cleanup_error:#}"),
525                    probe_error = format!("{probe_error:#}"),
526                    "target cleanup failed and exact absence could not be confirmed"
527                );
528                return Err(cleanup_error.context(format!(
529                    "target cleanup failed and exact absence could not be confirmed: {probe_error:#}"
530                )));
531            }
532        }
533    }
534    Ok(())
535}
536
537fn apply_close_checkpoint_started(record: &mut SessionRecord, updated_at: String) {
538    record.state = SessionState::Closing;
539    record.updated_at = updated_at;
540    record.last_checkpoint_error = None;
541}
542
543fn apply_interrupted_close_error(
544    record: &mut SessionRecord,
545    error: &anyhow::Error,
546    updated_at: &str,
547) {
548    let destroying = record.state == SessionState::Destroying;
549    if !destroying {
550        record.state = SessionState::Closing;
551    }
552    record.updated_at = updated_at.to_owned();
553    record.last_error = Some(if destroying {
554        format!("target cleanup is safely retryable from its verified checkpoint: {error:#}")
555    } else {
556        format!("close is safely resumable from its verified checkpoint: {error:#}")
557    });
558}
559
560#[cfg(test)]
561mod tests {
562    use std::cell::RefCell;
563    use std::collections::BTreeMap;
564
565    use anyhow::Result;
566
567    use crate::hel_controller::Controller;
568    use crate::hel_controller::test_support::{
569        checkpoint_test_session, committed_repository, managed_worktree_session, test_git,
570        write_checkpoint_gate_archive,
571    };
572    use hel::hel_config::{ContainerTemplate as ConfigContainer, HelConfig, TargetTemplate};
573    use hel::hel_state::{HelState, SessionState, TargetLocator};
574    use hel::hel_targets::{self, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
575
576    use super::*;
577
578    #[test]
579    fn starting_close_persists_its_intent_before_checkpointing() {
580        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
581        session.state = SessionState::Running;
582        session.last_checkpoint_error = Some("old failure".into());
583
584        apply_close_checkpoint_started(&mut session, "2026-08-14T12:00:00Z".into());
585
586        assert_eq!(session.state, SessionState::Closing);
587        assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
588        assert!(session.last_checkpoint_error.is_none());
589    }
590    #[test]
591    fn target_cleanup_persists_destroying_and_rechecks_the_installed_archive() {
592        struct RecordingExecutor {
593            commands: RefCell<Vec<CommandSpec>>,
594        }
595
596        impl CommandExecutor for RecordingExecutor {
597            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
598                self.commands.borrow_mut().push(command.clone());
599                Ok(CommandOutput {
600                    status: 0,
601                    stdout: Vec::new(),
602                    stderr: Vec::new(),
603                })
604            }
605        }
606
607        let directory = tempfile::tempdir().unwrap();
608        let session_id = "0123456789abcdef0123456789abcdef";
609        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
610        let mut session = checkpoint_test_session(session_id);
611        session.target_template_id = "local".into();
612        session.state = SessionState::Closing;
613        session.target = Some(TargetLocator::LocalBare {
614            worker_root: directory.path().join(session_id),
615        });
616        session.checkpoint = Some(checkpoint.clone());
617        let mut config = HelConfig::default();
618        config
619            .targets
620            .insert("local".into(), TargetTemplate::LocalBare);
621        let mut controller = Controller {
622            config,
623            state: HelState {
624                sessions: BTreeMap::from([(session_id.into(), session)]),
625                ..HelState::default()
626            },
627        };
628        let executor = RecordingExecutor {
629            commands: RefCell::new(Vec::new()),
630        };
631        let persisted = RefCell::new(Vec::new());
632
633        controller
634            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
635                persisted.borrow_mut().push(record.state);
636                Ok(())
637            })
638            .unwrap();
639
640        assert_eq!(
641            persisted.into_inner(),
642            vec![SessionState::Destroying, SessionState::Stopped]
643        );
644        assert_eq!(executor.commands.borrow().len(), 1);
645        let stopped = &controller.state.sessions[session_id];
646        assert_eq!(stopped.state, SessionState::Stopped);
647        assert!(stopped.target.is_none());
648    }
649
650    #[test]
651    fn podman_close_persists_stopped_before_deferred_storage_cleanup() {
652        struct RecordingExecutor {
653            commands: RefCell<Vec<CommandSpec>>,
654        }
655
656        impl CommandExecutor for RecordingExecutor {
657            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
658                self.commands.borrow_mut().push(command.clone());
659                Ok(CommandOutput {
660                    status: 0,
661                    stdout: Vec::new(),
662                    stderr: Vec::new(),
663                })
664            }
665        }
666
667        let directory = tempfile::tempdir().unwrap();
668        let session_id = "0123456789abcdef0123456789abcdef";
669        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
670        let container_id = hel_targets::resource_name(session_id).unwrap();
671        let volume = format!("{container_id}-workspace");
672        let mut session = checkpoint_test_session(session_id);
673        session.target_template_id = "podman".into();
674        session.state = SessionState::Closing;
675        session.target = Some(TargetLocator::LocalPodman {
676            container_id,
677            workspace_storage: hel::hel_state::PodmanWorkspaceLocator::Volume {
678                name: volume.clone(),
679            },
680        });
681        session.checkpoint = Some(checkpoint.clone());
682        let mut config = HelConfig::default();
683        config.targets.insert(
684            "podman".into(),
685            TargetTemplate::LocalPodman {
686                container: ConfigContainer {
687                    image: "test:latest".into(),
688                    pull_policy: Default::default(),
689                    platform: None,
690                    cpus: None,
691                    memory: None,
692                    environment: BTreeMap::new(),
693                    workspace_storage: hel::hel_config::PodmanWorkspaceStorage::PodmanVolume,
694                },
695            },
696        );
697        let mut controller = Controller {
698            config,
699            state: HelState {
700                sessions: BTreeMap::from([(session_id.into(), session)]),
701                ..HelState::default()
702            },
703        };
704        let executor = RecordingExecutor {
705            commands: RefCell::new(Vec::new()),
706        };
707        let persisted = RefCell::new(Vec::new());
708
709        let deferred = controller
710            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
711                persisted
712                    .borrow_mut()
713                    .push((record.state, record.target.is_some()));
714                Ok(())
715            })
716            .unwrap();
717
718        assert!(deferred);
719        assert_eq!(
720            persisted.borrow().as_slice(),
721            &[
722                (SessionState::Destroying, true),
723                (SessionState::Stopped, true)
724            ]
725        );
726        let commands = executor.commands.borrow();
727        assert_eq!(commands.len(), 1);
728        assert!(commands[0].args[1].contains("podman stop --time 0"));
729        assert!(!commands[0].args[1].contains("podman rm"));
730        drop(commands);
731
732        controller
733            .cleanup_stopped_target_with(session_id, &executor, |record| {
734                assert_eq!(record.state, SessionState::Stopped);
735                assert!(record.target.is_none());
736                Ok(())
737            })
738            .unwrap();
739
740        let commands = executor.commands.borrow();
741        assert_eq!(commands.len(), 4);
742        assert_eq!(
743            commands[1].stage,
744            Some(hel_targets::ProvisionStage::RemovingContainer)
745        );
746        assert_eq!(
747            commands[2].stage,
748            Some(hel_targets::ProvisionStage::RemovingStorage)
749        );
750        assert_eq!(
751            commands[3].stage,
752            Some(hel_targets::ProvisionStage::CleaningCache)
753        );
754        assert!(commands[2].args.contains(&volume));
755        assert!(controller.state.sessions[session_id].target.is_none());
756    }
757    #[test]
758    fn verified_close_retires_managed_checkout_but_keeps_archive_and_branch() {
759        let archive_directory = tempfile::tempdir().unwrap();
760        let repository = committed_repository();
761        let session_id = "0123456789abcdef0123456789abcdef";
762        let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
763        let mut session = managed_worktree_session(repository.path(), session_id);
764        let worktree = session.managed_worktree.clone().unwrap();
765        std::fs::write(worktree.worktree_root.join("dirty.txt"), "worktree state\n").unwrap();
766        session.state = SessionState::Closing;
767        session.target = Some(TargetLocator::LocalBare {
768            worker_root: archive_directory.path().join(session_id),
769        });
770        session.checkpoint = Some(checkpoint.clone());
771        let mut config = HelConfig::default();
772        config
773            .targets
774            .insert("local-bare".into(), TargetTemplate::LocalBare);
775        let mut controller = Controller {
776            config,
777            state: HelState {
778                sessions: BTreeMap::from([(session_id.into(), session)]),
779                ..HelState::default()
780            },
781        };
782
783        controller
784            .destroy_after_verified_checkpoint_with(
785                session_id,
786                &checkpoint,
787                &ProcessExecutor,
788                |_| Ok(()),
789            )
790            .unwrap();
791
792        assert!(!worktree.worktree_root.exists());
793        assert!(checkpoint.archive_path.is_file());
794        assert_eq!(
795            test_git(
796                repository.path(),
797                &[
798                    "show-ref",
799                    "--hash",
800                    &format!("refs/heads/{}", worktree.branch),
801                ],
802            )
803            .len(),
804            40
805        );
806        assert_eq!(
807            controller.state.sessions[session_id].state,
808            SessionState::Stopped
809        );
810    }
811    #[test]
812    fn force_stop_reuses_verified_archive_and_leaves_session_resumable() {
813        let archive_directory = tempfile::tempdir().unwrap();
814        let repository = committed_repository();
815        let session_id = "0123456789abcdef0123456789abcdef";
816        let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
817        let mut session = managed_worktree_session(repository.path(), session_id);
818        let worktree = session.managed_worktree.clone().unwrap();
819        session.state = SessionState::Running;
820        session.target = Some(TargetLocator::LocalBare {
821            worker_root: archive_directory.path().join(session_id),
822        });
823        session.checkpoint = Some(checkpoint.clone());
824        let mut config = HelConfig::default();
825        config
826            .targets
827            .insert("local-bare".into(), TargetTemplate::LocalBare);
828        let mut controller = Controller {
829            config,
830            state: HelState {
831                sessions: BTreeMap::from([(session_id.into(), session)]),
832                ..HelState::default()
833            },
834        };
835
836        controller
837            .force_stop_with(session_id, &ProcessExecutor, |_| Ok(()))
838            .unwrap();
839
840        let stopped = &controller.state.sessions[session_id];
841        assert_eq!(stopped.state, SessionState::Stopped);
842        assert!(stopped.target.is_none());
843        assert_eq!(stopped.checkpoint.as_ref(), Some(&checkpoint));
844        assert!(checkpoint.archive_path.is_file());
845        assert!(!worktree.worktree_root.exists());
846        assert!(
847            !test_git(
848                repository.path(),
849                &[
850                    "show-ref",
851                    "--hash",
852                    &format!("refs/heads/{}", worktree.branch),
853                ],
854            )
855            .is_empty()
856        );
857    }
858    #[test]
859    fn force_stop_without_a_recovery_archive_does_not_touch_the_target() {
860        struct RecordingExecutor {
861            calls: RefCell<usize>,
862        }
863
864        impl CommandExecutor for RecordingExecutor {
865            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
866                *self.calls.borrow_mut() += 1;
867                Ok(CommandOutput {
868                    status: 0,
869                    stdout: Vec::new(),
870                    stderr: Vec::new(),
871                })
872            }
873        }
874
875        let directory = tempfile::tempdir().unwrap();
876        let session_id = "0123456789abcdef0123456789abcdef";
877        let mut session = checkpoint_test_session(session_id);
878        session.state = SessionState::Running;
879        session.checkpoint = None;
880        session.target_template_id = "local".into();
881        session.target = Some(TargetLocator::LocalBare {
882            worker_root: directory.path().join(session_id),
883        });
884        let mut config = HelConfig::default();
885        config
886            .targets
887            .insert("local".into(), TargetTemplate::LocalBare);
888        let mut controller = Controller {
889            config,
890            state: HelState {
891                sessions: BTreeMap::from([(session_id.into(), session)]),
892                ..HelState::default()
893            },
894        };
895        let executor = RecordingExecutor {
896            calls: RefCell::new(0),
897        };
898
899        let error = controller
900            .force_stop_with(session_id, &executor, |_| Ok(()))
901            .unwrap_err();
902
903        assert!(error.to_string().contains("existing recovery archive"));
904        assert_eq!(*executor.calls.borrow(), 0);
905        assert_eq!(
906            controller.state.sessions[session_id].state,
907            SessionState::Running
908        );
909        assert!(controller.state.sessions[session_id].target.is_some());
910    }
911    #[test]
912    fn destroying_retry_blocks_cleanup_when_the_archive_gate_changed() {
913        struct RecordingExecutor {
914            calls: RefCell<usize>,
915        }
916
917        impl CommandExecutor for RecordingExecutor {
918            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
919                *self.calls.borrow_mut() += 1;
920                Ok(CommandOutput {
921                    status: 0,
922                    stdout: Vec::new(),
923                    stderr: Vec::new(),
924                })
925            }
926        }
927
928        let directory = tempfile::tempdir().unwrap();
929        let repository = committed_repository();
930        let session_id = "0123456789abcdef0123456789abcdef";
931        let mut checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
932        checkpoint.event_frontier = 8;
933        let mut session = managed_worktree_session(repository.path(), session_id);
934        let worktree = session.managed_worktree.clone().unwrap();
935        session.target_template_id = "local".into();
936        session.state = SessionState::Destroying;
937        session.target = Some(TargetLocator::LocalBare {
938            worker_root: directory.path().join(session_id),
939        });
940        session.checkpoint = Some(checkpoint.clone());
941        let mut config = HelConfig::default();
942        config
943            .targets
944            .insert("local".into(), TargetTemplate::LocalBare);
945        let mut controller = Controller {
946            config,
947            state: HelState {
948                sessions: BTreeMap::from([(session_id.into(), session)]),
949                ..HelState::default()
950            },
951        };
952        let executor = RecordingExecutor {
953            calls: RefCell::new(0),
954        };
955        let persisted = RefCell::new(Vec::new());
956
957        let error = controller
958            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
959                persisted.borrow_mut().push(record.state);
960                Ok(())
961            })
962            .unwrap_err();
963
964        assert!(error.to_string().contains("checkpoint frontier changed"));
965        assert_eq!(*executor.calls.borrow(), 0);
966        assert!(persisted.into_inner().is_empty());
967        assert!(worktree.worktree_root.is_dir());
968        assert_eq!(
969            controller.state.sessions[session_id].state,
970            SessionState::Destroying
971        );
972    }
973    #[test]
974    fn destroying_retry_finalizes_when_apple_container_is_confirmed_absent() {
975        struct AlreadyRemovedExecutor {
976            commands: RefCell<Vec<CommandSpec>>,
977        }
978
979        impl CommandExecutor for AlreadyRemovedExecutor {
980            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
981                self.commands.borrow_mut().push(command.clone());
982                if command.program == "sh"
983                    && command
984                        .args
985                        .get(1)
986                        .is_some_and(|script| script.contains("container rm --force"))
987                {
988                    Ok(CommandOutput {
989                        status: 1,
990                        stdout: Vec::new(),
991                        stderr: b"container not found".to_vec(),
992                    })
993                } else {
994                    Ok(CommandOutput {
995                        status: 0,
996                        stdout: Vec::new(),
997                        stderr: Vec::new(),
998                    })
999                }
1000            }
1001        }
1002
1003        let directory = tempfile::tempdir().unwrap();
1004        let session_id = "0123456789abcdef0123456789abcdef";
1005        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1006        let mut session = checkpoint_test_session(session_id);
1007        session.target_template_id = "apple".into();
1008        session.state = SessionState::Destroying;
1009        session.target = Some(TargetLocator::AppleContainer {
1010            container_id: hel_targets::resource_name(session_id).unwrap(),
1011        });
1012        session.checkpoint = Some(checkpoint.clone());
1013        let mut config = HelConfig::default();
1014        config.targets.insert(
1015            "apple".into(),
1016            TargetTemplate::AppleContainer {
1017                container: ConfigContainer {
1018                    image: "test:latest".into(),
1019                    pull_policy: Default::default(),
1020                    platform: None,
1021                    cpus: None,
1022                    memory: None,
1023                    environment: BTreeMap::new(),
1024                    workspace_storage: Default::default(),
1025                },
1026            },
1027        );
1028        let mut controller = Controller {
1029            config,
1030            state: HelState {
1031                sessions: BTreeMap::from([(session_id.into(), session)]),
1032                ..HelState::default()
1033            },
1034        };
1035        let executor = AlreadyRemovedExecutor {
1036            commands: RefCell::new(Vec::new()),
1037        };
1038        let persisted = RefCell::new(Vec::new());
1039
1040        controller
1041            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1042                persisted.borrow_mut().push(record.state);
1043                Ok(())
1044            })
1045            .unwrap();
1046
1047        let commands = executor.commands.borrow();
1048        assert_eq!(commands.len(), 2);
1049        assert_eq!(commands[0].program, "sh");
1050        assert!(commands[0].args[1].contains("container rm --force"));
1051        assert!(commands[0].args[1].contains(".cache/mjolnir/git/sessions"));
1052        assert_eq!(commands[1].args, ["list", "--all", "--quiet"]);
1053        assert_eq!(persisted.into_inner(), vec![SessionState::Stopped]);
1054        assert_eq!(
1055            controller.state.sessions[session_id].state,
1056            SessionState::Stopped
1057        );
1058    }
1059    /// Ending a session ends the local Git origin it was serving. Close,
1060    /// force stop, and permanent destruction all retire the broker on purpose:
1061    /// its spec and lock file go, so nothing restarts it against a target
1062    /// that is being torn down, and its log stays for reading afterwards.
1063    #[cfg(unix)]
1064    #[test]
1065    fn every_session_ending_retires_its_local_git_broker() {
1066        const RETIREMENT_TEST_CHILD: &str = "MJ_TEST_BROKER_RETIREMENT_CHILD";
1067
1068        struct SucceedingExecutor;
1069
1070        impl CommandExecutor for SucceedingExecutor {
1071            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1072                Ok(CommandOutput {
1073                    status: 0,
1074                    stdout: Vec::new(),
1075                    stderr: Vec::new(),
1076                })
1077            }
1078        }
1079
1080        // MJ_DATA_DIR is process-global, so run the half that reads it in an
1081        // exact child test instead of racing unrelated tests in this process.
1082        if std::env::var_os(RETIREMENT_TEST_CHILD).is_none() {
1083            let directory = tempfile::tempdir().unwrap();
1084            let test_name = format!(
1085                "{}::every_session_ending_retires_its_local_git_broker",
1086                module_path!()
1087                    .strip_prefix("mj_controller::")
1088                    .unwrap_or(module_path!())
1089            );
1090            let output = std::process::Command::new(std::env::current_exe().unwrap())
1091                .args(["--exact", &test_name, "--nocapture"])
1092                .env(RETIREMENT_TEST_CHILD, "1")
1093                .env("MJ_DATA_DIR", directory.path())
1094                .output()
1095                .unwrap();
1096            let reported = String::from_utf8_lossy(&output.stdout).into_owned();
1097            assert!(
1098                output.status.success(),
1099                "isolated broker retirement test failed\nstdout:\n{reported}\nstderr:\n{}",
1100                String::from_utf8_lossy(&output.stderr)
1101            );
1102            // A filter that matches nothing also exits zero, so insist the
1103            // child really ran this test.
1104            assert!(
1105                reported.contains("1 passed"),
1106                "the isolated broker retirement test never ran\nstdout:\n{reported}"
1107            );
1108            return;
1109        }
1110        // Alone in this child process, so it installs the one writer.
1111        let _writer = hel::hel_database::install_isolated_test_writer();
1112
1113        let brokers = hel::hel_config::data_dir().join("git-brokers");
1114        std::fs::create_dir_all(&brokers).unwrap();
1115        let seed_broker = |session_id: &str| {
1116            for (extension, contents) in [
1117                ("json", "{}"),
1118                // A PID file nobody holds a lock on: this session's broker is
1119                // already stopped, so ending the session only has to clear
1120                // what it would otherwise be restarted from.
1121                ("pid", "424242"),
1122                ("ready", "ready\n"),
1123                ("log", "broker log\n"),
1124            ] {
1125                std::fs::write(brokers.join(format!("{session_id}.{extension}")), contents)
1126                    .unwrap();
1127            }
1128        };
1129        let assert_retired = |session_id: &str| {
1130            for extension in ["json", "pid", "ready"] {
1131                let path = brokers.join(format!("{session_id}.{extension}"));
1132                assert!(!path.exists(), "{} outlived its session", path.display());
1133            }
1134            assert_eq!(
1135                std::fs::read_to_string(brokers.join(format!("{session_id}.log"))).unwrap(),
1136                "broker log\n",
1137                "the broker log must survive its session"
1138            );
1139        };
1140
1141        let directory = tempfile::tempdir().unwrap();
1142        let closing = "0123456789abcdef0123456789abcdef";
1143        let force_stopped = "0123456789abcdef0123456789abcdee";
1144        let destroyed = "0123456789abcdef0123456789abcded";
1145        let checkpoint = write_checkpoint_gate_archive(directory.path(), closing, 7);
1146        let mut closing_session = checkpoint_test_session(closing);
1147        closing_session.target_template_id = "local".into();
1148        closing_session.state = SessionState::Closing;
1149        closing_session.target = Some(TargetLocator::LocalBare {
1150            worker_root: directory.path().join(closing),
1151        });
1152        closing_session.checkpoint = Some(checkpoint.clone());
1153        let force_stop_checkpoint =
1154            write_checkpoint_gate_archive(directory.path(), force_stopped, 7);
1155        let mut force_stopped_session = checkpoint_test_session(force_stopped);
1156        force_stopped_session.target_template_id = "local".into();
1157        force_stopped_session.state = SessionState::Running;
1158        force_stopped_session.target = Some(TargetLocator::LocalBare {
1159            worker_root: directory.path().join(force_stopped),
1160        });
1161        force_stopped_session.checkpoint = Some(force_stop_checkpoint);
1162        let mut destroyed_session = checkpoint_test_session(destroyed);
1163        destroyed_session.target_template_id = "local".into();
1164        destroyed_session.state = SessionState::Stopped;
1165        let mut config = HelConfig::default();
1166        config
1167            .targets
1168            .insert("local".into(), TargetTemplate::LocalBare);
1169        let mut controller = Controller {
1170            config,
1171            state: HelState {
1172                sessions: BTreeMap::from([
1173                    (closing.into(), closing_session),
1174                    (force_stopped.into(), force_stopped_session),
1175                    (destroyed.into(), destroyed_session),
1176                ]),
1177                ..HelState::default()
1178            },
1179        };
1180        for session_id in [closing, force_stopped, destroyed] {
1181            seed_broker(session_id);
1182        }
1183
1184        controller
1185            .destroy_after_verified_checkpoint_with(
1186                closing,
1187                &checkpoint,
1188                &SucceedingExecutor,
1189                |_| Ok(()),
1190            )
1191            .unwrap();
1192        assert_retired(closing);
1193
1194        controller
1195            .force_stop_with(force_stopped, &SucceedingExecutor, |_| Ok(()))
1196            .unwrap();
1197        assert_retired(force_stopped);
1198
1199        controller
1200            .destroy_session_controlled(destroyed, &SucceedingExecutor)
1201            .unwrap();
1202        assert_retired(destroyed);
1203    }
1204    #[test]
1205    fn interrupted_close_error_preserves_destroying_phase() {
1206        let session_id = "0123456789abcdef0123456789abcdef";
1207        let mut session = checkpoint_test_session(session_id);
1208        session.state = SessionState::Destroying;
1209
1210        apply_interrupted_close_error(
1211            &mut session,
1212            &anyhow::anyhow!("podman unavailable"),
1213            "2026-08-14T12:00:00Z",
1214        );
1215
1216        assert_eq!(session.state, SessionState::Destroying);
1217        assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
1218        assert!(
1219            session
1220                .last_error
1221                .as_deref()
1222                .is_some_and(|error| error.contains("cleanup is safely retryable"))
1223        );
1224    }
1225}