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