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