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