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_database::delete_session(session_id)
573            .context("destroy stopped session in database")?;
574        self.state.destroy_stopped_session(session_id)?;
575        Ok(())
576    }
577
578    /// Permanently destroy a session from any state, without checkpointing
579    /// and without requiring a recovery archive.
580    ///
581    /// Unlike [`Controller::destroy_session_controlled`], this accepts active
582    /// states: it tears the live target down with the same close plan a
583    /// verified close uses, so the owning process group dies before any files
584    /// go. External cleanup happens before the durable record is dropped so
585    /// failures stay visible and retryable; the recovery archive is removed,
586    /// which is what makes the destruction irreversible.
587    pub fn force_destroy_session(
588        &mut self,
589        session_id: &str,
590        executor: &impl CommandExecutor,
591    ) -> Result<()> {
592        self.force_destroy_session_with(session_id, executor, hel::hel_database::delete_session)
593    }
594
595    fn force_destroy_session_with(
596        &mut self,
597        session_id: &str,
598        executor: &impl CommandExecutor,
599        delete: impl Fn(&str) -> Result<()>,
600    ) -> Result<()> {
601        let session = self
602            .state
603            .sessions
604            .get(session_id)
605            .with_context(|| format!("unknown session {session_id}"))?
606            .clone();
607        // A session destroyed for good keeps nothing, including a broker an
608        // earlier failure left running; retiring it first also stops a live
609        // writer from recreating files under the teardown below.
610        retire_git_broker(session_id).context("stop the session's local Git broker")?;
611        if let Some(locator) = &session.target {
612            let backend = backend_locator(locator, &session, &self.config)?;
613            execute_target_cleanup(&backend, session_id, executor)?;
614        }
615        if let Some(worktree) = &session.managed_worktree {
616            cleanup_managed_worktree(executor, worktree)
617                .context("remove managed raw-session worktree")?;
618        }
619        if let Some(checkpoint) = &session.checkpoint
620            && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
621            && error.kind() != std::io::ErrorKind::NotFound
622        {
623            return Err(error).with_context(|| {
624                format!(
625                    "remove session recovery archive {}",
626                    checkpoint.archive_path.display()
627                )
628            });
629        }
630        delete(session_id).context("force destroy session in database")?;
631        self.state.destroy_session_force(session_id)?;
632        Ok(())
633    }
634}
635
636fn execute_target_cleanup(
637    backend: &hel_targets::TargetLocator,
638    session_id: &str,
639    executor: &impl CommandExecutor,
640) -> Result<()> {
641    if let Err(cleanup_error) = hel_targets::close_plan(backend, session_id)?.execute(executor) {
642        match hel_targets::cleanup_target_is_confirmed_absent(backend, session_id, executor) {
643            Ok(true) => {
644                tracing::warn!(
645                    session_id,
646                    error = format!("{cleanup_error:#}"),
647                    "target cleanup command failed, but the target was confirmed absent"
648                );
649            }
650            Ok(false) => {
651                tracing::error!(
652                    session_id,
653                    error = format!("{cleanup_error:#}"),
654                    "target cleanup failed and the target is still present"
655                );
656                return Err(cleanup_error);
657            }
658            Err(probe_error) => {
659                tracing::error!(
660                    session_id,
661                    cleanup_error = format!("{cleanup_error:#}"),
662                    probe_error = format!("{probe_error:#}"),
663                    "target cleanup failed and exact absence could not be confirmed"
664                );
665                return Err(cleanup_error.context(format!(
666                    "target cleanup failed and exact absence could not be confirmed: {probe_error:#}"
667                )));
668            }
669        }
670    }
671    Ok(())
672}
673
674fn apply_close_checkpoint_started(record: &mut SessionRecord, updated_at: String) {
675    record.state = SessionState::Closing;
676    record.updated_at = updated_at;
677    record.last_checkpoint_error = None;
678}
679
680fn apply_interrupted_close_error(
681    record: &mut SessionRecord,
682    error: &anyhow::Error,
683    updated_at: &str,
684) {
685    let destroying = record.state == SessionState::Destroying;
686    if !destroying {
687        record.state = SessionState::Closing;
688    }
689    record.updated_at = updated_at.to_owned();
690    record.last_error = Some(if destroying {
691        format!("target cleanup is safely retryable from its verified checkpoint: {error:#}")
692    } else {
693        format!("close is safely resumable from its verified checkpoint: {error:#}")
694    });
695}
696
697#[cfg(test)]
698mod tests {
699    use std::cell::RefCell;
700    use std::collections::BTreeMap;
701
702    use anyhow::Result;
703
704    use crate::hel_controller::Controller;
705    use crate::hel_controller::test_support::{
706        checkpoint_test_session, committed_repository, managed_worktree_session, test_git,
707        write_checkpoint_gate_archive,
708    };
709    use hel::hel_config::{ContainerTemplate as ConfigContainer, HelConfig, TargetTemplate};
710    use hel::hel_state::{HelState, SessionState, TargetLocator};
711    use hel::hel_targets::{self, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
712
713    use super::*;
714
715    #[test]
716    fn starting_close_persists_its_intent_before_checkpointing() {
717        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
718        session.state = SessionState::Running;
719        session.last_checkpoint_error = Some("old failure".into());
720
721        apply_close_checkpoint_started(&mut session, "2026-08-14T12:00:00Z".into());
722
723        assert_eq!(session.state, SessionState::Closing);
724        assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
725        assert!(session.last_checkpoint_error.is_none());
726    }
727
728    struct DeferredCleanupExecutor {
729        statuses: RefCell<Vec<i32>>,
730    }
731
732    impl CommandExecutor for DeferredCleanupExecutor {
733        fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
734            let status = self
735                .statuses
736                .borrow_mut()
737                .pop()
738                .expect("test cleanup command status");
739            Ok(CommandOutput {
740                status,
741                stdout: Vec::new(),
742                stderr: if status != 0 {
743                    b"cleanup failed".to_vec()
744                } else {
745                    Vec::new()
746                },
747            })
748        }
749    }
750
751    fn stopped_podman_cleanup_controller(session_id: &str) -> Controller {
752        let container_id = hel_targets::resource_name(session_id).unwrap();
753        let volume = format!("{container_id}-workspace");
754        let mut session = checkpoint_test_session(session_id);
755        session.target_template_id = "podman".into();
756        session.state = SessionState::Stopped;
757        session.target = Some(TargetLocator::LocalPodman {
758            container_id,
759            workspace_storage: hel::hel_state::PodmanWorkspaceLocator::Volume { name: volume },
760        });
761        let mut config = HelConfig::default();
762        config.targets.insert(
763            "podman".into(),
764            TargetTemplate::LocalPodman {
765                container: ConfigContainer {
766                    image: "test:latest".into(),
767                    pull_policy: Default::default(),
768                    platform: None,
769                    cpus: None,
770                    memory: None,
771                    environment: BTreeMap::new(),
772                    workspace_storage: hel::hel_config::PodmanWorkspaceStorage::PodmanVolume,
773                },
774            },
775        );
776        Controller {
777            config,
778            state: HelState {
779                sessions: BTreeMap::from([(session_id.into(), session)]),
780                ..HelState::default()
781            },
782        }
783    }
784
785    #[test]
786    fn deferred_cleanup_failure_is_visible_and_successful_retry_clears_it() {
787        let session_id = "0123456789abcdef0123456789abcdef";
788        let mut controller = stopped_podman_cleanup_controller(session_id);
789        let persisted = RefCell::new(Vec::new());
790        let failure = controller
791            .cleanup_stopped_target_with(
792                session_id,
793                &DeferredCleanupExecutor {
794                    // The cleanup command fails, then the exact-absence probe
795                    // confirms that the owned target is still present.
796                    statuses: RefCell::new(vec![1, 1]),
797                },
798                |record| {
799                    persisted
800                        .borrow_mut()
801                        .push((record.target.is_some(), record.last_error.clone()));
802                    Ok(())
803                },
804            )
805            .unwrap_err();
806        assert!(format!("{failure:#}").contains("cleanup failed"));
807        assert_eq!(persisted.borrow().len(), 1);
808        assert!(persisted.borrow()[0].0);
809        assert!(
810            persisted.borrow()[0]
811                .1
812                .as_deref()
813                .is_some_and(|error| error.contains("deferred target cleanup failed"))
814        );
815        assert!(controller.state.sessions[session_id].target.is_some());
816        assert!(controller.state.sessions[session_id].last_error.is_some());
817
818        let retry_persisted = RefCell::new(Vec::new());
819        controller
820            .cleanup_stopped_target_with(
821                session_id,
822                &DeferredCleanupExecutor {
823                    statuses: RefCell::new(vec![0, 0, 0]),
824                },
825                |record| {
826                    retry_persisted
827                        .borrow_mut()
828                        .push((record.target.is_some(), record.last_error.clone()));
829                    Ok(())
830                },
831            )
832            .unwrap();
833        assert_eq!(retry_persisted.borrow().as_slice(), &[(false, None)]);
834        assert!(controller.state.sessions[session_id].target.is_none());
835        assert!(controller.state.sessions[session_id].last_error.is_none());
836    }
837
838    #[test]
839    fn deferred_cleanup_persistence_failure_restores_the_stopped_record() {
840        let session_id = "0123456789abcdef0123456789abcdef";
841        let mut controller = stopped_podman_cleanup_controller(session_id);
842        let previous = controller.state.sessions[session_id].clone();
843        let failure = controller
844            .cleanup_stopped_target_with(
845                session_id,
846                &DeferredCleanupExecutor {
847                    statuses: RefCell::new(vec![1, 1]),
848                },
849                |_| Err(anyhow::anyhow!("database unavailable")),
850            )
851            .unwrap_err();
852        let detail = format!("{failure:#}");
853        assert!(detail.contains("cleanup failed"), "{detail}");
854        assert!(detail.contains("database unavailable"), "{detail}");
855        assert_eq!(controller.state.sessions[session_id], previous);
856    }
857
858    #[test]
859    fn target_cleanup_persists_destroying_and_rechecks_the_installed_archive() {
860        struct RecordingExecutor {
861            commands: RefCell<Vec<CommandSpec>>,
862        }
863
864        impl CommandExecutor for RecordingExecutor {
865            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
866                self.commands.borrow_mut().push(command.clone());
867                Ok(CommandOutput {
868                    status: 0,
869                    stdout: Vec::new(),
870                    stderr: Vec::new(),
871                })
872            }
873        }
874
875        let directory = tempfile::tempdir().unwrap();
876        let session_id = "0123456789abcdef0123456789abcdef";
877        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
878        let mut session = checkpoint_test_session(session_id);
879        session.target_template_id = "local".into();
880        session.state = SessionState::Closing;
881        session.target = Some(TargetLocator::LocalBare {
882            worker_root: directory.path().join(session_id),
883        });
884        session.checkpoint = Some(checkpoint.clone());
885        let mut config = HelConfig::default();
886        config
887            .targets
888            .insert("local".into(), TargetTemplate::LocalBare);
889        let mut controller = Controller {
890            config,
891            state: HelState {
892                sessions: BTreeMap::from([(session_id.into(), session)]),
893                ..HelState::default()
894            },
895        };
896        let executor = RecordingExecutor {
897            commands: RefCell::new(Vec::new()),
898        };
899        let persisted = RefCell::new(Vec::new());
900
901        controller
902            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
903                persisted.borrow_mut().push(record.state);
904                Ok(())
905            })
906            .unwrap();
907
908        assert_eq!(
909            persisted.into_inner(),
910            vec![SessionState::Destroying, SessionState::Stopped]
911        );
912        assert_eq!(executor.commands.borrow().len(), 1);
913        let stopped = &controller.state.sessions[session_id];
914        assert_eq!(stopped.state, SessionState::Stopped);
915        assert!(stopped.target.is_none());
916    }
917
918    #[test]
919    fn podman_close_persists_stopped_before_deferred_storage_cleanup() {
920        struct RecordingExecutor {
921            commands: RefCell<Vec<CommandSpec>>,
922        }
923
924        impl CommandExecutor for RecordingExecutor {
925            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
926                self.commands.borrow_mut().push(command.clone());
927                Ok(CommandOutput {
928                    status: 0,
929                    stdout: Vec::new(),
930                    stderr: Vec::new(),
931                })
932            }
933        }
934
935        let directory = tempfile::tempdir().unwrap();
936        let session_id = "0123456789abcdef0123456789abcdef";
937        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
938        let container_id = hel_targets::resource_name(session_id).unwrap();
939        let volume = format!("{container_id}-workspace");
940        let mut session = checkpoint_test_session(session_id);
941        session.target_template_id = "podman".into();
942        session.state = SessionState::Closing;
943        session.target = Some(TargetLocator::LocalPodman {
944            container_id,
945            workspace_storage: hel::hel_state::PodmanWorkspaceLocator::Volume {
946                name: volume.clone(),
947            },
948        });
949        session.checkpoint = Some(checkpoint.clone());
950        let mut config = HelConfig::default();
951        config.targets.insert(
952            "podman".into(),
953            TargetTemplate::LocalPodman {
954                container: ConfigContainer {
955                    image: "test:latest".into(),
956                    pull_policy: Default::default(),
957                    platform: None,
958                    cpus: None,
959                    memory: None,
960                    environment: BTreeMap::new(),
961                    workspace_storage: hel::hel_config::PodmanWorkspaceStorage::PodmanVolume,
962                },
963            },
964        );
965        let mut controller = Controller {
966            config,
967            state: HelState {
968                sessions: BTreeMap::from([(session_id.into(), session)]),
969                ..HelState::default()
970            },
971        };
972        let executor = RecordingExecutor {
973            commands: RefCell::new(Vec::new()),
974        };
975        let persisted = RefCell::new(Vec::new());
976
977        let deferred = controller
978            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
979                persisted
980                    .borrow_mut()
981                    .push((record.state, record.target.is_some()));
982                Ok(())
983            })
984            .unwrap();
985
986        assert!(deferred);
987        assert_eq!(
988            persisted.borrow().as_slice(),
989            &[
990                (SessionState::Destroying, true),
991                (SessionState::Stopped, true)
992            ]
993        );
994        let commands = executor.commands.borrow();
995        assert_eq!(commands.len(), 1);
996        assert!(commands[0].args[1].contains("podman stop --time 0"));
997        assert!(!commands[0].args[1].contains("podman rm"));
998        drop(commands);
999
1000        controller
1001            .cleanup_stopped_target_with(session_id, &executor, |record| {
1002                assert_eq!(record.state, SessionState::Stopped);
1003                assert!(record.target.is_none());
1004                Ok(())
1005            })
1006            .unwrap();
1007
1008        let commands = executor.commands.borrow();
1009        assert_eq!(commands.len(), 4);
1010        assert_eq!(
1011            commands[1].stage,
1012            Some(hel_targets::ProvisionStage::RemovingContainer)
1013        );
1014        assert_eq!(
1015            commands[2].stage,
1016            Some(hel_targets::ProvisionStage::RemovingStorage)
1017        );
1018        assert_eq!(
1019            commands[3].stage,
1020            Some(hel_targets::ProvisionStage::CleaningCache)
1021        );
1022        assert!(commands[2].args.contains(&volume));
1023        assert!(controller.state.sessions[session_id].target.is_none());
1024    }
1025    #[test]
1026    fn verified_close_retires_managed_checkout_but_keeps_archive_and_branch() {
1027        let archive_directory = tempfile::tempdir().unwrap();
1028        let repository = committed_repository();
1029        let session_id = "0123456789abcdef0123456789abcdef";
1030        let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
1031        let mut session = managed_worktree_session(repository.path(), session_id);
1032        let worktree = session.managed_worktree.clone().unwrap();
1033        std::fs::write(worktree.worktree_root.join("dirty.txt"), "worktree state\n").unwrap();
1034        session.state = SessionState::Closing;
1035        session.target = Some(TargetLocator::LocalBare {
1036            worker_root: archive_directory.path().join(session_id),
1037        });
1038        session.checkpoint = Some(checkpoint.clone());
1039        let mut config = HelConfig::default();
1040        config
1041            .targets
1042            .insert("local-bare".into(), TargetTemplate::LocalBare);
1043        let mut controller = Controller {
1044            config,
1045            state: HelState {
1046                sessions: BTreeMap::from([(session_id.into(), session)]),
1047                ..HelState::default()
1048            },
1049        };
1050
1051        controller
1052            .destroy_after_verified_checkpoint_with(
1053                session_id,
1054                &checkpoint,
1055                &ProcessExecutor,
1056                |_| Ok(()),
1057            )
1058            .unwrap();
1059
1060        assert!(!worktree.worktree_root.exists());
1061        assert!(checkpoint.archive_path.is_file());
1062        assert_eq!(
1063            test_git(
1064                repository.path(),
1065                &[
1066                    "show-ref",
1067                    "--hash",
1068                    &format!("refs/heads/{}", worktree.branch),
1069                ],
1070            )
1071            .len(),
1072            40
1073        );
1074        assert_eq!(
1075            controller.state.sessions[session_id].state,
1076            SessionState::Stopped
1077        );
1078    }
1079    #[test]
1080    fn force_stop_reuses_verified_archive_and_leaves_session_resumable() {
1081        let archive_directory = tempfile::tempdir().unwrap();
1082        let repository = committed_repository();
1083        let session_id = "0123456789abcdef0123456789abcdef";
1084        let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
1085        let mut session = managed_worktree_session(repository.path(), session_id);
1086        let worktree = session.managed_worktree.clone().unwrap();
1087        session.state = SessionState::Running;
1088        session.target = Some(TargetLocator::LocalBare {
1089            worker_root: archive_directory.path().join(session_id),
1090        });
1091        session.checkpoint = Some(checkpoint.clone());
1092        let mut config = HelConfig::default();
1093        config
1094            .targets
1095            .insert("local-bare".into(), TargetTemplate::LocalBare);
1096        let mut controller = Controller {
1097            config,
1098            state: HelState {
1099                sessions: BTreeMap::from([(session_id.into(), session)]),
1100                ..HelState::default()
1101            },
1102        };
1103
1104        controller
1105            .force_stop_with(session_id, &ProcessExecutor, |_| Ok(()))
1106            .unwrap();
1107
1108        let stopped = &controller.state.sessions[session_id];
1109        assert_eq!(stopped.state, SessionState::Stopped);
1110        assert!(stopped.target.is_none());
1111        assert_eq!(stopped.checkpoint.as_ref(), Some(&checkpoint));
1112        assert!(checkpoint.archive_path.is_file());
1113        assert!(!worktree.worktree_root.exists());
1114        assert!(
1115            !test_git(
1116                repository.path(),
1117                &[
1118                    "show-ref",
1119                    "--hash",
1120                    &format!("refs/heads/{}", worktree.branch),
1121                ],
1122            )
1123            .is_empty()
1124        );
1125    }
1126    #[test]
1127    fn force_stop_without_a_recovery_archive_does_not_touch_the_target() {
1128        struct RecordingExecutor {
1129            calls: RefCell<usize>,
1130        }
1131
1132        impl CommandExecutor for RecordingExecutor {
1133            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1134                *self.calls.borrow_mut() += 1;
1135                Ok(CommandOutput {
1136                    status: 0,
1137                    stdout: Vec::new(),
1138                    stderr: Vec::new(),
1139                })
1140            }
1141        }
1142
1143        let directory = tempfile::tempdir().unwrap();
1144        let session_id = "0123456789abcdef0123456789abcdef";
1145        let mut session = checkpoint_test_session(session_id);
1146        session.state = SessionState::Running;
1147        session.checkpoint = None;
1148        session.target_template_id = "local".into();
1149        session.target = Some(TargetLocator::LocalBare {
1150            worker_root: directory.path().join(session_id),
1151        });
1152        let mut config = HelConfig::default();
1153        config
1154            .targets
1155            .insert("local".into(), TargetTemplate::LocalBare);
1156        let mut controller = Controller {
1157            config,
1158            state: HelState {
1159                sessions: BTreeMap::from([(session_id.into(), session)]),
1160                ..HelState::default()
1161            },
1162        };
1163        let executor = RecordingExecutor {
1164            calls: RefCell::new(0),
1165        };
1166
1167        let error = controller
1168            .force_stop_with(session_id, &executor, |_| Ok(()))
1169            .unwrap_err();
1170
1171        assert!(error.to_string().contains("existing recovery archive"));
1172        assert_eq!(*executor.calls.borrow(), 0);
1173        assert_eq!(
1174            controller.state.sessions[session_id].state,
1175            SessionState::Running
1176        );
1177        assert!(controller.state.sessions[session_id].target.is_some());
1178    }
1179    #[test]
1180    fn destroying_retry_blocks_cleanup_when_the_archive_gate_changed() {
1181        struct RecordingExecutor {
1182            calls: RefCell<usize>,
1183        }
1184
1185        impl CommandExecutor for RecordingExecutor {
1186            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1187                *self.calls.borrow_mut() += 1;
1188                Ok(CommandOutput {
1189                    status: 0,
1190                    stdout: Vec::new(),
1191                    stderr: Vec::new(),
1192                })
1193            }
1194        }
1195
1196        let directory = tempfile::tempdir().unwrap();
1197        let repository = committed_repository();
1198        let session_id = "0123456789abcdef0123456789abcdef";
1199        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1200        let mut session = managed_worktree_session(repository.path(), session_id);
1201        let worktree = session.managed_worktree.clone().unwrap();
1202        session.target_template_id = "local".into();
1203        session.state = SessionState::Destroying;
1204        session.target = Some(TargetLocator::LocalBare {
1205            worker_root: directory.path().join(session_id),
1206        });
1207        session.checkpoint = Some(checkpoint.clone());
1208        let mut config = HelConfig::default();
1209        config
1210            .targets
1211            .insert("local".into(), TargetTemplate::LocalBare);
1212        let mut controller = Controller {
1213            config,
1214            state: HelState {
1215                sessions: BTreeMap::from([(session_id.into(), session)]),
1216                ..HelState::default()
1217            },
1218        };
1219        let executor = RecordingExecutor {
1220            calls: RefCell::new(0),
1221        };
1222        let persisted = RefCell::new(Vec::new());
1223        std::fs::write(&checkpoint.archive_path, b"changed after checkpoint").unwrap();
1224
1225        let error = controller
1226            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1227                persisted.borrow_mut().push(record.state);
1228                Ok(())
1229            })
1230            .unwrap_err();
1231
1232        assert!(error.to_string().contains("checkpoint SHA changed"));
1233        assert_eq!(*executor.calls.borrow(), 0);
1234        assert!(persisted.into_inner().is_empty());
1235        assert!(worktree.worktree_root.is_dir());
1236        assert_eq!(
1237            controller.state.sessions[session_id].state,
1238            SessionState::Destroying
1239        );
1240    }
1241    #[test]
1242    fn destroying_retry_finalizes_when_apple_container_is_confirmed_absent() {
1243        struct AlreadyRemovedExecutor {
1244            commands: RefCell<Vec<CommandSpec>>,
1245        }
1246
1247        impl CommandExecutor for AlreadyRemovedExecutor {
1248            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1249                self.commands.borrow_mut().push(command.clone());
1250                if command.program == "sh"
1251                    && command
1252                        .args
1253                        .get(1)
1254                        .is_some_and(|script| script.contains("container rm --force"))
1255                {
1256                    Ok(CommandOutput {
1257                        status: 1,
1258                        stdout: Vec::new(),
1259                        stderr: b"container not found".to_vec(),
1260                    })
1261                } else {
1262                    Ok(CommandOutput {
1263                        status: 0,
1264                        stdout: Vec::new(),
1265                        stderr: Vec::new(),
1266                    })
1267                }
1268            }
1269        }
1270
1271        let directory = tempfile::tempdir().unwrap();
1272        let session_id = "0123456789abcdef0123456789abcdef";
1273        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1274        let mut session = checkpoint_test_session(session_id);
1275        session.target_template_id = "apple".into();
1276        session.state = SessionState::Destroying;
1277        session.target = Some(TargetLocator::AppleContainer {
1278            container_id: hel_targets::resource_name(session_id).unwrap(),
1279        });
1280        session.checkpoint = Some(checkpoint.clone());
1281        let mut config = HelConfig::default();
1282        config.targets.insert(
1283            "apple".into(),
1284            TargetTemplate::AppleContainer {
1285                container: ConfigContainer {
1286                    image: "test:latest".into(),
1287                    pull_policy: Default::default(),
1288                    platform: None,
1289                    cpus: None,
1290                    memory: None,
1291                    environment: BTreeMap::new(),
1292                    workspace_storage: Default::default(),
1293                },
1294            },
1295        );
1296        let mut controller = Controller {
1297            config,
1298            state: HelState {
1299                sessions: BTreeMap::from([(session_id.into(), session)]),
1300                ..HelState::default()
1301            },
1302        };
1303        let executor = AlreadyRemovedExecutor {
1304            commands: RefCell::new(Vec::new()),
1305        };
1306        let persisted = RefCell::new(Vec::new());
1307
1308        controller
1309            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1310                persisted.borrow_mut().push(record.state);
1311                Ok(())
1312            })
1313            .unwrap();
1314
1315        let commands = executor.commands.borrow();
1316        assert_eq!(commands.len(), 2);
1317        assert_eq!(commands[0].program, "sh");
1318        assert!(commands[0].args[1].contains("container rm --force"));
1319        assert!(commands[0].args[1].contains(".cache/mjolnir/git/sessions"));
1320        assert_eq!(commands[1].args, ["list", "--all", "--quiet"]);
1321        assert_eq!(persisted.into_inner(), vec![SessionState::Stopped]);
1322        assert_eq!(
1323            controller.state.sessions[session_id].state,
1324            SessionState::Stopped
1325        );
1326    }
1327    /// Ending a session ends the local Git origin it was serving. Close,
1328    /// force stop, and permanent destruction all retire the broker on purpose:
1329    /// its spec and lock file go, so nothing restarts it against a target
1330    /// that is being torn down, and its log stays for reading afterwards.
1331    #[cfg(unix)]
1332    #[test]
1333    fn every_session_ending_retires_its_local_git_broker() {
1334        const RETIREMENT_TEST_CHILD: &str = "MJ_TEST_BROKER_RETIREMENT_CHILD";
1335
1336        struct SucceedingExecutor;
1337
1338        impl CommandExecutor for SucceedingExecutor {
1339            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1340                Ok(CommandOutput {
1341                    status: 0,
1342                    stdout: Vec::new(),
1343                    stderr: Vec::new(),
1344                })
1345            }
1346        }
1347
1348        // MJ_DATA_DIR is process-global, so run the half that reads it in an
1349        // exact child test instead of racing unrelated tests in this process.
1350        if std::env::var_os(RETIREMENT_TEST_CHILD).is_none() {
1351            let directory = tempfile::tempdir().unwrap();
1352            let test_name = format!(
1353                "{}::every_session_ending_retires_its_local_git_broker",
1354                module_path!()
1355                    .strip_prefix("mj_controller::")
1356                    .unwrap_or(module_path!())
1357            );
1358            let output = std::process::Command::new(std::env::current_exe().unwrap())
1359                .args(["--exact", &test_name, "--nocapture"])
1360                .env(RETIREMENT_TEST_CHILD, "1")
1361                .env("MJ_DATA_DIR", directory.path())
1362                .output()
1363                .unwrap();
1364            let reported = String::from_utf8_lossy(&output.stdout).into_owned();
1365            assert!(
1366                output.status.success(),
1367                "isolated broker retirement test failed\nstdout:\n{reported}\nstderr:\n{}",
1368                String::from_utf8_lossy(&output.stderr)
1369            );
1370            // A filter that matches nothing also exits zero, so insist the
1371            // child really ran this test.
1372            assert!(
1373                reported.contains("1 passed"),
1374                "the isolated broker retirement test never ran\nstdout:\n{reported}"
1375            );
1376            return;
1377        }
1378        // Alone in this child process, so it installs the one writer.
1379        let _writer = hel::hel_database::install_isolated_test_writer();
1380
1381        let brokers = hel::hel_config::data_dir().join("git-brokers");
1382        std::fs::create_dir_all(&brokers).unwrap();
1383        let seed_broker = |session_id: &str| {
1384            for (extension, contents) in [
1385                ("json", "{}"),
1386                // A PID file nobody holds a lock on: this session's broker is
1387                // already stopped, so ending the session only has to clear
1388                // what it would otherwise be restarted from.
1389                ("pid", "424242"),
1390                ("ready", "ready\n"),
1391                ("log", "broker log\n"),
1392            ] {
1393                std::fs::write(brokers.join(format!("{session_id}.{extension}")), contents)
1394                    .unwrap();
1395            }
1396        };
1397        let assert_retired = |session_id: &str| {
1398            for extension in ["json", "pid", "ready"] {
1399                let path = brokers.join(format!("{session_id}.{extension}"));
1400                assert!(!path.exists(), "{} outlived its session", path.display());
1401            }
1402            assert_eq!(
1403                std::fs::read_to_string(brokers.join(format!("{session_id}.log"))).unwrap(),
1404                "broker log\n",
1405                "the broker log must survive its session"
1406            );
1407        };
1408
1409        let directory = tempfile::tempdir().unwrap();
1410        let closing = "0123456789abcdef0123456789abcdef";
1411        let force_stopped = "0123456789abcdef0123456789abcdee";
1412        let destroyed = "0123456789abcdef0123456789abcded";
1413        let checkpoint = write_checkpoint_gate_archive(directory.path(), closing, 7);
1414        let mut closing_session = checkpoint_test_session(closing);
1415        closing_session.target_template_id = "local".into();
1416        closing_session.state = SessionState::Closing;
1417        closing_session.target = Some(TargetLocator::LocalBare {
1418            worker_root: directory.path().join(closing),
1419        });
1420        closing_session.checkpoint = Some(checkpoint.clone());
1421        let force_stop_checkpoint =
1422            write_checkpoint_gate_archive(directory.path(), force_stopped, 7);
1423        let mut force_stopped_session = checkpoint_test_session(force_stopped);
1424        force_stopped_session.target_template_id = "local".into();
1425        force_stopped_session.state = SessionState::Running;
1426        force_stopped_session.target = Some(TargetLocator::LocalBare {
1427            worker_root: directory.path().join(force_stopped),
1428        });
1429        force_stopped_session.checkpoint = Some(force_stop_checkpoint);
1430        let mut destroyed_session = checkpoint_test_session(destroyed);
1431        destroyed_session.target_template_id = "local".into();
1432        destroyed_session.state = SessionState::Stopped;
1433        let mut config = HelConfig::default();
1434        config
1435            .targets
1436            .insert("local".into(), TargetTemplate::LocalBare);
1437        let mut controller = Controller {
1438            config,
1439            state: HelState {
1440                sessions: BTreeMap::from([
1441                    (closing.into(), closing_session),
1442                    (force_stopped.into(), force_stopped_session),
1443                    (destroyed.into(), destroyed_session),
1444                ]),
1445                ..HelState::default()
1446            },
1447        };
1448        for session_id in [closing, force_stopped, destroyed] {
1449            seed_broker(session_id);
1450        }
1451
1452        controller
1453            .destroy_after_verified_checkpoint_with(
1454                closing,
1455                &checkpoint,
1456                &SucceedingExecutor,
1457                |_| Ok(()),
1458            )
1459            .unwrap();
1460        assert_retired(closing);
1461
1462        controller
1463            .force_stop_with(force_stopped, &SucceedingExecutor, |_| Ok(()))
1464            .unwrap();
1465        assert_retired(force_stopped);
1466
1467        controller
1468            .destroy_session_controlled(destroyed, &SucceedingExecutor)
1469            .unwrap();
1470        assert_retired(destroyed);
1471    }
1472    #[test]
1473    fn interrupted_close_error_preserves_destroying_phase() {
1474        let session_id = "0123456789abcdef0123456789abcdef";
1475        let mut session = checkpoint_test_session(session_id);
1476        session.state = SessionState::Destroying;
1477
1478        apply_interrupted_close_error(
1479            &mut session,
1480            &anyhow::anyhow!("podman unavailable"),
1481            "2026-08-14T12:00:00Z",
1482        );
1483
1484        assert_eq!(session.state, SessionState::Destroying);
1485        assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
1486        assert!(
1487            session
1488                .last_error
1489                .as_deref()
1490                .is_some_and(|error| error.contains("cleanup is safely retryable"))
1491        );
1492    }
1493
1494    struct FailingExecutor;
1495
1496    impl CommandExecutor for FailingExecutor {
1497        fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1498            Ok(CommandOutput {
1499                status: 1,
1500                stdout: Vec::new(),
1501                stderr: b"teardown unavailable".to_vec(),
1502            })
1503        }
1504    }
1505
1506    fn branch_exists(repository: &std::path::Path, branch: &str) -> bool {
1507        std::process::Command::new("git")
1508            .arg("-C")
1509            .arg(repository)
1510            .args(["show-ref", "--verify", "--quiet"])
1511            .arg(format!("refs/heads/{branch}"))
1512            .output()
1513            .unwrap()
1514            .status
1515            .success()
1516    }
1517
1518    #[test]
1519    fn force_destroy_from_running_removes_target_worktree_branch_and_archive() {
1520        let directory = tempfile::tempdir().unwrap();
1521        let repository = committed_repository();
1522        let session_id = "0123456789abcdef0123456789abcdef";
1523        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1524        let worker_root = directory.path().join(session_id);
1525        std::fs::create_dir_all(&worker_root).unwrap();
1526        let mut session = managed_worktree_session(repository.path(), session_id);
1527        session.state = SessionState::Running;
1528        session.target_template_id = "local".into();
1529        session.target = Some(TargetLocator::LocalBare {
1530            worker_root: worker_root.clone(),
1531        });
1532        session.checkpoint = Some(checkpoint.clone());
1533        let mut config = HelConfig::default();
1534        config
1535            .targets
1536            .insert("local".into(), TargetTemplate::LocalBare);
1537        let mut controller = Controller {
1538            config,
1539            state: HelState {
1540                sessions: BTreeMap::from([(session_id.into(), session)]),
1541                ..HelState::default()
1542            },
1543        };
1544        let deleted = RefCell::new(Vec::new());
1545
1546        controller
1547            .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1548                deleted.borrow_mut().push(id.to_owned());
1549                Ok(())
1550            })
1551            .unwrap();
1552
1553        assert!(!worker_root.exists(), "local target must be removed");
1554        let worktree_root = repository.path().join(".mj/worktrees").join(session_id);
1555        assert!(!worktree_root.exists(), "managed worktree must be removed");
1556        assert!(
1557            !branch_exists(repository.path(), &format!("mj/{session_id}")),
1558            "generated branch must be removed"
1559        );
1560        assert!(!checkpoint.archive_path.exists(), "archive must be removed");
1561        assert!(!controller.state.sessions.contains_key(session_id));
1562        assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1563    }
1564
1565    #[test]
1566    fn force_destroy_without_a_target_or_archive_still_removes_the_record() {
1567        let session_id = "0123456789abcdef0123456789abcdef";
1568        let mut session = checkpoint_test_session(session_id);
1569        session.state = SessionState::Provisioning;
1570        session.target = None;
1571        session.checkpoint = None;
1572        let mut controller = Controller {
1573            config: HelConfig::default(),
1574            state: HelState {
1575                sessions: BTreeMap::from([(session_id.into(), session)]),
1576                ..HelState::default()
1577            },
1578        };
1579        let deleted = RefCell::new(Vec::new());
1580
1581        controller
1582            .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1583                deleted.borrow_mut().push(id.to_owned());
1584                Ok(())
1585            })
1586            .unwrap();
1587
1588        assert!(!controller.state.sessions.contains_key(session_id));
1589        assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1590    }
1591
1592    #[test]
1593    fn force_destroy_aborts_and_keeps_the_record_when_the_target_survives() {
1594        let directory = tempfile::tempdir().unwrap();
1595        let session_id = "0123456789abcdef0123456789abcdef";
1596        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1597        let mut session = checkpoint_test_session(session_id);
1598        session.target_template_id = "local".into();
1599        session.state = SessionState::Running;
1600        session.target = Some(TargetLocator::LocalBare {
1601            worker_root: directory.path().join(session_id),
1602        });
1603        session.checkpoint = Some(checkpoint.clone());
1604        let mut config = HelConfig::default();
1605        config
1606            .targets
1607            .insert("local".into(), TargetTemplate::LocalBare);
1608        let mut controller = Controller {
1609            config,
1610            state: HelState {
1611                sessions: BTreeMap::from([(session_id.into(), session)]),
1612                ..HelState::default()
1613            },
1614        };
1615        let deleted = RefCell::new(Vec::new());
1616
1617        let error = controller
1618            .force_destroy_session_with(session_id, &FailingExecutor, |id| {
1619                deleted.borrow_mut().push(id.to_owned());
1620                Ok(())
1621            })
1622            .unwrap_err();
1623
1624        assert!(
1625            error.to_string().contains("teardown unavailable"),
1626            "{error:#}"
1627        );
1628        assert!(
1629            controller.state.sessions.contains_key(session_id),
1630            "a surviving target must keep the record for a retry"
1631        );
1632        assert!(
1633            checkpoint.archive_path.exists(),
1634            "a surviving target must keep the recovery archive"
1635        );
1636        assert!(deleted.into_inner().is_empty());
1637    }
1638
1639    #[test]
1640    fn force_destroy_tolerates_a_missing_archive() {
1641        let directory = tempfile::tempdir().unwrap();
1642        let session_id = "0123456789abcdef0123456789abcdef";
1643        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1644        std::fs::remove_file(&checkpoint.archive_path).unwrap();
1645        let mut session = checkpoint_test_session(session_id);
1646        session.state = SessionState::Error;
1647        session.checkpoint = Some(checkpoint);
1648        let mut controller = Controller {
1649            config: HelConfig::default(),
1650            state: HelState {
1651                sessions: BTreeMap::from([(session_id.into(), session)]),
1652                ..HelState::default()
1653            },
1654        };
1655
1656        controller
1657            .force_destroy_session_with(session_id, &ProcessExecutor, |_| Ok(()))
1658            .unwrap();
1659
1660        assert!(!controller.state.sessions.contains_key(session_id));
1661    }
1662}