Skip to main content

mj_controller/controller/
subagent_park.rs

1//! Parking a sub-agent whose turn has ended, and starting it again.
2//!
3//! A sub-agent child runs inside its parent's target, and in a container every
4//! live child's harness holds hundreds of threads against the container's pids
5//! limit (#1161). A child that has finished its turn, and whose parent has
6//! been told so, therefore gives its processes back: its worker is stopped and
7//! its record says [`SessionState::Parked`]. Everything else stays: the
8//! record, the relation to the parent, the borrowed target locator, and the
9//! worker root with its relay journal and the harness's native session id. A
10//! parked child is started again in place, with the same sequence a worker
11//! restart uses, when its parent gives it more input.
12//!
13//! The daemon runs both as lifecycle operations of the child, so they are
14//! serialized with its close, suspend, destroy and each other.
15
16use std::time::Duration;
17
18use anyhow::{Context, Result, ensure};
19
20use mj_core::state::SessionState;
21
22use super::worker_binary::{
23    refresh_installed_worker_binary, replace_installed_worker_launch_config, start_worker_durably,
24    stop_worker_after_target_recovery,
25};
26use super::worker_restart::{InstalledWorkerRestart, WorkerRestartMessages};
27use super::{Controller, IdleWorkspaceLease};
28use crate::session_manager::SessionManagerControl;
29use crate::targets::{self, CommandExecutor};
30
31/// How long a park waits for the child's session actor to exist.
32const PARK_ACTOR_TIMEOUT: Duration = Duration::from_secs(5);
33
34/// How long a park waits, after recording `Parked`, for the session manager
35/// to drop the child. The manager rereads the store twice a second.
36const PARK_RELEASE_TIMEOUT: Duration = Duration::from_secs(15);
37
38/// What an unpark tells the operator at each step of the restart it runs.
39const RESTART_FROM_PARKED: WorkerRestartMessages = WorkerRestartMessages {
40    stop: "stop the parked sub-agent's worker",
41    replace: "install the current Mjolnir worker binary for the parked sub-agent",
42    start: "start the parked sub-agent's worker",
43    connect: "connect to the parked sub-agent's worker after starting it",
44    project_memory: "project memory will not be synchronized for the restarted sub-agent",
45    native_session: "wait for the parked sub-agent's harness to load its conversation",
46};
47
48/// How one park attempt ended. Only [`ParkOutcome::Parked`] changed anything.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ParkOutcome {
51    /// The worker was stopped and the record says `Parked`.
52    Parked,
53    /// The child had work in flight or queued, such as a prompt its parent
54    /// sent after the turn ended, so it was left running.
55    Busy,
56    /// The child is not a running sub-agent any more (it is closing, already
57    /// parked, or gone), so there was nothing to park.
58    NotRunning,
59}
60
61/// An executor for stopping what a failed unpark started. The unpark's own
62/// executor may be the reason it failed, cancelled by a close of the child.
63pub(crate) const FAILED_STARTUP_CLEANUP_TIMEOUT: Duration = Duration::from_secs(30);
64
65pub(crate) fn failed_launch_cleanup_executor() -> targets::CancellableProcessExecutor {
66    targets::CancellableProcessExecutor::with_timeout(FAILED_STARTUP_CLEANUP_TIMEOUT)
67}
68
69/// Say plainly that a sub-agent could not start because its target ran out of
70/// process slots, when `error` shows that, with the container's pid counts
71/// when they can be read; return any other error unchanged.
72///
73/// The read is best effort, bounded by [`targets::PIDS_USAGE_READ_TIMEOUT`],
74/// and itself fails in a container that is completely full, which the
75/// message then says.
76pub(super) fn explain_process_exhaustion(
77    error: anyhow::Error,
78    backend: &targets::TargetLocator,
79    session_id: &str,
80) -> anyhow::Error {
81    if !targets::shows_process_exhaustion(&error) {
82        return error;
83    }
84    let usage = targets::is_container(backend).then(|| {
85        let executor =
86            targets::CancellableProcessExecutor::with_timeout(targets::PIDS_USAGE_READ_TIMEOUT);
87        targets::read_pids_usage(&executor, backend, session_id)
88    });
89    anyhow::anyhow!(targets::process_exhaustion_message(usage.as_ref(), &error))
90}
91
92impl Controller {
93    /// Stop a running sub-agent's worker while it is idle, and record it as
94    /// parked.
95    ///
96    /// The worker is reserved the way an idle worker upgrade reserves it: the
97    /// actor's connection is leased only while the worker reports nothing
98    /// running or queued, and the worker holds an idle barrier until it is
99    /// stopped. A prompt that reaches the actor meanwhile waits behind the
100    /// lease. The lease is kept until the session manager has dropped the
101    /// child, which it does once the store says `Parked`, so such a prompt is
102    /// rejected as undelivered rather than sent to a stopped worker; the
103    /// caller that sent it can start the child again and resend it.
104    ///
105    /// Any failure before the record changes leaves the child running: the
106    /// lease is dropped and the actor reconnects, restarting the worker if the
107    /// stop got that far.
108    pub async fn park_subagent_worker(
109        &self,
110        session_id: &str,
111        executor: &(impl CommandExecutor + Sync),
112        manager: &SessionManagerControl,
113    ) -> Result<ParkOutcome> {
114        self.stop_idle_subagent_worker(session_id, executor, manager, None)
115            .await
116    }
117
118    /// Stop a sub-agent whose first prompt can never run and record it as
119    /// failed with `cause`, so every surface shows the failure instead of an
120    /// idle session holding a worker (I1-2). It is stopped exactly as a park
121    /// stops a child, behind the same idle reservation, and keeps its record,
122    /// relation and target; its close settles without a checkpoint (see
123    /// [`super::has_nothing_to_checkpoint`]).
124    ///
125    /// A child that took work meanwhile is left running and
126    /// [`ParkOutcome::Busy`] is returned. [`ParkOutcome::Parked`] means the
127    /// worker was stopped and the record now says `Error`.
128    pub async fn fail_subagent_start_worker(
129        &self,
130        session_id: &str,
131        cause: &str,
132        executor: &(impl CommandExecutor + Sync),
133        manager: &SessionManagerControl,
134    ) -> Result<ParkOutcome> {
135        self.stop_idle_subagent_worker(session_id, executor, manager, Some(cause))
136            .await
137    }
138
139    /// The stop behind a park and a failed start: the record becomes
140    /// `Parked`, or `Error` with `failure` as its cause.
141    async fn stop_idle_subagent_worker(
142        &self,
143        session_id: &str,
144        executor: &(impl CommandExecutor + Sync),
145        manager: &SessionManagerControl,
146        failure: Option<&str>,
147    ) -> Result<ParkOutcome> {
148        crate::worker_lifecycle::run(session_id, "stop idle subagent worker", executor, async {
149            crate::worker_lifecycle::require(session_id)?.verify_cached_target(&self.state)?;
150            ensure!(
151                self.state.subagents.contains_key(session_id),
152                "session {session_id} is not a sub-agent"
153            );
154            let Some(session) = self.state.sessions.get(session_id) else {
155                return Ok(ParkOutcome::NotRunning);
156            };
157            if session.state != SessionState::Running {
158                return Ok(ParkOutcome::NotRunning);
159            }
160            let (backend, worker_root) = self.worker_placement(session_id)?;
161            let handle = manager
162                .wait_for_session(session_id, PARK_ACTOR_TIMEOUT)
163                .await?;
164            let Some(mut lease) =
165                IdleWorkspaceLease::acquire_for_upgrade(&handle, session.harness_kind).await?
166            else {
167                return Ok(ParkOutcome::Busy);
168            };
169            if !lease.verify_for_upgrade().await? {
170                return Ok(ParkOutcome::Busy);
171            }
172            stop_worker_after_target_recovery(executor, &backend, session_id, &worker_root)
173                .context("stop the sub-agent's worker")?;
174            let mut record = session.clone();
175            record.state = if failure.is_some() {
176                SessionState::Error
177            } else {
178                SessionState::Parked
179            };
180            record.last_error = failure.map(str::to_owned);
181            record.updated_at = super::now();
182            crate::database::save_lifecycle_session(&record)
183                .context("record the stopped sub-agent")?;
184            let released = tokio::time::timeout(PARK_RELEASE_TIMEOUT, async {
185                while manager.session(session_id.to_owned()).await.is_ok() {
186                    tokio::time::sleep(Duration::from_millis(50)).await;
187                }
188            })
189            .await;
190            if released.is_err() {
191                tracing::warn!(
192                    session_id,
193                    "the session manager still held the stopped sub-agent; releasing it anyway"
194                );
195            }
196            drop(lease);
197            Ok(ParkOutcome::Parked)
198        })
199        .await
200    }
201
202    /// Accept a durable wake, launch the worker, and observe native readiness.
203    /// Preparation and readiness hold no daemon admission. Once the wake is
204    /// accepted, failure leaves its active record and boot intent for recovery;
205    /// it never stops a worker that may already have resumed accepted work.
206    pub async fn unpark_subagent_worker(
207        &self,
208        session_id: &str,
209        executor: &(impl CommandExecutor + Sync),
210    ) -> Result<()> {
211        crate::worker_lifecycle::run(session_id, "unpark subagent worker", executor, async {
212            let owner = crate::worker_lifecycle::require(session_id)?;
213            owner.verify_cached_target(&self.state)?;
214            ensure!(
215                self.state.subagents.contains_key(session_id),
216                "session {session_id} is not a sub-agent"
217            );
218            let session = self
219                .state
220                .sessions
221                .get(session_id)
222                .with_context(|| format!("unknown session {session_id}"))?;
223            if session.state == SessionState::Running {
224                return Ok(());
225            }
226            ensure!(
227                session.state == SessionState::Parked,
228                "sub-agent {session_id} is {} and cannot be started again",
229                session.state.as_str()
230            );
231            let (backend, worker_root) = self.worker_placement(session_id)?;
232            let reconnect = targets::reconnect_plan(&backend, session_id)?
233                .commands
234                .into_iter()
235                .next()
236                .context("reconnect plan is empty")?;
237            // An older failed wake may have left a parked record and boot
238            // intent. Only proved death permits retiring that intent.
239            if crate::database::load_worker_restart(session_id)?.is_some() {
240                let output = executor.execute(&super::worker_binary::worker_liveness_command(
241                    &backend,
242                    &worker_root,
243                ))?;
244                ensure!(
245                    output.status == 0 && String::from_utf8_lossy(&output.stdout).trim() == "dead",
246                    "parked sub-agent still has a live worker boot"
247                );
248                owner.settle_dead_restart(
249                    session
250                        .target
251                        .as_ref()
252                        .context("parked child has no target")?,
253                )?;
254            }
255            let launch = self.current_worker_launch_config(session_id, &backend)?;
256            refresh_installed_worker_binary(executor, &backend, session_id)
257                .context(RESTART_FROM_PARKED.replace)?;
258            replace_installed_worker_launch_config(executor, &backend, session_id, &launch)
259                .context("install the current Mjolnir worker launch configuration")?;
260            // Keep container startup concurrency bounded through native readiness,
261            // while cancellation releases this observation without stopping a worker.
262            let gate = super::provisioning::container_start_gate(&backend);
263            let _admitted = match &gate {
264                Some(gate) => {
265                    let acquiring = gate.acquire();
266                    tokio::pin!(acquiring);
267                    loop {
268                        ensure!(!executor.cancellation_requested(), "sub-agent wake cancelled");
269                        tokio::select! {
270                            permit = &mut acquiring => break Some(permit.context("container start admission closed")?),
271                            _ = tokio::time::sleep(Duration::from_millis(25)) => {}
272                        }
273                    }
274                }
275                None => None,
276            };
277            ensure!(
278                !executor.cancellation_requested(),
279                "sub-agent wake cancelled"
280            );
281            executor.end_resumable_work("subagent unpark launch")?;
282            let target = session
283                .target
284                .as_ref()
285                .context("parked child has no target")?;
286            crate::database::begin_subagent_unpark(
287                session_id,
288                &crate::database::WorkerRestartIntent {
289                    operation_id: owner.operation_id().to_owned(),
290                    target: target.clone(),
291                    desired_build: String::new(),
292                },
293            )?;
294            start_worker_durably(&owner, target, executor, &backend, &worker_root)
295                .context(RESTART_FROM_PARKED.start)
296                .map_err(|error| explain_process_exhaustion(error, &backend, session_id))?;
297            // The active record and boot intent are durable. Handoff may cancel
298            // this wait; the next daemon attaches to the same live worker.
299            executor.begin_resumable_work()?;
300            self.observe_started_worker(
301                session_id,
302                executor,
303                InstalledWorkerRestart {
304                    backend: &backend,
305                    worker_root: &worker_root,
306                    reconnect: &reconnect,
307                    launch: Some(&launch),
308                    prepared: true,
309                    messages: &RESTART_FROM_PARKED,
310                },
311            )
312            .await?;
313            Ok(())
314        })
315        .await
316    }
317}
318
319#[cfg(all(test, unix))]
320mod tests {
321    use std::sync::atomic::{AtomicBool, Ordering};
322    use std::sync::{Arc, Mutex};
323
324    use agent_client_protocol::schema::v1::ContentBlock;
325    use mj_core::relay::RelayCommand;
326    use mj_core::state::TargetLocator;
327
328    use super::*;
329    use crate::controller::checkpoint::tests::{
330        LATCH_RELAY_SESSION, ReleaseSupport, latch_relay_target,
331    };
332    use crate::controller::test_support::{IsolatedTest, checkpoint_test_session, test_name};
333    use crate::targets::{CommandOutput, CommandSpec};
334
335    const MARKER: &str = "MJ_TEST_SUBAGENT_PARK_CHILD";
336
337    /// Run the named test alone, with a store of its own. Returns whether
338    /// this process is that run.
339    fn isolated(test: &str) -> bool {
340        if std::env::var_os(MARKER).is_some() {
341            return true;
342        }
343        let directory = tempfile::tempdir().unwrap();
344        let worker = directory.path().join("worker-source");
345        std::fs::write(&worker, mj_core::worker_build::WORKER_BUILD_STAMP).unwrap();
346        IsolatedTest::new(test_name(module_path!(), test))
347            .env(MARKER, "1")
348            .env("MJ_WORKER_BINARY", worker)
349            .env("MJ_INSTANCE", "durable-unpark-test")
350            .isolated_store(directory.path())
351            .run();
352        false
353    }
354
355    /// Stands in for the target: every command succeeds, and the first one,
356    /// which is the park's stop, also sends the child a prompt through its
357    /// actor, the way a parent's `send_message` can race a park.
358    #[derive(Default)]
359    struct RacingStop {
360        purposes: Mutex<Vec<String>>,
361        racer: Mutex<
362            Option<(
363                crate::session_manager::ManagedSessionHandle,
364                tokio::runtime::Handle,
365            )>,
366        >,
367        raced: Mutex<Option<tokio::task::JoinHandle<Result<u64>>>>,
368    }
369
370    impl CommandExecutor for RacingStop {
371        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
372            self.purposes.lock().unwrap().push(command.purpose.clone());
373            if let Some((handle, runtime)) = self.racer.lock().unwrap().take() {
374                *self.raced.lock().unwrap() = Some(runtime.spawn(async move {
375                    handle
376                        .submit(
377                            "raced-prompt".into(),
378                            RelayCommand::Prompt {
379                                prompt: vec![ContentBlock::from("one more thing")],
380                            },
381                        )
382                        .await
383                }));
384            }
385            Ok(CommandOutput {
386                status: 0,
387                stdout: Vec::new(),
388                stderr: Vec::new(),
389            })
390        }
391    }
392
393    /// A parent, and the stand-in relay's session registered as its child on
394    /// a bare target under `root`, with a report it handed back.
395    fn register_child(root: &std::path::Path) {
396        crate::database::save_session(&checkpoint_test_session("parent-1")).unwrap();
397        let mut child = checkpoint_test_session(LATCH_RELAY_SESSION);
398        child.target = Some(TargetLocator::LocalBare {
399            worker_root: root.join(LATCH_RELAY_SESSION),
400        });
401        crate::database::save_subagent_session(
402            &child,
403            &mj_core::subagent::SubagentRecord {
404                child_session_id: LATCH_RELAY_SESSION.into(),
405                parent_session_id: "parent-1".into(),
406                task_name: "map the parser".into(),
407                profile_id: "codex".into(),
408                model: None,
409                effort: None,
410                working_directory: Default::default(),
411                initial_prompt: "map the parser".into(),
412                request_key: "request-1".into(),
413                created_at: "2026-09-25T00:00:00Z".into(),
414                noticed_turn: None,
415                reported_finish: None,
416                handback_tool: true,
417            },
418        )
419        .unwrap();
420        assert!(
421            crate::database::record_subagent_handback(
422                LATCH_RELAY_SESSION,
423                &mj_core::subagent::SubagentHandback {
424                    command_id: "task-1".into(),
425                    message: "The parser has three entry points.".into(),
426                    recorded_at_ms: 1,
427                },
428            )
429            .unwrap()
430        );
431    }
432
433    fn loaded_controller() -> Controller {
434        Controller {
435            config: mj_core::config::Config::default(),
436            state: crate::database::load_state().unwrap(),
437        }
438    }
439
440    /// Target command boundaries are controlled; the store and relay journal
441    /// are real. A detached launch must already have its active record and intent.
442    struct WakeTarget {
443        cancelled: AtomicBool,
444        purposes: Mutex<Vec<String>>,
445        admission: Mutex<Option<crate::upgrade::Work>>,
446        gate: Arc<crate::upgrade::Gate>,
447        observing: tokio::sync::Notify,
448    }
449
450    impl WakeTarget {
451        fn new() -> Self {
452            Self {
453                cancelled: AtomicBool::new(false),
454                purposes: Mutex::new(Vec::new()),
455                admission: Mutex::new(None),
456                gate: Arc::new(crate::upgrade::Gate::default()),
457                observing: tokio::sync::Notify::new(),
458            }
459        }
460    }
461
462    impl CommandExecutor for WakeTarget {
463        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
464            self.purposes.lock().unwrap().push(command.purpose.clone());
465            if command.purpose == "start detached Mjolnir worker" {
466                assert!(!self.gate.try_close(), "launch must hold admission");
467                assert_eq!(
468                    crate::database::load_session_state(LATCH_RELAY_SESSION)?,
469                    Some(SessionState::Running)
470                );
471                assert!(crate::database::load_worker_restart(LATCH_RELAY_SESSION)?.is_some());
472                crate::database::set_session_draft_input(LATCH_RELAY_SESSION, "newer draft")?;
473            }
474            let stdout = if command.purpose == "detect target platform" {
475                format!(
476                    "{} {}\n",
477                    if cfg!(target_os = "macos") {
478                        "Darwin"
479                    } else {
480                        "Linux"
481                    },
482                    std::env::consts::ARCH
483                )
484                .into_bytes()
485            } else if command.purpose == "probe Mjolnir worker daemon liveness" {
486                b"alive\n".to_vec()
487            } else {
488                Vec::new()
489            };
490            Ok(CommandOutput {
491                status: 0,
492                stdout,
493                stderr: Vec::new(),
494            })
495        }
496
497        fn cancellation_requested(&self) -> bool {
498            self.cancelled.load(Ordering::SeqCst)
499        }
500
501        fn begin_resumable_work(&self) -> Result<()> {
502            if self.admission.lock().unwrap().take().is_some() {
503                self.observing.notify_one();
504            }
505            Ok(())
506        }
507
508        fn end_resumable_work(&self, label: &'static str) -> Result<()> {
509            *self.admission.lock().unwrap() = Some(self.gate.enter(label)?);
510            Ok(())
511        }
512    }
513
514    fn waking_controller(root: &std::path::Path, startup_delay_ms: u64) -> Controller {
515        use std::os::unix::fs::PermissionsExt;
516        register_child(root);
517        let mut parent = crate::database::read_durable_session_record("parent-1")
518            .unwrap()
519            .unwrap();
520        parent.project_directory = Some(root.to_path_buf());
521        parent.target_template_id = "local-bare".into();
522        parent.target = Some(TargetLocator::LocalBare {
523            worker_root: root.join("parent-1"),
524        });
525        parent.no_project_memory = true;
526        crate::database::save_session(&parent).unwrap();
527        let mut child = crate::database::read_durable_session_record(LATCH_RELAY_SESSION)
528            .unwrap()
529            .unwrap();
530        child.state = SessionState::Parked;
531        child.target_template_id = "local-bare".into();
532        child.project_directory = Some(root.to_path_buf());
533        child.no_project_memory = true;
534        crate::database::save_session(&child).unwrap();
535        let worker_root = root.join(LATCH_RELAY_SESSION);
536        std::fs::create_dir_all(&worker_root).unwrap();
537        let relay = latch_relay_target(root, None, ReleaseSupport::Supported, true);
538        let mut script = String::from("#!/bin/sh\n");
539        for (key, value) in &relay.spec.env {
540            script.push_str(&format!("export {key}={}\n", targets::posix_quote(value)));
541        }
542        script.push_str(&format!(
543            "export MJ_TEST_LATCH_STARTUP_DELAY_MS={startup_delay_ms}\nexec {}\n",
544            targets::join_remote_command(
545                &[relay.spec.program.clone(),]
546                    .into_iter()
547                    .chain(relay.spec.args.iter().cloned())
548                    .collect::<Vec<_>>()
549            ),
550        ));
551        let worker = worker_root.join("hel");
552        std::fs::write(&worker, script).unwrap();
553        std::fs::set_permissions(&worker, std::fs::Permissions::from_mode(0o755)).unwrap();
554        let mut controller = loaded_controller();
555        controller.config = crate::controller::test_support::resume_compatibility_config();
556        let home = root.join("profile");
557        std::fs::create_dir_all(&home).unwrap();
558        controller.config.profiles.insert(
559            "codex".into(),
560            mj_core::config::HarnessProfile {
561                enabled: true,
562                kind: mj_core::config::HarnessKind::Codex,
563                home,
564                environment: Default::default(),
565                context_window_bytes: None,
566                legacy_subagents: Default::default(),
567                guardian_review_model: None,
568                codex_profile: None,
569            },
570        );
571        controller
572    }
573
574    // Hard-won: a6ad3cc3c: unpark waited for a resumed provider retry to finish and then stopped it.
575    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
576    async fn unpark_accepts_native_readiness_while_durable_work_is_running() {
577        if !isolated("unpark_accepts_native_readiness_while_durable_work_is_running") {
578            return;
579        }
580        let _writer = crate::database::install_isolated_test_writer();
581        let root = tempfile::tempdir().unwrap();
582        let controller = waking_controller(root.path(), 0);
583        let executor = WakeTarget::new();
584        tokio::time::timeout(
585            Duration::from_secs(10),
586            controller.unpark_subagent_worker(LATCH_RELAY_SESSION, &executor),
587        )
588        .await
589        .expect("a running turn must not delay readiness")
590        .unwrap();
591        assert!(executor.gate.try_close());
592        let record = crate::database::read_durable_session_record(LATCH_RELAY_SESSION)
593            .unwrap()
594            .unwrap();
595        assert_eq!(record.state, SessionState::Running);
596        assert_eq!(record.draft_input, "newer draft");
597        assert!(
598            crate::database::load_worker_restart(LATCH_RELAY_SESSION)
599                .unwrap()
600                .is_none()
601        );
602        let snapshot: mj_core::relay::RelaySnapshot = serde_json::from_slice(
603            &std::fs::read(root.path().join(mj_core::relay::RELAY_STATE_FILE)).unwrap(),
604        )
605        .unwrap();
606        assert_eq!(
607            snapshot.active_prompt.unwrap().command_id,
608            "seed-running-prompt"
609        );
610        assert!(
611            !executor
612                .purposes
613                .lock()
614                .unwrap()
615                .iter()
616                .any(|p| p == "stop Mjolnir worker daemon")
617        );
618    }
619
620    // Hard-won: a6ad3cc3c: readiness observation held daemon admission and cancellation stopped accepted worker work.
621    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
622    async fn slow_unpark_releases_handoff_and_its_next_actor_preserves_the_live_boot() {
623        if !isolated("slow_unpark_releases_handoff_and_its_next_actor_preserves_the_live_boot") {
624            return;
625        }
626        let _writer = crate::database::install_isolated_test_writer();
627        let root = tempfile::tempdir().unwrap();
628        let controller = waking_controller(root.path(), 60_000);
629        let executor = Arc::new(WakeTarget::new());
630        let waking = tokio::spawn({
631            let executor = executor.clone();
632            async move {
633                controller
634                    .unpark_subagent_worker(LATCH_RELAY_SESSION, &*executor)
635                    .await
636            }
637        });
638        tokio::time::timeout(Duration::from_secs(10), executor.observing.notified())
639            .await
640            .unwrap();
641        tokio::time::timeout(Duration::from_secs(10), async {
642            loop {
643                if std::fs::read(root.path().join(mj_core::relay::RELAY_STATE_FILE))
644                    .ok()
645                    .and_then(|bytes| {
646                        serde_json::from_slice::<mj_core::relay::RelaySnapshot>(&bytes).ok()
647                    })
648                    .is_some_and(|snapshot| snapshot.active_prompt.is_some())
649                {
650                    break;
651                }
652                tokio::time::sleep(Duration::from_millis(10)).await;
653            }
654        })
655        .await
656        .unwrap();
657        assert!(
658            executor.gate.try_close(),
659            "harness readiness must not hold handoff"
660        );
661        executor.cancelled.store(true, Ordering::SeqCst);
662        let error = tokio::time::timeout(Duration::from_secs(10), waking)
663            .await
664            .unwrap()
665            .unwrap()
666            .unwrap_err();
667        assert!(format!("{error:#}").contains("cancelled"), "{error:#}");
668        assert_eq!(
669            crate::database::load_session_state(LATCH_RELAY_SESSION).unwrap(),
670            Some(SessionState::Running)
671        );
672        assert_eq!(
673            crate::database::worker_restart_phase(LATCH_RELAY_SESSION).unwrap(),
674            Some(crate::database::WorkerRestartPhase::AwaitingReadiness)
675        );
676        let source_target = crate::database::read_durable_session_record(LATCH_RELAY_SESSION)
677            .unwrap()
678            .unwrap()
679            .target
680            .unwrap();
681        let snapshot: mj_core::relay::RelaySnapshot = serde_json::from_slice(
682            &std::fs::read(root.path().join(mj_core::relay::RELAY_STATE_FILE)).unwrap(),
683        )
684        .unwrap();
685        assert_eq!(
686            snapshot.active_prompt.unwrap().command_id,
687            "seed-running-prompt"
688        );
689        let mut target = latch_relay_target(root.path(), None, ReleaseSupport::Supported, false);
690        target.worker_recovery = Some(crate::session_manager::WorkerRecoveryPlan {
691            source_target,
692            target: None,
693            workspace: None,
694            exit_record: None,
695            liveness_probe: CommandSpec::new("printf", ["alive\n"])
696                .purpose("probe Mjolnir worker daemon liveness"),
697            binary_refresh: None,
698            launch_refresh: None,
699            restart: targets::CommandPlan {
700                description: "a live wake must not be restarted".into(),
701                commands: vec![CommandSpec::new("false", std::iter::empty::<&str>())],
702            },
703        });
704        executor.cancelled.store(false, Ordering::SeqCst);
705        assert_eq!(
706            crate::session_manager::recover_worker_controlled(
707                target.worker_recovery.clone().unwrap(),
708                true,
709                Some(LATCH_RELAY_SESSION),
710                &*executor,
711            )
712            .unwrap(),
713            crate::session_manager::WorkerRecoveryOutcome::Starting
714        );
715        let channels = crate::session_manager::spawn_session_manager().unwrap();
716        channels.targets.send(vec![target]).unwrap();
717        let handle = channels
718            .control
719            .wait_for_session(LATCH_RELAY_SESSION, Duration::from_secs(10))
720            .await
721            .unwrap();
722        handle.sync_now().await.unwrap();
723        assert!(
724            crate::database::load_worker_restart(LATCH_RELAY_SESSION)
725                .unwrap()
726                .is_none()
727        );
728        assert!(
729            handle
730                .view()
731                .snapshot
732                .unwrap()
733                .operational
734                .native_session_is_ready()
735        );
736        assert_eq!(
737            executor
738                .purposes
739                .lock()
740                .unwrap()
741                .iter()
742                .filter(|p| *p == "start detached Mjolnir worker")
743                .count(),
744            1
745        );
746        assert!(
747            !executor
748                .purposes
749                .lock()
750                .unwrap()
751                .iter()
752                .any(|p| p == "stop Mjolnir worker daemon")
753        );
754        channels.shutdown.shutdown().await.unwrap();
755    }
756
757    #[test]
758    fn failed_wake_publication_rolls_back_its_boot_intent() {
759        if !isolated("failed_wake_publication_rolls_back_its_boot_intent") {
760            return;
761        }
762        let _writer = crate::database::install_isolated_test_writer();
763        let root = tempfile::tempdir().unwrap();
764        let controller = waking_controller(root.path(), 0);
765        let connection = rusqlite::Connection::open(crate::database::database_path()).unwrap();
766        connection
767            .execute_batch(
768                "CREATE TRIGGER reject_wake BEFORE UPDATE OF state ON sessions
769             WHEN NEW.state='running' AND OLD.state='parked'
770             BEGIN SELECT RAISE(ABORT, 'wake publication failed'); END;",
771            )
772            .unwrap();
773        let owner = crate::worker_lifecycle::WorkerPermit::try_acquire(
774            LATCH_RELAY_SESSION,
775            "test wake transaction",
776        )
777        .unwrap()
778        .unwrap();
779        let error = owner
780            .scope_blocking(|| {
781                crate::database::begin_subagent_unpark(
782                    LATCH_RELAY_SESSION,
783                    &crate::database::WorkerRestartIntent {
784                        operation_id: owner.operation_id().into(),
785                        target: controller.state.sessions[LATCH_RELAY_SESSION]
786                            .target
787                            .clone()
788                            .unwrap(),
789                        desired_build: String::new(),
790                    },
791                )
792            })
793            .unwrap_err();
794        assert!(
795            format!("{error:#}").contains("wake publication failed"),
796            "{error:#}"
797        );
798        assert_eq!(
799            crate::database::load_session_state(LATCH_RELAY_SESSION).unwrap(),
800            Some(SessionState::Parked)
801        );
802        assert!(
803            crate::database::load_worker_restart(LATCH_RELAY_SESSION)
804                .unwrap()
805                .is_none()
806        );
807    }
808
809    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
810    async fn parking_stops_an_idle_child_keeps_its_record_and_turns_away_a_racing_prompt() {
811        if !isolated("parking_stops_an_idle_child_keeps_its_record_and_turns_away_a_racing_prompt")
812        {
813            return;
814        }
815        let _writer = crate::database::install_isolated_test_writer();
816        let root = tempfile::tempdir().unwrap();
817        register_child(root.path());
818        let crate::session_manager::SessionManagerChannels {
819            session_cpu: _,
820            targets,
821            control,
822            updates: _updates,
823            shutdown,
824        } = crate::session_manager::spawn_session_manager().unwrap();
825        targets
826            .send(vec![latch_relay_target(
827                root.path(),
828                None,
829                ReleaseSupport::Supported,
830                false,
831            )])
832            .unwrap();
833        let handle = control
834            .wait_for_session(LATCH_RELAY_SESSION, Duration::from_secs(10))
835            .await
836            .unwrap();
837        let executor = RacingStop::default();
838        *executor.racer.lock().unwrap() = Some((handle, tokio::runtime::Handle::current()));
839        // What the daemon's target refresher does: once the store says the
840        // child is parked, the session manager no longer holds it.
841        let refresher = tokio::spawn(async move {
842            while crate::database::load_session_state(LATCH_RELAY_SESSION).unwrap()
843                != Some(SessionState::Parked)
844            {
845                tokio::time::sleep(Duration::from_millis(25)).await;
846            }
847            targets.send_replace(Vec::new());
848            targets
849        });
850
851        let outcome = loaded_controller()
852            .park_subagent_worker(LATCH_RELAY_SESSION, &executor, &control)
853            .await
854            .unwrap();
855
856        assert_eq!(outcome, ParkOutcome::Parked);
857        assert!(
858            executor
859                .purposes
860                .lock()
861                .unwrap()
862                .iter()
863                .any(|purpose| purpose == "stop Mjolnir worker daemon"),
864            "the child's worker was stopped: {:?}",
865            executor.purposes.lock().unwrap()
866        );
867        // The prompt that arrived during the park never reached the stopped
868        // worker, and its sender is told so for certain, so it can start the
869        // child again and send it once more.
870        let raced = executor.raced.lock().unwrap().take();
871        let raced = raced
872            .expect("the stop raced a prompt")
873            .await
874            .unwrap()
875            .expect_err("a prompt that arrived during the park is turned away");
876        assert!(
877            raced
878                .downcast_ref::<mj_client::session::DeliveryUnconfirmed>()
879                .is_none(),
880            "a turned-away prompt is known not to be delivered: {raced:#}"
881        );
882        // Everything but the worker stays.
883        let stored = crate::database::load_state().unwrap();
884        let child = &stored.sessions[LATCH_RELAY_SESSION];
885        assert_eq!(child.state, SessionState::Parked);
886        assert!(child.target.is_some(), "a parked child keeps its target");
887        assert!(stored.subagents.contains_key(LATCH_RELAY_SESSION));
888        assert_eq!(
889            crate::database::load_subagent_report(LATCH_RELAY_SESSION)
890                .unwrap()
891                .handback
892                .map(|handback| handback.message)
893                .as_deref(),
894            Some("The parser has three entry points.")
895        );
896        assert!(control.session(LATCH_RELAY_SESSION).await.is_err());
897        let _targets = refresher.await.unwrap();
898        shutdown.shutdown().await.unwrap();
899    }
900
901    /// I1-2: a child whose first prompt is refused for good has its worker
902    /// stopped and is recorded as failed with the cause, keeping its record,
903    /// relation and target, instead of staying a live idle session.
904    // Hard-won: ba6c34276ced: a refused first prompt left a live idle child whose parent had already seen the error.
905    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
906    async fn a_child_whose_start_failed_is_stopped_and_recorded_as_failed() {
907        if !isolated("a_child_whose_start_failed_is_stopped_and_recorded_as_failed") {
908            return;
909        }
910        let _writer = crate::database::install_isolated_test_writer();
911        let root = tempfile::tempdir().unwrap();
912        register_child(root.path());
913        let crate::session_manager::SessionManagerChannels {
914            session_cpu: _,
915            targets,
916            control,
917            updates: _updates,
918            shutdown,
919        } = crate::session_manager::spawn_session_manager().unwrap();
920        targets
921            .send(vec![latch_relay_target(
922                root.path(),
923                None,
924                ReleaseSupport::Supported,
925                false,
926            )])
927            .unwrap();
928        control
929            .wait_for_session(LATCH_RELAY_SESSION, Duration::from_secs(10))
930            .await
931            .unwrap();
932        let refresher = tokio::spawn(async move {
933            while crate::database::load_session_state(LATCH_RELAY_SESSION).unwrap()
934                != Some(SessionState::Error)
935            {
936                tokio::time::sleep(Duration::from_millis(25)).await;
937            }
938            targets.send_replace(Vec::new());
939            targets
940        });
941        let executor = RacingStop::default();
942        let cause = "this agent does not offer high as a effort";
943
944        let outcome = loaded_controller()
945            .fail_subagent_start_worker(LATCH_RELAY_SESSION, cause, &executor, &control)
946            .await
947            .unwrap();
948
949        assert_eq!(outcome, ParkOutcome::Parked);
950        assert!(
951            executor
952                .purposes
953                .lock()
954                .unwrap()
955                .iter()
956                .any(|purpose| purpose == "stop Mjolnir worker daemon"),
957            "the child's worker was stopped: {:?}",
958            executor.purposes.lock().unwrap()
959        );
960        let stored = crate::database::load_state().unwrap();
961        let child = &stored.sessions[LATCH_RELAY_SESSION];
962        assert_eq!(child.state, SessionState::Error);
963        assert_eq!(child.last_error.as_deref(), Some(cause));
964        assert!(child.target.is_some(), "the failed child keeps its target");
965        assert!(stored.subagents.contains_key(LATCH_RELAY_SESSION));
966        assert!(control.session(LATCH_RELAY_SESSION).await.is_err());
967        let _targets = refresher.await.unwrap();
968        shutdown.shutdown().await.unwrap();
969    }
970
971    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
972    async fn a_child_with_work_in_flight_is_not_parked() {
973        if !isolated("a_child_with_work_in_flight_is_not_parked") {
974            return;
975        }
976        let _writer = crate::database::install_isolated_test_writer();
977        let root = tempfile::tempdir().unwrap();
978        register_child(root.path());
979        let channels = crate::session_manager::spawn_session_manager().unwrap();
980        channels
981            .targets
982            .send(vec![latch_relay_target(
983                root.path(),
984                None,
985                ReleaseSupport::Supported,
986                true,
987            )])
988            .unwrap();
989        channels
990            .control
991            .wait_for_session(LATCH_RELAY_SESSION, Duration::from_secs(10))
992            .await
993            .unwrap();
994        let executor = RacingStop::default();
995
996        let outcome = loaded_controller()
997            .park_subagent_worker(LATCH_RELAY_SESSION, &executor, &channels.control)
998            .await
999            .unwrap();
1000
1001        assert_eq!(outcome, ParkOutcome::Busy);
1002        assert!(
1003            executor.purposes.lock().unwrap().is_empty(),
1004            "nothing stopped"
1005        );
1006        assert_eq!(
1007            crate::database::load_session_state(LATCH_RELAY_SESSION).unwrap(),
1008            Some(SessionState::Running)
1009        );
1010        channels.shutdown.shutdown().await.unwrap();
1011    }
1012
1013    // Hard-won: 6927da2ba976: Issue #1161 exhausted a shipped container's process slots and left users with a Cannot fork failure.
1014    #[test]
1015    fn only_a_full_target_is_rewritten_and_a_bare_one_reads_no_container_counts() {
1016        let backend = targets::TargetLocator::LocalBare {
1017            worker_root: "/tmp/workers/child".into(),
1018        };
1019        let unrelated =
1020            explain_process_exhaustion(anyhow::anyhow!("the harness exited"), &backend, "child");
1021        assert_eq!(format!("{unrelated:#}"), "the harness exited");
1022
1023        let full = explain_process_exhaustion(
1024            anyhow::anyhow!("sh: 1: Cannot fork").context("start the parked sub-agent's worker"),
1025            &backend,
1026            "child",
1027        );
1028        let message = format!("{full:#}");
1029        assert!(
1030            message.starts_with("the target machine ran out of process slots"),
1031            "{message}"
1032        );
1033        assert!(
1034            message.contains("Close sub-agents you no longer need"),
1035            "{message}"
1036        );
1037        assert!(
1038            message.contains("Cannot fork"),
1039            "the original error stays: {message}"
1040        );
1041    }
1042}