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