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 mj_core::hex::lower_hex;
8use sha2::{Digest, Sha256};
9
10use super::lifecycle::SourceTargetDisposition;
11use super::{Controller, SessionResumeOptions, now};
12
13fn mutation_holds() -> &'static std::sync::Mutex<std::collections::BTreeSet<String>> {
14    static HOLDS: std::sync::OnceLock<std::sync::Mutex<std::collections::BTreeSet<String>>> =
15        std::sync::OnceLock::new();
16    HOLDS.get_or_init(Default::default)
17}
18
19pub fn move_owns_session(session_id: &str) -> bool {
20    move_has_pending_queue(session_id)
21        || mutation_holds()
22            .lock()
23            .unwrap_or_else(std::sync::PoisonError::into_inner)
24            .contains(session_id)
25}
26
27fn queue_holds() -> &'static std::sync::Mutex<std::collections::BTreeSet<String>> {
28    static HOLDS: std::sync::OnceLock<std::sync::Mutex<std::collections::BTreeSet<String>>> =
29        std::sync::OnceLock::new();
30    HOLDS.get_or_init(Default::default)
31}
32
33pub fn move_has_pending_queue(session_id: &str) -> bool {
34    queue_holds()
35        .lock()
36        .unwrap_or_else(std::sync::PoisonError::into_inner)
37        .contains(session_id)
38}
39
40pub fn release_move_queue_hold(session_id: &str) {
41    queue_holds()
42        .lock()
43        .unwrap_or_else(std::sync::PoisonError::into_inner)
44        .remove(session_id);
45}
46
47pub fn restore_move_queue_hold(operation: &MoveOperation) {
48    let mut holds = queue_holds()
49        .lock()
50        .unwrap_or_else(std::sync::PoisonError::into_inner);
51    if operation.queue_admission_started && !operation.queue_admission_finished {
52        holds.insert(operation.selection.session_id.clone());
53    } else {
54        holds.remove(&operation.selection.session_id);
55    }
56}
57
58/// What a recovered, interrupted source stop tells the person.
59///
60/// An in-place move promised to keep the environment; a recovery that tears it
61/// down has broken that promise, so the message says so instead of leaving the
62/// person to infer it from an unchanged "retry" line.
63fn interrupted_source_stop_message(recovered: &str, in_place: bool) -> String {
64    if in_place {
65        format!("{recovered}; the in-place swap was interrupted; the environment was released")
66    } else {
67        recovered.to_owned()
68    }
69}
70
71pub struct MoveMutationGuard(String);
72
73impl MoveMutationGuard {
74    pub fn reserve(session_id: &str) -> Result<Self> {
75        ensure!(
76            mutation_holds()
77                .lock()
78                .unwrap_or_else(std::sync::PoisonError::into_inner)
79                .insert(session_id.to_owned()),
80            "session already has a move owner"
81        );
82        Ok(Self(session_id.to_owned()))
83    }
84}
85
86impl Drop for MoveMutationGuard {
87    fn drop(&mut self) {
88        mutation_holds()
89            .lock()
90            .unwrap_or_else(std::sync::PoisonError::into_inner)
91            .remove(&self.0);
92    }
93}
94
95pub(crate) fn move_refuses_command(session_id: &str, command: &RelayCommand) -> bool {
96    move_owns_session(session_id)
97        && matches!(
98            command,
99            RelayCommand::Prompt { .. }
100                | RelayCommand::SetConfig { .. }
101                | RelayCommand::SetSessionMode { .. }
102                | RelayCommand::RunUserShell { .. }
103                | RelayCommand::CancelUserShell { .. }
104                | RelayCommand::Cancel
105                | RelayCommand::RemoveQueuedPrompt { .. }
106                | RelayCommand::ClearQueuedPrompts
107        )
108}
109use crate::session_manager::{SessionManagerControl, StandaloneSession, new_command_id};
110use mj_checkpoint::archive::{CanonicalQueuedCommandKind, verify_archive_streaming};
111use mj_core::state::{MoveOperation, MovePhase, ResumeQueueDisposition, SessionState};
112
113pub use mj_core::state::{MoveOutcome, MovePreparation, MoveSelection, MoveSessionRequest};
114
115use crate::targets::{CommandExecutor, ProvisionStage, ProvisionStageGuard};
116use mj_core::relay::RelayCommand;
117
118/// Refresh source state without turning a dead source harness into a Move prerequisite.
119/// Leasing preserves typed transport failures, unlike the UI's string-valued sync reply.
120pub async fn refresh_move_source(
121    manager: &SessionManagerControl,
122    id: &str,
123) -> Result<Option<mj_core::state::ManagedSessionSnapshot>> {
124    let handle = manager
125        .wait_for_session(id, std::time::Duration::from_secs(5))
126        .await?;
127    let result = async {
128        let mut lease = handle.lease_connection().await?;
129        let snapshot = lease.connection_mut().sync().await?;
130        lease.release();
131        Ok(snapshot)
132    }
133    .await;
134    match result {
135        Ok(snapshot) => Ok(Some(snapshot)),
136        Err(error) if crate::worker_client::RelayTransportDead::marks(&error) => {
137            tracing::warn!(session_id = id, error = %error, "Move will recover the unavailable source without its harness");
138            Ok(None)
139        }
140        Err(error) => Err(error),
141    }
142}
143
144fn digest(value: &impl serde::Serialize) -> Result<String> {
145    Ok(lower_hex(Sha256::digest(serde_json::to_vec(value)?)))
146}
147
148struct MovePhaseTimer<'a> {
149    session_id: &'a str,
150    phase: &'static str,
151    started: std::time::Instant,
152}
153
154impl<'a> MovePhaseTimer<'a> {
155    fn new(session_id: &'a str, phase: &'static str) -> Self {
156        Self {
157            session_id,
158            phase,
159            started: std::time::Instant::now(),
160        }
161    }
162}
163
164impl Drop for MovePhaseTimer<'_> {
165    fn drop(&mut self) {
166        tracing::info!(
167            session_id = self.session_id,
168            phase = self.phase,
169            elapsed_ms = self.started.elapsed().as_millis() as u64,
170            "move phase finished"
171        );
172    }
173}
174
175/// Confirmation is an inspector, not transport for attachment bytes: every
176/// surface that shows a move preparation sees a placeholder for each queued
177/// image. Replay always reads the verified archive after destination readiness.
178fn replace_queued_images_with_placeholders(
179    queued_commands: &mut [mj_core::state::MaterializedQueuedPrompt],
180) {
181    for block in queued_commands
182        .iter_mut()
183        .flat_map(|command| command.content.iter_mut())
184    {
185        if block.get("type").and_then(serde_json::Value::as_str) != Some("image") {
186            continue;
187        }
188        let mime = block
189            .get("mimeType")
190            .or_else(|| block.get("mime_type"))
191            .and_then(serde_json::Value::as_str)
192            .unwrap_or("image");
193        *block = serde_json::json!({"type": "text", "text": format!("[Image attachment: {mime}]")});
194    }
195}
196
197impl Controller {
198    /// Returns the planned conversion when this move turns a local checkout
199    /// into an isolated workspace, so the caller can describe it without
200    /// reading Git a second time.
201    fn validate_move_destination_paths(
202        &self,
203        source: &mj_core::state::SessionRecord,
204        target_id: &str,
205        executor: &(impl CommandExecutor + Sync),
206    ) -> Result<Option<super::worktree::RawToWorkspaceConversion>> {
207        use super::worktree::ResumePlan;
208        match super::worktree::resume_compatibility(source, &self.config, target_id)
209            .map_err(anyhow::Error::msg)?
210        {
211            ResumePlan::RawToWorkspace => {
212                return Ok(Some(super::worktree::plan_raw_to_workspace(
213                    source,
214                    &self.config,
215                    executor,
216                )?));
217            }
218            ResumePlan::WorkspaceToRaw => {
219                self.plan_workspace_to_raw(source, target_id, executor)?;
220            }
221            ResumePlan::InPlace if source.managed_worktree.is_none() => {
222                if let Some(path) = &source.project_directory {
223                    self.validate_project_directory(target_id, path, executor)?;
224                }
225            }
226            ResumePlan::InPlace => {}
227        }
228        Ok(None)
229    }
230    fn move_confirmation(
231        &self,
232        selection: &MoveSelection,
233        conversion: Option<&mj_core::state::RawConversionPreview>,
234    ) -> Result<(bool, Vec<mj_core::state::MaterializedQueuedPrompt>, String)> {
235        let source = self
236            .state
237            .sessions
238            .get(&selection.session_id)
239            .context("unknown move session")?;
240        let (mut active, mut queued) = crate::database::move_pending_work(&source.id)?;
241        if let Some(operation) = crate::database::load_move_operation(&source.id)?
242            && operation.queue_admission_started
243            && !operation.queue_admission_finished
244        {
245            ensure!(
246                operation.selection == *selection,
247                "queue admission is incomplete on the live destination; retry that move before selecting another destination"
248            );
249            let checkpoint = operation
250                .checkpoint
251                .as_ref()
252                .context("retained queue checkpoint is missing")?;
253            let verified = verify_archive_streaming(&checkpoint.archive_path)?;
254            ensure!(
255                verified.archive_sha256 == checkpoint.sha256
256                    && verified.manifest.session.id == source.id,
257                "retained move checkpoint verification failed"
258            );
259            queued = verified
260                .canonical_session
261                .queued_prompts
262                .into_iter()
263                .map(|entry| mj_core::state::MaterializedQueuedPrompt {
264                    accepted_ordinal: None,
265                    command_id: entry.command_id,
266                    kind: match entry.kind {
267                        CanonicalQueuedCommandKind::Prompt => {
268                            mj_core::state::QueuedCommandKind::Prompt
269                        }
270                        CanonicalQueuedCommandKind::SetConfig { key, value } => {
271                            mj_core::state::QueuedCommandKind::SetConfig { key, value }
272                        }
273                    },
274                    content: entry.content,
275                    queued_at_ms: entry.queued_at_ms,
276                })
277                .collect();
278            active = false;
279        }
280        let fingerprint = digest(&(
281            &source.last_profile,
282            &source.target_template_id,
283            &source.target,
284            &source.native_session_id,
285            &source.resource_allocation,
286            &source.additional_mounts,
287            &source.container_cpus,
288            &source.container_memory,
289            selection,
290            self.move_configuration_fingerprint(selection)?,
291            &queued,
292            // Only what the destination is built from. The dirty counts move
293            // with every keystroke of a live agent, and hashing them would
294            // invalidate the confirmation the person is reading.
295            conversion.map(|preview| {
296                (
297                    &preview.fetch_url,
298                    &preview.push_urls,
299                    &preview.branch,
300                    &preview.destination,
301                )
302            }),
303        ))?;
304        Ok((active, queued, fingerprint))
305    }
306    pub(super) fn move_configuration_fingerprint(
307        &self,
308        selection: &MoveSelection,
309    ) -> Result<String> {
310        let profile = self
311            .config
312            .profiles
313            .get(
314                selection
315                    .profile_id
316                    .as_deref()
317                    .context("move profile is unresolved")?,
318            )
319            .context("move profile no longer exists")?;
320        let target = self
321            .config
322            .targets
323            .get(
324                selection
325                    .target_template_id
326                    .as_deref()
327                    .context("move target is unresolved")?,
328            )
329            .context("move target no longer exists")?;
330        // Only a digest crosses IPC or enters the move record. Configuration
331        // may contain credential-bearing values and must never be copied there.
332        let source = self
333            .state
334            .sessions
335            .get(&selection.session_id)
336            .context("unknown move session")?;
337        digest(&(
338            profile,
339            target,
340            &self.config.bundles,
341            self.config.targets.get(&source.target_template_id),
342            self.config.profiles.get(&source.last_profile),
343        ))
344    }
345
346    async fn validate_move_destination_configuration(
347        &self,
348        selection: &MoveSelection,
349        source_harness: mj_core::config::HarnessKind,
350        operational: &mj_core::relay::RelayOperationalState,
351    ) -> Result<()> {
352        let profile_id = selection
353            .profile_id
354            .as_deref()
355            .context("move profile is unresolved")?;
356        let profile = self
357            .config
358            .profiles
359            .get(profile_id)
360            .context("move profile no longer exists")?;
361        let source = self
362            .state
363            .sessions
364            .get(&selection.session_id)
365            .context("unknown move session")?;
366        if profile.kind != source_harness || profile_id == source.last_profile {
367            return Ok(());
368        }
369        let accepted = mj_core::acp::AcceptedSessionConfig::from_configuration(
370            &operational.config,
371            &operational.config_options,
372        );
373        if accepted.model.is_none() && accepted.effort.is_none() {
374            return Ok(());
375        }
376        // A fresh probe prevents a stale local catalogue from approving a
377        // move that the destination profile will immediately reset.
378        let choices =
379            super::profile_config::discover(profile_id.to_owned(), accepted.model.clone(), true)
380                .await
381                .with_context(|| {
382                    format!("discover destination profile {profile_id:?} configuration")
383                })?;
384        validate_preserved_configuration(profile_id, &accepted, &choices)
385    }
386
387    pub async fn prepare_move_session_controlled(
388        &self,
389        mut selection: MoveSelection,
390        executor: &(impl CommandExecutor + Sync),
391    ) -> Result<MovePreparation> {
392        ensure!(
393            selection.profile_id.is_some() || selection.target_template_id.is_some(),
394            "move requires a target or profile selection"
395        );
396        let source = self
397            .state
398            .sessions
399            .get(&selection.session_id)
400            .context("unknown session")?;
401        ensure!(
402            !self.state.subagents.contains_key(&source.id),
403            "sub-agent sessions cannot move independently of their parent"
404        );
405        ensure!(
406            !self.state.subagents.values().any(|child| {
407                child.parent_session_id == source.id
408                    && self
409                        .state
410                        .sessions
411                        .get(&child.child_session_id)
412                        .is_some_and(|session| session.state.is_active())
413            }),
414            "stop active sub-agents before moving their parent session"
415        );
416        let previous = crate::database::load_move_operation(&source.id)?;
417        let retry = previous.as_ref().is_some_and(|op| {
418            !matches!(op.phase, MovePhase::Completed) && source.checkpoint.is_some()
419        });
420        ensure!(
421            matches!(
422                source.state,
423                SessionState::Running | SessionState::Disconnected
424            ) || retry,
425            "only active sessions can move; run `mj resume` (or POST /api/v1/sessions/{}/resume) for a stopped or lost session",
426            source.id
427        );
428        selection
429            .profile_id
430            .get_or_insert_with(|| source.last_profile.clone());
431        selection
432            .target_template_id
433            .get_or_insert_with(|| source.target_template_id.clone());
434        selection
435            .additional_mounts
436            .get_or_insert_with(|| source.additional_mounts.clone());
437        ensure!(
438            !selection.clear_resource_allocation || selection.resource_allocation.is_none(),
439            "select resource allocation or explicitly clear it, not both"
440        );
441        if !selection.clear_resource_allocation {
442            selection.resource_allocation = selection
443                .resource_allocation
444                .or_else(|| source.resource_allocation.clone());
445        }
446        let profile_id = selection.profile_id.as_deref().unwrap();
447        let target_id = selection.target_template_id.as_deref().unwrap();
448        let profile = self
449            .config
450            .profiles
451            .get(profile_id)
452            .context("unknown destination profile")?;
453        ensure!(
454            profile.enabled,
455            "destination profile {profile_id:?} is disabled"
456        );
457        let target = self
458            .config
459            .targets
460            .get(target_id)
461            .context("unknown destination target")?;
462        self.validate_muse_resume_destination(source, profile.kind, target_id)?;
463        super::worktree::resume_compatibility(source, &self.config, target_id)
464            .map_err(anyhow::Error::msg)?;
465        super::backend::validate_resource_allocation(
466            target,
467            selection.resource_allocation.as_ref(),
468        )?;
469        let mounts = selection.additional_mounts.as_deref().unwrap_or_default();
470        ensure!(
471            profile.kind != mj_core::config::HarnessKind::Muse || mounts.is_empty(),
472            "Muse Code ACP supports one workspace root; attached directories are unsupported"
473        );
474        ensure!(
475            mounts.is_empty() || mj_core::config::mount_history_host(target).is_some(),
476            "attached resources are unsupported for this target; select compatible resources explicitly"
477        );
478        crate::targets::validate_additional_mounts(mounts)?;
479        for mount in mounts {
480            self.validate_mount_source(target_id, &mount.source, executor)?;
481        }
482        let planned_conversion =
483            self.validate_move_destination_paths(source, target_id, executor)?;
484        ensure!(
485            profile.home.is_dir(),
486            "destination profile home is unavailable; configure the profile before moving"
487        );
488        super::worker_binary::preflight_worker_binary(target)?;
489        super::backend::preflight_target(target, executor)?;
490        let source_harness = previous
491            .as_ref()
492            .filter(|operation| {
493                !operation.queue_admission_started && operation.phase != MovePhase::Completed
494            })
495            .and_then(|operation| operation.recovery_session.as_ref())
496            .map_or(source.harness_kind, |record| record.harness_kind);
497        let cross_harness = profile.kind != source_harness;
498        if cross_harness {
499            let cancel = tokio_util::sync::CancellationToken::new();
500            let resolve =
501                crate::utility_llm::UtilityLlmRuntime::shared().resolve(&self.config, &cancel);
502            tokio::pin!(resolve);
503            loop {
504                tokio::select! {
505                    result = &mut resolve => { result.context("cross-harness move needs an available utility model")?; break; }
506                    _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {
507                        if executor.cancellation_requested() { cancel.cancel(); bail!("move preparation cancelled"); }
508                    }
509                }
510            }
511        }
512        // What moving a local checkout into a target really does, computed
513        // before anything is stopped so a person can confirm it.
514        let conversion = planned_conversion
515            .map(|conversion| {
516                super::worktree::raw_conversion_preview(source, &conversion, executor)
517                    .context("describe the move of this checkout into the target")
518            })
519            .transpose()?
520            .map(Box::new);
521        let (active, mut queued_commands, fingerprint) =
522            self.move_confirmation(&selection, conversion.as_deref())?;
523        replace_queued_images_with_placeholders(&mut queued_commands);
524        let operation_id = previous
525            .as_ref()
526            .filter(|operation| {
527                operation.selection == selection
528                    && operation.phase != MovePhase::Completed
529                    && operation.checkpoint.is_some()
530            })
531            .map(|operation| operation.operation_id.clone())
532            .unwrap_or(new_command_id("move")?);
533        Ok(MovePreparation {
534            source_unavailable: false,
535            in_place: in_place_move_eligible(
536                source,
537                &selection,
538                self.state.subagents.contains_key(&source.id),
539                retry,
540            ),
541            conversion,
542            selection,
543            source_profile_id: source.last_profile.clone(),
544            source_target_template_id: source.target_template_id.clone(),
545            cross_harness,
546            active,
547            queued_commands,
548            fingerprint,
549            operation_id,
550        })
551    }
552
553    /// Called with one daemon lifecycle and recovery reservation already held.
554    pub async fn move_session_managed_controlled(
555        &mut self,
556        request: MoveSessionRequest,
557        executor: &(impl CommandExecutor + Sync),
558        manager: &SessionManagerControl,
559    ) -> Result<MoveOutcome> {
560        let prepared = &request.preparation;
561        let id = prepared.selection.session_id.clone();
562        let started = std::time::Instant::now();
563        executor.notify_notice("Checking destination");
564        let checked = {
565            let _checking_destination =
566                ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
567            let mut checked = self
568                .prepare_move_session_controlled(prepared.selection.clone(), executor)
569                .await?;
570            // Destination checks can outlast a turn. Refresh confirmation from the
571            // relay immediately before interruption, while new submissions are held.
572            if matches!(
573                self.state.sessions[&id].state,
574                SessionState::Running | SessionState::Disconnected
575            ) {
576                let source_harness = self.state.sessions[&id].harness_kind;
577                let snapshot = refresh_move_source(manager, &id).await?;
578                let (active, queue, fingerprint) =
579                    self.move_confirmation(&checked.selection, checked.conversion.as_deref())?;
580                checked.source_unavailable = snapshot
581                    .as_ref()
582                    .is_none_or(|snapshot| !snapshot.operational.native_session_is_ready());
583                checked.active = active
584                    || checked.source_unavailable
585                    || snapshot.as_ref().is_some_and(|snapshot| {
586                        let mut operational = snapshot.operational.clone();
587                        operational.queued_prompts.clear();
588                        operational.checkpoint_barrier = None;
589                        !operational.safe_to_replace(source_harness)
590                    });
591                if let Some(snapshot) = &snapshot {
592                    self.validate_move_destination_configuration(
593                        &checked.selection,
594                        source_harness,
595                        &snapshot.operational,
596                    )
597                    .await?;
598                }
599                checked.queued_commands = queue;
600                checked.fingerprint = fingerprint;
601            }
602            checked
603        };
604        ensure!(
605            checked.fingerprint == prepared.fingerprint,
606            "session, pending work, or destination configuration changed; prepare and confirm Move again"
607        );
608        let queue = request.queue.unwrap_or(ResumeQueueDisposition::Discard);
609        let source = self.state.sessions[&id].clone();
610        let old_operation = crate::database::load_move_operation(&id)?;
611        let retry = old_operation.filter(|op| {
612            op.selection == prepared.selection
613                && op.phase != MovePhase::Completed
614                && op.checkpoint.is_some()
615        });
616        if retry.is_none()
617            && source.state == SessionState::Running
618            && source.last_profile == checked.selection.profile_id.as_deref().unwrap()
619            && source.target_template_id == checked.selection.target_template_id.as_deref().unwrap()
620            && Some(&source.additional_mounts) == checked.selection.additional_mounts.as_ref()
621            && source.resource_allocation == checked.selection.resource_allocation
622            && (!checked.selection.clear_resource_allocation
623                || (source.container_cpus.is_none() && source.container_memory.is_none()))
624        {
625            return Ok(outcome(
626                &prepared.operation_id,
627                &prepared.selection,
628                "unchanged",
629                None,
630                None,
631            ));
632        }
633        ensure!(
634            !checked.active || request.acknowledge_interruption,
635            "active work will be interrupted; confirm Move again with interruption acknowledgement"
636        );
637        ensure!(
638            checked.queued_commands.is_empty() || request.queue.is_some(),
639            "pending work requires an explicit queue choice: discard or start"
640        );
641        let timestamp = now();
642        let mut operation = match retry {
643            Some(mut op) => {
644                ensure!(
645                    !op.queue_admission_started || op.queue == queue,
646                    "queued work may already have run; retry with the original queue choice on the same destination"
647                );
648                crate::database::clear_move_cancellation_for_retry(&id)?;
649                op.configuration_fingerprint =
650                    self.move_configuration_fingerprint(&checked.selection)?;
651                op.queue = queue;
652                op.cancellation_requested = false;
653                op.error = None;
654                op
655            }
656            None => MoveOperation {
657                source_checkpoint_only: false,
658                in_place: in_place_move_eligible(
659                    &source,
660                    &checked.selection,
661                    self.state.subagents.contains_key(&id),
662                    false,
663                ),
664                operation_id: prepared.operation_id.clone(),
665                selection: checked.selection.clone(),
666                source_profile_id: source.last_profile.clone(),
667                source_target_template_id: source.target_template_id.clone(),
668                source_target: source.target.clone(),
669                source_native_session_id: source.native_session_id.clone(),
670                source_additional_mounts: source.additional_mounts.clone(),
671                source_resource_allocation: source.resource_allocation.clone(),
672                destination_target: None,
673                destination_native_session_id: None,
674                destination_store_id: None,
675                configuration_fingerprint: self
676                    .move_configuration_fingerprint(&checked.selection)?,
677                checkpoint: None,
678                recovery_session: None,
679                queue,
680                phase: MovePhase::Preparing,
681                queue_admission_started: false,
682                queue_admission_finished: false,
683                cancellation_requested: false,
684                created_at: timestamp.clone(),
685                updated_at: timestamp,
686                error: None,
687            },
688        };
689        crate::database::save_move_operation(&operation)?;
690        tracing::info!(
691            session_id = id,
692            phase = "preflight",
693            elapsed_ms = started.elapsed().as_millis() as u64,
694            "move phase completed"
695        );
696        let result = self
697            .execute_move(&mut operation, Some(&checked), executor, manager)
698            .await;
699        self.finish_move_result(&mut operation, result, executor)
700    }
701
702    fn finish_move_result(
703        &self,
704        operation: &mut MoveOperation,
705        result: Result<()>,
706        executor: &impl CommandExecutor,
707    ) -> Result<MoveOutcome> {
708        let (status, error, recovery) = match result {
709            Ok(()) => {
710                operation.phase = MovePhase::Completed;
711                ("completed", None, None)
712            }
713            Err(error) => {
714                let cancelled =
715                    executor.cancellation_requested() || operation.cancellation_requested;
716                operation.phase = if cancelled {
717                    MovePhase::Cancelled
718                } else {
719                    MovePhase::Failed
720                };
721                operation.cancellation_requested = cancelled;
722                let recovery = if operation.queue_admission_started {
723                    "Destination is live; retry queue admission on this same destination. Already accepted work may have effects."
724                } else if self
725                    .state
726                    .sessions
727                    .get(&operation.selection.session_id)
728                    .is_some_and(|s| s.state == SessionState::Stopped)
729                {
730                    "Session is stopped with a verified checkpoint. Retry move or Resume with previous settings."
731                } else {
732                    "Source or partial destination is retained. Retry move after resolving the reported error."
733                };
734                let error = format!("{error:#}");
735                operation.error = Some(error.clone());
736                (
737                    if cancelled { "cancelled" } else { "failed" },
738                    Some(error),
739                    Some(recovery.to_owned()),
740                )
741            }
742        };
743        operation.updated_at = now();
744        crate::database::save_move_operation(operation)?;
745        Ok(outcome(
746            &operation.operation_id,
747            &operation.selection,
748            status,
749            error,
750            recovery,
751        ))
752    }
753
754    pub async fn recover_move_managed_controlled(
755        &mut self,
756        mut operation: MoveOperation,
757        executor: &(impl CommandExecutor + Sync),
758        manager: &SessionManagerControl,
759    ) -> Result<MoveOutcome> {
760        let id = operation.selection.session_id.clone();
761        let result = async {
762            let session = self.state.sessions.get(&id).context("move session is missing")?.clone();
763            if operation.queue_admission_started {
764                ensure!(!operation.cancellation_requested, "Move was cancelled; destination retained without further queue admission");
765                return self.admit_move_queue(&mut operation, executor).await;
766            }
767            if matches!(session.state, SessionState::Closing | SessionState::Destroying) {
768                let cleanup = crate::targets::CancellableProcessExecutor::with_timeout(std::time::Duration::from_secs(15));
769                self.recover_move_source_stop(&mut operation, &cleanup, manager).await?;
770                operation.checkpoint = self.state.sessions[&id].checkpoint.clone();
771                if self.state.sessions[&id].state == SessionState::Stopped && self.state.sessions[&id].target.is_some() {
772                    self.cleanup_stopped_target(&id, &cleanup)?;
773                }
774                bail!("{}", interrupted_source_stop_message(
775                    "Move source stop recovered; no destination work was started. Retry Move or Resume with previous settings",
776                    operation.in_place,
777                ));
778            }
779            match operation.phase {
780                MovePhase::Preparing => bail!("Move preparation was interrupted; source retained. Prepare Move again."),
781                MovePhase::ResumingDestination if session.state == SessionState::Running => {
782                    // Running is installed only after native readiness and the
783                    // handoff. A crash before the next intent write is safe to
784                    // advance, since restore started with an empty queue.
785                    ensure!(Some(&session.last_profile) == operation.selection.profile_id.as_ref()
786                        && Some(&session.target_template_id) == operation.selection.target_template_id.as_ref(),
787                        "ready destination does not match the move intent");
788                    operation.destination_target = session.target.clone();
789                    operation.destination_native_session_id = session.native_session_id.clone();
790                    operation.queue_admission_started = true;
791                    operation.phase = MovePhase::StartingQueue;
792                    crate::database::save_move_operation(&operation)?;
793                    restore_move_queue_hold(&operation);
794                    ensure!(!operation.cancellation_requested, "Move was cancelled; ready destination retained");
795                    self.admit_move_queue(&mut operation, executor).await
796                }
797                MovePhase::ResumingDestination => {
798                    let previous = operation.recovery_session.as_ref().context("move lacks its stopped recovery identity; retain resources for inspection")?;
799                    // An in-place swap still owns the checkout the source was
800                    // using, so the rollback retires it exactly as a resume
801                    // that recreated one.
802                    let error = self.rollback_failed_resume(&id, previous, operation.in_place,
803                        anyhow::anyhow!("destination restoration was interrupted; checkpoint retained for an explicit retry"), executor)?;
804                    Err(error)
805                }
806                MovePhase::ClosingSource => {
807                    if matches!(session.state, SessionState::Closing | SessionState::Destroying) {
808                        self.recover_move_source_stop(&mut operation, executor, manager).await?;
809                    }
810                    operation.checkpoint = self.state.sessions[&id].checkpoint.clone();
811                    if self.state.sessions[&id].state == SessionState::Stopped && self.state.sessions[&id].target.is_some() {
812                        self.cleanup_stopped_target(&id, executor)?;
813                    }
814                    bail!("{}", interrupted_source_stop_message(
815                        "source stop was recovered; verified checkpoint retained. Retry move or Resume with previous settings",
816                        operation.in_place,
817                    ))
818                }
819                _ => bail!("Move requires an explicit retry after the daemon restarted"),
820            }
821        }.await;
822        self.finish_move_result(&mut operation, result, executor)
823    }
824
825    async fn recover_move_source_stop(
826        &mut self,
827        operation: &mut MoveOperation,
828        executor: &(impl CommandExecutor + Sync),
829        manager: &SessionManagerControl,
830    ) -> Result<()> {
831        let id = operation.selection.session_id.clone();
832        if self.state.sessions[&id].state == SessionState::Closing {
833            self.prepare_move_source_checkpoint(&id, executor, manager, operation)
834                .await?;
835            let handle = manager
836                .wait_for_session(&id, std::time::Duration::from_secs(5))
837                .await?;
838            let mut lease = handle.lease_connection().await?;
839            let execution = lease.connection_mut().sync().await?.operational.execution;
840            lease.release();
841            if matches!(
842                execution,
843                mj_core::relay::RelayExecutionState::Idle
844                    | mj_core::relay::RelayExecutionState::Running
845            ) {
846                if operation.cancellation_requested {
847                    let record = self.state.sessions.get_mut(&id).unwrap();
848                    record.state = SessionState::Running;
849                    record.updated_at = now();
850                    record.last_error =
851                        Some("Move was cancelled before the source was sealed".into());
852                    crate::database::save_lifecycle_session(record)?;
853                } else {
854                    // A recovered source stop always tears its target down: an
855                    // interrupted in-place swap falls back to the fresh path.
856                    self.close_session_for_move(
857                        &id,
858                        executor,
859                        manager,
860                        operation,
861                        None,
862                        SourceTargetDisposition::Destroy,
863                    )
864                    .await?;
865                }
866                return Ok(());
867            }
868        }
869        self.recover_interrupted_close_managed(&id, executor, manager)
870            .await?;
871        Ok(())
872    }
873
874    async fn execute_move(
875        &mut self,
876        operation: &mut MoveOperation,
877        preparation: Option<&MovePreparation>,
878        executor: &(impl CommandExecutor + Sync),
879        manager: &SessionManagerControl,
880    ) -> Result<()> {
881        let id = operation.selection.session_id.clone();
882        ensure!(
883            !executor.cancellation_requested(),
884            "move cancelled before source interruption"
885        );
886        if !operation.queue_admission_started {
887            if self.state.sessions[&id].state == SessionState::Error
888                && let Some(previous) = operation.recovery_session.as_ref()
889            {
890                // A prior failed teardown retains its exact partial checkout.
891                // Restore source identity only after that owner is stopped.
892                let failure = self.rollback_failed_resume(
893                    &id,
894                    previous,
895                    false,
896                    anyhow::anyhow!("clean up the partial Move destination before retry"),
897                    executor,
898                )?;
899                ensure!(
900                    self.state.sessions[&id].state == SessionState::Stopped,
901                    "{failure:#}"
902                );
903            }
904            let state = self.state.sessions[&id].state;
905            if matches!(state, SessionState::Closing | SessionState::Destroying) {
906                self.recover_move_source_stop(operation, executor, manager)
907                    .await?;
908                operation.checkpoint = self.state.sessions[&id].checkpoint.clone();
909            } else if matches!(state, SessionState::Running | SessionState::Disconnected)
910                && operation.destination_target.is_none()
911            {
912                executor.notify_notice("Stopping source");
913                let _timing = MovePhaseTimer::new(&id, "checkpoint and source stop");
914                operation.phase = MovePhase::ClosingSource;
915                operation.updated_at = now();
916                crate::database::save_move_operation(operation)?;
917                // An eligible move keeps its environment: the close stops at
918                // the sealed relay and the record keeps its target for
919                // `restore_session_in_place`.
920                let disposition = if operation.in_place {
921                    SourceTargetDisposition::RetainForInPlaceSwap
922                } else {
923                    SourceTargetDisposition::Destroy
924                };
925                self.close_session_for_move(
926                    &id,
927                    executor,
928                    manager,
929                    operation,
930                    preparation,
931                    disposition,
932                )
933                .await?;
934            }
935            // An in-place move never reaches `Stopped` with a target: its
936            // source stays `Closing` in the environment the destination reuses.
937            if !operation.in_place
938                && self.state.sessions[&id].state == SessionState::Stopped
939                && self.state.sessions[&id].target.is_some()
940            {
941                executor.notify_notice("Cleaning up source");
942                let _timing = MovePhaseTimer::new(&id, "source storage cleanup");
943                self.cleanup_stopped_target(&id, executor)?;
944            }
945            operation.checkpoint = operation
946                .checkpoint
947                .clone()
948                .or_else(|| self.state.sessions[&id].checkpoint.clone());
949            ensure!(
950                operation.checkpoint.is_some(),
951                "move has no verified checkpoint"
952            );
953            ensure!(
954                !executor.cancellation_requested(),
955                "move cancelled after source teardown; session is stopped"
956            );
957            operation.phase = MovePhase::ResumingDestination;
958            operation.recovery_session = Some(self.state.sessions[&id].clone());
959            operation.updated_at = now();
960            crate::database::save_move_operation(operation)?;
961            executor.notify_notice("Preparing destination");
962            if operation.in_place {
963                // The environment is kept, so there is nothing to provision and
964                // no allocation to clear: eligibility already required the
965                // allocation to be unchanged.
966                self.restore_session_in_place(
967                    &id,
968                    operation.selection.profile_id.as_deref().unwrap(),
969                    executor,
970                )
971                .await?;
972            } else {
973                if operation.selection.clear_resource_allocation {
974                    let session = self.state.sessions.get_mut(&id).unwrap();
975                    session.resource_allocation = None;
976                    session.container_cpus = None;
977                    session.container_memory = None;
978                    crate::database::save_session(session)?;
979                }
980                self.resume_session_controlled(
981                    &id,
982                    operation.selection.profile_id.as_deref().unwrap(),
983                    operation.selection.target_template_id.as_deref().unwrap(),
984                    SessionResumeOptions {
985                        additional_mounts: operation.selection.additional_mounts.clone(),
986                        resource_allocation: operation.selection.resource_allocation.clone(),
987                        discard_queue: true,
988                    },
989                    executor,
990                )
991                .await?;
992            }
993            let destination = &self.state.sessions[&id];
994            operation.destination_target = destination.target.clone();
995            operation.destination_native_session_id = destination.native_session_id.clone();
996            // Persist readiness before submitting even the first queued command.
997            operation.phase = MovePhase::StartingQueue;
998            operation.queue_admission_started = true;
999            operation.updated_at = now();
1000            crate::database::save_move_operation(operation)?;
1001        }
1002        restore_move_queue_hold(operation);
1003        self.admit_move_queue(operation, executor).await
1004    }
1005
1006    async fn admit_move_queue(
1007        &self,
1008        operation: &mut MoveOperation,
1009        executor: &(impl CommandExecutor + Sync),
1010    ) -> Result<()> {
1011        let timing_id = operation.selection.session_id.clone();
1012        let _timing = MovePhaseTimer::new(&timing_id, "queue admission");
1013        let id = &operation.selection.session_id;
1014        let mut relay = {
1015            let _checking_destination =
1016                ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
1017            let destination = &self.state.sessions[id];
1018            ensure!(
1019                destination.state == SessionState::Running
1020                    && destination.target == operation.destination_target
1021                    && destination.native_session_id == operation.destination_native_session_id,
1022                "cannot prove the same ready destination; refusing to replay potentially executed work"
1023            );
1024            let spec = self.reconnect_command(id)?;
1025            let relay = StandaloneSession::connect_command(&spec, id).await?;
1026            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")?;
1027            if let Some(expected) = &operation.destination_store_id {
1028                ensure!(
1029                    *expected == store_id,
1030                    "destination relay storage was replaced; refusing to replay potentially executed work"
1031                );
1032            } else {
1033                // No command can be admitted until this identity is durable.
1034                operation.destination_store_id = Some(store_id);
1035                crate::database::save_move_operation(operation)?;
1036            }
1037            ensure!(
1038                relay.snapshot().operational.native_session_id
1039                    == operation.destination_native_session_id,
1040                "destination relay native identity changed; refusing queue replay"
1041            );
1042            relay
1043        };
1044        if operation.queue == ResumeQueueDisposition::Start && !operation.queue_admission_finished {
1045            let _starting_queue = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
1046            executor.notify_notice("Starting queued work");
1047            let checkpoint = operation
1048                .checkpoint
1049                .as_ref()
1050                .context("move queue archive is missing")?;
1051            let verified = verify_archive_streaming(&checkpoint.archive_path)?;
1052            ensure!(
1053                verified.archive_sha256 == checkpoint.sha256 && verified.manifest.session.id == *id,
1054                "move queue checkpoint verification failed"
1055            );
1056            for queued in verified.canonical_session.queued_prompts {
1057                ensure!(
1058                    !executor.cancellation_requested(),
1059                    "move cancelled during queue admission; destination retained"
1060                );
1061                let command = match queued.kind {
1062                    CanonicalQueuedCommandKind::Prompt => RelayCommand::Prompt {
1063                        prompt: queued
1064                            .content
1065                            .into_iter()
1066                            .map(serde_json::from_value)
1067                            .collect::<serde_json::Result<_>>()?,
1068                    },
1069                    CanonicalQueuedCommandKind::SetConfig { key, value } => {
1070                        RelayCommand::SetConfig { key, value }
1071                    }
1072                };
1073                relay.submit(queued.command_id, command).await?;
1074            }
1075        }
1076        operation.queue_admission_finished = true;
1077        crate::database::save_move_operation(operation)?;
1078        restore_move_queue_hold(operation);
1079        let queue_sentence = if operation.queue == ResumeQueueDisposition::Discard {
1080            "Queued work was discarded; ready and idle."
1081        } else {
1082            "Queued work was accepted."
1083        };
1084        let source_profile = &operation.source_profile_id;
1085        let source_target = &operation.source_target_template_id;
1086        let destination_profile = operation.selection.profile_id.as_deref().unwrap();
1087        let destination_target = operation.selection.target_template_id.as_deref().unwrap();
1088        // The two moves are different events for the person reading the
1089        // conversation: one rebuilt the environment, the other kept it.
1090        let text = if operation.in_place {
1091            format!(
1092                "Switched from {source_profile} / {source_target} to {destination_profile} / {destination_target} in place; the workspace and environment were kept. {queue_sentence} The interrupted prompt was not replayed."
1093            )
1094        } else {
1095            format!(
1096                "Moved from {source_profile} / {source_target} to {destination_profile} / {destination_target} in a fresh environment. {queue_sentence} The interrupted prompt was not replayed."
1097            )
1098        };
1099        relay
1100            .submit(
1101                format!("{}-notice", operation.operation_id),
1102                RelayCommand::RecordNotice { text },
1103            )
1104            .await?;
1105        Ok(())
1106    }
1107
1108    pub(super) fn validate_move_checkpoint(
1109        &self,
1110        operation: &MoveOperation,
1111        preparation: Option<&MovePreparation>,
1112        executor: &(impl CommandExecutor + Sync),
1113    ) -> Result<()> {
1114        let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
1115        let current = Controller {
1116            config: mj_core::config::Config::load()?,
1117            state: self.state.clone(),
1118        };
1119        ensure!(
1120            current.move_configuration_fingerprint(&operation.selection)?
1121                == operation.configuration_fingerprint,
1122            "destination configuration changed during move"
1123        );
1124        let id = &operation.selection.session_id;
1125        current.validate_move_destination_paths(
1126            &self.state.sessions[id],
1127            operation.selection.target_template_id.as_deref().unwrap(),
1128            executor,
1129        )?;
1130        if let super::ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) = self
1131            .preflight_resume_repository_sources(
1132                id,
1133                operation.selection.target_template_id.as_deref().unwrap(),
1134                executor,
1135            )?
1136        {
1137            bail!(
1138                "destination repository source is missing checkpoint commit {}; source retained",
1139                mismatch.missing_commit
1140            );
1141        }
1142        if let Some(prepared) = preparation {
1143            let checkpoint = self.state.sessions[id]
1144                .checkpoint
1145                .as_ref()
1146                .context("no move checkpoint")?;
1147            let verified = verify_archive_streaming(&checkpoint.archive_path)?;
1148            let actual: Vec<_> = verified
1149                .canonical_session
1150                .queued_prompts
1151                .iter()
1152                .map(|p| p.command_id.as_str())
1153                .collect();
1154            let expected: Vec<_> = prepared
1155                .queued_commands
1156                .iter()
1157                .map(|p| p.command_id.as_str())
1158                .collect();
1159            ensure!(
1160                actual == expected,
1161                "pending queue changed before checkpoint capture; source retained, confirm Move again"
1162            );
1163        }
1164        Ok(())
1165    }
1166}
1167
1168fn validate_preserved_configuration(
1169    profile_id: &str,
1170    accepted: &mj_core::acp::AcceptedSessionConfig,
1171    choices: &mj_core::worker_launch::ProfileConfig,
1172) -> Result<()> {
1173    for (key, value, offered) in [
1174        ("model", accepted.model.as_deref(), &choices.models),
1175        ("effort", accepted.effort.as_deref(), &choices.efforts),
1176    ] {
1177        let Some(value) = value else { continue };
1178        ensure!(
1179            offered.iter().any(|choice| choice.value == value),
1180            "destination profile {profile_id:?} does not offer the session's accepted {key} {value:?}; choices: {}",
1181            offered
1182                .iter()
1183                .map(|choice| choice.value.as_str())
1184                .collect::<Vec<_>>()
1185                .join(", ")
1186        );
1187    }
1188    Ok(())
1189}
1190
1191/// Whether this move can replace only the harness inside the source target.
1192///
1193/// The environment is kept only when nothing about it changes: the same target
1194/// template, the same attached mounts, and the same resource allocation. A
1195/// retry starts from a torn-down or unknown source, and a sub-agent never owns
1196/// its own target, so both take the full fresh-environment path.
1197pub(super) fn in_place_move_eligible(
1198    source: &mj_core::state::SessionRecord,
1199    selection: &MoveSelection,
1200    is_subagent: bool,
1201    retry: bool,
1202) -> bool {
1203    !retry
1204        && !is_subagent
1205        && source.target.is_some()
1206        && matches!(
1207            source.state,
1208            SessionState::Running | SessionState::Disconnected
1209        )
1210        && Some(&source.target_template_id) == selection.target_template_id.as_ref()
1211        && Some(&source.additional_mounts) == selection.additional_mounts.as_ref()
1212        && source.resource_allocation == selection.resource_allocation
1213        && !selection.clear_resource_allocation
1214}
1215
1216fn outcome(
1217    operation_id: &str,
1218    selection: &MoveSelection,
1219    status: &str,
1220    error: Option<String>,
1221    recovery: Option<String>,
1222) -> MoveOutcome {
1223    MoveOutcome {
1224        operation_id: operation_id.into(),
1225        session_id: selection.session_id.clone(),
1226        profile_id: selection.profile_id.clone().unwrap_or_default(),
1227        target_template_id: selection.target_template_id.clone().unwrap_or_default(),
1228        outcome: status.into(),
1229        error,
1230        recovery,
1231    }
1232}