Skip to main content

mj_controller/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::session_manager::{SessionManagerControl, new_command_id};
8use mj_core::state::{CheckpointMetadata, SessionRecord, SessionState};
9
10use crate::targets::{self, CommandExecutor, ProcessExecutor, ProvisionStage, ProvisionStageGuard};
11use mj_core::relay::{RelayCommand, RelayExecutionState};
12
13use super::backend::backend_locator;
14use super::checkpoint::{
15    CheckpointExportPolicy, LatchExclusivity, prune_replaced_checkpoint,
16    release_projection_behind_checkpoint, verify_installed_checkpoint_gate, wait_for_relay_closed,
17};
18use super::worktree::{cleanup_managed_worktree, retire_managed_worktree};
19use super::{Controller, now, persist_session_record_transition_or_restore};
20
21impl Controller {
22    /// Checkpoint, ask the harness to close, and only then tear down the exact
23    /// provisioned target. Checkpoint failure is deliberately non-destructive.
24    pub async fn close_session(&mut self, session_id: &str) -> Result<()> {
25        self.close_session_controlled(session_id, &ProcessExecutor)
26            .await
27    }
28
29    pub async fn close_session_controlled(
30        &mut self,
31        session_id: &str,
32        executor: &(impl CommandExecutor + Sync),
33    ) -> Result<()> {
34        if self
35            .close_session_controlled_with_manager(session_id, executor, None, None)
36            .await?
37        {
38            self.cleanup_stopped_target(session_id, executor)?;
39        }
40        Ok(())
41    }
42
43    pub async fn close_session_managed_controlled(
44        &mut self,
45        session_id: &str,
46        executor: &(impl CommandExecutor + Sync),
47        manager: &SessionManagerControl,
48    ) -> Result<bool> {
49        self.close_session_controlled_with_manager(session_id, executor, Some(manager), None)
50            .await
51    }
52
53    pub(super) async fn close_session_for_move(
54        &mut self,
55        session_id: &str,
56        executor: &(impl CommandExecutor + Sync),
57        manager: &SessionManagerControl,
58        operation: &mut mj_core::state::MoveOperation,
59        preparation: Option<&mj_core::state::MovePreparation>,
60    ) -> Result<bool> {
61        self.prepare_move_source_checkpoint(session_id, executor, manager, operation)
62            .await?;
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 mj_core::state::MoveOperation,
79            Option<&mj_core::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            crate::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            crate::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 target_mutex = crate::recovery_gate::worker_target_mutex(session_id);
305        let _target_guard = target_mutex.lock().map_err(|_| {
306            anyhow::anyhow!("worker target ownership lock poisoned for {session_id}")
307        })?;
308        let session = self
309            .state
310            .sessions
311            .get(session_id)
312            .with_context(|| format!("unknown session {session_id}"))?
313            .clone();
314        ensure!(
315            matches!(
316                session.state,
317                SessionState::Closing | SessionState::Destroying
318            ),
319            "refusing to destroy session {session_id}: it is not closing or destroying"
320        );
321        ensure!(
322            session.checkpoint.as_ref() == Some(verified),
323            "refusing to destroy session {session_id}: verified checkpoint gate is stale"
324        );
325        if session.state == SessionState::Closing {
326            let record = self.state.sessions.get_mut(session_id).unwrap();
327            record.state = SessionState::Destroying;
328            record.updated_at = now();
329            record.last_error = None;
330            persist_session_record_transition_or_restore(
331                &mut self.state,
332                session_id,
333                &session,
334                "persist destroying state before target cleanup",
335                &persist,
336            )?;
337        }
338
339        let destroying = self
340            .state
341            .sessions
342            .get(session_id)
343            .expect("destroying session disappeared")
344            .clone();
345        {
346            let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
347            verify_installed_checkpoint_gate(session_id, verified)?;
348        }
349        // The reviewer's native session lives on the target that is about to
350        // go. Recording that now, before the target is torn down, is what
351        // stops a resumed session from trying to reload a conversation that no
352        // longer exists; its transcript is kept for reference either way.
353        if let Err(error) = crate::database::lose_reviewer_continuity(session_id) {
354            tracing::warn!(
355                session_id,
356                error = format!("{error:#}"),
357                "could not record that the second-opinion conversation ends with this target"
358            );
359        }
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 self.state.subagents.contains_key(session_id) {
366            targets::borrowed_worker_cleanup_plan(&backend, session_id)?.execute(executor)?;
367            false
368        } else if let Some(plan) = targets::quiesce_plan(&backend, session_id)? {
369            plan.execute(executor)?;
370            true
371        } else {
372            execute_target_cleanup(&backend, session_id, executor)?;
373            false
374        };
375        if let Some(worktree) = &destroying.managed_worktree {
376            retire_managed_worktree(executor, worktree)
377                .context("retire managed raw-session worktree after verified close")?;
378        }
379        let record = self.state.sessions.get_mut(session_id).unwrap();
380        record.state = SessionState::Stopped;
381        if !deferred {
382            record.target = None;
383        }
384        record.updated_at = now();
385        record.last_error = None;
386        persist_session_record_transition_or_restore(
387            &mut self.state,
388            session_id,
389            &destroying,
390            "persist stopped state after target cleanup",
391            &persist,
392        )?;
393        Ok(deferred)
394    }
395
396    /// Finish storage cleanup for a stopped Podman target retained by the
397    /// quiescence transition. The locator stays durable until every command
398    /// succeeds, making daemon restart and explicit retry idempotent.
399    pub fn cleanup_stopped_target(
400        &mut self,
401        session_id: &str,
402        executor: &impl CommandExecutor,
403    ) -> Result<()> {
404        self.cleanup_stopped_target_with(
405            session_id,
406            executor,
407            crate::database::save_lifecycle_session,
408        )
409    }
410
411    fn cleanup_stopped_target_with(
412        &mut self,
413        session_id: &str,
414        executor: &impl CommandExecutor,
415        persist: impl Fn(&SessionRecord) -> Result<()>,
416    ) -> Result<()> {
417        let target_mutex = crate::recovery_gate::worker_target_mutex(session_id);
418        let _target_guard = target_mutex.lock().map_err(|_| {
419            anyhow::anyhow!("worker target ownership lock poisoned for {session_id}")
420        })?;
421        let previous = self
422            .state
423            .sessions
424            .get(session_id)
425            .with_context(|| format!("unknown session {session_id}"))?
426            .clone();
427        ensure!(
428            previous.state == SessionState::Stopped,
429            "refusing deferred cleanup for active session {session_id}"
430        );
431        let Some(locator) = previous.target.as_ref() else {
432            return Ok(());
433        };
434        let backend = backend_locator(locator, &previous, &self.config)?;
435        ensure!(
436            targets::quiesce_plan(&backend, session_id)?.is_some(),
437            "session {session_id} retained a non-Podman target after stopping"
438        );
439        if let Err(error) = execute_target_cleanup(&backend, session_id, executor) {
440            let record = self.state.sessions.get_mut(session_id).unwrap();
441            record.updated_at = now();
442            record.last_error = Some(format!("deferred target cleanup failed: {error:#}"));
443            let persisted = persist_session_record_transition_or_restore(
444                &mut self.state,
445                session_id,
446                &previous,
447                "persist deferred target cleanup failure",
448                &persist,
449            );
450            return match persisted {
451                Ok(()) => Err(error),
452                Err(persist_error) => Err(error.context(format!(
453                    "also failed to persist deferred target cleanup failure: {persist_error:#}"
454                ))),
455            };
456        }
457        let record = self.state.sessions.get_mut(session_id).unwrap();
458        record.target = None;
459        record.updated_at = now();
460        record.last_error = None;
461        persist_session_record_transition_or_restore(
462            &mut self.state,
463            session_id,
464            &previous,
465            "persist completion of deferred Podman target cleanup",
466            &persist,
467        )
468    }
469
470    /// Tear down the current target without taking a fresh checkpoint, then
471    /// leave the logical session resumable from its latest verified archive.
472    pub fn force_stop(
473        &mut self,
474        session_id: &str,
475        executor: &impl CommandExecutor,
476    ) -> Result<bool> {
477        self.force_stop_with(
478            session_id,
479            executor,
480            crate::database::save_lifecycle_session,
481        )
482    }
483
484    fn force_stop_with(
485        &mut self,
486        session_id: &str,
487        executor: &impl CommandExecutor,
488        persist: impl Fn(&SessionRecord) -> Result<()>,
489    ) -> Result<bool> {
490        let session = self
491            .state
492            .sessions
493            .get(session_id)
494            .with_context(|| format!("unknown session {session_id}"))?
495            .clone();
496        ensure!(
497            session.state.is_active(),
498            "session {session_id} is already inactive"
499        );
500        let checkpoint = session
501            .checkpoint
502            .as_ref()
503            .context("force stop requires an existing recovery archive")?;
504        // Force stop skips a new checkpoint, never the checksum gate on the
505        // archive that makes the logical session resumable afterwards.
506        {
507            let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
508            verify_installed_checkpoint_gate(session_id, checkpoint)
509                .context("verify the recovery archive before force stopping")?;
510        }
511        let mut deferred = false;
512        if let Some(locator) = &session.target {
513            let backend = backend_locator(locator, &session, &self.config)?;
514            if let Some(plan) = targets::quiesce_plan(&backend, session_id)? {
515                plan.execute(executor)?;
516                deferred = true;
517            } else {
518                execute_target_cleanup(&backend, session_id, executor)?;
519            }
520        }
521        if let Some(worktree) = &session.managed_worktree {
522            retire_managed_worktree(executor, worktree)
523                .context("retire managed raw-session worktree after force stop")?;
524        }
525        let record = self.state.sessions.get_mut(session_id).unwrap();
526        record.state = SessionState::Stopped;
527        if !deferred {
528            record.target = None;
529        }
530        record.updated_at = now();
531        record.last_error = None;
532        record.last_checkpoint_error = None;
533        persist_session_record_transition_or_restore(
534            &mut self.state,
535            session_id,
536            &session,
537            "persist stopped state after force stopping the current target",
538            &persist,
539        )?;
540        Ok(deferred)
541    }
542
543    /// Permanently destroy an inactive session and every artifact Hel owns for it.
544    /// External cleanup happens before the durable record is dropped so failures
545    /// remain visible and retryable.
546    pub fn destroy_session_controlled(
547        &mut self,
548        session_id: &str,
549        executor: &impl CommandExecutor,
550    ) -> Result<()> {
551        let session = self
552            .state
553            .sessions
554            .get(session_id)
555            .with_context(|| format!("unknown session {session_id}"))?
556            .clone();
557        if session.state.is_active() {
558            bail!("refusing to destroy active session {session_id}");
559        }
560        if let Some(worktree) = &session.managed_worktree {
561            cleanup_managed_worktree(executor, worktree)
562                .context("remove managed raw-session worktree")?;
563        }
564        if let Some(checkpoint) = &session.checkpoint
565            && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
566            && error.kind() != std::io::ErrorKind::NotFound
567        {
568            return Err(error).with_context(|| {
569                format!(
570                    "remove session recovery archive {}",
571                    checkpoint.archive_path.display()
572                )
573            });
574        }
575        mj_core::attachment::AttachmentStore::controller(session_id)?
576            .remove_session_data()
577            .context("remove session image attachments")?;
578        crate::database::delete_session(session_id)
579            .context("destroy stopped session in database")?;
580        self.state.subagents.remove(session_id);
581        self.state.destroy_stopped_session(session_id)?;
582        Ok(())
583    }
584
585    /// Permanently destroy a session from any state, without checkpointing
586    /// and without requiring a recovery archive.
587    ///
588    /// Unlike [`Controller::destroy_session_controlled`], this accepts active
589    /// states: it tears the live target down with the same close plan a
590    /// verified close uses, so the owning process group dies before any files
591    /// go. External cleanup happens before the durable record is dropped so
592    /// failures stay visible and retryable; the recovery archive is removed,
593    /// which is what makes the destruction irreversible.
594    pub fn force_destroy_session(
595        &mut self,
596        session_id: &str,
597        executor: &impl CommandExecutor,
598    ) -> Result<()> {
599        self.force_destroy_session_with(session_id, executor, crate::database::delete_session)
600    }
601
602    fn force_destroy_session_with(
603        &mut self,
604        session_id: &str,
605        executor: &impl CommandExecutor,
606        delete: impl Fn(&str) -> Result<()>,
607    ) -> Result<()> {
608        let session = self
609            .state
610            .sessions
611            .get(session_id)
612            .with_context(|| format!("unknown session {session_id}"))?
613            .clone();
614        // A session destroyed for good keeps nothing, including a broker an
615        // earlier failure left running; retiring it first also stops a live
616        // writer from recreating files under the teardown below.
617        if let Some(locator) = &session.target {
618            let backend = backend_locator(locator, &session, &self.config)?;
619            if self.state.subagents.contains_key(session_id) {
620                targets::borrowed_worker_cleanup_plan(&backend, session_id)?.execute(executor)?;
621            } else {
622                execute_target_cleanup(&backend, session_id, executor)?;
623            }
624        }
625        if let Some(worktree) = &session.managed_worktree {
626            cleanup_managed_worktree(executor, worktree)
627                .context("remove managed raw-session worktree")?;
628        }
629        if let Some(checkpoint) = &session.checkpoint
630            && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
631            && error.kind() != std::io::ErrorKind::NotFound
632        {
633            return Err(error).with_context(|| {
634                format!(
635                    "remove session recovery archive {}",
636                    checkpoint.archive_path.display()
637                )
638            });
639        }
640        mj_core::attachment::AttachmentStore::controller(session_id)?
641            .remove_session_data()
642            .context("remove session image attachments")?;
643        delete(session_id).context("force destroy session in database")?;
644        self.state.subagents.remove(session_id);
645        self.state.destroy_session_force(session_id)?;
646        Ok(())
647    }
648}
649
650fn execute_target_cleanup(
651    backend: &targets::TargetLocator,
652    session_id: &str,
653    executor: &impl CommandExecutor,
654) -> Result<()> {
655    if let Err(cleanup_error) = targets::close_plan(backend, session_id)?.execute(executor) {
656        match targets::cleanup_target_is_confirmed_absent(backend, session_id, executor) {
657            Ok(true) => {
658                tracing::warn!(
659                    session_id,
660                    error = format!("{cleanup_error:#}"),
661                    "target cleanup command failed, but the target was confirmed absent"
662                );
663            }
664            Ok(false) => {
665                tracing::error!(
666                    session_id,
667                    error = format!("{cleanup_error:#}"),
668                    "target cleanup failed and the target is still present"
669                );
670                return Err(cleanup_error);
671            }
672            Err(probe_error) => {
673                tracing::error!(
674                    session_id,
675                    cleanup_error = format!("{cleanup_error:#}"),
676                    probe_error = format!("{probe_error:#}"),
677                    "target cleanup failed and exact absence could not be confirmed"
678                );
679                return Err(cleanup_error.context(format!(
680                    "target cleanup failed and exact absence could not be confirmed: {probe_error:#}"
681                )));
682            }
683        }
684    }
685    Ok(())
686}
687
688fn apply_close_checkpoint_started(record: &mut SessionRecord, updated_at: String) {
689    record.state = SessionState::Closing;
690    record.updated_at = updated_at;
691    record.last_checkpoint_error = None;
692}
693
694fn apply_interrupted_close_error(
695    record: &mut SessionRecord,
696    error: &anyhow::Error,
697    updated_at: &str,
698) {
699    let destroying = record.state == SessionState::Destroying;
700    if !destroying {
701        record.state = SessionState::Closing;
702    }
703    record.updated_at = updated_at.to_owned();
704    record.last_error = Some(if destroying {
705        format!("target cleanup is safely retryable from its verified checkpoint: {error:#}")
706    } else {
707        format!("close is safely resumable from its verified checkpoint: {error:#}")
708    });
709}
710
711#[cfg(test)]
712mod tests {
713    use std::cell::RefCell;
714    use std::collections::BTreeMap;
715
716    use anyhow::Result;
717
718    use crate::controller::Controller;
719    use crate::controller::test_support::{
720        checkpoint_test_session, committed_repository, managed_worktree_session, test_git,
721        write_checkpoint_gate_archive,
722    };
723    use mj_core::config::{Config, ContainerTemplate as ConfigContainer, TargetTemplate};
724    use mj_core::state::{SessionState, State, TargetLocator};
725
726    use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
727
728    use super::*;
729
730    #[test]
731    fn starting_close_persists_its_intent_before_checkpointing() {
732        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
733        session.state = SessionState::Running;
734        session.last_checkpoint_error = Some("old failure".into());
735
736        apply_close_checkpoint_started(&mut session, "2026-08-14T12:00:00Z".into());
737
738        assert_eq!(session.state, SessionState::Closing);
739        assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
740        assert!(session.last_checkpoint_error.is_none());
741    }
742
743    struct DeferredCleanupExecutor {
744        statuses: RefCell<Vec<i32>>,
745    }
746
747    impl CommandExecutor for DeferredCleanupExecutor {
748        fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
749            let status = self
750                .statuses
751                .borrow_mut()
752                .pop()
753                .expect("test cleanup command status");
754            Ok(CommandOutput {
755                status,
756                stdout: Vec::new(),
757                stderr: if status != 0 {
758                    b"cleanup failed".to_vec()
759                } else {
760                    Vec::new()
761                },
762            })
763        }
764    }
765
766    fn stopped_podman_cleanup_controller(session_id: &str) -> Controller {
767        let container_id = targets::resource_name(session_id).unwrap();
768        let volume = format!("{container_id}-workspace");
769        let mut session = checkpoint_test_session(session_id);
770        session.target_template_id = "podman".into();
771        session.state = SessionState::Stopped;
772        session.target = Some(TargetLocator::LocalPodman {
773            container_id,
774            workspace_storage: mj_core::state::PodmanWorkspaceLocator::Volume { name: volume },
775        });
776        let mut config = Config::default();
777        config.targets.insert(
778            "podman".into(),
779            TargetTemplate::LocalPodman {
780                container: ConfigContainer {
781                    image: "test:latest".into(),
782                    pull_policy: Default::default(),
783                    platform: None,
784                    cpus: None,
785                    memory: None,
786                    environment: BTreeMap::new(),
787                    workspace_storage: mj_core::config::PodmanWorkspaceStorage::PodmanVolume,
788                },
789            },
790        );
791        Controller {
792            config,
793            state: State {
794                sessions: BTreeMap::from([(session_id.into(), session)]),
795                ..State::default()
796            },
797        }
798    }
799
800    #[test]
801    fn deferred_cleanup_failure_is_visible_and_successful_retry_clears_it() {
802        let session_id = "0123456789abcdef0123456789abcdef";
803        let mut controller = stopped_podman_cleanup_controller(session_id);
804        let persisted = RefCell::new(Vec::new());
805        let failure = controller
806            .cleanup_stopped_target_with(
807                session_id,
808                &DeferredCleanupExecutor {
809                    // The cleanup command fails, then the exact-absence probe
810                    // confirms that the owned target is still present.
811                    statuses: RefCell::new(vec![1, 1]),
812                },
813                |record| {
814                    persisted
815                        .borrow_mut()
816                        .push((record.target.is_some(), record.last_error.clone()));
817                    Ok(())
818                },
819            )
820            .unwrap_err();
821        assert!(format!("{failure:#}").contains("cleanup failed"));
822        assert_eq!(persisted.borrow().len(), 1);
823        assert!(persisted.borrow()[0].0);
824        assert!(
825            persisted.borrow()[0]
826                .1
827                .as_deref()
828                .is_some_and(|error| error.contains("deferred target cleanup failed"))
829        );
830        assert!(controller.state.sessions[session_id].target.is_some());
831        assert!(controller.state.sessions[session_id].last_error.is_some());
832
833        let retry_persisted = RefCell::new(Vec::new());
834        controller
835            .cleanup_stopped_target_with(
836                session_id,
837                &DeferredCleanupExecutor {
838                    statuses: RefCell::new(vec![0, 0, 0]),
839                },
840                |record| {
841                    retry_persisted
842                        .borrow_mut()
843                        .push((record.target.is_some(), record.last_error.clone()));
844                    Ok(())
845                },
846            )
847            .unwrap();
848        assert_eq!(retry_persisted.borrow().as_slice(), &[(false, None)]);
849        assert!(controller.state.sessions[session_id].target.is_none());
850        assert!(controller.state.sessions[session_id].last_error.is_none());
851    }
852
853    #[test]
854    fn deferred_cleanup_persistence_failure_restores_the_stopped_record() {
855        let session_id = "0123456789abcdef0123456789abcdef";
856        let mut controller = stopped_podman_cleanup_controller(session_id);
857        let previous = controller.state.sessions[session_id].clone();
858        let failure = controller
859            .cleanup_stopped_target_with(
860                session_id,
861                &DeferredCleanupExecutor {
862                    statuses: RefCell::new(vec![1, 1]),
863                },
864                |_| Err(anyhow::anyhow!("database unavailable")),
865            )
866            .unwrap_err();
867        let detail = format!("{failure:#}");
868        assert!(detail.contains("cleanup failed"), "{detail}");
869        assert!(detail.contains("database unavailable"), "{detail}");
870        assert_eq!(controller.state.sessions[session_id], previous);
871    }
872
873    #[test]
874    fn target_cleanup_persists_destroying_and_rechecks_the_installed_archive() {
875        struct RecordingExecutor {
876            commands: RefCell<Vec<CommandSpec>>,
877        }
878
879        impl CommandExecutor for RecordingExecutor {
880            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
881                self.commands.borrow_mut().push(command.clone());
882                Ok(CommandOutput {
883                    status: 0,
884                    stdout: Vec::new(),
885                    stderr: Vec::new(),
886                })
887            }
888        }
889
890        let directory = tempfile::tempdir().unwrap();
891        let session_id = "0123456789abcdef0123456789abcdef";
892        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
893        let mut session = checkpoint_test_session(session_id);
894        session.target_template_id = "local".into();
895        session.state = SessionState::Closing;
896        session.target = Some(TargetLocator::LocalBare {
897            worker_root: directory.path().join(session_id),
898        });
899        session.checkpoint = Some(checkpoint.clone());
900        let mut config = Config::default();
901        config
902            .targets
903            .insert("local".into(), TargetTemplate::LocalBare);
904        let mut controller = Controller {
905            config,
906            state: State {
907                sessions: BTreeMap::from([(session_id.into(), session)]),
908                ..State::default()
909            },
910        };
911        let executor = RecordingExecutor {
912            commands: RefCell::new(Vec::new()),
913        };
914        let persisted = RefCell::new(Vec::new());
915
916        controller
917            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
918                persisted.borrow_mut().push(record.state);
919                Ok(())
920            })
921            .unwrap();
922
923        assert_eq!(
924            persisted.into_inner(),
925            vec![SessionState::Destroying, SessionState::Stopped]
926        );
927        assert_eq!(executor.commands.borrow().len(), 1);
928        let stopped = &controller.state.sessions[session_id];
929        assert_eq!(stopped.state, SessionState::Stopped);
930        assert!(stopped.target.is_none());
931    }
932
933    #[test]
934    fn destruction_waits_for_recovery_and_failed_cleanup_never_restarts_the_target() {
935        use crate::session_manager::{
936            WorkerRecoveryOutcome, WorkerRecoveryPlan, recover_worker_controlled,
937        };
938        use std::sync::{Mutex, mpsc};
939        const CHILD: &str = "MJ_DESTRUCTION_RECOVERY_TEST_CHILD";
940        const TEST: &str = "controller::lifecycle::tests::destruction_waits_for_recovery_and_failed_cleanup_never_restarts_the_target";
941        if std::env::var_os(CHILD).is_none() {
942            let directory = tempfile::tempdir().unwrap();
943            let output = std::process::Command::new(std::env::current_exe().unwrap())
944                .args(["--exact", TEST, "--nocapture"])
945                .env(CHILD, "1")
946                .env("MJ_DATA_DIR", directory.path())
947                .env("MJ_CONFIG_DIR", directory.path().join("config"))
948                .output()
949                .unwrap();
950            assert!(
951                output.status.success(),
952                "{}\n{}",
953                String::from_utf8_lossy(&output.stdout),
954                String::from_utf8_lossy(&output.stderr)
955            );
956            return;
957        }
958        let _writer = crate::database::install_isolated_test_writer();
959        let directory = tempfile::tempdir().unwrap();
960        let session_id = "0123456789abcdef0123456789abcdef";
961        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
962        let mut controller = stopped_podman_cleanup_controller(session_id);
963        let record = controller.state.sessions.get_mut(session_id).unwrap();
964        record.state = SessionState::Closing;
965        record.checkpoint = Some(checkpoint.clone());
966        crate::database::save_session(record).unwrap();
967        let plan = WorkerRecoveryPlan {
968            source_target: record.target.clone().unwrap(),
969            target: None,
970            workspace: None,
971            liveness_probe: CommandSpec::new("probe", std::iter::empty::<&str>()),
972            binary_refresh: None,
973            launch_refresh: None,
974            restart: targets::CommandPlan {
975                description: "restart worker".into(),
976                commands: vec![CommandSpec::new("restart", std::iter::empty::<&str>())],
977            },
978        };
979        struct PausedRecovery {
980            entered: mpsc::Sender<()>,
981            release: Mutex<mpsc::Receiver<()>>,
982            restarted: Mutex<bool>,
983        }
984        impl CommandExecutor for PausedRecovery {
985            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
986                if command.program == "probe" {
987                    self.entered.send(()).unwrap();
988                    self.release
989                        .lock()
990                        .unwrap()
991                        .recv_timeout(Duration::from_secs(5))
992                        .unwrap();
993                } else {
994                    assert_eq!(command.program, "restart");
995                    assert_eq!(crate::database::load_state().unwrap().sessions["0123456789abcdef0123456789abcdef"].state,
996                        SessionState::Closing, "recovery must finish before destruction is persisted");
997                    *self.restarted.lock().unwrap() = true;
998                }
999                Ok(CommandOutput {
1000                    status: 0,
1001                    stdout: b"dead\n".to_vec(),
1002                    stderr: Vec::new(),
1003                })
1004            }
1005        }
1006        let (entered_tx, entered_rx) = mpsc::channel();
1007        let (release_tx, release_rx) = mpsc::channel();
1008        let recovery = PausedRecovery {
1009            entered: entered_tx,
1010            release: Mutex::new(release_rx),
1011            restarted: Mutex::new(false),
1012        };
1013        let (persisted_tx, persisted_rx) = mpsc::channel();
1014        std::thread::scope(|scope| {
1015            let recovering = scope.spawn(|| {
1016                recover_worker_controlled(plan.clone(), false, Some(session_id), &recovery)
1017            });
1018            entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1019            let closing = scope.spawn(|| {
1020                controller.destroy_after_verified_checkpoint_with(
1021                    session_id,
1022                    &checkpoint,
1023                    &DeferredCleanupExecutor {
1024                        statuses: RefCell::new(vec![125]),
1025                    },
1026                    |record| {
1027                        assert!(*recovery.restarted.lock().unwrap());
1028                        crate::database::save_lifecycle_session(record)?;
1029                        persisted_tx.send(record.state).unwrap();
1030                        Ok(())
1031                    },
1032                )
1033            });
1034            assert!(
1035                persisted_rx
1036                    .recv_timeout(Duration::from_millis(50))
1037                    .is_err()
1038            );
1039            release_tx.send(()).unwrap();
1040            assert_eq!(
1041                recovering.join().unwrap().unwrap(),
1042                WorkerRecoveryOutcome::RestartedDead
1043            );
1044            assert!(closing.join().unwrap().is_err());
1045        });
1046        assert_eq!(
1047            persisted_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
1048            SessionState::Destroying
1049        );
1050        assert_eq!(
1051            crate::database::load_state().unwrap().sessions[session_id].state,
1052            SessionState::Destroying
1053        );
1054        assert_eq!(
1055            controller.state.sessions[session_id].checkpoint.as_ref(),
1056            Some(&checkpoint)
1057        );
1058        // An empty executor panics if a stale retry attempts any target command.
1059        for _ in 0..3 {
1060            assert!(crate::pollers::dashboard_worker_targets(&controller).is_empty());
1061            assert_eq!(
1062                recover_worker_controlled(
1063                    plan.clone(),
1064                    true,
1065                    Some(session_id),
1066                    &DeferredCleanupExecutor {
1067                        statuses: RefCell::new(Vec::new())
1068                    }
1069                )
1070                .unwrap(),
1071                WorkerRecoveryOutcome::Suppressed
1072            );
1073        }
1074        assert!(
1075            controller
1076                .destroy_after_verified_checkpoint_with(
1077                    session_id,
1078                    &checkpoint,
1079                    &DeferredCleanupExecutor {
1080                        statuses: RefCell::new(vec![0])
1081                    },
1082                    crate::database::save_lifecycle_session
1083                )
1084                .unwrap()
1085        );
1086        assert_eq!(
1087            crate::database::load_state().unwrap().sessions[session_id].state,
1088            SessionState::Stopped
1089        );
1090        assert_eq!(
1091            mj_checkpoint::checkpoint::checkpoint_sha256(&checkpoint.archive_path).unwrap(),
1092            checkpoint.sha256
1093        );
1094    }
1095
1096    #[test]
1097    fn podman_close_persists_stopped_before_deferred_storage_cleanup() {
1098        struct RecordingExecutor {
1099            commands: RefCell<Vec<CommandSpec>>,
1100        }
1101
1102        impl CommandExecutor for RecordingExecutor {
1103            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1104                self.commands.borrow_mut().push(command.clone());
1105                Ok(CommandOutput {
1106                    status: 0,
1107                    stdout: Vec::new(),
1108                    stderr: Vec::new(),
1109                })
1110            }
1111        }
1112
1113        let directory = tempfile::tempdir().unwrap();
1114        let session_id = "0123456789abcdef0123456789abcdef";
1115        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1116        let container_id = targets::resource_name(session_id).unwrap();
1117        let volume = format!("{container_id}-workspace");
1118        let mut session = checkpoint_test_session(session_id);
1119        session.target_template_id = "podman".into();
1120        session.state = SessionState::Closing;
1121        session.target = Some(TargetLocator::LocalPodman {
1122            container_id,
1123            workspace_storage: mj_core::state::PodmanWorkspaceLocator::Volume {
1124                name: volume.clone(),
1125            },
1126        });
1127        session.checkpoint = Some(checkpoint.clone());
1128        let mut config = Config::default();
1129        config.targets.insert(
1130            "podman".into(),
1131            TargetTemplate::LocalPodman {
1132                container: ConfigContainer {
1133                    image: "test:latest".into(),
1134                    pull_policy: Default::default(),
1135                    platform: None,
1136                    cpus: None,
1137                    memory: None,
1138                    environment: BTreeMap::new(),
1139                    workspace_storage: mj_core::config::PodmanWorkspaceStorage::PodmanVolume,
1140                },
1141            },
1142        );
1143        let mut controller = Controller {
1144            config,
1145            state: State {
1146                sessions: BTreeMap::from([(session_id.into(), session)]),
1147                ..State::default()
1148            },
1149        };
1150        let executor = RecordingExecutor {
1151            commands: RefCell::new(Vec::new()),
1152        };
1153        let persisted = RefCell::new(Vec::new());
1154
1155        let deferred = controller
1156            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1157                persisted
1158                    .borrow_mut()
1159                    .push((record.state, record.target.is_some()));
1160                Ok(())
1161            })
1162            .unwrap();
1163
1164        assert!(deferred);
1165        assert_eq!(
1166            persisted.borrow().as_slice(),
1167            &[
1168                (SessionState::Destroying, true),
1169                (SessionState::Stopped, true)
1170            ]
1171        );
1172        let commands = executor.commands.borrow();
1173        assert_eq!(commands.len(), 1);
1174        assert!(commands[0].args[1].contains("podman stop --time 0"));
1175        assert!(!commands[0].args[1].contains("podman rm"));
1176        drop(commands);
1177
1178        controller
1179            .cleanup_stopped_target_with(session_id, &executor, |record| {
1180                assert_eq!(record.state, SessionState::Stopped);
1181                assert!(record.target.is_none());
1182                Ok(())
1183            })
1184            .unwrap();
1185
1186        let commands = executor.commands.borrow();
1187        assert_eq!(commands.len(), 4);
1188        assert_eq!(
1189            commands[1].stage,
1190            Some(targets::ProvisionStage::RemovingContainer)
1191        );
1192        assert_eq!(
1193            commands[2].stage,
1194            Some(targets::ProvisionStage::RemovingStorage)
1195        );
1196        assert_eq!(
1197            commands[3].stage,
1198            Some(targets::ProvisionStage::CleaningCache)
1199        );
1200        assert!(commands[2].args.contains(&volume));
1201        assert!(controller.state.sessions[session_id].target.is_none());
1202    }
1203    #[test]
1204    fn verified_close_retires_managed_checkout_but_keeps_archive_and_branch() {
1205        let archive_directory = tempfile::tempdir().unwrap();
1206        let repository = committed_repository();
1207        let session_id = "0123456789abcdef0123456789abcdef";
1208        let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
1209        let mut session = managed_worktree_session(repository.path(), session_id);
1210        let worktree = session.managed_worktree.clone().unwrap();
1211        std::fs::write(worktree.worktree_root.join("dirty.txt"), "worktree state\n").unwrap();
1212        session.state = SessionState::Closing;
1213        session.target = Some(TargetLocator::LocalBare {
1214            worker_root: archive_directory.path().join(session_id),
1215        });
1216        session.checkpoint = Some(checkpoint.clone());
1217        let mut config = Config::default();
1218        config
1219            .targets
1220            .insert("local-bare".into(), TargetTemplate::LocalBare);
1221        let mut controller = Controller {
1222            config,
1223            state: State {
1224                sessions: BTreeMap::from([(session_id.into(), session)]),
1225                ..State::default()
1226            },
1227        };
1228
1229        controller
1230            .destroy_after_verified_checkpoint_with(
1231                session_id,
1232                &checkpoint,
1233                &ProcessExecutor,
1234                |_| Ok(()),
1235            )
1236            .unwrap();
1237
1238        assert!(!worktree.worktree_root.exists());
1239        assert!(checkpoint.archive_path.is_file());
1240        assert_eq!(
1241            test_git(
1242                repository.path(),
1243                &[
1244                    "show-ref",
1245                    "--hash",
1246                    &format!("refs/heads/{}", worktree.branch),
1247                ],
1248            )
1249            .len(),
1250            40
1251        );
1252        assert_eq!(
1253            controller.state.sessions[session_id].state,
1254            SessionState::Stopped
1255        );
1256    }
1257    #[test]
1258    fn force_stop_reuses_verified_archive_and_leaves_session_resumable() {
1259        let archive_directory = tempfile::tempdir().unwrap();
1260        let repository = committed_repository();
1261        let session_id = "0123456789abcdef0123456789abcdef";
1262        let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
1263        let mut session = managed_worktree_session(repository.path(), session_id);
1264        let worktree = session.managed_worktree.clone().unwrap();
1265        session.state = SessionState::Running;
1266        session.target = Some(TargetLocator::LocalBare {
1267            worker_root: archive_directory.path().join(session_id),
1268        });
1269        session.checkpoint = Some(checkpoint.clone());
1270        let mut config = Config::default();
1271        config
1272            .targets
1273            .insert("local-bare".into(), TargetTemplate::LocalBare);
1274        let mut controller = Controller {
1275            config,
1276            state: State {
1277                sessions: BTreeMap::from([(session_id.into(), session)]),
1278                ..State::default()
1279            },
1280        };
1281
1282        controller
1283            .force_stop_with(session_id, &ProcessExecutor, |_| Ok(()))
1284            .unwrap();
1285
1286        let stopped = &controller.state.sessions[session_id];
1287        assert_eq!(stopped.state, SessionState::Stopped);
1288        assert!(stopped.target.is_none());
1289        assert_eq!(stopped.checkpoint.as_ref(), Some(&checkpoint));
1290        assert!(checkpoint.archive_path.is_file());
1291        assert!(!worktree.worktree_root.exists());
1292        assert!(
1293            !test_git(
1294                repository.path(),
1295                &[
1296                    "show-ref",
1297                    "--hash",
1298                    &format!("refs/heads/{}", worktree.branch),
1299                ],
1300            )
1301            .is_empty()
1302        );
1303    }
1304    #[test]
1305    fn force_stop_without_a_recovery_archive_does_not_touch_the_target() {
1306        struct RecordingExecutor {
1307            calls: RefCell<usize>,
1308        }
1309
1310        impl CommandExecutor for RecordingExecutor {
1311            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1312                *self.calls.borrow_mut() += 1;
1313                Ok(CommandOutput {
1314                    status: 0,
1315                    stdout: Vec::new(),
1316                    stderr: Vec::new(),
1317                })
1318            }
1319        }
1320
1321        let directory = tempfile::tempdir().unwrap();
1322        let session_id = "0123456789abcdef0123456789abcdef";
1323        let mut session = checkpoint_test_session(session_id);
1324        session.state = SessionState::Running;
1325        session.checkpoint = None;
1326        session.target_template_id = "local".into();
1327        session.target = Some(TargetLocator::LocalBare {
1328            worker_root: directory.path().join(session_id),
1329        });
1330        let mut config = Config::default();
1331        config
1332            .targets
1333            .insert("local".into(), TargetTemplate::LocalBare);
1334        let mut controller = Controller {
1335            config,
1336            state: State {
1337                sessions: BTreeMap::from([(session_id.into(), session)]),
1338                ..State::default()
1339            },
1340        };
1341        let executor = RecordingExecutor {
1342            calls: RefCell::new(0),
1343        };
1344
1345        let error = controller
1346            .force_stop_with(session_id, &executor, |_| Ok(()))
1347            .unwrap_err();
1348
1349        assert!(error.to_string().contains("existing recovery archive"));
1350        assert_eq!(*executor.calls.borrow(), 0);
1351        assert_eq!(
1352            controller.state.sessions[session_id].state,
1353            SessionState::Running
1354        );
1355        assert!(controller.state.sessions[session_id].target.is_some());
1356    }
1357    #[test]
1358    fn destroying_retry_blocks_cleanup_when_the_archive_gate_changed() {
1359        struct RecordingExecutor {
1360            calls: RefCell<usize>,
1361        }
1362
1363        impl CommandExecutor for RecordingExecutor {
1364            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1365                *self.calls.borrow_mut() += 1;
1366                Ok(CommandOutput {
1367                    status: 0,
1368                    stdout: Vec::new(),
1369                    stderr: Vec::new(),
1370                })
1371            }
1372        }
1373
1374        let directory = tempfile::tempdir().unwrap();
1375        let repository = committed_repository();
1376        let session_id = "0123456789abcdef0123456789abcdef";
1377        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1378        let mut session = managed_worktree_session(repository.path(), session_id);
1379        let worktree = session.managed_worktree.clone().unwrap();
1380        session.target_template_id = "local".into();
1381        session.state = SessionState::Destroying;
1382        session.target = Some(TargetLocator::LocalBare {
1383            worker_root: directory.path().join(session_id),
1384        });
1385        session.checkpoint = Some(checkpoint.clone());
1386        let mut config = Config::default();
1387        config
1388            .targets
1389            .insert("local".into(), TargetTemplate::LocalBare);
1390        let mut controller = Controller {
1391            config,
1392            state: State {
1393                sessions: BTreeMap::from([(session_id.into(), session)]),
1394                ..State::default()
1395            },
1396        };
1397        let executor = RecordingExecutor {
1398            calls: RefCell::new(0),
1399        };
1400        let persisted = RefCell::new(Vec::new());
1401        std::fs::write(&checkpoint.archive_path, b"changed after checkpoint").unwrap();
1402
1403        let error = controller
1404            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1405                persisted.borrow_mut().push(record.state);
1406                Ok(())
1407            })
1408            .unwrap_err();
1409
1410        assert!(error.to_string().contains("checkpoint SHA changed"));
1411        assert_eq!(*executor.calls.borrow(), 0);
1412        assert!(persisted.into_inner().is_empty());
1413        assert!(worktree.worktree_root.is_dir());
1414        assert_eq!(
1415            controller.state.sessions[session_id].state,
1416            SessionState::Destroying
1417        );
1418    }
1419    #[test]
1420    fn destroying_retry_finalizes_when_apple_container_is_confirmed_absent() {
1421        struct AlreadyRemovedExecutor {
1422            commands: RefCell<Vec<CommandSpec>>,
1423        }
1424
1425        impl CommandExecutor for AlreadyRemovedExecutor {
1426            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1427                self.commands.borrow_mut().push(command.clone());
1428                if command.program == "sh"
1429                    && command
1430                        .args
1431                        .get(1)
1432                        .is_some_and(|script| script.contains("container rm --force"))
1433                {
1434                    Ok(CommandOutput {
1435                        status: 1,
1436                        stdout: Vec::new(),
1437                        stderr: b"container not found".to_vec(),
1438                    })
1439                } else {
1440                    Ok(CommandOutput {
1441                        status: 0,
1442                        stdout: Vec::new(),
1443                        stderr: Vec::new(),
1444                    })
1445                }
1446            }
1447        }
1448
1449        let directory = tempfile::tempdir().unwrap();
1450        let session_id = "0123456789abcdef0123456789abcdef";
1451        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1452        let mut session = checkpoint_test_session(session_id);
1453        session.target_template_id = "apple".into();
1454        session.state = SessionState::Destroying;
1455        session.target = Some(TargetLocator::AppleContainer {
1456            container_id: targets::resource_name(session_id).unwrap(),
1457        });
1458        session.checkpoint = Some(checkpoint.clone());
1459        let mut config = Config::default();
1460        config.targets.insert(
1461            "apple".into(),
1462            TargetTemplate::AppleContainer {
1463                container: ConfigContainer {
1464                    image: "test:latest".into(),
1465                    pull_policy: Default::default(),
1466                    platform: None,
1467                    cpus: None,
1468                    memory: None,
1469                    environment: BTreeMap::new(),
1470                    workspace_storage: Default::default(),
1471                },
1472            },
1473        );
1474        let mut controller = Controller {
1475            config,
1476            state: State {
1477                sessions: BTreeMap::from([(session_id.into(), session)]),
1478                ..State::default()
1479            },
1480        };
1481        let executor = AlreadyRemovedExecutor {
1482            commands: RefCell::new(Vec::new()),
1483        };
1484        let persisted = RefCell::new(Vec::new());
1485
1486        controller
1487            .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1488                persisted.borrow_mut().push(record.state);
1489                Ok(())
1490            })
1491            .unwrap();
1492
1493        let commands = executor.commands.borrow();
1494        assert_eq!(commands.len(), 2);
1495        assert_eq!(commands[0].program, "sh");
1496        assert!(commands[0].args[1].contains("container rm --force"));
1497        assert!(commands[0].args[1].contains(".cache/mjolnir/git/sessions"));
1498        assert_eq!(commands[1].args, ["list", "--all", "--quiet"]);
1499        assert_eq!(persisted.into_inner(), vec![SessionState::Stopped]);
1500        assert_eq!(
1501            controller.state.sessions[session_id].state,
1502            SessionState::Stopped
1503        );
1504    }
1505
1506    #[test]
1507    fn interrupted_close_error_preserves_destroying_phase() {
1508        let session_id = "0123456789abcdef0123456789abcdef";
1509        let mut session = checkpoint_test_session(session_id);
1510        session.state = SessionState::Destroying;
1511
1512        apply_interrupted_close_error(
1513            &mut session,
1514            &anyhow::anyhow!("podman unavailable"),
1515            "2026-08-14T12:00:00Z",
1516        );
1517
1518        assert_eq!(session.state, SessionState::Destroying);
1519        assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
1520        assert!(
1521            session
1522                .last_error
1523                .as_deref()
1524                .is_some_and(|error| error.contains("cleanup is safely retryable"))
1525        );
1526    }
1527
1528    struct FailingExecutor;
1529
1530    impl CommandExecutor for FailingExecutor {
1531        fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1532            Ok(CommandOutput {
1533                status: 1,
1534                stdout: Vec::new(),
1535                stderr: b"teardown unavailable".to_vec(),
1536            })
1537        }
1538    }
1539
1540    fn branch_exists(repository: &std::path::Path, branch: &str) -> bool {
1541        std::process::Command::new("git")
1542            .arg("-C")
1543            .arg(repository)
1544            .args(["show-ref", "--verify", "--quiet"])
1545            .arg(format!("refs/heads/{branch}"))
1546            .output()
1547            .unwrap()
1548            .status
1549            .success()
1550    }
1551
1552    #[test]
1553    fn force_destroy_from_running_removes_target_worktree_branch_and_archive() {
1554        let directory = tempfile::tempdir().unwrap();
1555        let repository = committed_repository();
1556        let session_id = "0123456789abcdef0123456789abcdef";
1557        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1558        let worker_root = directory.path().join(session_id);
1559        std::fs::create_dir_all(&worker_root).unwrap();
1560        let mut session = managed_worktree_session(repository.path(), session_id);
1561        session.state = SessionState::Running;
1562        session.target_template_id = "local".into();
1563        session.target = Some(TargetLocator::LocalBare {
1564            worker_root: worker_root.clone(),
1565        });
1566        session.checkpoint = Some(checkpoint.clone());
1567        let mut config = Config::default();
1568        config
1569            .targets
1570            .insert("local".into(), TargetTemplate::LocalBare);
1571        let mut controller = Controller {
1572            config,
1573            state: State {
1574                sessions: BTreeMap::from([(session_id.into(), session)]),
1575                ..State::default()
1576            },
1577        };
1578        let deleted = RefCell::new(Vec::new());
1579
1580        controller
1581            .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1582                deleted.borrow_mut().push(id.to_owned());
1583                Ok(())
1584            })
1585            .unwrap();
1586
1587        assert!(!worker_root.exists(), "local target must be removed");
1588        let worktree_root = repository.path().join(".mj/worktrees").join(session_id);
1589        assert!(!worktree_root.exists(), "managed worktree must be removed");
1590        assert!(
1591            !branch_exists(repository.path(), &format!("mj/{session_id}")),
1592            "generated branch must be removed"
1593        );
1594        assert!(!checkpoint.archive_path.exists(), "archive must be removed");
1595        assert!(!controller.state.sessions.contains_key(session_id));
1596        assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1597    }
1598
1599    #[test]
1600    fn force_destroy_without_a_target_or_archive_still_removes_the_record() {
1601        let session_id = "0123456789abcdef0123456789abcdef";
1602        let mut session = checkpoint_test_session(session_id);
1603        session.state = SessionState::Provisioning;
1604        session.target = None;
1605        session.checkpoint = None;
1606        let mut controller = Controller {
1607            config: Config::default(),
1608            state: State {
1609                sessions: BTreeMap::from([(session_id.into(), session)]),
1610                ..State::default()
1611            },
1612        };
1613        let deleted = RefCell::new(Vec::new());
1614
1615        controller
1616            .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1617                deleted.borrow_mut().push(id.to_owned());
1618                Ok(())
1619            })
1620            .unwrap();
1621
1622        assert!(!controller.state.sessions.contains_key(session_id));
1623        assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1624    }
1625
1626    #[test]
1627    fn force_destroy_aborts_and_keeps_the_record_when_the_target_survives() {
1628        let directory = tempfile::tempdir().unwrap();
1629        let session_id = "0123456789abcdef0123456789abcdef";
1630        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1631        let mut session = checkpoint_test_session(session_id);
1632        session.target_template_id = "local".into();
1633        session.state = SessionState::Running;
1634        session.target = Some(TargetLocator::LocalBare {
1635            worker_root: directory.path().join(session_id),
1636        });
1637        session.checkpoint = Some(checkpoint.clone());
1638        let mut config = Config::default();
1639        config
1640            .targets
1641            .insert("local".into(), TargetTemplate::LocalBare);
1642        let mut controller = Controller {
1643            config,
1644            state: State {
1645                sessions: BTreeMap::from([(session_id.into(), session)]),
1646                ..State::default()
1647            },
1648        };
1649        let deleted = RefCell::new(Vec::new());
1650
1651        let error = controller
1652            .force_destroy_session_with(session_id, &FailingExecutor, |id| {
1653                deleted.borrow_mut().push(id.to_owned());
1654                Ok(())
1655            })
1656            .unwrap_err();
1657
1658        assert!(
1659            error.to_string().contains("teardown unavailable"),
1660            "{error:#}"
1661        );
1662        assert!(
1663            controller.state.sessions.contains_key(session_id),
1664            "a surviving target must keep the record for a retry"
1665        );
1666        assert!(
1667            checkpoint.archive_path.exists(),
1668            "a surviving target must keep the recovery archive"
1669        );
1670        assert!(deleted.into_inner().is_empty());
1671    }
1672
1673    #[test]
1674    fn force_destroy_tolerates_a_missing_archive() {
1675        let directory = tempfile::tempdir().unwrap();
1676        let session_id = "0123456789abcdef0123456789abcdef";
1677        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1678        std::fs::remove_file(&checkpoint.archive_path).unwrap();
1679        let mut session = checkpoint_test_session(session_id);
1680        session.state = SessionState::Error;
1681        session.checkpoint = Some(checkpoint);
1682        let mut controller = Controller {
1683            config: Config::default(),
1684            state: State {
1685                sessions: BTreeMap::from([(session_id.into(), session)]),
1686                ..State::default()
1687            },
1688        };
1689
1690        controller
1691            .force_destroy_session_with(session_id, &ProcessExecutor, |_| Ok(()))
1692            .unwrap();
1693
1694        assert!(!controller.state.sessions.contains_key(session_id));
1695    }
1696}