1use 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
31const PARK_ACTOR_TIMEOUT: Duration = Duration::from_secs(5);
33
34const PARK_RELEASE_TIMEOUT: Duration = Duration::from_secs(15);
37
38const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ParkOutcome {
51 Parked,
53 Busy,
56 NotRunning,
59}
60
61pub(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
69pub(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 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 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 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 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 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 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 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 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 #[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 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 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 #[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 #[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 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 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 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 #[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 #[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}