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::{
10    self, CommandExecutor, ProcessExecutor, ProvisionStage, ProvisionStageGuard,
11};
12use hel::hel_worker::{RelayCommand, RelayExecutionState};
13
14use super::backend::backend_locator;
15use super::checkpoint::{
16    CheckpointExportPolicy, LatchExclusivity, prune_replaced_checkpoint,
17    release_projection_behind_checkpoint, verify_installed_checkpoint_gate, wait_for_relay_closed,
18};
19use super::worktree::{cleanup_managed_worktree, retire_managed_worktree};
20use super::{Controller, now, persist_session_record_transition_or_restore};
21
22impl Controller {
23    /// Checkpoint, ask the harness to close, and only then tear down the exact
24    /// provisioned target. Checkpoint failure is deliberately non-destructive.
25    pub async fn close_session(&mut self, session_id: &str) -> Result<()> {
26        self.close_session_controlled(session_id, &ProcessExecutor)
27            .await
28    }
29
30    pub async fn close_session_controlled(
31        &mut self,
32        session_id: &str,
33        executor: &(impl CommandExecutor + Sync),
34    ) -> Result<()> {
35        if self
36            .close_session_controlled_with_manager(session_id, executor, None, None)
37            .await?
38        {
39            self.cleanup_stopped_target(session_id, executor)?;
40        }
41        Ok(())
42    }
43
44    pub async fn close_session_managed_controlled(
45        &mut self,
46        session_id: &str,
47        executor: &(impl CommandExecutor + Sync),
48        manager: &SessionManagerControl,
49    ) -> Result<bool> {
50        self.close_session_controlled_with_manager(session_id, executor, Some(manager), None)
51            .await
52    }
53
54    pub(super) async fn close_session_for_move(
55        &mut self,
56        session_id: &str,
57        executor: &(impl CommandExecutor + Sync),
58        manager: &SessionManagerControl,
59        operation: &mut hel::hel_state::MoveOperation,
60        preparation: Option<&hel::hel_state::MovePreparation>,
61    ) -> Result<bool> {
62        self.close_session_controlled_with_manager(
63            session_id,
64            executor,
65            Some(manager),
66            Some((operation, preparation)),
67        )
68        .await
69    }
70
71    async fn close_session_controlled_with_manager(
72        &mut self,
73        session_id: &str,
74        executor: &(impl CommandExecutor + Sync),
75        manager: Option<&SessionManagerControl>,
76        move_intent: Option<(
77            &mut hel::hel_state::MoveOperation,
78            Option<&hel::hel_state::MovePreparation>,
79        )>,
80    ) -> Result<bool> {
81        let previous = self
82            .state
83            .sessions
84            .get(session_id)
85            .with_context(|| format!("unknown session {session_id}"))?
86            .clone();
87        let record = self.state.sessions.get_mut(session_id).unwrap();
88        // Persist the close intent before beginning its checkpoint. A process
89        // exit anywhere below must leave enough state for the next controller
90        // to retry the close, even when no checkpoint has been installed yet.
91        apply_close_checkpoint_started(record, now());
92        self.persist_session_transition_or_restore(
93            session_id,
94            &previous,
95            "persist closing state before checkpointing the session",
96        )?;
97
98        // Close seals the relay at the exact latched cursor, so this checkpoint
99        // keeps its exclusive connection until the relay reports Closed.
100        let mut latched = match self
101            .checkpoint_session_latched(
102                session_id,
103                executor,
104                manager,
105                LatchExclusivity::HoldThroughClose,
106                CheckpointExportPolicy::ReuseUnchangedArchive,
107            )
108            .await
109        {
110            Ok(latched) => latched,
111            Err(error) => {
112                let record = self.state.sessions.get_mut(session_id).unwrap();
113                record.state = previous.state;
114                record.updated_at = now();
115                record.last_checkpoint_error = Some(format!("{error:#}"));
116                return Err(
117                    self.persist_failed_checkpoint_state_or_restore(session_id, &previous, error)
118                );
119            }
120        };
121
122        let artifact = latched.artifact.clone();
123        let record = self.state.sessions.get_mut(session_id).unwrap();
124        record.state = SessionState::Closing;
125        record.native_session_id = Some(artifact.native_session_id.clone());
126        record.checkpoint = Some(artifact.metadata.clone());
127        record.updated_at = now();
128        record.last_error = None;
129        record.last_checkpoint_error = None;
130        self.persist_checkpoint_transition_or_restore(
131            session_id,
132            &previous,
133            "persist verified checkpoint and closing state before sealing the relay",
134        )?;
135        if let Some((operation, preparation)) = move_intent {
136            // The source is still behind an unsealed barrier. A destination
137            // preflight error must release it and leave its processes alive.
138            if let Err(error) = self.validate_move_checkpoint(operation, preparation, executor) {
139                let record = self.state.sessions.get_mut(session_id).unwrap();
140                record.state = previous.state;
141                record.last_error = Some(format!("{error:#}"));
142                self.persist_session_transition_or_restore(
143                    session_id,
144                    &previous,
145                    "restore source after move preflight failure",
146                )?;
147                return Err(error);
148            }
149            operation.checkpoint = Some(artifact.metadata.clone());
150            operation.updated_at = now();
151            hel::hel_database::save_move_operation(operation)?;
152        }
153        prune_replaced_checkpoint(previous.checkpoint.as_ref(), &artifact.metadata);
154        // A stopping session will not checkpoint again, so this is its last
155        // chance to release what its checkpoint now covers.
156        release_projection_behind_checkpoint(session_id, &artifact.metadata);
157
158        let close_command_id = new_command_id("close")?;
159        let barrier_command_id = latched.barrier_command_id.clone();
160        let close_result = {
161            let _closing = ProvisionStageGuard::new(executor, ProvisionStage::Closing);
162            latched
163                .relay
164                .connection_mut()
165                .submit(
166                    close_command_id,
167                    RelayCommand::Close {
168                        barrier_command_id: barrier_command_id.clone(),
169                        expected: latched.cursor.clone(),
170                    },
171                )
172                .await
173        };
174        if let Err(error) = close_result {
175            self.record_interrupted_close(session_id, &error)?;
176            return Err(error.context("seal verified checkpoint for close"));
177        }
178        let close_result = {
179            let _closing = ProvisionStageGuard::new(executor, ProvisionStage::Closing);
180            latched
181                .relay
182                .connection_mut()
183                .submit(
184                    new_command_id("checkpoint-complete")?,
185                    RelayCommand::CompleteCheckpoint { barrier_command_id },
186                )
187                .await
188        };
189        if let Err(error) = close_result {
190            self.record_interrupted_close(session_id, &error)?;
191            return Err(error.context("release verified close checkpoint"));
192        }
193        let close_result = {
194            let _closing = ProvisionStageGuard::new(executor, ProvisionStage::Closing);
195            wait_for_relay_closed(latched.relay.connection_mut()).await
196        };
197        if let Err(error) = close_result {
198            self.record_interrupted_close(session_id, &error)?;
199            return Err(error);
200        }
201        latched.relay.release();
202
203        match self.destroy_after_verified_checkpoint(session_id, &artifact.metadata, executor) {
204            Ok(deferred) => Ok(deferred),
205            Err(error) => {
206                self.record_interrupted_close(session_id, &error)?;
207                Err(error)
208            }
209        }
210    }
211
212    /// Resume the durable closing state after a controller restart. If the
213    /// relay had accepted Close, wait for it and destroy through the exact
214    /// installed checkpoint gate. If it had not, take a fresh checkpoint;
215    /// the previously installed archive may have become stale after EOF
216    /// released its barrier.
217    pub async fn recover_interrupted_close_managed(
218        &mut self,
219        session_id: &str,
220        executor: &(impl CommandExecutor + Sync),
221        manager: &SessionManagerControl,
222    ) -> Result<bool> {
223        let (state, verified) = {
224            let session = self
225                .state
226                .sessions
227                .get(session_id)
228                .with_context(|| format!("unknown session {session_id}"))?;
229            ensure!(
230                matches!(
231                    session.state,
232                    SessionState::Closing | SessionState::Destroying
233                ),
234                "session {session_id} has no interrupted close to recover"
235            );
236            (session.state, session.checkpoint.clone())
237        };
238        if state == SessionState::Destroying {
239            let verified = verified.context("destroying session has no verified checkpoint")?;
240            return self.destroy_after_verified_checkpoint(session_id, &verified, executor);
241        }
242        ensure!(
243            state == SessionState::Closing,
244            "session {session_id} has no relay close to recover"
245        );
246        let handle = manager
247            .wait_for_session(session_id, Duration::from_secs(5))
248            .await?;
249        let mut lease = handle.lease_connection().await?;
250        let execution = lease.connection_mut().sync().await?.operational.execution;
251        match execution {
252            RelayExecutionState::Closed => {}
253            RelayExecutionState::Closing => {
254                let _closing = ProvisionStageGuard::new(executor, ProvisionStage::Closing);
255                wait_for_relay_closed(lease.connection_mut()).await?;
256            }
257            RelayExecutionState::Idle | RelayExecutionState::Running => {
258                lease.release();
259                return self
260                    .close_session_controlled_with_manager(
261                        session_id,
262                        executor,
263                        Some(manager),
264                        None,
265                    )
266                    .await;
267            }
268        }
269        lease.release();
270        let verified = verified.context("closed relay has no verified checkpoint")?;
271        self.destroy_after_verified_checkpoint(session_id, &verified, executor)
272    }
273
274    fn record_interrupted_close(&mut self, session_id: &str, error: &anyhow::Error) -> Result<()> {
275        let record = self.state.sessions.get_mut(session_id).unwrap();
276        apply_interrupted_close_error(record, error, &now());
277        self.persist_session_state(session_id)
278    }
279
280    /// Execute cleanup only after the close state machine has installed a
281    /// verified checkpoint on the record.
282    fn destroy_after_verified_checkpoint(
283        &mut self,
284        session_id: &str,
285        verified: &CheckpointMetadata,
286        executor: &impl CommandExecutor,
287    ) -> Result<bool> {
288        self.destroy_after_verified_checkpoint_with(
289            session_id,
290            verified,
291            executor,
292            hel::hel_database::save_lifecycle_session,
293        )
294    }
295
296    fn destroy_after_verified_checkpoint_with(
297        &mut self,
298        session_id: &str,
299        verified: &CheckpointMetadata,
300        executor: &impl CommandExecutor,
301        persist: impl Fn(&SessionRecord) -> Result<()>,
302    ) -> Result<bool> {
303        let session = self
304            .state
305            .sessions
306            .get(session_id)
307            .with_context(|| format!("unknown session {session_id}"))?
308            .clone();
309        ensure!(
310            matches!(
311                session.state,
312                SessionState::Closing | SessionState::Destroying
313            ),
314            "refusing to destroy session {session_id}: it is not closing or destroying"
315        );
316        ensure!(
317            session.checkpoint.as_ref() == Some(verified),
318            "refusing to destroy session {session_id}: verified checkpoint gate is stale"
319        );
320        if session.state == SessionState::Closing {
321            let record = self.state.sessions.get_mut(session_id).unwrap();
322            record.state = SessionState::Destroying;
323            record.updated_at = now();
324            record.last_error = None;
325            persist_session_record_transition_or_restore(
326                &mut self.state,
327                session_id,
328                &session,
329                "persist destroying state before target cleanup",
330                &persist,
331            )?;
332        }
333
334        let destroying = self
335            .state
336            .sessions
337            .get(session_id)
338            .expect("destroying session disappeared")
339            .clone();
340        {
341            let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
342            verify_installed_checkpoint_gate(session_id, verified)?;
343        }
344        // The reviewer's native session lives on the target that is about to
345        // go. Recording that now, before the target is torn down, is what
346        // stops a resumed session from trying to reload a conversation that no
347        // longer exists; its transcript is kept for reference either way.
348        if let Err(error) = hel::hel_database::lose_reviewer_continuity(session_id) {
349            tracing::warn!(
350                session_id,
351                error = format!("{error:#}"),
352                "could not record that the second-opinion conversation ends with this target"
353            );
354        }
355        let locator = destroying
356            .target
357            .as_ref()
358            .context("session has no target")?;
359        let backend = backend_locator(locator, &destroying, &self.config)?;
360        let deferred = if let Some(plan) = hel_targets::quiesce_plan(&backend, session_id)? {
361            plan.execute(executor)?;
362            true
363        } else {
364            execute_target_cleanup(&backend, session_id, executor)?;
365            false
366        };
367        if let Some(worktree) = &destroying.managed_worktree {
368            retire_managed_worktree(executor, worktree)
369                .context("retire managed raw-session worktree after verified close")?;
370        }
371        let record = self.state.sessions.get_mut(session_id).unwrap();
372        record.state = SessionState::Stopped;
373        if !deferred {
374            record.target = None;
375        }
376        record.updated_at = now();
377        record.last_error = None;
378        persist_session_record_transition_or_restore(
379            &mut self.state,
380            session_id,
381            &destroying,
382            "persist stopped state after target cleanup",
383            &persist,
384        )?;
385        Ok(deferred)
386    }
387
388    /// Finish storage cleanup for a stopped Podman target retained by the
389    /// quiescence transition. The locator stays durable until every command
390    /// succeeds, making daemon restart and explicit retry idempotent.
391    pub fn cleanup_stopped_target(
392        &mut self,
393        session_id: &str,
394        executor: &impl CommandExecutor,
395    ) -> Result<()> {
396        self.cleanup_stopped_target_with(
397            session_id,
398            executor,
399            hel::hel_database::save_lifecycle_session,
400        )
401    }
402
403    fn cleanup_stopped_target_with(
404        &mut self,
405        session_id: &str,
406        executor: &impl CommandExecutor,
407        persist: impl Fn(&SessionRecord) -> Result<()>,
408    ) -> Result<()> {
409        let previous = self
410            .state
411            .sessions
412            .get(session_id)
413            .with_context(|| format!("unknown session {session_id}"))?
414            .clone();
415        ensure!(
416            previous.state == SessionState::Stopped,
417            "refusing deferred cleanup for active session {session_id}"
418        );
419        let Some(locator) = previous.target.as_ref() else {
420            return Ok(());
421        };
422        let backend = backend_locator(locator, &previous, &self.config)?;
423        ensure!(
424            hel_targets::quiesce_plan(&backend, session_id)?.is_some(),
425            "session {session_id} retained a non-Podman target after stopping"
426        );
427        if let Err(error) = execute_target_cleanup(&backend, session_id, executor) {
428            let record = self.state.sessions.get_mut(session_id).unwrap();
429            record.updated_at = now();
430            record.last_error = Some(format!("deferred target cleanup failed: {error:#}"));
431            let persisted = persist_session_record_transition_or_restore(
432                &mut self.state,
433                session_id,
434                &previous,
435                "persist deferred target cleanup failure",
436                &persist,
437            );
438            return match persisted {
439                Ok(()) => Err(error),
440                Err(persist_error) => Err(error.context(format!(
441                    "also failed to persist deferred target cleanup failure: {persist_error:#}"
442                ))),
443            };
444        }
445        let record = self.state.sessions.get_mut(session_id).unwrap();
446        record.target = None;
447        record.updated_at = now();
448        record.last_error = None;
449        persist_session_record_transition_or_restore(
450            &mut self.state,
451            session_id,
452            &previous,
453            "persist completion of deferred Podman target cleanup",
454            &persist,
455        )
456    }
457
458    /// Tear down the current target without taking a fresh checkpoint, then
459    /// leave the logical session resumable from its latest verified archive.
460    pub fn force_stop(
461        &mut self,
462        session_id: &str,
463        executor: &impl CommandExecutor,
464    ) -> Result<bool> {
465        self.force_stop_with(
466            session_id,
467            executor,
468            hel::hel_database::save_lifecycle_session,
469        )
470    }
471
472    fn force_stop_with(
473        &mut self,
474        session_id: &str,
475        executor: &impl CommandExecutor,
476        persist: impl Fn(&SessionRecord) -> Result<()>,
477    ) -> Result<bool> {
478        let session = self
479            .state
480            .sessions
481            .get(session_id)
482            .with_context(|| format!("unknown session {session_id}"))?
483            .clone();
484        ensure!(
485            session.state.is_active(),
486            "session {session_id} is already inactive"
487        );
488        let checkpoint = session
489            .checkpoint
490            .as_ref()
491            .context("force stop requires an existing recovery archive")?;
492        // Force stop skips a new checkpoint, never the checksum gate on the
493        // archive that makes the logical session resumable afterwards.
494        {
495            let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
496            verify_installed_checkpoint_gate(session_id, checkpoint)
497                .context("verify the recovery archive before force stopping")?;
498        }
499        let mut deferred = false;
500        if let Some(locator) = &session.target {
501            let backend = backend_locator(locator, &session, &self.config)?;
502            if let Some(plan) = hel_targets::quiesce_plan(&backend, session_id)? {
503                plan.execute(executor)?;
504                deferred = true;
505            } else {
506                execute_target_cleanup(&backend, session_id, executor)?;
507            }
508        }
509        if let Some(worktree) = &session.managed_worktree {
510            retire_managed_worktree(executor, worktree)
511                .context("retire managed raw-session worktree after force stop")?;
512        }
513        let record = self.state.sessions.get_mut(session_id).unwrap();
514        record.state = SessionState::Stopped;
515        if !deferred {
516            record.target = None;
517        }
518        record.updated_at = now();
519        record.last_error = None;
520        record.last_checkpoint_error = None;
521        persist_session_record_transition_or_restore(
522            &mut self.state,
523            session_id,
524            &session,
525            "persist stopped state after force stopping the current target",
526            &persist,
527        )?;
528        Ok(deferred)
529    }
530
531    /// Permanently destroy an inactive session and every artifact Hel owns for it.
532    /// External cleanup happens before the durable record is dropped so failures
533    /// remain visible and retryable.
534    pub fn destroy_session_controlled(
535        &mut self,
536        session_id: &str,
537        executor: &impl CommandExecutor,
538    ) -> Result<()> {
539        let session = self
540            .state
541            .sessions
542            .get(session_id)
543            .with_context(|| format!("unknown session {session_id}"))?
544            .clone();
545        if session.state.is_active() {
546            bail!("refusing to destroy active session {session_id}");
547        }
548        if let Some(worktree) = &session.managed_worktree {
549            cleanup_managed_worktree(executor, worktree)
550                .context("remove managed raw-session worktree")?;
551        }
552        if let Some(checkpoint) = &session.checkpoint
553            && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
554            && error.kind() != std::io::ErrorKind::NotFound
555        {
556            return Err(error).with_context(|| {
557                format!(
558                    "remove session recovery archive {}",
559                    checkpoint.archive_path.display()
560                )
561            });
562        }
563        hel::hel_attachment::AttachmentStore::controller(session_id)?
564            .remove_session_data()
565            .context("remove session image attachments")?;
566        hel::hel_database::delete_session(session_id)
567            .context("destroy stopped session in database")?;
568        self.state.destroy_stopped_session(session_id)?;
569        Ok(())
570    }
571
572    /// Permanently destroy a session from any state, without checkpointing
573    /// and without requiring a recovery archive.
574    ///
575    /// Unlike [`Controller::destroy_session_controlled`], this accepts active
576    /// states: it tears the live target down with the same close plan a
577    /// verified close uses, so the owning process group dies before any files
578    /// go. External cleanup happens before the durable record is dropped so
579    /// failures stay visible and retryable; the recovery archive is removed,
580    /// which is what makes the destruction irreversible.
581    pub fn force_destroy_session(
582        &mut self,
583        session_id: &str,
584        executor: &impl CommandExecutor,
585    ) -> Result<()> {
586        self.force_destroy_session_with(session_id, executor, hel::hel_database::delete_session)
587    }
588
589    fn force_destroy_session_with(
590        &mut self,
591        session_id: &str,
592        executor: &impl CommandExecutor,
593        delete: impl Fn(&str) -> Result<()>,
594    ) -> Result<()> {
595        let session = self
596            .state
597            .sessions
598            .get(session_id)
599            .with_context(|| format!("unknown session {session_id}"))?
600            .clone();
601        // A session destroyed for good keeps nothing, including a broker an
602        // earlier failure left running; retiring it first also stops a live
603        // writer from recreating files under the teardown below.
604        if let Some(locator) = &session.target {
605            let backend = backend_locator(locator, &session, &self.config)?;
606            execute_target_cleanup(&backend, session_id, executor)?;
607        }
608        if let Some(worktree) = &session.managed_worktree {
609            cleanup_managed_worktree(executor, worktree)
610                .context("remove managed raw-session worktree")?;
611        }
612        if let Some(checkpoint) = &session.checkpoint
613            && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
614            && error.kind() != std::io::ErrorKind::NotFound
615        {
616            return Err(error).with_context(|| {
617                format!(
618                    "remove session recovery archive {}",
619                    checkpoint.archive_path.display()
620                )
621            });
622        }
623        hel::hel_attachment::AttachmentStore::controller(session_id)?
624            .remove_session_data()
625            .context("remove session image attachments")?;
626        delete(session_id).context("force destroy session in database")?;
627        self.state.destroy_session_force(session_id)?;
628        Ok(())
629    }
630}
631
632fn execute_target_cleanup(
633    backend: &hel_targets::TargetLocator,
634    session_id: &str,
635    executor: &impl CommandExecutor,
636) -> Result<()> {
637    if let Err(cleanup_error) = hel_targets::close_plan(backend, session_id)?.execute(executor) {
638        match hel_targets::cleanup_target_is_confirmed_absent(backend, session_id, executor) {
639            Ok(true) => {
640                tracing::warn!(
641                    session_id,
642                    error = format!("{cleanup_error:#}"),
643                    "target cleanup command failed, but the target was confirmed absent"
644                );
645            }
646            Ok(false) => {
647                tracing::error!(
648                    session_id,
649                    error = format!("{cleanup_error:#}"),
650                    "target cleanup failed and the target is still present"
651                );
652                return Err(cleanup_error);
653            }
654            Err(probe_error) => {
655                tracing::error!(
656                    session_id,
657                    cleanup_error = format!("{cleanup_error:#}"),
658                    probe_error = format!("{probe_error:#}"),
659                    "target cleanup failed and exact absence could not be confirmed"
660                );
661                return Err(cleanup_error.context(format!(
662                    "target cleanup failed and exact absence could not be confirmed: {probe_error:#}"
663                )));
664            }
665        }
666    }
667    Ok(())
668}
669
670fn apply_close_checkpoint_started(record: &mut SessionRecord, updated_at: String) {
671    record.state = SessionState::Closing;
672    record.updated_at = updated_at;
673    record.last_checkpoint_error = None;
674}
675
676fn apply_interrupted_close_error(
677    record: &mut SessionRecord,
678    error: &anyhow::Error,
679    updated_at: &str,
680) {
681    let destroying = record.state == SessionState::Destroying;
682    if !destroying {
683        record.state = SessionState::Closing;
684    }
685    record.updated_at = updated_at.to_owned();
686    record.last_error = Some(if destroying {
687        format!("target cleanup is safely retryable from its verified checkpoint: {error:#}")
688    } else {
689        format!("close is safely resumable from its verified checkpoint: {error:#}")
690    });
691}
692
693#[cfg(test)]
694mod tests {
695    use std::cell::RefCell;
696    use std::collections::BTreeMap;
697
698    use anyhow::Result;
699
700    use crate::hel_controller::Controller;
701    use crate::hel_controller::test_support::{
702        checkpoint_test_session, committed_repository, managed_worktree_session, test_git,
703        write_checkpoint_gate_archive,
704    };
705    use hel::hel_config::{ContainerTemplate as ConfigContainer, HelConfig, TargetTemplate};
706    use hel::hel_state::{HelState, SessionState, TargetLocator};
707    use hel::hel_targets::{self, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
708
709    use super::*;
710
711    #[test]
712    fn starting_close_persists_its_intent_before_checkpointing() {
713        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
714        session.state = SessionState::Running;
715        session.last_checkpoint_error = Some("old failure".into());
716
717        apply_close_checkpoint_started(&mut session, "2026-08-14T12:00:00Z".into());
718
719        assert_eq!(session.state, SessionState::Closing);
720        assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
721        assert!(session.last_checkpoint_error.is_none());
722    }
723
724    struct DeferredCleanupExecutor {
725        statuses: RefCell<Vec<i32>>,
726    }
727
728    impl CommandExecutor for DeferredCleanupExecutor {
729        fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
730            let status = self
731                .statuses
732                .borrow_mut()
733                .pop()
734                .expect("test cleanup command status");
735            Ok(CommandOutput {
736                status,
737                stdout: Vec::new(),
738                stderr: if status != 0 {
739                    b"cleanup failed".to_vec()
740                } else {
741                    Vec::new()
742                },
743            })
744        }
745    }
746
747    fn stopped_podman_cleanup_controller(session_id: &str) -> Controller {
748        let container_id = hel_targets::resource_name(session_id).unwrap();
749        let volume = format!("{container_id}-workspace");
750        let mut session = checkpoint_test_session(session_id);
751        session.target_template_id = "podman".into();
752        session.state = SessionState::Stopped;
753        session.target = Some(TargetLocator::LocalPodman {
754            container_id,
755            workspace_storage: hel::hel_state::PodmanWorkspaceLocator::Volume { name: volume },
756        });
757        let mut config = HelConfig::default();
758        config.targets.insert(
759            "podman".into(),
760            TargetTemplate::LocalPodman {
761                container: ConfigContainer {
762                    image: "test:latest".into(),
763                    pull_policy: Default::default(),
764                    platform: None,
765                    cpus: None,
766                    memory: None,
767                    environment: BTreeMap::new(),
768                    workspace_storage: hel::hel_config::PodmanWorkspaceStorage::PodmanVolume,
769                },
770            },
771        );
772        Controller {
773            config,
774            state: HelState {
775                sessions: BTreeMap::from([(session_id.into(), session)]),
776                ..HelState::default()
777            },
778        }
779    }
780
781    #[test]
782    fn deferred_cleanup_failure_is_visible_and_successful_retry_clears_it() {
783        let session_id = "0123456789abcdef0123456789abcdef";
784        let mut controller = stopped_podman_cleanup_controller(session_id);
785        let persisted = RefCell::new(Vec::new());
786        let failure = controller
787            .cleanup_stopped_target_with(
788                session_id,
789                &DeferredCleanupExecutor {
790                    // The cleanup command fails, then the exact-absence probe
791                    // confirms that the owned target is still present.
792                    statuses: RefCell::new(vec![1, 1]),
793                },
794                |record| {
795                    persisted
796                        .borrow_mut()
797                        .push((record.target.is_some(), record.last_error.clone()));
798                    Ok(())
799                },
800            )
801            .unwrap_err();
802        assert!(format!("{failure:#}").contains("cleanup failed"));
803        assert_eq!(persisted.borrow().len(), 1);
804        assert!(persisted.borrow()[0].0);
805        assert!(
806            persisted.borrow()[0]
807                .1
808                .as_deref()
809                .is_some_and(|error| error.contains("deferred target cleanup failed"))
810        );
811        assert!(controller.state.sessions[session_id].target.is_some());
812        assert!(controller.state.sessions[session_id].last_error.is_some());
813
814        let retry_persisted = RefCell::new(Vec::new());
815        controller
816            .cleanup_stopped_target_with(
817                session_id,
818                &DeferredCleanupExecutor {
819                    statuses: RefCell::new(vec![0, 0, 0]),
820                },
821                |record| {
822                    retry_persisted
823                        .borrow_mut()
824                        .push((record.target.is_some(), record.last_error.clone()));
825                    Ok(())
826                },
827            )
828            .unwrap();
829        assert_eq!(retry_persisted.borrow().as_slice(), &[(false, None)]);
830        assert!(controller.state.sessions[session_id].target.is_none());
831        assert!(controller.state.sessions[session_id].last_error.is_none());
832    }
833
834    #[test]
835    fn deferred_cleanup_persistence_failure_restores_the_stopped_record() {
836        let session_id = "0123456789abcdef0123456789abcdef";
837        let mut controller = stopped_podman_cleanup_controller(session_id);
838        let previous = controller.state.sessions[session_id].clone();
839        let failure = controller
840            .cleanup_stopped_target_with(
841                session_id,
842                &DeferredCleanupExecutor {
843                    statuses: RefCell::new(vec![1, 1]),
844                },
845                |_| Err(anyhow::anyhow!("database unavailable")),
846            )
847            .unwrap_err();
848        let detail = format!("{failure:#}");
849        assert!(detail.contains("cleanup failed"), "{detail}");
850        assert!(detail.contains("database unavailable"), "{detail}");
851        assert_eq!(controller.state.sessions[session_id], previous);
852    }
853
854    #[test]
855    fn target_cleanup_persists_destroying_and_rechecks_the_installed_archive() {
856        struct RecordingExecutor {
857            commands: RefCell<Vec<CommandSpec>>,
858        }
859
860        impl CommandExecutor for RecordingExecutor {
861            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
862                self.commands.borrow_mut().push(command.clone());
863                Ok(CommandOutput {
864                    status: 0,
865                    stdout: Vec::new(),
866                    stderr: Vec::new(),
867                })
868            }
869        }
870
871        let directory = tempfile::tempdir().unwrap();
872        let session_id = "0123456789abcdef0123456789abcdef";
873        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
874        let mut session = checkpoint_test_session(session_id);
875        session.target_template_id = "local".into();
876        session.state = SessionState::Closing;
877        session.target = Some(TargetLocator::LocalBare {
878            worker_root: directory.path().join(session_id),
879        });
880        session.checkpoint = Some(checkpoint.clone());
881        let mut config = HelConfig::default();
882        config
883            .targets
884            .insert("local".into(), TargetTemplate::LocalBare);
885        let mut controller = Controller {
886            config,
887            state: HelState {
888                sessions: BTreeMap::from([(session_id.into(), session)]),
889                ..HelState::default()
890            },
891        };
892        let executor = RecordingExecutor {
893            commands: RefCell::new(Vec::new()),
894        };
895        let persisted = RefCell::new(Vec::new());
896
897        controller
898            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
899                persisted.borrow_mut().push(record.state);
900                Ok(())
901            })
902            .unwrap();
903
904        assert_eq!(
905            persisted.into_inner(),
906            vec![SessionState::Destroying, SessionState::Stopped]
907        );
908        assert_eq!(executor.commands.borrow().len(), 1);
909        let stopped = &controller.state.sessions[session_id];
910        assert_eq!(stopped.state, SessionState::Stopped);
911        assert!(stopped.target.is_none());
912    }
913
914    #[test]
915    fn podman_close_persists_stopped_before_deferred_storage_cleanup() {
916        struct RecordingExecutor {
917            commands: RefCell<Vec<CommandSpec>>,
918        }
919
920        impl CommandExecutor for RecordingExecutor {
921            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
922                self.commands.borrow_mut().push(command.clone());
923                Ok(CommandOutput {
924                    status: 0,
925                    stdout: Vec::new(),
926                    stderr: Vec::new(),
927                })
928            }
929        }
930
931        let directory = tempfile::tempdir().unwrap();
932        let session_id = "0123456789abcdef0123456789abcdef";
933        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
934        let container_id = hel_targets::resource_name(session_id).unwrap();
935        let volume = format!("{container_id}-workspace");
936        let mut session = checkpoint_test_session(session_id);
937        session.target_template_id = "podman".into();
938        session.state = SessionState::Closing;
939        session.target = Some(TargetLocator::LocalPodman {
940            container_id,
941            workspace_storage: hel::hel_state::PodmanWorkspaceLocator::Volume {
942                name: volume.clone(),
943            },
944        });
945        session.checkpoint = Some(checkpoint.clone());
946        let mut config = HelConfig::default();
947        config.targets.insert(
948            "podman".into(),
949            TargetTemplate::LocalPodman {
950                container: ConfigContainer {
951                    image: "test:latest".into(),
952                    pull_policy: Default::default(),
953                    platform: None,
954                    cpus: None,
955                    memory: None,
956                    environment: BTreeMap::new(),
957                    workspace_storage: hel::hel_config::PodmanWorkspaceStorage::PodmanVolume,
958                },
959            },
960        );
961        let mut controller = Controller {
962            config,
963            state: HelState {
964                sessions: BTreeMap::from([(session_id.into(), session)]),
965                ..HelState::default()
966            },
967        };
968        let executor = RecordingExecutor {
969            commands: RefCell::new(Vec::new()),
970        };
971        let persisted = RefCell::new(Vec::new());
972
973        let deferred = controller
974            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
975                persisted
976                    .borrow_mut()
977                    .push((record.state, record.target.is_some()));
978                Ok(())
979            })
980            .unwrap();
981
982        assert!(deferred);
983        assert_eq!(
984            persisted.borrow().as_slice(),
985            &[
986                (SessionState::Destroying, true),
987                (SessionState::Stopped, true)
988            ]
989        );
990        let commands = executor.commands.borrow();
991        assert_eq!(commands.len(), 1);
992        assert!(commands[0].args[1].contains("podman stop --time 0"));
993        assert!(!commands[0].args[1].contains("podman rm"));
994        drop(commands);
995
996        controller
997            .cleanup_stopped_target_with(session_id, &executor, |record| {
998                assert_eq!(record.state, SessionState::Stopped);
999                assert!(record.target.is_none());
1000                Ok(())
1001            })
1002            .unwrap();
1003
1004        let commands = executor.commands.borrow();
1005        assert_eq!(commands.len(), 4);
1006        assert_eq!(
1007            commands[1].stage,
1008            Some(hel_targets::ProvisionStage::RemovingContainer)
1009        );
1010        assert_eq!(
1011            commands[2].stage,
1012            Some(hel_targets::ProvisionStage::RemovingStorage)
1013        );
1014        assert_eq!(
1015            commands[3].stage,
1016            Some(hel_targets::ProvisionStage::CleaningCache)
1017        );
1018        assert!(commands[2].args.contains(&volume));
1019        assert!(controller.state.sessions[session_id].target.is_none());
1020    }
1021    #[test]
1022    fn verified_close_retires_managed_checkout_but_keeps_archive_and_branch() {
1023        let archive_directory = tempfile::tempdir().unwrap();
1024        let repository = committed_repository();
1025        let session_id = "0123456789abcdef0123456789abcdef";
1026        let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
1027        let mut session = managed_worktree_session(repository.path(), session_id);
1028        let worktree = session.managed_worktree.clone().unwrap();
1029        std::fs::write(worktree.worktree_root.join("dirty.txt"), "worktree state\n").unwrap();
1030        session.state = SessionState::Closing;
1031        session.target = Some(TargetLocator::LocalBare {
1032            worker_root: archive_directory.path().join(session_id),
1033        });
1034        session.checkpoint = Some(checkpoint.clone());
1035        let mut config = HelConfig::default();
1036        config
1037            .targets
1038            .insert("local-bare".into(), TargetTemplate::LocalBare);
1039        let mut controller = Controller {
1040            config,
1041            state: HelState {
1042                sessions: BTreeMap::from([(session_id.into(), session)]),
1043                ..HelState::default()
1044            },
1045        };
1046
1047        controller
1048            .destroy_after_verified_checkpoint_with(
1049                session_id,
1050                &checkpoint,
1051                &ProcessExecutor,
1052                |_| Ok(()),
1053            )
1054            .unwrap();
1055
1056        assert!(!worktree.worktree_root.exists());
1057        assert!(checkpoint.archive_path.is_file());
1058        assert_eq!(
1059            test_git(
1060                repository.path(),
1061                &[
1062                    "show-ref",
1063                    "--hash",
1064                    &format!("refs/heads/{}", worktree.branch),
1065                ],
1066            )
1067            .len(),
1068            40
1069        );
1070        assert_eq!(
1071            controller.state.sessions[session_id].state,
1072            SessionState::Stopped
1073        );
1074    }
1075    #[test]
1076    fn force_stop_reuses_verified_archive_and_leaves_session_resumable() {
1077        let archive_directory = tempfile::tempdir().unwrap();
1078        let repository = committed_repository();
1079        let session_id = "0123456789abcdef0123456789abcdef";
1080        let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
1081        let mut session = managed_worktree_session(repository.path(), session_id);
1082        let worktree = session.managed_worktree.clone().unwrap();
1083        session.state = SessionState::Running;
1084        session.target = Some(TargetLocator::LocalBare {
1085            worker_root: archive_directory.path().join(session_id),
1086        });
1087        session.checkpoint = Some(checkpoint.clone());
1088        let mut config = HelConfig::default();
1089        config
1090            .targets
1091            .insert("local-bare".into(), TargetTemplate::LocalBare);
1092        let mut controller = Controller {
1093            config,
1094            state: HelState {
1095                sessions: BTreeMap::from([(session_id.into(), session)]),
1096                ..HelState::default()
1097            },
1098        };
1099
1100        controller
1101            .force_stop_with(session_id, &ProcessExecutor, |_| Ok(()))
1102            .unwrap();
1103
1104        let stopped = &controller.state.sessions[session_id];
1105        assert_eq!(stopped.state, SessionState::Stopped);
1106        assert!(stopped.target.is_none());
1107        assert_eq!(stopped.checkpoint.as_ref(), Some(&checkpoint));
1108        assert!(checkpoint.archive_path.is_file());
1109        assert!(!worktree.worktree_root.exists());
1110        assert!(
1111            !test_git(
1112                repository.path(),
1113                &[
1114                    "show-ref",
1115                    "--hash",
1116                    &format!("refs/heads/{}", worktree.branch),
1117                ],
1118            )
1119            .is_empty()
1120        );
1121    }
1122    #[test]
1123    fn force_stop_without_a_recovery_archive_does_not_touch_the_target() {
1124        struct RecordingExecutor {
1125            calls: RefCell<usize>,
1126        }
1127
1128        impl CommandExecutor for RecordingExecutor {
1129            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1130                *self.calls.borrow_mut() += 1;
1131                Ok(CommandOutput {
1132                    status: 0,
1133                    stdout: Vec::new(),
1134                    stderr: Vec::new(),
1135                })
1136            }
1137        }
1138
1139        let directory = tempfile::tempdir().unwrap();
1140        let session_id = "0123456789abcdef0123456789abcdef";
1141        let mut session = checkpoint_test_session(session_id);
1142        session.state = SessionState::Running;
1143        session.checkpoint = None;
1144        session.target_template_id = "local".into();
1145        session.target = Some(TargetLocator::LocalBare {
1146            worker_root: directory.path().join(session_id),
1147        });
1148        let mut config = HelConfig::default();
1149        config
1150            .targets
1151            .insert("local".into(), TargetTemplate::LocalBare);
1152        let mut controller = Controller {
1153            config,
1154            state: HelState {
1155                sessions: BTreeMap::from([(session_id.into(), session)]),
1156                ..HelState::default()
1157            },
1158        };
1159        let executor = RecordingExecutor {
1160            calls: RefCell::new(0),
1161        };
1162
1163        let error = controller
1164            .force_stop_with(session_id, &executor, |_| Ok(()))
1165            .unwrap_err();
1166
1167        assert!(error.to_string().contains("existing recovery archive"));
1168        assert_eq!(*executor.calls.borrow(), 0);
1169        assert_eq!(
1170            controller.state.sessions[session_id].state,
1171            SessionState::Running
1172        );
1173        assert!(controller.state.sessions[session_id].target.is_some());
1174    }
1175    #[test]
1176    fn destroying_retry_blocks_cleanup_when_the_archive_gate_changed() {
1177        struct RecordingExecutor {
1178            calls: RefCell<usize>,
1179        }
1180
1181        impl CommandExecutor for RecordingExecutor {
1182            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1183                *self.calls.borrow_mut() += 1;
1184                Ok(CommandOutput {
1185                    status: 0,
1186                    stdout: Vec::new(),
1187                    stderr: Vec::new(),
1188                })
1189            }
1190        }
1191
1192        let directory = tempfile::tempdir().unwrap();
1193        let repository = committed_repository();
1194        let session_id = "0123456789abcdef0123456789abcdef";
1195        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1196        let mut session = managed_worktree_session(repository.path(), session_id);
1197        let worktree = session.managed_worktree.clone().unwrap();
1198        session.target_template_id = "local".into();
1199        session.state = SessionState::Destroying;
1200        session.target = Some(TargetLocator::LocalBare {
1201            worker_root: directory.path().join(session_id),
1202        });
1203        session.checkpoint = Some(checkpoint.clone());
1204        let mut config = HelConfig::default();
1205        config
1206            .targets
1207            .insert("local".into(), TargetTemplate::LocalBare);
1208        let mut controller = Controller {
1209            config,
1210            state: HelState {
1211                sessions: BTreeMap::from([(session_id.into(), session)]),
1212                ..HelState::default()
1213            },
1214        };
1215        let executor = RecordingExecutor {
1216            calls: RefCell::new(0),
1217        };
1218        let persisted = RefCell::new(Vec::new());
1219        std::fs::write(&checkpoint.archive_path, b"changed after checkpoint").unwrap();
1220
1221        let error = controller
1222            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1223                persisted.borrow_mut().push(record.state);
1224                Ok(())
1225            })
1226            .unwrap_err();
1227
1228        assert!(error.to_string().contains("checkpoint SHA changed"));
1229        assert_eq!(*executor.calls.borrow(), 0);
1230        assert!(persisted.into_inner().is_empty());
1231        assert!(worktree.worktree_root.is_dir());
1232        assert_eq!(
1233            controller.state.sessions[session_id].state,
1234            SessionState::Destroying
1235        );
1236    }
1237    #[test]
1238    fn destroying_retry_finalizes_when_apple_container_is_confirmed_absent() {
1239        struct AlreadyRemovedExecutor {
1240            commands: RefCell<Vec<CommandSpec>>,
1241        }
1242
1243        impl CommandExecutor for AlreadyRemovedExecutor {
1244            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1245                self.commands.borrow_mut().push(command.clone());
1246                if command.program == "sh"
1247                    && command
1248                        .args
1249                        .get(1)
1250                        .is_some_and(|script| script.contains("container rm --force"))
1251                {
1252                    Ok(CommandOutput {
1253                        status: 1,
1254                        stdout: Vec::new(),
1255                        stderr: b"container not found".to_vec(),
1256                    })
1257                } else {
1258                    Ok(CommandOutput {
1259                        status: 0,
1260                        stdout: Vec::new(),
1261                        stderr: Vec::new(),
1262                    })
1263                }
1264            }
1265        }
1266
1267        let directory = tempfile::tempdir().unwrap();
1268        let session_id = "0123456789abcdef0123456789abcdef";
1269        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1270        let mut session = checkpoint_test_session(session_id);
1271        session.target_template_id = "apple".into();
1272        session.state = SessionState::Destroying;
1273        session.target = Some(TargetLocator::AppleContainer {
1274            container_id: hel_targets::resource_name(session_id).unwrap(),
1275        });
1276        session.checkpoint = Some(checkpoint.clone());
1277        let mut config = HelConfig::default();
1278        config.targets.insert(
1279            "apple".into(),
1280            TargetTemplate::AppleContainer {
1281                container: ConfigContainer {
1282                    image: "test:latest".into(),
1283                    pull_policy: Default::default(),
1284                    platform: None,
1285                    cpus: None,
1286                    memory: None,
1287                    environment: BTreeMap::new(),
1288                    workspace_storage: Default::default(),
1289                },
1290            },
1291        );
1292        let mut controller = Controller {
1293            config,
1294            state: HelState {
1295                sessions: BTreeMap::from([(session_id.into(), session)]),
1296                ..HelState::default()
1297            },
1298        };
1299        let executor = AlreadyRemovedExecutor {
1300            commands: RefCell::new(Vec::new()),
1301        };
1302        let persisted = RefCell::new(Vec::new());
1303
1304        controller
1305            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1306                persisted.borrow_mut().push(record.state);
1307                Ok(())
1308            })
1309            .unwrap();
1310
1311        let commands = executor.commands.borrow();
1312        assert_eq!(commands.len(), 2);
1313        assert_eq!(commands[0].program, "sh");
1314        assert!(commands[0].args[1].contains("container rm --force"));
1315        assert!(commands[0].args[1].contains(".cache/mjolnir/git/sessions"));
1316        assert_eq!(commands[1].args, ["list", "--all", "--quiet"]);
1317        assert_eq!(persisted.into_inner(), vec![SessionState::Stopped]);
1318        assert_eq!(
1319            controller.state.sessions[session_id].state,
1320            SessionState::Stopped
1321        );
1322    }
1323
1324    #[test]
1325    fn interrupted_close_error_preserves_destroying_phase() {
1326        let session_id = "0123456789abcdef0123456789abcdef";
1327        let mut session = checkpoint_test_session(session_id);
1328        session.state = SessionState::Destroying;
1329
1330        apply_interrupted_close_error(
1331            &mut session,
1332            &anyhow::anyhow!("podman unavailable"),
1333            "2026-08-14T12:00:00Z",
1334        );
1335
1336        assert_eq!(session.state, SessionState::Destroying);
1337        assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
1338        assert!(
1339            session
1340                .last_error
1341                .as_deref()
1342                .is_some_and(|error| error.contains("cleanup is safely retryable"))
1343        );
1344    }
1345
1346    struct FailingExecutor;
1347
1348    impl CommandExecutor for FailingExecutor {
1349        fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1350            Ok(CommandOutput {
1351                status: 1,
1352                stdout: Vec::new(),
1353                stderr: b"teardown unavailable".to_vec(),
1354            })
1355        }
1356    }
1357
1358    fn branch_exists(repository: &std::path::Path, branch: &str) -> bool {
1359        std::process::Command::new("git")
1360            .arg("-C")
1361            .arg(repository)
1362            .args(["show-ref", "--verify", "--quiet"])
1363            .arg(format!("refs/heads/{branch}"))
1364            .output()
1365            .unwrap()
1366            .status
1367            .success()
1368    }
1369
1370    #[test]
1371    fn force_destroy_from_running_removes_target_worktree_branch_and_archive() {
1372        let directory = tempfile::tempdir().unwrap();
1373        let repository = committed_repository();
1374        let session_id = "0123456789abcdef0123456789abcdef";
1375        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1376        let worker_root = directory.path().join(session_id);
1377        std::fs::create_dir_all(&worker_root).unwrap();
1378        let mut session = managed_worktree_session(repository.path(), session_id);
1379        session.state = SessionState::Running;
1380        session.target_template_id = "local".into();
1381        session.target = Some(TargetLocator::LocalBare {
1382            worker_root: worker_root.clone(),
1383        });
1384        session.checkpoint = Some(checkpoint.clone());
1385        let mut config = HelConfig::default();
1386        config
1387            .targets
1388            .insert("local".into(), TargetTemplate::LocalBare);
1389        let mut controller = Controller {
1390            config,
1391            state: HelState {
1392                sessions: BTreeMap::from([(session_id.into(), session)]),
1393                ..HelState::default()
1394            },
1395        };
1396        let deleted = RefCell::new(Vec::new());
1397
1398        controller
1399            .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1400                deleted.borrow_mut().push(id.to_owned());
1401                Ok(())
1402            })
1403            .unwrap();
1404
1405        assert!(!worker_root.exists(), "local target must be removed");
1406        let worktree_root = repository.path().join(".mj/worktrees").join(session_id);
1407        assert!(!worktree_root.exists(), "managed worktree must be removed");
1408        assert!(
1409            !branch_exists(repository.path(), &format!("mj/{session_id}")),
1410            "generated branch must be removed"
1411        );
1412        assert!(!checkpoint.archive_path.exists(), "archive must be removed");
1413        assert!(!controller.state.sessions.contains_key(session_id));
1414        assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1415    }
1416
1417    #[test]
1418    fn force_destroy_without_a_target_or_archive_still_removes_the_record() {
1419        let session_id = "0123456789abcdef0123456789abcdef";
1420        let mut session = checkpoint_test_session(session_id);
1421        session.state = SessionState::Provisioning;
1422        session.target = None;
1423        session.checkpoint = None;
1424        let mut controller = Controller {
1425            config: HelConfig::default(),
1426            state: HelState {
1427                sessions: BTreeMap::from([(session_id.into(), session)]),
1428                ..HelState::default()
1429            },
1430        };
1431        let deleted = RefCell::new(Vec::new());
1432
1433        controller
1434            .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1435                deleted.borrow_mut().push(id.to_owned());
1436                Ok(())
1437            })
1438            .unwrap();
1439
1440        assert!(!controller.state.sessions.contains_key(session_id));
1441        assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1442    }
1443
1444    #[test]
1445    fn force_destroy_aborts_and_keeps_the_record_when_the_target_survives() {
1446        let directory = tempfile::tempdir().unwrap();
1447        let session_id = "0123456789abcdef0123456789abcdef";
1448        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1449        let mut session = checkpoint_test_session(session_id);
1450        session.target_template_id = "local".into();
1451        session.state = SessionState::Running;
1452        session.target = Some(TargetLocator::LocalBare {
1453            worker_root: directory.path().join(session_id),
1454        });
1455        session.checkpoint = Some(checkpoint.clone());
1456        let mut config = HelConfig::default();
1457        config
1458            .targets
1459            .insert("local".into(), TargetTemplate::LocalBare);
1460        let mut controller = Controller {
1461            config,
1462            state: HelState {
1463                sessions: BTreeMap::from([(session_id.into(), session)]),
1464                ..HelState::default()
1465            },
1466        };
1467        let deleted = RefCell::new(Vec::new());
1468
1469        let error = controller
1470            .force_destroy_session_with(session_id, &FailingExecutor, |id| {
1471                deleted.borrow_mut().push(id.to_owned());
1472                Ok(())
1473            })
1474            .unwrap_err();
1475
1476        assert!(
1477            error.to_string().contains("teardown unavailable"),
1478            "{error:#}"
1479        );
1480        assert!(
1481            controller.state.sessions.contains_key(session_id),
1482            "a surviving target must keep the record for a retry"
1483        );
1484        assert!(
1485            checkpoint.archive_path.exists(),
1486            "a surviving target must keep the recovery archive"
1487        );
1488        assert!(deleted.into_inner().is_empty());
1489    }
1490
1491    #[test]
1492    fn force_destroy_tolerates_a_missing_archive() {
1493        let directory = tempfile::tempdir().unwrap();
1494        let session_id = "0123456789abcdef0123456789abcdef";
1495        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1496        std::fs::remove_file(&checkpoint.archive_path).unwrap();
1497        let mut session = checkpoint_test_session(session_id);
1498        session.state = SessionState::Error;
1499        session.checkpoint = Some(checkpoint);
1500        let mut controller = Controller {
1501            config: HelConfig::default(),
1502            state: HelState {
1503                sessions: BTreeMap::from([(session_id.into(), session)]),
1504                ..HelState::default()
1505            },
1506        };
1507
1508        controller
1509            .force_destroy_session_with(session_id, &ProcessExecutor, |_| Ok(()))
1510            .unwrap();
1511
1512        assert!(!controller.state.sessions.contains_key(session_id));
1513    }
1514}