Skip to main content

mj_controller/daemon/
subagent_park.rs

1//! The daemon's side of parking sub-agents (#1161): each park and unpark is a
2//! lifecycle operation of the child, so the per-session lifecycle map
3//! serializes it with the child's close, suspend, destroy and every other
4//! park or unpark. The work itself is in `crate::controller::subagent_park`.
5
6use super::*;
7
8/// How long a whole park may run: an idle reservation, one stop, one write.
9const PARK_TIMEOUT: Duration = Duration::from_secs(120);
10
11/// Preparation may download a harness; it holds no daemon handoff admission.
12const UNPARK_TIMEOUT: Duration = Duration::from_secs(600);
13
14impl RuntimeState {
15    const WAIT_PROMPT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
16
17    pub(crate) fn install_wait_prompt_backend(
18        &self,
19        backend: &Arc<crate::server_runtime::api::ApiBackend>,
20    ) {
21        assert!(
22            self.wait_prompt_backend
23                .set(Arc::downgrade(backend))
24                .is_ok(),
25            "delegation wait-prompt backend is installed once"
26        );
27    }
28
29    pub(crate) async fn ensure_parent_wait_prompt(&self, parent_session_id: &str) -> Result<()> {
30        if let Some(backend) = self
31            .wait_prompt_backend
32            .get()
33            .and_then(std::sync::Weak::upgrade)
34        {
35            match backend.ensure_parent_wait_prompt(parent_session_id).await {
36                Ok(()) => {
37                    self.wait_prompt_retries
38                        .lock()
39                        .unwrap_or_else(PoisonError::into_inner)
40                        .remove(parent_session_id);
41                }
42                Err(error) => {
43                    self.schedule_wait_prompt_retry(parent_session_id);
44                    return Err(error);
45                }
46            }
47        }
48        Ok(())
49    }
50
51    pub(crate) fn schedule_wait_prompt_retry(&self, parent_session_id: &str) {
52        self.wait_prompt_retries
53            .lock()
54            .unwrap_or_else(PoisonError::into_inner)
55            .insert(
56                parent_session_id.to_owned(),
57                std::time::Instant::now() + Self::WAIT_PROMPT_RETRY_DELAY,
58            );
59    }
60
61    pub(crate) fn take_due_wait_prompt_retries(
62        &self,
63        now: std::time::Instant,
64        limit: usize,
65    ) -> Vec<String> {
66        let mut retries = self
67            .wait_prompt_retries
68            .lock()
69            .unwrap_or_else(PoisonError::into_inner);
70        let due = retries
71            .iter()
72            .filter(|(_, due)| **due <= now)
73            .take(limit)
74            .map(|(parent, _)| parent.clone())
75            .collect::<Vec<_>>();
76        for parent in &due {
77            retries.remove(parent);
78        }
79        due
80    }
81
82    /// Park a sub-agent whose turn ended and whose parent was told. A child
83    /// that has work in flight or queued, or that is no longer running, is
84    /// left as it is. Any failure leaves the child running; the caller logs it.
85    pub async fn park_subagent(
86        self: &Arc<Self>,
87        child_session_id: String,
88    ) -> Result<crate::controller::ParkOutcome> {
89        self.stop_idle_subagent(child_session_id, None).await
90    }
91
92    /// Record a sub-agent whose first prompt was refused for good as failed
93    /// with `cause`, stopping its worker (I1-2). It runs as the child's park
94    /// operation: the same idle stop, serialized with its close and every
95    /// other lifecycle operation. A child that took work meanwhile is left
96    /// running. Failures are logged; the parent already reads the cause from
97    /// the failed startup whatever happens here.
98    pub(super) async fn fail_subagent_start(self: &Arc<Self>, child_session_id: &str, cause: &str) {
99        let parent_session_id = self
100            .owner()
101            .controller()
102            .state
103            .subagents
104            .get(child_session_id)
105            .map(|relation| relation.parent_session_id.clone());
106        if !self
107            .owner()
108            .controller()
109            .state
110            .subagents
111            .contains_key(child_session_id)
112        {
113            return;
114        }
115        match self
116            .stop_idle_subagent(child_session_id.to_owned(), Some(cause.to_owned()))
117            .await
118        {
119            Ok(crate::controller::ParkOutcome::Parked) => {
120                tracing::info!(
121                    session_id = child_session_id,
122                    cause,
123                    "sub-agent recorded as failed: its first prompt was refused"
124                );
125            }
126            Ok(outcome) => tracing::warn!(
127                session_id = child_session_id,
128                ?outcome,
129                "a sub-agent whose first prompt was refused was left running"
130            ),
131            Err(error) => tracing::warn!(
132                session_id = child_session_id,
133                error = format!("{error:#}"),
134                "could not record a sub-agent whose first prompt was refused as failed"
135            ),
136        }
137        if let Some(parent_session_id) = parent_session_id
138            && let Err(error) = self.ensure_parent_wait_prompt(&parent_session_id).await
139        {
140            tracing::warn!(
141                child_session_id,
142                parent_session_id,
143                error = format!("{error:#}"),
144                "could not reconcile the parent's sub-agent wait prompt after startup failure"
145            );
146        }
147    }
148
149    /// The park lifecycle: stop an idle child's worker and record it
150    /// `Parked`, or `Error` with `failure`.
151    async fn stop_idle_subagent(
152        self: &Arc<Self>,
153        child_session_id: String,
154        failure: Option<String>,
155    ) -> Result<crate::controller::ParkOutcome> {
156        let result = self
157            .run_lifecycle(
158                child_session_id,
159                LifecycleKind::Park,
160                move |state, session_id, _cancelled| async move {
161                    // A park is short and not cancellable: a close that asks for
162                    // the child waits for it instead, so it never finds a worker
163                    // stopped under a record that still says running.
164                    let never = AtomicBool::new(false);
165                    let _recovery_reservation = blocking({
166                        let observer = state.recovery_observer.clone();
167                        let session_id = session_id.clone();
168                        move || reserve_recovery_or_cancel(&observer, &session_id, &never)
169                    })
170                    .await?;
171                    if state.close_is_requested(&session_id) {
172                        return Ok(DaemonLifecycleResult::Park(
173                            crate::controller::ParkOutcome::NotRunning,
174                        ));
175                    }
176                    let controller = blocking(Controller::load).await?;
177                    let executor = CancellableProcessExecutor::with_timeout(PARK_TIMEOUT);
178                    let outcome = match &failure {
179                        None => {
180                            controller
181                                .park_subagent_worker(
182                                    &session_id,
183                                    &executor,
184                                    &state.session_manager,
185                                )
186                                .await?
187                        }
188                        Some(cause) => {
189                            controller
190                                .fail_subagent_start_worker(
191                                    &session_id,
192                                    cause,
193                                    &executor,
194                                    &state.session_manager,
195                                )
196                                .await?
197                        }
198                    };
199                    tracing::info!(%session_id, ?outcome, "sub-agent park finished");
200                    Ok(DaemonLifecycleResult::Park(outcome))
201                },
202            )
203            .await?;
204        self.publish_revision();
205        match result {
206            DaemonLifecycleResult::Park(outcome) => Ok(outcome),
207            _ => unreachable!("a park returns its worker's reservation outcome"),
208        }
209    }
210
211    /// Accept a durable wake and launch the worker. Its actor observes startup
212    /// independently, including after daemon replacement.
213    pub async fn unpark_subagent(self: &Arc<Self>, child_session_id: String) -> Result<()> {
214        // A park still finishing would otherwise refuse this as a second
215        // lifecycle operation; waiting for it keeps a `send_message` that raced
216        // the park from failing.
217        self.wait_for_subagent_park(&child_session_id).await;
218        let parked = self
219            .session_state(&child_session_id)
220            .is_some_and(|state| state == SessionState::Parked);
221        if parked {
222            self.run_lifecycle(
223                child_session_id.clone(),
224                LifecycleKind::Unpark,
225                // A close cancels preparation or readiness observation, then
226                // settles the child through its ordinary lifecycle.
227                |state, session_id, cancelled| async move {
228                    let executor = DaemonStageReportingExecutor::new(
229                        CancellableProcessExecutor::new(cancelled.clone())
230                            .with_deadline(UNPARK_TIMEOUT),
231                        state.clone(),
232                        session_id.clone(),
233                    );
234                    executor.begin_resumable_work()?;
235                    // Waiting for a cancelled recovery copy is interruptible
236                    // preparation, not an admitted database operation.
237                    let _recovery_reservation = tokio::task::spawn_blocking({
238                        let observer = state.recovery_observer.clone();
239                        let session_id = session_id.clone();
240                        let cancelled = cancelled.clone();
241                        move || reserve_recovery_or_cancel(&observer, &session_id, &cancelled)
242                    })
243                    .await
244                    .context("reserve recovery for sub-agent wake")??;
245                    let controller = blocking(Controller::load).await?;
246                    controller
247                        .unpark_subagent_worker(&session_id, &executor)
248                        .await?;
249                    Ok(DaemonLifecycleResult::Done)
250                },
251            )
252            .await?;
253            self.publish_revision();
254        }
255        Ok(())
256    }
257
258    /// Whether a park of this child has started and not finished.
259    pub fn subagent_park_running(&self, child_session_id: &str) -> bool {
260        self.owner()
261            .lifecycle
262            .get(child_session_id)
263            .is_some_and(|active| active.kind == LifecycleKind::Park && active.is_running())
264    }
265
266    /// Wait for a park of this child that is still running, if there is one.
267    async fn wait_for_subagent_park(self: &Arc<Self>, child_session_id: &str) {
268        let pending = self
269            .owner()
270            .lifecycle
271            .get(child_session_id)
272            .filter(|active| active.kind == LifecycleKind::Park)
273            .map(|active| active.result.clone());
274        if let Some(pending) = pending {
275            if let Err(error) = Self::wait_lifecycle_result(pending.clone()).await {
276                tracing::debug!(
277                    session_id = %child_session_id,
278                    error = format!("{error:#}"),
279                    "the sub-agent park a restart waited for failed"
280                );
281            }
282            self.remove_completed_lifecycle(&pending);
283        }
284    }
285}