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