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