Skip to main content

mj_controller/hel_controller/
move_session.rs

1//! One recoverable stop/restore operation, independent of its initiating viewer.
2
3#[cfg(test)]
4mod tests;
5
6use anyhow::{Context, Result, bail, ensure};
7use sha2::{Digest, Sha256};
8
9use super::{Controller, SessionResumeOptions, now};
10
11fn mutation_holds() -> &'static std::sync::Mutex<std::collections::BTreeSet<String>> {
12    static HOLDS: std::sync::OnceLock<std::sync::Mutex<std::collections::BTreeSet<String>>> =
13        std::sync::OnceLock::new();
14    HOLDS.get_or_init(Default::default)
15}
16
17pub fn move_owns_session(session_id: &str) -> bool {
18    move_has_pending_queue(session_id)
19        || mutation_holds()
20            .lock()
21            .unwrap_or_else(std::sync::PoisonError::into_inner)
22            .contains(session_id)
23}
24
25fn queue_holds() -> &'static std::sync::Mutex<std::collections::BTreeSet<String>> {
26    static HOLDS: std::sync::OnceLock<std::sync::Mutex<std::collections::BTreeSet<String>>> =
27        std::sync::OnceLock::new();
28    HOLDS.get_or_init(Default::default)
29}
30
31pub fn move_has_pending_queue(session_id: &str) -> bool {
32    queue_holds()
33        .lock()
34        .unwrap_or_else(std::sync::PoisonError::into_inner)
35        .contains(session_id)
36}
37
38pub fn release_move_queue_hold(session_id: &str) {
39    queue_holds()
40        .lock()
41        .unwrap_or_else(std::sync::PoisonError::into_inner)
42        .remove(session_id);
43}
44
45pub fn restore_move_queue_hold(operation: &MoveOperation) {
46    let mut holds = queue_holds()
47        .lock()
48        .unwrap_or_else(std::sync::PoisonError::into_inner);
49    if operation.queue_admission_started && !operation.queue_admission_finished {
50        holds.insert(operation.selection.session_id.clone());
51    } else {
52        holds.remove(&operation.selection.session_id);
53    }
54}
55
56pub struct MoveMutationGuard(String);
57
58impl MoveMutationGuard {
59    pub fn reserve(session_id: &str) -> Result<Self> {
60        ensure!(
61            mutation_holds()
62                .lock()
63                .unwrap_or_else(std::sync::PoisonError::into_inner)
64                .insert(session_id.to_owned()),
65            "session already has a move owner"
66        );
67        Ok(Self(session_id.to_owned()))
68    }
69}
70
71impl Drop for MoveMutationGuard {
72    fn drop(&mut self) {
73        mutation_holds()
74            .lock()
75            .unwrap_or_else(std::sync::PoisonError::into_inner)
76            .remove(&self.0);
77    }
78}
79
80pub(crate) fn move_refuses_command(session_id: &str, command: &RelayCommand) -> bool {
81    move_owns_session(session_id)
82        && matches!(
83            command,
84            RelayCommand::Prompt { .. }
85                | RelayCommand::SetConfig { .. }
86                | RelayCommand::SetSessionMode { .. }
87                | RelayCommand::RunUserShell { .. }
88                | RelayCommand::CancelUserShell { .. }
89                | RelayCommand::Cancel
90                | RelayCommand::RemoveQueuedPrompt { .. }
91                | RelayCommand::ClearQueuedPrompts
92        )
93}
94use crate::hel_session_manager::{SessionManagerControl, StandaloneSession, new_command_id};
95use hel::hel_archive::{CanonicalQueuedCommandKind, verify_archive_streaming};
96use hel::hel_state::{MoveOperation, MovePhase, ResumeQueueDisposition, SessionState};
97pub use hel::hel_state::{MoveOutcome, MovePreparation, MoveSelection, MoveSessionRequest};
98use hel::hel_targets::{CommandExecutor, ProvisionStage, ProvisionStageGuard};
99use hel::hel_worker::RelayCommand;
100
101fn digest(value: &impl serde::Serialize) -> Result<String> {
102    Ok(format!("{:x}", Sha256::digest(serde_json::to_vec(value)?)))
103}
104
105struct MovePhaseTimer<'a> {
106    session_id: &'a str,
107    phase: &'static str,
108    started: std::time::Instant,
109}
110
111impl<'a> MovePhaseTimer<'a> {
112    fn new(session_id: &'a str, phase: &'static str) -> Self {
113        Self {
114            session_id,
115            phase,
116            started: std::time::Instant::now(),
117        }
118    }
119}
120
121impl Drop for MovePhaseTimer<'_> {
122    fn drop(&mut self) {
123        tracing::info!(
124            session_id = self.session_id,
125            phase = self.phase,
126            elapsed_ms = self.started.elapsed().as_millis() as u64,
127            "move phase finished"
128        );
129    }
130}
131
132impl Controller {
133    fn validate_move_destination_paths(
134        &self,
135        source: &hel::hel_state::SessionRecord,
136        target_id: &str,
137        executor: &(impl CommandExecutor + Sync),
138    ) -> Result<()> {
139        use super::worktree::ResumePlan;
140        match super::worktree::resume_compatibility(source, &self.config, target_id)
141            .map_err(anyhow::Error::msg)?
142        {
143            ResumePlan::RawToWorkspace => {
144                super::worktree::plan_raw_to_workspace(source, &self.config, executor)?;
145            }
146            ResumePlan::WorkspaceToRaw => {
147                self.plan_workspace_to_raw(source, target_id, executor)?;
148            }
149            ResumePlan::InPlace if source.managed_worktree.is_none() => {
150                if let Some(path) = &source.project_directory {
151                    self.validate_project_directory(target_id, path, executor)?;
152                }
153            }
154            ResumePlan::InPlace => {}
155        }
156        Ok(())
157    }
158    fn move_confirmation(
159        &self,
160        selection: &MoveSelection,
161    ) -> Result<(bool, Vec<hel::hel_state::MaterializedQueuedPrompt>, String)> {
162        let source = self
163            .state
164            .sessions
165            .get(&selection.session_id)
166            .context("unknown move session")?;
167        let (mut active, mut queued) = hel::hel_database::move_pending_work(&source.id)?;
168        if let Some(operation) = hel::hel_database::load_move_operation(&source.id)?
169            && operation.queue_admission_started
170            && !operation.queue_admission_finished
171        {
172            ensure!(
173                operation.selection == *selection,
174                "queue admission is incomplete on the live destination; retry that move before selecting another destination"
175            );
176            let checkpoint = operation
177                .checkpoint
178                .as_ref()
179                .context("retained queue checkpoint is missing")?;
180            let verified = verify_archive_streaming(&checkpoint.archive_path)?;
181            ensure!(
182                verified.archive_sha256 == checkpoint.sha256
183                    && verified.manifest.session.id == source.id,
184                "retained move checkpoint verification failed"
185            );
186            queued = verified
187                .canonical_session
188                .queued_prompts
189                .into_iter()
190                .map(|entry| hel::hel_state::MaterializedQueuedPrompt {
191                    command_id: entry.command_id,
192                    kind: match entry.kind {
193                        CanonicalQueuedCommandKind::Prompt => {
194                            hel::hel_state::QueuedCommandKind::Prompt
195                        }
196                        CanonicalQueuedCommandKind::SetConfig { key, value } => {
197                            hel::hel_state::QueuedCommandKind::SetConfig { key, value }
198                        }
199                    },
200                    content: entry.content,
201                    queued_at_ms: entry.queued_at_ms,
202                })
203                .collect();
204            active = false;
205        }
206        let fingerprint = digest(&(
207            &source.last_profile,
208            &source.target_template_id,
209            &source.target,
210            &source.native_session_id,
211            &source.resource_allocation,
212            &source.additional_mounts,
213            &source.container_cpus,
214            &source.container_memory,
215            selection,
216            self.move_configuration_fingerprint(selection)?,
217            &queued,
218        ))?;
219        Ok((active, queued, fingerprint))
220    }
221    fn move_configuration_fingerprint(&self, selection: &MoveSelection) -> Result<String> {
222        let profile = self
223            .config
224            .profiles
225            .get(
226                selection
227                    .profile_id
228                    .as_deref()
229                    .context("move profile is unresolved")?,
230            )
231            .context("move profile no longer exists")?;
232        let target = self
233            .config
234            .targets
235            .get(
236                selection
237                    .target_template_id
238                    .as_deref()
239                    .context("move target is unresolved")?,
240            )
241            .context("move target no longer exists")?;
242        // Only a digest crosses IPC or enters the move record. Configuration
243        // may contain credential-bearing values and must never be copied there.
244        let source = self
245            .state
246            .sessions
247            .get(&selection.session_id)
248            .context("unknown move session")?;
249        digest(&(
250            profile,
251            target,
252            &self.config.bundles,
253            self.config.targets.get(&source.target_template_id),
254            self.config.profiles.get(&source.last_profile),
255        ))
256    }
257
258    pub async fn prepare_move_session_controlled(
259        &self,
260        mut selection: MoveSelection,
261        executor: &(impl CommandExecutor + Sync),
262    ) -> Result<MovePreparation> {
263        ensure!(
264            selection.profile_id.is_some() || selection.target_template_id.is_some(),
265            "move requires a target or profile selection"
266        );
267        let source = self
268            .state
269            .sessions
270            .get(&selection.session_id)
271            .context("unknown session")?;
272        let previous = hel::hel_database::load_move_operation(&source.id)?;
273        let retry = previous.as_ref().is_some_and(|op| {
274            !matches!(op.phase, MovePhase::Completed) && source.checkpoint.is_some()
275        });
276        ensure!(
277            matches!(
278                source.state,
279                SessionState::Running | SessionState::Disconnected
280            ) || retry,
281            "only active sessions can move; use Resume for a stopped or lost session"
282        );
283        selection
284            .profile_id
285            .get_or_insert_with(|| source.last_profile.clone());
286        selection
287            .target_template_id
288            .get_or_insert_with(|| source.target_template_id.clone());
289        selection
290            .additional_mounts
291            .get_or_insert_with(|| source.additional_mounts.clone());
292        ensure!(
293            !selection.clear_resource_allocation || selection.resource_allocation.is_none(),
294            "select resource allocation or explicitly clear it, not both"
295        );
296        if !selection.clear_resource_allocation {
297            selection.resource_allocation = selection
298                .resource_allocation
299                .or_else(|| source.resource_allocation.clone());
300        }
301        let profile_id = selection.profile_id.as_deref().unwrap();
302        let target_id = selection.target_template_id.as_deref().unwrap();
303        let profile = self
304            .config
305            .profiles
306            .get(profile_id)
307            .context("unknown destination profile")?;
308        ensure!(
309            profile.enabled,
310            "destination profile {profile_id:?} is disabled"
311        );
312        let target = self
313            .config
314            .targets
315            .get(target_id)
316            .context("unknown destination target")?;
317        self.validate_muse_resume_destination(source, profile.kind, target_id)?;
318        super::worktree::resume_compatibility(source, &self.config, target_id)
319            .map_err(anyhow::Error::msg)?;
320        super::backend::validate_resource_allocation(
321            target,
322            selection.resource_allocation.as_ref(),
323        )?;
324        let mounts = selection.additional_mounts.as_deref().unwrap_or_default();
325        ensure!(
326            profile.kind != hel::hel_config::HarnessKind::Muse || mounts.is_empty(),
327            "Muse Code ACP supports one workspace root; attached directories are unsupported"
328        );
329        ensure!(
330            mounts.is_empty() || hel::hel_config::mount_history_host(target).is_some(),
331            "attached resources are unsupported for this target; select compatible resources explicitly"
332        );
333        hel::hel_targets::validate_additional_mounts(mounts)?;
334        for mount in mounts {
335            self.validate_mount_source(target_id, &mount.source, executor)?;
336        }
337        self.validate_move_destination_paths(source, target_id, executor)?;
338        ensure!(
339            profile.home.is_dir(),
340            "destination profile home is unavailable; configure the profile before moving"
341        );
342        super::worker_binary::preflight_worker_binary(target)?;
343        super::backend::preflight_target(target, executor)?;
344        let source_harness = previous
345            .as_ref()
346            .filter(|operation| {
347                !operation.queue_admission_started && operation.phase != MovePhase::Completed
348            })
349            .and_then(|operation| operation.recovery_session.as_ref())
350            .map_or(source.harness_kind, |record| record.harness_kind);
351        let cross_harness = profile.kind != source_harness;
352        if cross_harness {
353            let cancel = tokio_util::sync::CancellationToken::new();
354            let resolve =
355                crate::hel_utility_llm::UtilityLlmRuntime::shared().resolve(&self.config, &cancel);
356            tokio::pin!(resolve);
357            loop {
358                tokio::select! {
359                    result = &mut resolve => { result.context("cross-harness move needs an available utility model")?; break; }
360                    _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {
361                        if executor.cancellation_requested() { cancel.cancel(); bail!("move preparation cancelled"); }
362                    }
363                }
364            }
365        }
366        let (active, mut queued_commands, fingerprint) = self.move_confirmation(&selection)?;
367        // Confirmation is an inspector, not transport for attachment bytes.
368        // Replay always reads the verified archive after destination readiness.
369        for entry in &mut queued_commands {
370            for content in &mut entry.content {
371                if content.get("type").and_then(serde_json::Value::as_str) == Some("image") {
372                    let mime = content
373                        .get("mimeType")
374                        .and_then(serde_json::Value::as_str)
375                        .unwrap_or("image");
376                    *content = serde_json::json!({"type":"text", "text":format!("[Image attachment: {mime}]")});
377                }
378            }
379        }
380        let operation_id = previous
381            .as_ref()
382            .filter(|operation| {
383                operation.selection == selection
384                    && operation.phase != MovePhase::Completed
385                    && operation.checkpoint.is_some()
386            })
387            .map(|operation| operation.operation_id.clone())
388            .unwrap_or(new_command_id("move")?);
389        Ok(MovePreparation {
390            selection,
391            source_profile_id: source.last_profile.clone(),
392            source_target_template_id: source.target_template_id.clone(),
393            cross_harness,
394            active,
395            queued_commands,
396            fingerprint,
397            operation_id,
398        })
399    }
400
401    /// Called with one daemon lifecycle and recovery reservation already held.
402    pub async fn move_session_managed_controlled(
403        &mut self,
404        request: MoveSessionRequest,
405        executor: &(impl CommandExecutor + Sync),
406        manager: &SessionManagerControl,
407    ) -> Result<MoveOutcome> {
408        let prepared = &request.preparation;
409        let id = prepared.selection.session_id.clone();
410        let started = std::time::Instant::now();
411        executor.notify_notice("Checking destination");
412        let checked = {
413            let _checking_destination =
414                ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
415            let mut checked = self
416                .prepare_move_session_controlled(prepared.selection.clone(), executor)
417                .await?;
418            // Destination checks can outlast a turn. Refresh confirmation from the
419            // relay immediately before interruption, while new submissions are held.
420            if matches!(
421                self.state.sessions[&id].state,
422                SessionState::Running | SessionState::Disconnected
423            ) {
424                let source_harness = self.state.sessions[&id].harness_kind;
425                let handle = manager
426                    .wait_for_session(&id, std::time::Duration::from_secs(5))
427                    .await?;
428                handle.sync_now().await?;
429                let (active, queue, fingerprint) = self.move_confirmation(&checked.selection)?;
430                checked.active = active
431                    || handle.view().snapshot.as_ref().is_some_and(|snapshot| {
432                        let mut operational = snapshot.operational.clone();
433                        operational.queued_prompts.clear();
434                        operational.checkpoint_barrier = None;
435                        !operational.safe_to_replace(source_harness)
436                    });
437                checked.queued_commands = queue;
438                checked.fingerprint = fingerprint;
439            }
440            checked
441        };
442        ensure!(
443            checked.fingerprint == prepared.fingerprint,
444            "session, pending work, or destination configuration changed; prepare and confirm Move again"
445        );
446        let queue = request.queue.unwrap_or(ResumeQueueDisposition::Discard);
447        let source = self.state.sessions[&id].clone();
448        let old_operation = hel::hel_database::load_move_operation(&id)?;
449        let retry = old_operation.filter(|op| {
450            op.selection == prepared.selection
451                && op.phase != MovePhase::Completed
452                && op.checkpoint.is_some()
453        });
454        if retry.is_none()
455            && source.state == SessionState::Running
456            && source.last_profile == checked.selection.profile_id.as_deref().unwrap()
457            && source.target_template_id == checked.selection.target_template_id.as_deref().unwrap()
458            && Some(&source.additional_mounts) == checked.selection.additional_mounts.as_ref()
459            && source.resource_allocation == checked.selection.resource_allocation
460            && (!checked.selection.clear_resource_allocation
461                || (source.container_cpus.is_none() && source.container_memory.is_none()))
462        {
463            return Ok(outcome(
464                &prepared.operation_id,
465                &prepared.selection,
466                "unchanged",
467                None,
468                None,
469            ));
470        }
471        ensure!(
472            !checked.active || request.acknowledge_interruption,
473            "active work will be interrupted; confirm Move again with interruption acknowledgement"
474        );
475        ensure!(
476            checked.queued_commands.is_empty() || request.queue.is_some(),
477            "pending work requires an explicit queue choice: discard or start"
478        );
479        let timestamp = now();
480        let mut operation = match retry {
481            Some(mut op) => {
482                ensure!(
483                    !op.queue_admission_started || op.queue == queue,
484                    "queued work may already have run; retry with the original queue choice on the same destination"
485                );
486                hel::hel_database::clear_move_cancellation_for_retry(&id)?;
487                op.configuration_fingerprint =
488                    self.move_configuration_fingerprint(&checked.selection)?;
489                op.queue = queue;
490                op.cancellation_requested = false;
491                op.error = None;
492                op
493            }
494            None => MoveOperation {
495                operation_id: prepared.operation_id.clone(),
496                selection: checked.selection.clone(),
497                source_profile_id: source.last_profile.clone(),
498                source_target_template_id: source.target_template_id.clone(),
499                source_target: source.target.clone(),
500                source_native_session_id: source.native_session_id.clone(),
501                source_additional_mounts: source.additional_mounts.clone(),
502                source_resource_allocation: source.resource_allocation.clone(),
503                destination_target: None,
504                destination_native_session_id: None,
505                destination_store_id: None,
506                configuration_fingerprint: self
507                    .move_configuration_fingerprint(&checked.selection)?,
508                checkpoint: None,
509                recovery_session: None,
510                queue,
511                phase: MovePhase::Preparing,
512                queue_admission_started: false,
513                queue_admission_finished: false,
514                cancellation_requested: false,
515                created_at: timestamp.clone(),
516                updated_at: timestamp,
517                error: None,
518            },
519        };
520        hel::hel_database::save_move_operation(&operation)?;
521        tracing::info!(
522            session_id = id,
523            phase = "preflight",
524            elapsed_ms = started.elapsed().as_millis() as u64,
525            "move phase completed"
526        );
527        let result = self
528            .execute_move(&mut operation, Some(&checked), executor, manager)
529            .await;
530        self.finish_move_result(&mut operation, result, executor)
531    }
532
533    fn finish_move_result(
534        &self,
535        operation: &mut MoveOperation,
536        result: Result<()>,
537        executor: &impl CommandExecutor,
538    ) -> Result<MoveOutcome> {
539        let (status, error, recovery) = match result {
540            Ok(()) => {
541                operation.phase = MovePhase::Completed;
542                ("completed", None, None)
543            }
544            Err(error) => {
545                let cancelled =
546                    executor.cancellation_requested() || operation.cancellation_requested;
547                operation.phase = if cancelled {
548                    MovePhase::Cancelled
549                } else {
550                    MovePhase::Failed
551                };
552                operation.cancellation_requested = cancelled;
553                let recovery = if operation.queue_admission_started {
554                    "Destination is live; retry queue admission on this same destination. Already accepted work may have effects."
555                } else if self
556                    .state
557                    .sessions
558                    .get(&operation.selection.session_id)
559                    .is_some_and(|s| s.state == SessionState::Stopped)
560                {
561                    "Session is stopped with a verified checkpoint. Retry move or Resume with previous settings."
562                } else {
563                    "Source or partial destination is retained. Retry move after resolving the reported error."
564                };
565                let error = format!("{error:#}");
566                operation.error = Some(error.clone());
567                (
568                    if cancelled { "cancelled" } else { "failed" },
569                    Some(error),
570                    Some(recovery.to_owned()),
571                )
572            }
573        };
574        operation.updated_at = now();
575        hel::hel_database::save_move_operation(operation)?;
576        Ok(outcome(
577            &operation.operation_id,
578            &operation.selection,
579            status,
580            error,
581            recovery,
582        ))
583    }
584
585    pub async fn recover_move_managed_controlled(
586        &mut self,
587        mut operation: MoveOperation,
588        executor: &(impl CommandExecutor + Sync),
589        manager: &SessionManagerControl,
590    ) -> Result<MoveOutcome> {
591        let id = operation.selection.session_id.clone();
592        let result = async {
593            let session = self.state.sessions.get(&id).context("move session is missing")?.clone();
594            if operation.queue_admission_started {
595                ensure!(!operation.cancellation_requested, "Move was cancelled; destination retained without further queue admission");
596                return self.admit_move_queue(&mut operation, executor).await;
597            }
598            if matches!(session.state, SessionState::Closing | SessionState::Destroying) {
599                let cleanup = hel::hel_targets::CancellableProcessExecutor::with_timeout(std::time::Duration::from_secs(15));
600                self.recover_move_source_stop(&mut operation, &cleanup, manager).await?;
601                operation.checkpoint = self.state.sessions[&id].checkpoint.clone();
602                if self.state.sessions[&id].state == SessionState::Stopped && self.state.sessions[&id].target.is_some() {
603                    self.cleanup_stopped_target(&id, &cleanup)?;
604                }
605                bail!("Move source stop recovered; no destination work was started. Retry Move or Resume with previous settings");
606            }
607            match operation.phase {
608                MovePhase::Preparing => bail!("Move preparation was interrupted; source retained. Prepare Move again."),
609                MovePhase::ResumingDestination if session.state == SessionState::Running => {
610                    // Running is installed only after native readiness and the
611                    // handoff. A crash before the next intent write is safe to
612                    // advance, since restore started with an empty queue.
613                    ensure!(Some(&session.last_profile) == operation.selection.profile_id.as_ref()
614                        && Some(&session.target_template_id) == operation.selection.target_template_id.as_ref(),
615                        "ready destination does not match the move intent");
616                    operation.destination_target = session.target.clone();
617                    operation.destination_native_session_id = session.native_session_id.clone();
618                    operation.queue_admission_started = true;
619                    operation.phase = MovePhase::StartingQueue;
620                    hel::hel_database::save_move_operation(&operation)?;
621                    restore_move_queue_hold(&operation);
622                    ensure!(!operation.cancellation_requested, "Move was cancelled; ready destination retained");
623                    self.admit_move_queue(&mut operation, executor).await
624                }
625                MovePhase::ResumingDestination => {
626                    let previous = operation.recovery_session.as_ref().context("move lacks its stopped recovery identity; retain resources for inspection")?;
627                    let error = self.rollback_failed_resume(&id, previous, false,
628                        anyhow::anyhow!("destination restoration was interrupted; checkpoint retained for an explicit retry"), executor)?;
629                    Err(error)
630                }
631                MovePhase::ClosingSource => {
632                    if matches!(session.state, SessionState::Closing | SessionState::Destroying) {
633                        self.recover_move_source_stop(&mut operation, executor, manager).await?;
634                    }
635                    operation.checkpoint = self.state.sessions[&id].checkpoint.clone();
636                    if self.state.sessions[&id].state == SessionState::Stopped && self.state.sessions[&id].target.is_some() {
637                        self.cleanup_stopped_target(&id, executor)?;
638                    }
639                    bail!("source stop was recovered; verified checkpoint retained. Retry move or Resume with previous settings")
640                }
641                _ => bail!("Move requires an explicit retry after the daemon restarted"),
642            }
643        }.await;
644        self.finish_move_result(&mut operation, result, executor)
645    }
646
647    async fn recover_move_source_stop(
648        &mut self,
649        operation: &mut MoveOperation,
650        executor: &(impl CommandExecutor + Sync),
651        manager: &SessionManagerControl,
652    ) -> Result<()> {
653        let id = operation.selection.session_id.clone();
654        if self.state.sessions[&id].state == SessionState::Closing {
655            let handle = manager
656                .wait_for_session(&id, std::time::Duration::from_secs(5))
657                .await?;
658            let mut lease = handle.lease_connection().await?;
659            let execution = lease.connection_mut().sync().await?.operational.execution;
660            lease.release();
661            if matches!(
662                execution,
663                hel::hel_worker::RelayExecutionState::Idle
664                    | hel::hel_worker::RelayExecutionState::Running
665            ) {
666                if operation.cancellation_requested {
667                    let record = self.state.sessions.get_mut(&id).unwrap();
668                    record.state = SessionState::Running;
669                    record.updated_at = now();
670                    record.last_error =
671                        Some("Move was cancelled before the source was sealed".into());
672                    hel::hel_database::save_lifecycle_session(record)?;
673                } else {
674                    self.close_session_for_move(&id, executor, manager, operation, None)
675                        .await?;
676                }
677                return Ok(());
678            }
679        }
680        self.recover_interrupted_close_managed(&id, executor, manager)
681            .await?;
682        Ok(())
683    }
684
685    async fn execute_move(
686        &mut self,
687        operation: &mut MoveOperation,
688        preparation: Option<&MovePreparation>,
689        executor: &(impl CommandExecutor + Sync),
690        manager: &SessionManagerControl,
691    ) -> Result<()> {
692        let id = operation.selection.session_id.clone();
693        ensure!(
694            !executor.cancellation_requested(),
695            "move cancelled before source interruption"
696        );
697        if !operation.queue_admission_started {
698            if self.state.sessions[&id].state == SessionState::Error
699                && let Some(previous) = operation.recovery_session.as_ref()
700            {
701                // A prior failed teardown retains its exact partial checkout.
702                // Restore source identity only after that owner is stopped.
703                let failure = self.rollback_failed_resume(
704                    &id,
705                    previous,
706                    false,
707                    anyhow::anyhow!("clean up the partial Move destination before retry"),
708                    executor,
709                )?;
710                ensure!(
711                    self.state.sessions[&id].state == SessionState::Stopped,
712                    "{failure:#}"
713                );
714            }
715            let state = self.state.sessions[&id].state;
716            if matches!(state, SessionState::Closing | SessionState::Destroying) {
717                self.recover_move_source_stop(operation, executor, manager)
718                    .await?;
719                operation.checkpoint = self.state.sessions[&id].checkpoint.clone();
720            } else if matches!(state, SessionState::Running | SessionState::Disconnected)
721                && operation.destination_target.is_none()
722            {
723                executor.notify_notice("Stopping source");
724                let _timing = MovePhaseTimer::new(&id, "checkpoint and source stop");
725                operation.phase = MovePhase::ClosingSource;
726                operation.updated_at = now();
727                hel::hel_database::save_move_operation(operation)?;
728                self.close_session_for_move(&id, executor, manager, operation, preparation)
729                    .await?;
730            }
731            if self.state.sessions[&id].state == SessionState::Stopped
732                && self.state.sessions[&id].target.is_some()
733            {
734                executor.notify_notice("Cleaning up source");
735                let _timing = MovePhaseTimer::new(&id, "source storage cleanup");
736                self.cleanup_stopped_target(&id, executor)?;
737            }
738            operation.checkpoint = operation
739                .checkpoint
740                .clone()
741                .or_else(|| self.state.sessions[&id].checkpoint.clone());
742            ensure!(
743                operation.checkpoint.is_some(),
744                "move has no verified checkpoint"
745            );
746            ensure!(
747                !executor.cancellation_requested(),
748                "move cancelled after source teardown; session is stopped"
749            );
750            operation.phase = MovePhase::ResumingDestination;
751            operation.recovery_session = Some(self.state.sessions[&id].clone());
752            operation.updated_at = now();
753            hel::hel_database::save_move_operation(operation)?;
754            executor.notify_notice("Preparing destination");
755            if operation.selection.clear_resource_allocation {
756                let session = self.state.sessions.get_mut(&id).unwrap();
757                session.resource_allocation = None;
758                session.container_cpus = None;
759                session.container_memory = None;
760                hel::hel_database::save_session(session)?;
761            }
762            self.resume_session_controlled(
763                &id,
764                operation.selection.profile_id.as_deref().unwrap(),
765                operation.selection.target_template_id.as_deref().unwrap(),
766                SessionResumeOptions {
767                    additional_mounts: operation.selection.additional_mounts.clone(),
768                    resource_allocation: operation.selection.resource_allocation.clone(),
769                    discard_queue: true,
770                },
771                executor,
772            )
773            .await?;
774            let destination = &self.state.sessions[&id];
775            operation.destination_target = destination.target.clone();
776            operation.destination_native_session_id = destination.native_session_id.clone();
777            // Persist readiness before submitting even the first queued command.
778            operation.phase = MovePhase::StartingQueue;
779            operation.queue_admission_started = true;
780            operation.updated_at = now();
781            hel::hel_database::save_move_operation(operation)?;
782        }
783        restore_move_queue_hold(operation);
784        self.admit_move_queue(operation, executor).await
785    }
786
787    async fn admit_move_queue(
788        &self,
789        operation: &mut MoveOperation,
790        executor: &(impl CommandExecutor + Sync),
791    ) -> Result<()> {
792        let timing_id = operation.selection.session_id.clone();
793        let _timing = MovePhaseTimer::new(&timing_id, "queue admission");
794        let id = &operation.selection.session_id;
795        let mut relay = {
796            let _checking_destination =
797                ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
798            let destination = &self.state.sessions[id];
799            ensure!(
800                destination.state == SessionState::Running
801                    && destination.target == operation.destination_target
802                    && destination.native_session_id == operation.destination_native_session_id,
803                "cannot prove the same ready destination; refusing to replay potentially executed work"
804            );
805            let spec = self.reconnect_command(id)?;
806            let relay = StandaloneSession::connect_command(&spec, id).await?;
807            let store_id = relay.snapshot().operational.store_id.context("destination worker does not expose its durable store identity; upgrade the worker before admitting queued work")?;
808            if let Some(expected) = &operation.destination_store_id {
809                ensure!(
810                    *expected == store_id,
811                    "destination relay storage was replaced; refusing to replay potentially executed work"
812                );
813            } else {
814                // No command can be admitted until this identity is durable.
815                operation.destination_store_id = Some(store_id);
816                hel::hel_database::save_move_operation(operation)?;
817            }
818            ensure!(
819                relay.snapshot().operational.native_session_id
820                    == operation.destination_native_session_id,
821                "destination relay native identity changed; refusing queue replay"
822            );
823            relay
824        };
825        if operation.queue == ResumeQueueDisposition::Start && !operation.queue_admission_finished {
826            let _starting_queue = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
827            executor.notify_notice("Starting queued work");
828            let checkpoint = operation
829                .checkpoint
830                .as_ref()
831                .context("move queue archive is missing")?;
832            let verified = verify_archive_streaming(&checkpoint.archive_path)?;
833            ensure!(
834                verified.archive_sha256 == checkpoint.sha256 && verified.manifest.session.id == *id,
835                "move queue checkpoint verification failed"
836            );
837            for queued in verified.canonical_session.queued_prompts {
838                ensure!(
839                    !executor.cancellation_requested(),
840                    "move cancelled during queue admission; destination retained"
841                );
842                let command = match queued.kind {
843                    CanonicalQueuedCommandKind::Prompt => RelayCommand::Prompt {
844                        prompt: queued
845                            .content
846                            .into_iter()
847                            .map(serde_json::from_value)
848                            .collect::<serde_json::Result<_>>()?,
849                    },
850                    CanonicalQueuedCommandKind::SetConfig { key, value } => {
851                        RelayCommand::SetConfig { key, value }
852                    }
853                };
854                relay.submit(queued.command_id, command).await?;
855            }
856        }
857        operation.queue_admission_finished = true;
858        hel::hel_database::save_move_operation(operation)?;
859        restore_move_queue_hold(operation);
860        relay.submit(format!("{}-notice", operation.operation_id), RelayCommand::RecordNotice {
861            text: format!("Moved from {} / {} to {} / {} in a fresh environment. {} The interrupted prompt was not replayed.",
862                operation.source_profile_id, operation.source_target_template_id,
863                operation.selection.profile_id.as_deref().unwrap(), operation.selection.target_template_id.as_deref().unwrap(),
864                if operation.queue == ResumeQueueDisposition::Discard { "Queued work was discarded; ready and idle." } else { "Queued work was accepted." }),
865        }).await?;
866        Ok(())
867    }
868
869    pub(super) fn validate_move_checkpoint(
870        &self,
871        operation: &MoveOperation,
872        preparation: Option<&MovePreparation>,
873        executor: &(impl CommandExecutor + Sync),
874    ) -> Result<()> {
875        let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
876        let current = Controller {
877            config: hel::hel_config::HelConfig::load()?,
878            state: self.state.clone(),
879        };
880        ensure!(
881            current.move_configuration_fingerprint(&operation.selection)?
882                == operation.configuration_fingerprint,
883            "destination configuration changed during move"
884        );
885        let id = &operation.selection.session_id;
886        current.validate_move_destination_paths(
887            &self.state.sessions[id],
888            operation.selection.target_template_id.as_deref().unwrap(),
889            executor,
890        )?;
891        if let super::ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) = self
892            .preflight_resume_repository_sources(
893                id,
894                operation.selection.target_template_id.as_deref().unwrap(),
895                executor,
896            )?
897        {
898            bail!(
899                "destination repository source is missing checkpoint commit {}; source retained",
900                mismatch.missing_commit
901            );
902        }
903        if let Some(prepared) = preparation {
904            let checkpoint = self.state.sessions[id]
905                .checkpoint
906                .as_ref()
907                .context("no move checkpoint")?;
908            let verified = verify_archive_streaming(&checkpoint.archive_path)?;
909            let actual: Vec<_> = verified
910                .canonical_session
911                .queued_prompts
912                .iter()
913                .map(|p| p.command_id.as_str())
914                .collect();
915            let expected: Vec<_> = prepared
916                .queued_commands
917                .iter()
918                .map(|p| p.command_id.as_str())
919                .collect();
920            ensure!(
921                actual == expected,
922                "pending queue changed before checkpoint capture; source retained, confirm Move again"
923            );
924        }
925        Ok(())
926    }
927}
928
929fn outcome(
930    operation_id: &str,
931    selection: &MoveSelection,
932    status: &str,
933    error: Option<String>,
934    recovery: Option<String>,
935) -> MoveOutcome {
936    MoveOutcome {
937        operation_id: operation_id.into(),
938        session_id: selection.session_id.clone(),
939        profile_id: selection.profile_id.clone().unwrap_or_default(),
940        target_template_id: selection.target_template_id.clone().unwrap_or_default(),
941        outcome: status.into(),
942        error,
943        recovery,
944    }
945}