Skip to main content

mj_controller/controller/
move_session.rs

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