1use std::time::Duration;
4
5use anyhow::{Context, Result, bail, ensure};
6
7use crate::hel_session_manager::{SessionManagerControl, new_command_id};
8use hel::hel_state::{CheckpointMetadata, SessionRecord, SessionState};
9use hel::hel_targets::{self, CommandExecutor, ProcessExecutor};
10use hel::hel_worker::{RelayCommand, RelayExecutionState};
11
12use super::backend::backend_locator;
13use super::checkpoint::{
14 CheckpointExportPolicy, LatchExclusivity, prune_replaced_checkpoint,
15 release_projection_behind_checkpoint, verify_installed_checkpoint_gate, wait_for_relay_closed,
16};
17use super::provisioning::retire_git_broker;
18use super::worktree::{cleanup_managed_worktree, retire_managed_worktree};
19use super::{Controller, now, persist_session_record_transition_or_restore};
20
21impl Controller {
22 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)
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))
50 .await
51 }
52
53 async fn close_session_controlled_with_manager(
54 &mut self,
55 session_id: &str,
56 executor: &(impl CommandExecutor + Sync),
57 manager: Option<&SessionManagerControl>,
58 ) -> Result<bool> {
59 let previous = self
60 .state
61 .sessions
62 .get(session_id)
63 .with_context(|| format!("unknown session {session_id}"))?
64 .clone();
65 let record = self.state.sessions.get_mut(session_id).unwrap();
66 apply_close_checkpoint_started(record, now());
70 self.persist_session_transition_or_restore(
71 session_id,
72 &previous,
73 "persist closing state before checkpointing the session",
74 )?;
75
76 let mut latched = match self
79 .checkpoint_session_latched(
80 session_id,
81 executor,
82 manager,
83 LatchExclusivity::HoldThroughClose,
84 CheckpointExportPolicy::ReuseUnchangedArchive,
85 )
86 .await
87 {
88 Ok(latched) => latched,
89 Err(error) => {
90 let record = self.state.sessions.get_mut(session_id).unwrap();
91 record.state = previous.state;
92 record.updated_at = now();
93 record.last_checkpoint_error = Some(format!("{error:#}"));
94 return Err(
95 self.persist_failed_checkpoint_state_or_restore(session_id, &previous, error)
96 );
97 }
98 };
99
100 let artifact = latched.artifact.clone();
101 let record = self.state.sessions.get_mut(session_id).unwrap();
102 record.state = SessionState::Closing;
103 record.native_session_id = Some(artifact.native_session_id.clone());
104 record.checkpoint = Some(artifact.metadata.clone());
105 record.updated_at = now();
106 record.last_error = None;
107 record.last_checkpoint_error = None;
108 self.persist_checkpoint_transition_or_restore(
109 session_id,
110 &previous,
111 "persist verified checkpoint and closing state before sealing the relay",
112 )?;
113 prune_replaced_checkpoint(previous.checkpoint.as_ref(), &artifact.metadata);
114 release_projection_behind_checkpoint(session_id, &artifact.metadata);
117
118 let close_command_id = new_command_id("close")?;
119 let barrier_command_id = latched.barrier_command_id.clone();
120 if let Err(error) = latched
121 .relay
122 .connection_mut()
123 .submit(
124 close_command_id,
125 RelayCommand::Close {
126 barrier_command_id: barrier_command_id.clone(),
127 expected: latched.cursor.clone(),
128 },
129 )
130 .await
131 {
132 self.record_interrupted_close(session_id, &error)?;
133 return Err(error.context("seal verified checkpoint for close"));
134 }
135 if let Err(error) = latched
136 .relay
137 .connection_mut()
138 .submit(
139 new_command_id("checkpoint-complete")?,
140 RelayCommand::CompleteCheckpoint { barrier_command_id },
141 )
142 .await
143 {
144 self.record_interrupted_close(session_id, &error)?;
145 return Err(error.context("release verified close checkpoint"));
146 }
147 if let Err(error) = wait_for_relay_closed(latched.relay.connection_mut()).await {
148 self.record_interrupted_close(session_id, &error)?;
149 return Err(error);
150 }
151 latched.relay.release();
152
153 match self.destroy_after_verified_checkpoint(session_id, &artifact.metadata, executor) {
154 Ok(deferred) => Ok(deferred),
155 Err(error) => {
156 self.record_interrupted_close(session_id, &error)?;
157 Err(error)
158 }
159 }
160 }
161
162 pub async fn recover_interrupted_close_managed(
168 &mut self,
169 session_id: &str,
170 executor: &(impl CommandExecutor + Sync),
171 manager: &SessionManagerControl,
172 ) -> Result<bool> {
173 let (state, verified) = {
174 let session = self
175 .state
176 .sessions
177 .get(session_id)
178 .with_context(|| format!("unknown session {session_id}"))?;
179 ensure!(
180 matches!(
181 session.state,
182 SessionState::Closing | SessionState::Destroying
183 ),
184 "session {session_id} has no interrupted close to recover"
185 );
186 (session.state, session.checkpoint.clone())
187 };
188 if state == SessionState::Destroying {
189 let verified = verified.context("destroying session has no verified checkpoint")?;
190 return self.destroy_after_verified_checkpoint(session_id, &verified, executor);
191 }
192 ensure!(
193 state == SessionState::Closing,
194 "session {session_id} has no relay close to recover"
195 );
196 let handle = manager
197 .wait_for_session(session_id, Duration::from_secs(5))
198 .await?;
199 let mut lease = handle.lease_connection().await?;
200 let execution = lease.connection_mut().sync().await?.operational.execution;
201 match execution {
202 RelayExecutionState::Closed => {}
203 RelayExecutionState::Closing => {
204 wait_for_relay_closed(lease.connection_mut()).await?;
205 }
206 RelayExecutionState::Idle | RelayExecutionState::Running => {
207 lease.release();
208 return self
209 .close_session_controlled_with_manager(session_id, executor, Some(manager))
210 .await;
211 }
212 }
213 lease.release();
214 let verified = verified.context("closed relay has no verified checkpoint")?;
215 self.destroy_after_verified_checkpoint(session_id, &verified, executor)
216 }
217
218 fn record_interrupted_close(&mut self, session_id: &str, error: &anyhow::Error) -> Result<()> {
219 let record = self.state.sessions.get_mut(session_id).unwrap();
220 apply_interrupted_close_error(record, error, &now());
221 self.persist_session_state(session_id)
222 }
223
224 fn destroy_after_verified_checkpoint(
227 &mut self,
228 session_id: &str,
229 verified: &CheckpointMetadata,
230 executor: &impl CommandExecutor,
231 ) -> Result<bool> {
232 self.destroy_after_verified_checkpoint_with(
233 session_id,
234 verified,
235 executor,
236 hel::hel_database::save_lifecycle_session,
237 )
238 }
239
240 fn destroy_after_verified_checkpoint_with(
241 &mut self,
242 session_id: &str,
243 verified: &CheckpointMetadata,
244 executor: &impl CommandExecutor,
245 persist: impl Fn(&SessionRecord) -> Result<()>,
246 ) -> Result<bool> {
247 let session = self
248 .state
249 .sessions
250 .get(session_id)
251 .with_context(|| format!("unknown session {session_id}"))?
252 .clone();
253 ensure!(
254 matches!(
255 session.state,
256 SessionState::Closing | SessionState::Destroying
257 ),
258 "refusing to destroy session {session_id}: it is not closing or destroying"
259 );
260 ensure!(
261 session.checkpoint.as_ref() == Some(verified),
262 "refusing to destroy session {session_id}: verified checkpoint gate is stale"
263 );
264 if session.state == SessionState::Closing {
265 let record = self.state.sessions.get_mut(session_id).unwrap();
266 record.state = SessionState::Destroying;
267 record.updated_at = now();
268 record.last_error = None;
269 persist_session_record_transition_or_restore(
270 &mut self.state,
271 session_id,
272 &session,
273 "persist destroying state before target cleanup",
274 &persist,
275 )?;
276 }
277
278 let destroying = self
279 .state
280 .sessions
281 .get(session_id)
282 .expect("destroying session disappeared")
283 .clone();
284 verify_installed_checkpoint_gate(session_id, verified)?;
285 if let Err(error) = hel::hel_database::lose_reviewer_continuity(session_id) {
290 tracing::warn!(
291 session_id,
292 error = format!("{error:#}"),
293 "could not record that the second-opinion conversation ends with this target"
294 );
295 }
296 retire_git_broker(session_id).context("stop the session's local Git broker")?;
300 let locator = destroying
301 .target
302 .as_ref()
303 .context("session has no target")?;
304 let backend = backend_locator(locator, &destroying, &self.config)?;
305 let deferred = if let Some(plan) = hel_targets::quiesce_plan(&backend, session_id)? {
306 plan.execute(executor)?;
307 true
308 } else {
309 execute_target_cleanup(&backend, session_id, executor)?;
310 false
311 };
312 if let Some(worktree) = &destroying.managed_worktree {
313 retire_managed_worktree(executor, worktree)
314 .context("retire managed raw-session worktree after verified close")?;
315 }
316 let record = self.state.sessions.get_mut(session_id).unwrap();
317 record.state = SessionState::Stopped;
318 if !deferred {
319 record.target = None;
320 }
321 record.updated_at = now();
322 record.last_error = None;
323 persist_session_record_transition_or_restore(
324 &mut self.state,
325 session_id,
326 &destroying,
327 "persist stopped state after target cleanup",
328 &persist,
329 )?;
330 Ok(deferred)
331 }
332
333 pub fn cleanup_stopped_target(
337 &mut self,
338 session_id: &str,
339 executor: &impl CommandExecutor,
340 ) -> Result<()> {
341 self.cleanup_stopped_target_with(
342 session_id,
343 executor,
344 hel::hel_database::save_lifecycle_session,
345 )
346 }
347
348 fn cleanup_stopped_target_with(
349 &mut self,
350 session_id: &str,
351 executor: &impl CommandExecutor,
352 persist: impl Fn(&SessionRecord) -> Result<()>,
353 ) -> Result<()> {
354 let previous = self
355 .state
356 .sessions
357 .get(session_id)
358 .with_context(|| format!("unknown session {session_id}"))?
359 .clone();
360 ensure!(
361 previous.state == SessionState::Stopped,
362 "refusing deferred cleanup for active session {session_id}"
363 );
364 let Some(locator) = previous.target.as_ref() else {
365 return Ok(());
366 };
367 let backend = backend_locator(locator, &previous, &self.config)?;
368 ensure!(
369 hel_targets::quiesce_plan(&backend, session_id)?.is_some(),
370 "session {session_id} retained a non-Podman target after stopping"
371 );
372 execute_target_cleanup(&backend, session_id, executor)?;
373 let record = self.state.sessions.get_mut(session_id).unwrap();
374 record.target = None;
375 record.updated_at = now();
376 record.last_error = None;
377 persist_session_record_transition_or_restore(
378 &mut self.state,
379 session_id,
380 &previous,
381 "persist completion of deferred Podman target cleanup",
382 &persist,
383 )
384 }
385
386 pub fn force_stop(
389 &mut self,
390 session_id: &str,
391 executor: &impl CommandExecutor,
392 ) -> Result<bool> {
393 self.force_stop_with(
394 session_id,
395 executor,
396 hel::hel_database::save_lifecycle_session,
397 )
398 }
399
400 fn force_stop_with(
401 &mut self,
402 session_id: &str,
403 executor: &impl CommandExecutor,
404 persist: impl Fn(&SessionRecord) -> Result<()>,
405 ) -> Result<bool> {
406 let session = self
407 .state
408 .sessions
409 .get(session_id)
410 .with_context(|| format!("unknown session {session_id}"))?
411 .clone();
412 ensure!(
413 session.state.is_active(),
414 "session {session_id} is already inactive"
415 );
416 let checkpoint = session
417 .checkpoint
418 .as_ref()
419 .context("force stop requires an existing recovery archive")?;
420 verify_installed_checkpoint_gate(session_id, checkpoint)
423 .context("verify the recovery archive before force stopping")?;
424 retire_git_broker(session_id).context("stop the session's local Git broker")?;
425 let mut deferred = false;
426 if let Some(locator) = &session.target {
427 let backend = backend_locator(locator, &session, &self.config)?;
428 if let Some(plan) = hel_targets::quiesce_plan(&backend, session_id)? {
429 plan.execute(executor)?;
430 deferred = true;
431 } else {
432 execute_target_cleanup(&backend, session_id, executor)?;
433 }
434 }
435 if let Some(worktree) = &session.managed_worktree {
436 retire_managed_worktree(executor, worktree)
437 .context("retire managed raw-session worktree after force stop")?;
438 }
439 let record = self.state.sessions.get_mut(session_id).unwrap();
440 record.state = SessionState::Stopped;
441 if !deferred {
442 record.target = None;
443 }
444 record.updated_at = now();
445 record.last_error = None;
446 record.last_checkpoint_error = None;
447 persist_session_record_transition_or_restore(
448 &mut self.state,
449 session_id,
450 &session,
451 "persist stopped state after force stopping the current target",
452 &persist,
453 )?;
454 Ok(deferred)
455 }
456
457 pub fn destroy_session_controlled(
461 &mut self,
462 session_id: &str,
463 executor: &impl CommandExecutor,
464 ) -> Result<()> {
465 let session = self
466 .state
467 .sessions
468 .get(session_id)
469 .with_context(|| format!("unknown session {session_id}"))?
470 .clone();
471 if session.state.is_active() {
472 bail!("refusing to destroy active session {session_id}");
473 }
474 retire_git_broker(session_id).context("stop the session's local Git broker")?;
477 if let Some(worktree) = &session.managed_worktree {
478 cleanup_managed_worktree(executor, worktree)
479 .context("remove managed raw-session worktree")?;
480 }
481 if let Some(checkpoint) = &session.checkpoint
482 && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
483 && error.kind() != std::io::ErrorKind::NotFound
484 {
485 return Err(error).with_context(|| {
486 format!(
487 "remove session recovery archive {}",
488 checkpoint.archive_path.display()
489 )
490 });
491 }
492 hel::hel_database::delete_session(session_id)
493 .context("destroy stopped session in database")?;
494 self.state.destroy_stopped_session(session_id)?;
495 Ok(())
496 }
497}
498
499fn execute_target_cleanup(
500 backend: &hel_targets::TargetLocator,
501 session_id: &str,
502 executor: &impl CommandExecutor,
503) -> Result<()> {
504 if let Err(cleanup_error) = hel_targets::close_plan(backend, session_id)?.execute(executor) {
505 match hel_targets::cleanup_target_is_confirmed_absent(backend, session_id, executor) {
506 Ok(true) => {
507 tracing::warn!(
508 session_id,
509 error = format!("{cleanup_error:#}"),
510 "target cleanup command failed, but the target was confirmed absent"
511 );
512 }
513 Ok(false) => {
514 tracing::error!(
515 session_id,
516 error = format!("{cleanup_error:#}"),
517 "target cleanup failed and the target is still present"
518 );
519 return Err(cleanup_error);
520 }
521 Err(probe_error) => {
522 tracing::error!(
523 session_id,
524 cleanup_error = format!("{cleanup_error:#}"),
525 probe_error = format!("{probe_error:#}"),
526 "target cleanup failed and exact absence could not be confirmed"
527 );
528 return Err(cleanup_error.context(format!(
529 "target cleanup failed and exact absence could not be confirmed: {probe_error:#}"
530 )));
531 }
532 }
533 }
534 Ok(())
535}
536
537fn apply_close_checkpoint_started(record: &mut SessionRecord, updated_at: String) {
538 record.state = SessionState::Closing;
539 record.updated_at = updated_at;
540 record.last_checkpoint_error = None;
541}
542
543fn apply_interrupted_close_error(
544 record: &mut SessionRecord,
545 error: &anyhow::Error,
546 updated_at: &str,
547) {
548 let destroying = record.state == SessionState::Destroying;
549 if !destroying {
550 record.state = SessionState::Closing;
551 }
552 record.updated_at = updated_at.to_owned();
553 record.last_error = Some(if destroying {
554 format!("target cleanup is safely retryable from its verified checkpoint: {error:#}")
555 } else {
556 format!("close is safely resumable from its verified checkpoint: {error:#}")
557 });
558}
559
560#[cfg(test)]
561mod tests {
562 use std::cell::RefCell;
563 use std::collections::BTreeMap;
564
565 use anyhow::Result;
566
567 use crate::hel_controller::Controller;
568 use crate::hel_controller::test_support::{
569 checkpoint_test_session, committed_repository, managed_worktree_session, test_git,
570 write_checkpoint_gate_archive,
571 };
572 use hel::hel_config::{ContainerTemplate as ConfigContainer, HelConfig, TargetTemplate};
573 use hel::hel_state::{HelState, SessionState, TargetLocator};
574 use hel::hel_targets::{self, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
575
576 use super::*;
577
578 #[test]
579 fn starting_close_persists_its_intent_before_checkpointing() {
580 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
581 session.state = SessionState::Running;
582 session.last_checkpoint_error = Some("old failure".into());
583
584 apply_close_checkpoint_started(&mut session, "2026-08-14T12:00:00Z".into());
585
586 assert_eq!(session.state, SessionState::Closing);
587 assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
588 assert!(session.last_checkpoint_error.is_none());
589 }
590 #[test]
591 fn target_cleanup_persists_destroying_and_rechecks_the_installed_archive() {
592 struct RecordingExecutor {
593 commands: RefCell<Vec<CommandSpec>>,
594 }
595
596 impl CommandExecutor for RecordingExecutor {
597 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
598 self.commands.borrow_mut().push(command.clone());
599 Ok(CommandOutput {
600 status: 0,
601 stdout: Vec::new(),
602 stderr: Vec::new(),
603 })
604 }
605 }
606
607 let directory = tempfile::tempdir().unwrap();
608 let session_id = "0123456789abcdef0123456789abcdef";
609 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
610 let mut session = checkpoint_test_session(session_id);
611 session.target_template_id = "local".into();
612 session.state = SessionState::Closing;
613 session.target = Some(TargetLocator::LocalBare {
614 worker_root: directory.path().join(session_id),
615 });
616 session.checkpoint = Some(checkpoint.clone());
617 let mut config = HelConfig::default();
618 config
619 .targets
620 .insert("local".into(), TargetTemplate::LocalBare);
621 let mut controller = Controller {
622 config,
623 state: HelState {
624 sessions: BTreeMap::from([(session_id.into(), session)]),
625 ..HelState::default()
626 },
627 };
628 let executor = RecordingExecutor {
629 commands: RefCell::new(Vec::new()),
630 };
631 let persisted = RefCell::new(Vec::new());
632
633 controller
634 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
635 persisted.borrow_mut().push(record.state);
636 Ok(())
637 })
638 .unwrap();
639
640 assert_eq!(
641 persisted.into_inner(),
642 vec![SessionState::Destroying, SessionState::Stopped]
643 );
644 assert_eq!(executor.commands.borrow().len(), 1);
645 let stopped = &controller.state.sessions[session_id];
646 assert_eq!(stopped.state, SessionState::Stopped);
647 assert!(stopped.target.is_none());
648 }
649
650 #[test]
651 fn podman_close_persists_stopped_before_deferred_storage_cleanup() {
652 struct RecordingExecutor {
653 commands: RefCell<Vec<CommandSpec>>,
654 }
655
656 impl CommandExecutor for RecordingExecutor {
657 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
658 self.commands.borrow_mut().push(command.clone());
659 Ok(CommandOutput {
660 status: 0,
661 stdout: Vec::new(),
662 stderr: Vec::new(),
663 })
664 }
665 }
666
667 let directory = tempfile::tempdir().unwrap();
668 let session_id = "0123456789abcdef0123456789abcdef";
669 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
670 let container_id = hel_targets::resource_name(session_id).unwrap();
671 let volume = format!("{container_id}-workspace");
672 let mut session = checkpoint_test_session(session_id);
673 session.target_template_id = "podman".into();
674 session.state = SessionState::Closing;
675 session.target = Some(TargetLocator::LocalPodman {
676 container_id,
677 workspace_storage: hel::hel_state::PodmanWorkspaceLocator::Volume {
678 name: volume.clone(),
679 },
680 });
681 session.checkpoint = Some(checkpoint.clone());
682 let mut config = HelConfig::default();
683 config.targets.insert(
684 "podman".into(),
685 TargetTemplate::LocalPodman {
686 container: ConfigContainer {
687 image: "test:latest".into(),
688 pull_policy: Default::default(),
689 platform: None,
690 cpus: None,
691 memory: None,
692 environment: BTreeMap::new(),
693 workspace_storage: hel::hel_config::PodmanWorkspaceStorage::PodmanVolume,
694 },
695 },
696 );
697 let mut controller = Controller {
698 config,
699 state: HelState {
700 sessions: BTreeMap::from([(session_id.into(), session)]),
701 ..HelState::default()
702 },
703 };
704 let executor = RecordingExecutor {
705 commands: RefCell::new(Vec::new()),
706 };
707 let persisted = RefCell::new(Vec::new());
708
709 let deferred = controller
710 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
711 persisted
712 .borrow_mut()
713 .push((record.state, record.target.is_some()));
714 Ok(())
715 })
716 .unwrap();
717
718 assert!(deferred);
719 assert_eq!(
720 persisted.borrow().as_slice(),
721 &[
722 (SessionState::Destroying, true),
723 (SessionState::Stopped, true)
724 ]
725 );
726 let commands = executor.commands.borrow();
727 assert_eq!(commands.len(), 1);
728 assert!(commands[0].args[1].contains("podman stop --time 0"));
729 assert!(!commands[0].args[1].contains("podman rm"));
730 drop(commands);
731
732 controller
733 .cleanup_stopped_target_with(session_id, &executor, |record| {
734 assert_eq!(record.state, SessionState::Stopped);
735 assert!(record.target.is_none());
736 Ok(())
737 })
738 .unwrap();
739
740 let commands = executor.commands.borrow();
741 assert_eq!(commands.len(), 4);
742 assert_eq!(
743 commands[1].stage,
744 Some(hel_targets::ProvisionStage::RemovingContainer)
745 );
746 assert_eq!(
747 commands[2].stage,
748 Some(hel_targets::ProvisionStage::RemovingStorage)
749 );
750 assert_eq!(
751 commands[3].stage,
752 Some(hel_targets::ProvisionStage::CleaningCache)
753 );
754 assert!(commands[2].args.contains(&volume));
755 assert!(controller.state.sessions[session_id].target.is_none());
756 }
757 #[test]
758 fn verified_close_retires_managed_checkout_but_keeps_archive_and_branch() {
759 let archive_directory = tempfile::tempdir().unwrap();
760 let repository = committed_repository();
761 let session_id = "0123456789abcdef0123456789abcdef";
762 let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
763 let mut session = managed_worktree_session(repository.path(), session_id);
764 let worktree = session.managed_worktree.clone().unwrap();
765 std::fs::write(worktree.worktree_root.join("dirty.txt"), "worktree state\n").unwrap();
766 session.state = SessionState::Closing;
767 session.target = Some(TargetLocator::LocalBare {
768 worker_root: archive_directory.path().join(session_id),
769 });
770 session.checkpoint = Some(checkpoint.clone());
771 let mut config = HelConfig::default();
772 config
773 .targets
774 .insert("local-bare".into(), TargetTemplate::LocalBare);
775 let mut controller = Controller {
776 config,
777 state: HelState {
778 sessions: BTreeMap::from([(session_id.into(), session)]),
779 ..HelState::default()
780 },
781 };
782
783 controller
784 .destroy_after_verified_checkpoint_with(
785 session_id,
786 &checkpoint,
787 &ProcessExecutor,
788 |_| Ok(()),
789 )
790 .unwrap();
791
792 assert!(!worktree.worktree_root.exists());
793 assert!(checkpoint.archive_path.is_file());
794 assert_eq!(
795 test_git(
796 repository.path(),
797 &[
798 "show-ref",
799 "--hash",
800 &format!("refs/heads/{}", worktree.branch),
801 ],
802 )
803 .len(),
804 40
805 );
806 assert_eq!(
807 controller.state.sessions[session_id].state,
808 SessionState::Stopped
809 );
810 }
811 #[test]
812 fn force_stop_reuses_verified_archive_and_leaves_session_resumable() {
813 let archive_directory = tempfile::tempdir().unwrap();
814 let repository = committed_repository();
815 let session_id = "0123456789abcdef0123456789abcdef";
816 let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
817 let mut session = managed_worktree_session(repository.path(), session_id);
818 let worktree = session.managed_worktree.clone().unwrap();
819 session.state = SessionState::Running;
820 session.target = Some(TargetLocator::LocalBare {
821 worker_root: archive_directory.path().join(session_id),
822 });
823 session.checkpoint = Some(checkpoint.clone());
824 let mut config = HelConfig::default();
825 config
826 .targets
827 .insert("local-bare".into(), TargetTemplate::LocalBare);
828 let mut controller = Controller {
829 config,
830 state: HelState {
831 sessions: BTreeMap::from([(session_id.into(), session)]),
832 ..HelState::default()
833 },
834 };
835
836 controller
837 .force_stop_with(session_id, &ProcessExecutor, |_| Ok(()))
838 .unwrap();
839
840 let stopped = &controller.state.sessions[session_id];
841 assert_eq!(stopped.state, SessionState::Stopped);
842 assert!(stopped.target.is_none());
843 assert_eq!(stopped.checkpoint.as_ref(), Some(&checkpoint));
844 assert!(checkpoint.archive_path.is_file());
845 assert!(!worktree.worktree_root.exists());
846 assert!(
847 !test_git(
848 repository.path(),
849 &[
850 "show-ref",
851 "--hash",
852 &format!("refs/heads/{}", worktree.branch),
853 ],
854 )
855 .is_empty()
856 );
857 }
858 #[test]
859 fn force_stop_without_a_recovery_archive_does_not_touch_the_target() {
860 struct RecordingExecutor {
861 calls: RefCell<usize>,
862 }
863
864 impl CommandExecutor for RecordingExecutor {
865 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
866 *self.calls.borrow_mut() += 1;
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 mut session = checkpoint_test_session(session_id);
878 session.state = SessionState::Running;
879 session.checkpoint = None;
880 session.target_template_id = "local".into();
881 session.target = Some(TargetLocator::LocalBare {
882 worker_root: directory.path().join(session_id),
883 });
884 let mut config = HelConfig::default();
885 config
886 .targets
887 .insert("local".into(), TargetTemplate::LocalBare);
888 let mut controller = Controller {
889 config,
890 state: HelState {
891 sessions: BTreeMap::from([(session_id.into(), session)]),
892 ..HelState::default()
893 },
894 };
895 let executor = RecordingExecutor {
896 calls: RefCell::new(0),
897 };
898
899 let error = controller
900 .force_stop_with(session_id, &executor, |_| Ok(()))
901 .unwrap_err();
902
903 assert!(error.to_string().contains("existing recovery archive"));
904 assert_eq!(*executor.calls.borrow(), 0);
905 assert_eq!(
906 controller.state.sessions[session_id].state,
907 SessionState::Running
908 );
909 assert!(controller.state.sessions[session_id].target.is_some());
910 }
911 #[test]
912 fn destroying_retry_blocks_cleanup_when_the_archive_gate_changed() {
913 struct RecordingExecutor {
914 calls: RefCell<usize>,
915 }
916
917 impl CommandExecutor for RecordingExecutor {
918 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
919 *self.calls.borrow_mut() += 1;
920 Ok(CommandOutput {
921 status: 0,
922 stdout: Vec::new(),
923 stderr: Vec::new(),
924 })
925 }
926 }
927
928 let directory = tempfile::tempdir().unwrap();
929 let repository = committed_repository();
930 let session_id = "0123456789abcdef0123456789abcdef";
931 let mut checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
932 checkpoint.event_frontier = 8;
933 let mut session = managed_worktree_session(repository.path(), session_id);
934 let worktree = session.managed_worktree.clone().unwrap();
935 session.target_template_id = "local".into();
936 session.state = SessionState::Destroying;
937 session.target = Some(TargetLocator::LocalBare {
938 worker_root: directory.path().join(session_id),
939 });
940 session.checkpoint = Some(checkpoint.clone());
941 let mut config = HelConfig::default();
942 config
943 .targets
944 .insert("local".into(), TargetTemplate::LocalBare);
945 let mut controller = Controller {
946 config,
947 state: HelState {
948 sessions: BTreeMap::from([(session_id.into(), session)]),
949 ..HelState::default()
950 },
951 };
952 let executor = RecordingExecutor {
953 calls: RefCell::new(0),
954 };
955 let persisted = RefCell::new(Vec::new());
956
957 let error = controller
958 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
959 persisted.borrow_mut().push(record.state);
960 Ok(())
961 })
962 .unwrap_err();
963
964 assert!(error.to_string().contains("checkpoint frontier changed"));
965 assert_eq!(*executor.calls.borrow(), 0);
966 assert!(persisted.into_inner().is_empty());
967 assert!(worktree.worktree_root.is_dir());
968 assert_eq!(
969 controller.state.sessions[session_id].state,
970 SessionState::Destroying
971 );
972 }
973 #[test]
974 fn destroying_retry_finalizes_when_apple_container_is_confirmed_absent() {
975 struct AlreadyRemovedExecutor {
976 commands: RefCell<Vec<CommandSpec>>,
977 }
978
979 impl CommandExecutor for AlreadyRemovedExecutor {
980 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
981 self.commands.borrow_mut().push(command.clone());
982 if command.program == "sh"
983 && command
984 .args
985 .get(1)
986 .is_some_and(|script| script.contains("container rm --force"))
987 {
988 Ok(CommandOutput {
989 status: 1,
990 stdout: Vec::new(),
991 stderr: b"container not found".to_vec(),
992 })
993 } else {
994 Ok(CommandOutput {
995 status: 0,
996 stdout: Vec::new(),
997 stderr: Vec::new(),
998 })
999 }
1000 }
1001 }
1002
1003 let directory = tempfile::tempdir().unwrap();
1004 let session_id = "0123456789abcdef0123456789abcdef";
1005 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1006 let mut session = checkpoint_test_session(session_id);
1007 session.target_template_id = "apple".into();
1008 session.state = SessionState::Destroying;
1009 session.target = Some(TargetLocator::AppleContainer {
1010 container_id: hel_targets::resource_name(session_id).unwrap(),
1011 });
1012 session.checkpoint = Some(checkpoint.clone());
1013 let mut config = HelConfig::default();
1014 config.targets.insert(
1015 "apple".into(),
1016 TargetTemplate::AppleContainer {
1017 container: ConfigContainer {
1018 image: "test:latest".into(),
1019 pull_policy: Default::default(),
1020 platform: None,
1021 cpus: None,
1022 memory: None,
1023 environment: BTreeMap::new(),
1024 workspace_storage: Default::default(),
1025 },
1026 },
1027 );
1028 let mut controller = Controller {
1029 config,
1030 state: HelState {
1031 sessions: BTreeMap::from([(session_id.into(), session)]),
1032 ..HelState::default()
1033 },
1034 };
1035 let executor = AlreadyRemovedExecutor {
1036 commands: RefCell::new(Vec::new()),
1037 };
1038 let persisted = RefCell::new(Vec::new());
1039
1040 controller
1041 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1042 persisted.borrow_mut().push(record.state);
1043 Ok(())
1044 })
1045 .unwrap();
1046
1047 let commands = executor.commands.borrow();
1048 assert_eq!(commands.len(), 2);
1049 assert_eq!(commands[0].program, "sh");
1050 assert!(commands[0].args[1].contains("container rm --force"));
1051 assert!(commands[0].args[1].contains(".cache/mjolnir/git/sessions"));
1052 assert_eq!(commands[1].args, ["list", "--all", "--quiet"]);
1053 assert_eq!(persisted.into_inner(), vec![SessionState::Stopped]);
1054 assert_eq!(
1055 controller.state.sessions[session_id].state,
1056 SessionState::Stopped
1057 );
1058 }
1059 #[cfg(unix)]
1064 #[test]
1065 fn every_session_ending_retires_its_local_git_broker() {
1066 const RETIREMENT_TEST_CHILD: &str = "MJ_TEST_BROKER_RETIREMENT_CHILD";
1067
1068 struct SucceedingExecutor;
1069
1070 impl CommandExecutor for SucceedingExecutor {
1071 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1072 Ok(CommandOutput {
1073 status: 0,
1074 stdout: Vec::new(),
1075 stderr: Vec::new(),
1076 })
1077 }
1078 }
1079
1080 if std::env::var_os(RETIREMENT_TEST_CHILD).is_none() {
1083 let directory = tempfile::tempdir().unwrap();
1084 let test_name = format!(
1085 "{}::every_session_ending_retires_its_local_git_broker",
1086 module_path!()
1087 .strip_prefix("mj_controller::")
1088 .unwrap_or(module_path!())
1089 );
1090 let output = std::process::Command::new(std::env::current_exe().unwrap())
1091 .args(["--exact", &test_name, "--nocapture"])
1092 .env(RETIREMENT_TEST_CHILD, "1")
1093 .env("MJ_DATA_DIR", directory.path())
1094 .output()
1095 .unwrap();
1096 let reported = String::from_utf8_lossy(&output.stdout).into_owned();
1097 assert!(
1098 output.status.success(),
1099 "isolated broker retirement test failed\nstdout:\n{reported}\nstderr:\n{}",
1100 String::from_utf8_lossy(&output.stderr)
1101 );
1102 assert!(
1105 reported.contains("1 passed"),
1106 "the isolated broker retirement test never ran\nstdout:\n{reported}"
1107 );
1108 return;
1109 }
1110 let _writer = hel::hel_database::install_isolated_test_writer();
1112
1113 let brokers = hel::hel_config::data_dir().join("git-brokers");
1114 std::fs::create_dir_all(&brokers).unwrap();
1115 let seed_broker = |session_id: &str| {
1116 for (extension, contents) in [
1117 ("json", "{}"),
1118 ("pid", "424242"),
1122 ("ready", "ready\n"),
1123 ("log", "broker log\n"),
1124 ] {
1125 std::fs::write(brokers.join(format!("{session_id}.{extension}")), contents)
1126 .unwrap();
1127 }
1128 };
1129 let assert_retired = |session_id: &str| {
1130 for extension in ["json", "pid", "ready"] {
1131 let path = brokers.join(format!("{session_id}.{extension}"));
1132 assert!(!path.exists(), "{} outlived its session", path.display());
1133 }
1134 assert_eq!(
1135 std::fs::read_to_string(brokers.join(format!("{session_id}.log"))).unwrap(),
1136 "broker log\n",
1137 "the broker log must survive its session"
1138 );
1139 };
1140
1141 let directory = tempfile::tempdir().unwrap();
1142 let closing = "0123456789abcdef0123456789abcdef";
1143 let force_stopped = "0123456789abcdef0123456789abcdee";
1144 let destroyed = "0123456789abcdef0123456789abcded";
1145 let checkpoint = write_checkpoint_gate_archive(directory.path(), closing, 7);
1146 let mut closing_session = checkpoint_test_session(closing);
1147 closing_session.target_template_id = "local".into();
1148 closing_session.state = SessionState::Closing;
1149 closing_session.target = Some(TargetLocator::LocalBare {
1150 worker_root: directory.path().join(closing),
1151 });
1152 closing_session.checkpoint = Some(checkpoint.clone());
1153 let force_stop_checkpoint =
1154 write_checkpoint_gate_archive(directory.path(), force_stopped, 7);
1155 let mut force_stopped_session = checkpoint_test_session(force_stopped);
1156 force_stopped_session.target_template_id = "local".into();
1157 force_stopped_session.state = SessionState::Running;
1158 force_stopped_session.target = Some(TargetLocator::LocalBare {
1159 worker_root: directory.path().join(force_stopped),
1160 });
1161 force_stopped_session.checkpoint = Some(force_stop_checkpoint);
1162 let mut destroyed_session = checkpoint_test_session(destroyed);
1163 destroyed_session.target_template_id = "local".into();
1164 destroyed_session.state = SessionState::Stopped;
1165 let mut config = HelConfig::default();
1166 config
1167 .targets
1168 .insert("local".into(), TargetTemplate::LocalBare);
1169 let mut controller = Controller {
1170 config,
1171 state: HelState {
1172 sessions: BTreeMap::from([
1173 (closing.into(), closing_session),
1174 (force_stopped.into(), force_stopped_session),
1175 (destroyed.into(), destroyed_session),
1176 ]),
1177 ..HelState::default()
1178 },
1179 };
1180 for session_id in [closing, force_stopped, destroyed] {
1181 seed_broker(session_id);
1182 }
1183
1184 controller
1185 .destroy_after_verified_checkpoint_with(
1186 closing,
1187 &checkpoint,
1188 &SucceedingExecutor,
1189 |_| Ok(()),
1190 )
1191 .unwrap();
1192 assert_retired(closing);
1193
1194 controller
1195 .force_stop_with(force_stopped, &SucceedingExecutor, |_| Ok(()))
1196 .unwrap();
1197 assert_retired(force_stopped);
1198
1199 controller
1200 .destroy_session_controlled(destroyed, &SucceedingExecutor)
1201 .unwrap();
1202 assert_retired(destroyed);
1203 }
1204 #[test]
1205 fn interrupted_close_error_preserves_destroying_phase() {
1206 let session_id = "0123456789abcdef0123456789abcdef";
1207 let mut session = checkpoint_test_session(session_id);
1208 session.state = SessionState::Destroying;
1209
1210 apply_interrupted_close_error(
1211 &mut session,
1212 &anyhow::anyhow!("podman unavailable"),
1213 "2026-08-14T12:00:00Z",
1214 );
1215
1216 assert_eq!(session.state, SessionState::Destroying);
1217 assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
1218 assert!(
1219 session
1220 .last_error
1221 .as_deref()
1222 .is_some_and(|error| error.contains("cleanup is safely retryable"))
1223 );
1224 }
1225}