Skip to main content

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