Skip to main content

mj_controller/controller/worker_binary/
launch.rs

1use super::*;
2
3impl Controller {
4    /// Where this session's worker lives. This is decided from the session
5    /// record and configuration alone, so a caller can name the worker root
6    /// before anything is installed into it.
7    pub(in crate::controller) fn worker_placement(
8        &self,
9        session_id: &str,
10    ) -> Result<(targets::TargetLocator, String)> {
11        let session = self
12            .state
13            .sessions
14            .get(session_id)
15            .with_context(|| format!("unknown session {session_id}"))?;
16        let locator = session
17            .target
18            .as_ref()
19            .context("session target is missing")?;
20        let backend = backend_locator(locator, session, &self.config)?;
21        let worker_root = targets::worker_root(&backend, session_id)?;
22        Ok((backend, worker_root))
23    }
24
25    pub(in crate::controller) fn prepare_worker_files(
26        &self,
27        session_id: &str,
28        backend: &targets::TargetLocator,
29        worker_root: &str,
30        executor: &impl CommandExecutor,
31    ) -> Result<()> {
32        let session = self
33            .state
34            .sessions
35            .get(session_id)
36            .with_context(|| format!("unknown session {session_id}"))?;
37        session.validate_configuration(&self.config)?;
38        let profile = self
39            .config
40            .profiles
41            .get(&session.last_profile)
42            .context("session profile is missing")?;
43        let bundle = session
44            .project_directory
45            .is_none()
46            .then(|| self.config.bundles.get(&session.bundle_id))
47            .flatten();
48        let target = self
49            .config
50            .targets
51            .get(&session.target_template_id)
52            .context("session target template is missing")?;
53        let subagent = crate::database::load_subagent(session_id)?;
54        // A sub-agent child shares its parent's container, so it works in the
55        // parent's workspace. The parent record is authoritative for that path.
56        let (workspace_session_id, workspace_container) = match subagent.as_ref() {
57            Some(child) => {
58                let parent = self
59                    .state
60                    .sessions
61                    .get(&child.parent_session_id)
62                    .context("sub-agent parent session is missing")?;
63                (parent.id.clone(), parent.container_workspace.clone())
64            }
65            None => (session_id.to_owned(), session.container_workspace.clone()),
66        };
67        let (mut launch, project_memory, target_profile_home) = worker_launch_config(
68            session,
69            profile,
70            bundle,
71            backend,
72            &workspace_session_id,
73            workspace_container.as_deref(),
74            target,
75        )?;
76        launch.subagent_tools =
77            subagent_tools_enabled(session, self.config.subagents.enabled, subagent.is_some());
78        if let Some(subagent) = &subagent {
79            let parent = self
80                .state
81                .sessions
82                .get(&subagent.parent_session_id)
83                .context("sub-agent parent session is missing")?;
84            let parent_profile = self
85                .config
86                .profiles
87                .get(&parent.last_profile)
88                .context("sub-agent parent profile is missing")?;
89            let parent_target = self
90                .config
91                .targets
92                .get(&parent.target_template_id)
93                .context("sub-agent parent target template is missing")?;
94            let parent_locator = parent
95                .target
96                .as_ref()
97                .context("sub-agent parent has no live target")?;
98            let parent_backend = backend_locator(parent_locator, parent, &self.config)?;
99            let parent_bundle = parent
100                .project_directory
101                .is_none()
102                .then(|| self.config.bundles.get(&parent.bundle_id))
103                .flatten();
104            let (parent_launch, _, _) = worker_launch_config(
105                parent,
106                parent_profile,
107                parent_bundle,
108                &parent_backend,
109                &parent.id,
110                parent.container_workspace.as_deref(),
111                parent_target,
112            )?;
113            launch.cwd = if subagent.working_directory.as_os_str().is_empty() {
114                parent_launch.cwd
115            } else {
116                parent_launch.cwd.join(&subagent.working_directory)
117            };
118            launch.additional_directories = parent_launch.additional_directories;
119        }
120
121        if session.native_session_id.is_some()
122            && profile.kind == mj_core::config::HarnessKind::Codex
123        {
124            launch.goal_resume_request = Some(mj_core::state::new_session_id()?);
125        }
126        let staging = tempfile::tempdir().context("create worker staging directory")?;
127        let launch_path = staging.path().join("launch.json");
128        launch.write(&launch_path)?;
129        let ownership_path = staging.path().join("ownership.json");
130        WorkerOwnership {
131            version: WorkerOwnership::VERSION,
132            workspace_id: session.workspace_id.clone(),
133            session_id: session_id.to_string(),
134            profile_id: session.last_profile.clone(),
135            bundle_id: session.bundle_id.clone(),
136            target_template_id: session.target_template_id.clone(),
137            instance_id: Some(mj_core::config::instance_identity()),
138        }
139        .write(&ownership_path)?;
140        let profile_stage = staging.path().join("profile");
141        if !matches!(backend, targets::TargetLocator::LocalBare { .. })
142            || matches!(
143                profile.kind,
144                mj_core::config::HarnessKind::Claude | mj_core::config::HarnessKind::Muse
145            )
146            || crate::controller::requires_private_profile_home(profile)
147        {
148            let started = Instant::now();
149            let result = stage_profile(profile, &profile_stage);
150            tracing::debug!(
151                session_id,
152                elapsed_ms = started.elapsed().as_millis(),
153                "profile staging completed"
154            );
155            result?;
156            stage_codex_catalog(
157                &session.last_profile,
158                profile,
159                &profile_stage,
160                &fetch_catalog_over_https,
161                &SharedCatalogCache,
162            )?;
163            append_hel_target_environment(profile.kind, &profile_stage, backend)?;
164            apply_staged_execution_setting(profile.kind, launch.execution_policy, &profile_stage)?;
165            if launch.subagent_tools && profile.kind == mj_core::config::HarnessKind::Claude {
166                configure_claude_subagent_mcp(&profile_stage, worker_root)?;
167            }
168            stage_memory_replica(
169                &project_memory,
170                Path::new(&target_profile_home),
171                &profile_stage,
172            )?;
173            if project_memory.mcp_delivery == ProjectMemoryMcpDelivery::HarnessProfile {
174                configure_kimi_project_memory_mcp(&profile_stage, worker_root, &project_memory)?;
175            }
176        } else {
177            seed_local_memory_replica(&project_memory)?;
178        }
179        let worker_binary = worker_binary_for(backend, executor)?;
180
181        install_worker_files(
182            executor,
183            backend,
184            session_id,
185            worker_root,
186            &target_profile_home,
187            &worker_binary,
188            &launch_path,
189            &ownership_path,
190            &profile_stage,
191        )?;
192        // The build cache is an optimization: a failure here leaves the
193        // session running the image's own Cargo.
194        if session.build_cache.is_some()
195            && let Err(error) = self.install_build_cache_shim(session, backend, executor)
196        {
197            tracing::warn!(
198                session_id,
199                "installing the mbx build cache failed: {error:#}"
200            );
201        }
202        prepare_installed_managed_harness(executor, backend, worker_root, &launch)
203    }
204
205    /// Put the pinned mbx binary and its `cargo` shim in the session's `bin`
206    /// directory, which the worker prepends to `PATH` for the harness, its
207    /// terminals, and `bash -lc` shells. mbx invoked as `cargo` removes that
208    /// directory from `PATH` and runs the image's real Cargo underneath.
209    fn install_build_cache_shim(
210        &self,
211        session: &mj_core::state::SessionRecord,
212        backend: &targets::TargetLocator,
213        executor: &impl CommandExecutor,
214    ) -> Result<()> {
215        let worker_root = targets::worker_root(backend, &session.id)?;
216        // The download is the one build-cache failure worth telling the user
217        // about: it is fixable, and it is the only step that reaches the
218        // network.
219        let binary =
220            crate::controller::mbx::binary_for(backend, executor).inspect_err(|error| {
221                executor.notify_notice(&format!(
222                    "The Rust build cache is unavailable: {error:#}; this session builds without it."
223                ));
224            })?;
225        let configuration = self
226            .config
227            .targets
228            .get(&session.target_template_id)
229            .map(|template| {
230                crate::controller::backend::backend_target(
231                    template,
232                    session.resource_allocation.as_ref(),
233                    crate::controller::backend::ContainerOverrides::for_session(session),
234                )
235            })
236            .transpose()?
237            .and_then(|target| {
238                crate::controller::mbx::host_configuration(
239                    &target,
240                    &self.config.build_cache,
241                    executor,
242                )
243            });
244        install_mbx_files(
245            executor,
246            backend,
247            &session.id,
248            &worker_root,
249            &binary,
250            configuration.as_deref(),
251        )
252    }
253
254    /// Probe the installed binary and collect the dead worker's exit record
255    /// and log tail after a session becomes unreachable. Best-effort; returns
256    /// `None` when the target no longer exists or has no diagnostics.
257    pub fn diagnose_worker(&self, session_id: &str) -> Option<String> {
258        self.diagnose_worker_controlled(session_id, &crate::targets::ProcessExecutor)
259    }
260
261    pub fn diagnose_worker_controlled(
262        &self,
263        session_id: &str,
264        executor: &impl CommandExecutor,
265    ) -> Option<String> {
266        let session = self.state.sessions.get(session_id)?;
267        let locator = session.target.as_ref()?;
268        let backend = match backend_locator(locator, session, &self.config) {
269            Ok(backend) => backend,
270            Err(error) => {
271                tracing::debug!(
272                    session_id,
273                    error = format!("{error:#}"),
274                    "could not construct a worker diagnostic probe"
275                );
276                return None;
277            }
278        };
279        let worker_root = match targets::worker_root(&backend, session_id) {
280            Ok(root) => root,
281            Err(error) => {
282                tracing::debug!(
283                    session_id,
284                    error = format!("{error:#}"),
285                    "could not derive the worker diagnostic root"
286                );
287                return None;
288            }
289        };
290        let binary_failure = worker_binary_probe_failure(executor, &backend, &worker_root);
291        let last_words = worker_last_words(executor, &backend, &worker_root);
292        match (binary_failure, last_words) {
293            (Some(binary_failure), Some(last_words)) => {
294                Some(format!("{binary_failure}; {last_words}"))
295            }
296            (Some(binary_failure), None) => Some(binary_failure),
297            (None, last_words) => last_words,
298        }
299    }
300
301    /// A non-destructive liveness probe plus commands that replace a confirmed
302    /// dead session worker without touching its durable relay files. The
303    /// session manager runs both off its async actor.
304    pub fn worker_recovery_plan(&self, session_id: &str) -> Result<WorkerRecoveryPlan> {
305        let (backend, worker_root) = self.worker_placement(session_id)?;
306        let launch = self.current_worker_launch_config(session_id, &backend)?;
307        let workspace = worker_workspace_for_recovery(&backend, &launch.cwd);
308        Ok(WorkerRecoveryPlan {
309            source_target: self.state.sessions[session_id]
310                .target
311                .clone()
312                .context("session target is missing")?,
313            target: targets::target_recovery_plan(&backend, session_id)?,
314            workspace,
315            liveness_probe: worker_liveness_command(&backend, &worker_root),
316            binary_refresh: worker_binary_refresh_plan(&backend, session_id)?,
317            launch_refresh: Some(worker_launch_refresh_plan(&backend, session_id, &launch)?),
318            restart: CommandPlan {
319                description: format!("restart Mjolnir worker for session {session_id}"),
320                commands: vec![
321                    stop_worker_command(&backend, &worker_root),
322                    start_worker_command(&backend, &worker_root),
323                ],
324            },
325        })
326    }
327
328    pub(in crate::controller) fn current_worker_launch_config(
329        &self,
330        session_id: &str,
331        backend: &targets::TargetLocator,
332    ) -> Result<WorkerLaunchConfig> {
333        let session = self
334            .state
335            .sessions
336            .get(session_id)
337            .with_context(|| format!("unknown session {session_id}"))?;
338        session.validate_configuration(&self.config)?;
339        let profile = self
340            .config
341            .profiles
342            .get(&session.last_profile)
343            .context("session profile is missing")?;
344        let bundle = session
345            .project_directory
346            .is_none()
347            .then(|| self.config.bundles.get(&session.bundle_id))
348            .flatten();
349        let target = self
350            .config
351            .targets
352            .get(&session.target_template_id)
353            .context("session target template is missing")?;
354        let (mut launch, _, _) = worker_launch_config(
355            session,
356            profile,
357            bundle,
358            backend,
359            session_id,
360            session.container_workspace.as_deref(),
361            target,
362        )?;
363        if crate::database::load_move_operation(session_id)?.is_some_and(|operation| {
364            operation.source_checkpoint_only
365                && operation.destination_target.is_none()
366                && matches!(
367                    operation.phase,
368                    mj_core::state::MovePhase::Preparing
369                        | mj_core::state::MovePhase::ClosingSource
370                        | mj_core::state::MovePhase::Failed
371                        | mj_core::state::MovePhase::Cancelled
372                )
373                && session.last_profile == operation.source_profile_id
374                && session.target == operation.source_target
375                && matches!(
376                    session.state,
377                    mj_core::state::SessionState::Running
378                        | mj_core::state::SessionState::Disconnected
379                        | mj_core::state::SessionState::Closing
380                )
381        }) {
382            launch.run_mode = mj_core::worker_launch::WorkerRunMode::CheckpointOnly;
383        }
384        Ok(launch)
385    }
386
387    pub fn project_memory_sync_target(&self, session_id: &str) -> Result<ProjectMemorySyncTarget> {
388        let session = self
389            .state
390            .sessions
391            .get(session_id)
392            .with_context(|| format!("unknown session {session_id}"))?;
393        session.validate_configuration(&self.config)?;
394        let locator = session
395            .target
396            .as_ref()
397            .context("session target is missing")?;
398        let backend = backend_locator(locator, session, &self.config)?;
399        let profile = self
400            .config
401            .profiles
402            .get(&session.last_profile)
403            .context("session profile is missing")?;
404        let bundle = session
405            .project_directory
406            .is_none()
407            .then(|| self.config.bundles.get(&session.bundle_id))
408            .flatten();
409        let workspace = if let Some(project_directory) = &session.project_directory {
410            (project_directory.to_string_lossy().into_owned(), Vec::new())
411        } else {
412            workspace_paths(
413                &backend,
414                bundle.context("session bundle is missing")?,
415                session_id,
416                session.container_workspace.as_deref(),
417            )?
418        };
419        let target_home = target_profile_home(&backend, session_id, profile);
420        let launch = project_memory_launch(session, bundle, &workspace, &target_home)?;
421        Ok(ProjectMemorySyncTarget {
422            canonical_root: canonical_memory_root(&launch.project_key),
423        })
424    }
425}
426
427/// Whether this session gets Mjolnir's delegation tools in place of its
428/// harness's own. The session's stored choice governs and `None` follows the
429/// global `[subagents] enabled` setting, so a session created before the
430/// per-session choice existed behaves as it always did. A child never gets
431/// them, and only Claude and Codex can receive them at all.
432pub(super) fn subagent_tools_enabled(
433    session: &mj_core::state::SessionRecord,
434    global_enabled: bool,
435    is_child: bool,
436) -> bool {
437    session.mjolnir_subagents.unwrap_or(global_enabled)
438        && !is_child
439        && matches!(
440            session.harness_kind,
441            mj_core::config::HarnessKind::Claude | mj_core::config::HarnessKind::Codex
442        )
443}
444
445pub(super) fn worker_workspace_for_recovery(
446    backend: &targets::TargetLocator,
447    directory: &Path,
448) -> Option<WorkerWorkspace> {
449    let target = match backend {
450        targets::TargetLocator::LocalBare { .. } => mj_core::state::ManagedWorktreeTarget::Local,
451        targets::TargetLocator::SshBare { ssh, .. } => mj_core::state::ManagedWorktreeTarget::Ssh {
452            destination: ssh.destination.clone(),
453            ssh_args: ssh.ssh_args.clone(),
454        },
455        targets::TargetLocator::LocalPodman { .. }
456        | targets::TargetLocator::LocalDocker { .. }
457        | targets::TargetLocator::AppleContainer { .. }
458        | targets::TargetLocator::AwsEc2 { .. }
459        | targets::TargetLocator::SshPodman { .. }
460        | targets::TargetLocator::SshDocker { .. } => return None,
461    };
462    Some(WorkerWorkspace {
463        target,
464        directory: directory.to_path_buf(),
465    })
466}
467
468pub(super) fn worker_launch_config(
469    session: &mj_core::state::SessionRecord,
470    profile: &mj_core::config::HarnessProfile,
471    bundle: Option<&ProjectBundle>,
472    backend: &targets::TargetLocator,
473    workspace_session_id: &str,
474    workspace_container: Option<&Path>,
475    target: &mj_core::config::TargetTemplate,
476) -> Result<(WorkerLaunchConfig, ProjectMemoryLaunchConfig, String)> {
477    let session_id = session.id.as_str();
478    let execution_policy = profile
479        .kind
480        .effective_execution_policy(target.execution_policy());
481    let target_profile_home = target_profile_home(backend, session_id, profile);
482    let workspace = if let Some(project_directory) = &session.project_directory {
483        (project_directory.to_string_lossy().into_owned(), Vec::new())
484    } else {
485        workspace_paths(
486            backend,
487            bundle.context("session bundle is missing")?,
488            workspace_session_id,
489            workspace_container,
490        )?
491    };
492    let mut additional_directories = workspace.1.iter().map(PathBuf::from).collect::<Vec<_>>();
493    additional_directories.extend(
494        session
495            .additional_mounts
496            .iter()
497            .map(|resource| resource.destination.clone()),
498    );
499    if profile.kind == mj_core::config::HarnessKind::Muse && !additional_directories.is_empty() {
500        bail!(
501            "{} ACP does not support multiple workspace roots; use a single-repository bundle",
502            profile.kind.display_name()
503        );
504    }
505    let (bridge_command, bridge_args) = bridge_launch(profile.kind, execution_policy);
506    use mj_core::config::TargetTemplate;
507    let target_environment = match target {
508        TargetTemplate::LocalPodman { container }
509        | TargetTemplate::LocalDocker { container }
510        | TargetTemplate::AppleContainer { container }
511        | TargetTemplate::SshPodman { container, .. }
512        | TargetTemplate::SshDocker { container, .. } => container.environment.clone(),
513        _ => Default::default(),
514    };
515    let mut target_environment = target_environment;
516    // The build cache reaches the harness, its terminals, and the reviewer
517    // sidecar, all of which run Cargo through the mbx shim.
518    if let Some(build_cache) = &session.build_cache {
519        target_environment.insert(
520            "MBX_CACHE_DIR".into(),
521            build_cache.directory.to_string_lossy().into_owned(),
522        );
523        if let Some(max_size) = &build_cache.max_size {
524            target_environment.insert("MBX_GC_MAX_SIZE".into(), max_size.clone());
525        }
526    }
527    let mut environment = target_environment.clone();
528    environment.extend(profile.environment.clone());
529    profile
530        .kind
531        .configure_home_environment(Path::new(&target_profile_home), &mut environment);
532    profile
533        .kind
534        .configure_execution_environment(execution_policy, &mut environment)?;
535    let mut project_memory =
536        project_memory_launch(session, bundle, &workspace, &target_profile_home)?;
537    project_memory.mcp_delivery = project_memory_mcp_delivery(profile.kind, backend);
538    if profile.kind == mj_core::config::HarnessKind::Claude {
539        environment.insert(
540            "CLAUDE_CODE_PROJECT_DIR_NAME".into(),
541            project_memory_replica_slug(&project_memory.project_key, session_id),
542        );
543    }
544    apply_claude_setup_token(
545        &mut environment,
546        profile.kind,
547        &mj_core::credentials::claude_oauth_token_path(&session.last_profile),
548    );
549    Ok((
550        WorkerLaunchConfig {
551            goal_resume_request: None,
552            target_environment,
553            seed_image_environment: backend.container_engine().is_some(),
554            run_mode: Default::default(),
555            session_id: session_id.to_string(),
556            subagent_tools: false,
557            harness: profile.kind,
558            // The staged home mirrors the profile home, so the controller's
559            // marker file name is the one the worker must check.
560            authentication_marker: profile
561                .authentication_marker()
562                .file_name()
563                .map(|name| name.to_string_lossy().into_owned()),
564            bridge_command: PathBuf::from(bridge_command),
565            bridge_args,
566            harness_runtime: harness_runtime_policy(backend),
567            environment,
568            cwd: PathBuf::from(&workspace.0),
569            additional_directories,
570            native_session_id: session.native_session_id.clone(),
571            project_memory: profile
572                .kind
573                .supports_injected_mcp()
574                .then(|| project_memory.clone()),
575            execution_policy,
576        },
577        project_memory,
578        target_profile_home,
579    ))
580}
581
582pub(super) fn harness_runtime_policy(backend: &targets::TargetLocator) -> HarnessRuntimePolicy {
583    match backend {
584        targets::TargetLocator::LocalBare { .. }
585        | targets::TargetLocator::AwsEc2 { .. }
586        | targets::TargetLocator::SshBare { .. } => HarnessRuntimePolicy::Managed,
587        _ => HarnessRuntimePolicy::Ambient,
588    }
589}