Skip to main content

mj_controller/controller/
worker_restart.rs

1//! Replacing a session's worker process in place.
2//!
3//! Two things ask for this: a checkpoint whose ACP turn will not finish, and a
4//! session whose worker predates the controller now talking to it. Both stop
5//! the worker, install the binary this controller would provision, start it
6//! and reconnect, so the sequence lives here once and each caller supplies
7//! only what it tells the operator.
8
9use std::time::Duration;
10
11use anyhow::{Context, Result, bail};
12
13use crate::native_continuity::{NativeContinuityInputs, recover_native_continuity};
14use crate::session_manager::{SessionManagerControl, StandaloneSession};
15use crate::targets::{self, CommandExecutor, CommandSpec};
16use mj_core::relay::RelayExecutionState;
17
18use super::Controller;
19use super::readiness::{connect_started_worker_with_timeout, wait_for_native_session};
20use super::worker_binary::{
21    prepare_managed_harness_for_upgrade, replace_installed_worker_binary,
22    replace_installed_worker_launch_config, start_worker, stop_worker_after_target_recovery,
23    worker_binary_for, worker_probe_diagnosis,
24};
25
26/// How long a restarted worker has to recover its journal, bind `control.sock`
27/// and report an idle ACP session. Journal recovery over a long transcript
28/// runs before the socket exists, so this has to outlast it.
29const WORKER_RESTART_TIMEOUT: Duration = Duration::from_secs(300);
30
31/// How long a quiet session's upgrade waits for its actor and its lease.
32const UPGRADE_LEASE_TIMEOUT: Duration = Duration::from_secs(5);
33
34/// The worker was stopped so it could be replaced, and no worker came back:
35/// the binary swap, start, connect, or ACP readiness after it failed. The
36/// session has no live worker until something restarts one.
37#[derive(Debug)]
38pub struct WorkerRestartLeftNoWorker;
39
40impl WorkerRestartLeftNoWorker {
41    /// Whether a failed operation left the session without a live worker.
42    ///
43    /// The marker is carried by the error, not by its text. Callers wrap
44    /// restart errors in further context, and `anyhow`'s downcast walks those
45    /// layers, so added context does not hide it.
46    #[must_use]
47    pub fn marks(error: &anyhow::Error) -> bool {
48        error.downcast_ref::<Self>().is_some()
49    }
50}
51
52impl std::fmt::Display for WorkerRestartLeftNoWorker {
53    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        formatter.write_str("the worker restart left the session without a live worker")
55    }
56}
57
58impl std::error::Error for WorkerRestartLeftNoWorker {}
59
60/// After a restarted worker has answered once, only a dead transport proves
61/// the worker is gone again; any other failure leaves a live worker behind.
62fn mark_if_transport_died(error: anyhow::Error) -> anyhow::Error {
63    if crate::worker_client::RelayTransportDead::marks(&error) {
64        error.context(WorkerRestartLeftNoWorker)
65    } else {
66        error
67    }
68}
69
70/// What one restart tells the operator at each step. The steps are identical;
71/// only the reason differs, and a diagnostic that named the wrong reason would
72/// send someone looking in the wrong place.
73pub(super) struct WorkerRestartMessages {
74    pub stop: &'static str,
75    pub replace: &'static str,
76    pub start: &'static str,
77    pub connect: &'static str,
78    pub project_memory: &'static str,
79    pub native_session: &'static str,
80}
81
82pub(super) struct InstalledWorkerRestart<'a> {
83    pub backend: &'a targets::TargetLocator,
84    pub worker_root: &'a str,
85    pub reconnect: &'a CommandSpec,
86    pub launch: Option<&'a mj_core::worker_launch::WorkerLaunchConfig>,
87    pub messages: &'a WorkerRestartMessages,
88}
89
90/// A wedged ACP turn is being killed so a checkpoint barrier can be admitted.
91pub(super) const RESTART_FOR_CHECKPOINT: WorkerRestartMessages = WorkerRestartMessages {
92    stop: "stop wedged Mjolnir worker before retrying checkpoint",
93    replace: "replace Mjolnir worker binary before retrying checkpoint",
94    start: "start Mjolnir worker after interrupting a wedged ACP turn",
95    connect: "connect to Mjolnir worker after restarting it for checkpoint",
96    project_memory: "project memory will not be synchronized after checkpoint worker restart",
97    native_session: "wait for ACP session after restarting the worker for checkpoint",
98};
99
100/// A quiet session is being moved onto the worker binary this controller
101/// would install.
102const RESTART_FOR_UPGRADE: WorkerRestartMessages = WorkerRestartMessages {
103    stop: "stop the Mjolnir worker before installing the current binary",
104    replace: "install the current Mjolnir worker binary",
105    start: "start Mjolnir worker on the current binary",
106    connect: "connect to Mjolnir worker after upgrading its binary",
107    project_memory: "project memory will not be synchronized after the worker upgrade",
108    native_session: "wait for ACP session after upgrading the worker",
109};
110
111/// What an upgrade attempt found. Nothing here is a failure: a worker that is
112/// already current and a session that started working again are both ordinary.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum WorkerUpgradeOutcome {
115    /// The worker was replaced and the managed session now speaks to one
116    /// running this build.
117    Upgraded { build: String },
118    /// The worker already runs the binary this controller would install.
119    AlreadyCurrent { build: String },
120    /// The session was working when the upgrade reached it. A worker restart
121    /// would have killed that work, so nothing was touched.
122    Deferred,
123}
124
125impl WorkerUpgradeOutcome {
126    /// The build the session's worker runs now, or `None` when the attempt
127    /// stood down without establishing one.
128    #[must_use]
129    pub fn build(&self) -> Option<&str> {
130        match self {
131            Self::Upgraded { build } | Self::AlreadyCurrent { build } => Some(build),
132            Self::Deferred => None,
133        }
134    }
135}
136
137/// Whether a worker that reported `reported` in hello is running `installed`,
138/// the binary this controller would provision.
139///
140/// A worker that reported nothing is not: the field postdates it, so its
141/// binary does too.
142fn worker_runs_installed_build(reported: Option<&str>, installed: &str) -> bool {
143    reported.is_some_and(|reported| reported == installed)
144}
145
146impl Controller {
147    /// Replace a session's worker with the binary this controller would
148    /// install, when the session is quiet and its worker is a different build.
149    ///
150    /// `reported_build` is the digest the worker gave the observer that asked
151    /// for this. It only saves work: a match returns before anything is leased.
152    /// The decision that matters is taken again under the lease, against a
153    /// snapshot read from the worker itself, because a session can start
154    /// working between an observation and this call.
155    pub async fn upgrade_session_worker(
156        &self,
157        session_id: &str,
158        executor: &(impl CommandExecutor + Sync),
159        manager: &SessionManagerControl,
160        reported_build: Option<&str>,
161    ) -> Result<WorkerUpgradeOutcome> {
162        let (backend, worker_root) = self.worker_placement(session_id)?;
163        let reconnect = targets::reconnect_plan(&backend, session_id)?
164            .commands
165            .into_iter()
166            .next()
167            .context("reconnect plan is empty")?;
168        let binary = worker_binary_for(&backend, executor)
169            .context("resolve the worker binary this controller would install")?;
170        let installed = mj_core::worker_launch::worker_executable_digest(&binary)?;
171        if worker_runs_installed_build(reported_build, &installed) {
172            return Ok(WorkerUpgradeOutcome::AlreadyCurrent { build: installed });
173        }
174
175        // From here the session actor holds no connection, so no prompt it
176        // accepts can reach the worker: submissions queue until the lease
177        // returns. That is what makes the quiet check below decisive.
178        let handle = manager
179            .wait_for_session(session_id, UPGRADE_LEASE_TIMEOUT)
180            .await?;
181        let mut lease = handle.lease_connection().await?;
182        let snapshot = lease
183            .connection_mut()
184            .sync()
185            .await
186            .context("read the session state before upgrading its worker")?;
187        if worker_runs_installed_build(snapshot.worker_build.as_deref(), &installed) {
188            lease.release();
189            return Ok(WorkerUpgradeOutcome::AlreadyCurrent { build: installed });
190        }
191        let harness = self.state.sessions[session_id].harness_kind;
192        let safe_to_replace = snapshot.operational.safe_to_replace(harness);
193        tracing::debug!(
194            session_id,
195            safe_to_replace,
196            goal_synchronized = snapshot.operational.goal.synchronized(),
197            goal_active = snapshot.operational.goal.active(),
198            native_running = snapshot.operational.goal.running(),
199            "evaluated automatic worker replacement"
200        );
201        if !safe_to_replace {
202            lease.release();
203            return Ok(WorkerUpgradeOutcome::Deferred);
204        }
205        let launch = self.current_worker_launch_config(session_id, &backend)?;
206        if let Err(error) =
207            prepare_managed_harness_for_upgrade(executor, &backend, session_id, &binary, &launch)
208                .context("prepare the current managed harness before replacing the worker")
209        {
210            // Preparation never touches the running worker. Return its live
211            // connection directly instead of making the actor reconnect.
212            lease.release();
213            return Err(error);
214        }
215
216        let restarted = self
217            .restart_worker_with_installed_binary(
218                session_id,
219                executor,
220                InstalledWorkerRestart {
221                    backend: &backend,
222                    worker_root: &worker_root,
223                    reconnect: &reconnect,
224                    launch: Some(&launch),
225                    messages: &RESTART_FOR_UPGRADE,
226                },
227            )
228            .await;
229        match restarted {
230            Ok(connection) => {
231                lease.replace_connection(connection);
232                lease.release();
233                Ok(WorkerUpgradeOutcome::Upgraded { build: installed })
234            }
235            Err(error) => {
236                // Dropping the lease returns the actor to reconnecting on its
237                // own, which is the recovery for a half-finished restart.
238                drop(lease);
239                Err(error)
240            }
241        }
242    }
243
244    /// Stop the worker, install the binary this controller would provision,
245    /// start it and reconnect to the session it recovers.
246    pub(super) async fn restart_worker_with_installed_binary(
247        &self,
248        session_id: &str,
249        executor: &(impl CommandExecutor + Sync),
250        restart: InstalledWorkerRestart<'_>,
251    ) -> Result<StandaloneSession> {
252        let InstalledWorkerRestart {
253            backend,
254            worker_root,
255            reconnect,
256            launch,
257            messages,
258        } = restart;
259        // A failed stop may leave the old worker alive, so it stays outside the
260        // marker below: only steps after a successful stop can leave the
261        // session with no worker at all.
262        stop_worker_after_target_recovery(executor, backend, session_id, worker_root)
263            .context(messages.stop)?;
264        // Everything up to the first successful connection either fails with no
265        // worker running or cannot tell: the marker covers all of it.
266        let mut connection = async {
267            // Copy through hel.next and rename. scp/cp onto a still-mapped hel
268            // fails with ETXTBSY ("dest open ... Failure") even after SIGKILL,
269            // and prepare_worker_files writes that path in place.
270            let binary = worker_binary_for(backend, executor)?;
271            replace_installed_worker_binary(executor, backend, session_id, &binary)
272                .context(messages.replace)?;
273            if let Some(launch) = launch {
274                replace_installed_worker_launch_config(executor, backend, session_id, launch)
275                    .context("install the current Mjolnir worker launch configuration")?;
276            }
277            start_worker(executor, backend, worker_root).context(messages.start)?;
278            // Journal recovery runs before the daemon binds control.sock. A long
279            // kimi session can take well over the ordinary 30s startup window.
280            match connect_started_worker_with_timeout(
281                reconnect,
282                session_id,
283                executor,
284                backend,
285                worker_root,
286                WORKER_RESTART_TIMEOUT,
287            )
288            .await
289            {
290                Ok(connection) => Ok(connection),
291                Err(error) => Err(
292                    worker_probe_diagnosis(executor, backend, worker_root, error)
293                        .context(messages.connect),
294                ),
295            }
296        }
297        .await
298        .map_err(|error| error.context(WorkerRestartLeftNoWorker))?;
299        let project_memory = match self.project_memory_sync_target(session_id) {
300            Ok(target) => Some(target),
301            Err(error) => {
302                tracing::warn!(
303                    session_id,
304                    error = format!("{error:#}"),
305                    "{}",
306                    messages.project_memory
307                );
308                None
309            }
310        };
311        connection.set_project_memory_target(project_memory);
312        // A worker answered, so a failure from here on only means "no worker"
313        // when the transport to it died again.
314        async {
315            let checkpoint_only = connection.sync().await?.operational.checkpoint_only;
316            if let Some(launch) = launch {
317                anyhow::ensure!(
318                    checkpoint_only
319                        == (launch.run_mode
320                            == mj_core::worker_launch::WorkerRunMode::CheckpointOnly),
321                    "restarted worker did not enter the requested execution mode"
322                );
323            }
324            if checkpoint_only {
325                return Ok(());
326            }
327            wait_for_native_session(&mut connection, executor)
328                .await
329                .context(messages.native_session)?;
330            wait_for_idle_projection(&mut connection, WORKER_RESTART_TIMEOUT)
331                .await
332                .context("wait for ACP to go idle after worker restart")
333        }
334        .await
335        .map_err(mark_if_transport_died)?;
336        // A harness that keeps no per-session native state may have opened a
337        // fresh native session while recovering. The worker reports that; the
338        // record has to follow it and the conversation has to be handed over,
339        // or the session talks to an agent that has never seen it.
340        if let Some(record) = self.state.sessions.get(session_id) {
341            let inputs = NativeContinuityInputs::from_record(&self.config, record);
342            if let Err(error) =
343                recover_native_continuity(session_id, &inputs, &mut connection).await
344            {
345                tracing::warn!(
346                    session_id,
347                    error = format!("{error:#}"),
348                    "could not reconcile the native session after a worker restart"
349                );
350            }
351        }
352        Ok(connection)
353    }
354}
355
356/// Wait until a restarted worker's projection stops moving and reports idle.
357///
358/// Three stable polls, not one: a worker that has just recovered its journal
359/// can report idle between two events it is still applying.
360async fn wait_for_idle_projection(relay: &mut StandaloneSession, timeout: Duration) -> Result<()> {
361    let deadline = tokio::time::Instant::now() + timeout;
362    let mut last_ordinal = None;
363    let mut stable_polls = 0_u8;
364    loop {
365        let snapshot = relay.sync().await?;
366        let ordinal = snapshot.operational.latest_ordinal;
367        let idle = snapshot.operational.native_session_is_ready()
368            && (snapshot.operational.execution == RelayExecutionState::Idle
369                || (snapshot.operational.goal.synchronized()
370                    && snapshot.operational.goal.active()));
371        if idle && (snapshot.operational.goal.active() || last_ordinal == Some(ordinal)) {
372            stable_polls = stable_polls.saturating_add(1);
373            if stable_polls >= 3 {
374                return Ok(());
375            }
376        } else {
377            stable_polls = 0;
378        }
379        last_ordinal = Some(ordinal);
380        if snapshot.operational.execution == RelayExecutionState::Closed {
381            bail!("ACP runtime stopped before becoming idle");
382        }
383        if tokio::time::Instant::now() >= deadline {
384            bail!(
385                "ACP runtime did not become idle after worker restart (execution={:?}, ordinal={ordinal})",
386                snapshot.operational.execution
387            );
388        }
389        tokio::time::sleep(Duration::from_millis(200)).await;
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    #[test]
396    fn a_dead_transport_after_reconnect_marks_the_restart_as_leaving_no_worker() {
397        let died = anyhow::Error::new(crate::worker_client::RelayTransportDead::new(
398            "relay proxy disconnected during attach",
399        ))
400        .context("wait for ACP session after restarting the worker for checkpoint");
401        assert!(super::WorkerRestartLeftNoWorker::marks(
402            &super::mark_if_transport_died(died)
403        ));
404
405        let slow = anyhow::anyhow!("timed out waiting for the ACP session")
406            .context("wait for ACP session after restarting the worker for checkpoint");
407        let slow = super::mark_if_transport_died(slow);
408        assert!(!super::WorkerRestartLeftNoWorker::marks(&slow), "{slow:#}");
409    }
410
411    use super::*;
412
413    #[cfg(unix)]
414    use std::sync::Mutex;
415
416    #[cfg(unix)]
417    use crate::targets::CommandOutput;
418
419    /// Fails every command after the first, so a restart gets past its stop and
420    /// then loses the worker it was replacing.
421    #[cfg(unix)]
422    struct StopSucceedsThenFails {
423        executed: Mutex<Vec<String>>,
424    }
425
426    #[cfg(unix)]
427    impl CommandExecutor for StopSucceedsThenFails {
428        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
429            let mut executed = self.executed.lock().expect("executed commands");
430            executed.push(command.program.clone());
431            if executed.len() == 1 {
432                return Ok(CommandOutput {
433                    status: 0,
434                    stdout: Vec::new(),
435                    stderr: Vec::new(),
436                });
437            }
438            Ok(CommandOutput {
439                status: 1,
440                stdout: Vec::new(),
441                stderr: b"no such target".to_vec(),
442            })
443        }
444    }
445
446    #[cfg(unix)]
447    struct FailingStop;
448
449    #[cfg(unix)]
450    impl CommandExecutor for FailingStop {
451        fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
452            Ok(CommandOutput {
453                status: 1,
454                stdout: Vec::new(),
455                stderr: b"permission denied".to_vec(),
456            })
457        }
458    }
459
460    #[cfg(unix)]
461    fn bare_restart_controller() -> Controller {
462        Controller {
463            config: mj_core::config::Config::default(),
464            state: mj_core::state::State::default(),
465        }
466    }
467
468    #[cfg(unix)]
469    async fn restart_error(
470        session_id: &str,
471        executor: &(impl CommandExecutor + Sync),
472    ) -> anyhow::Error {
473        let worker_root = format!("/tmp/mjolnir-restart-test/{session_id}");
474        let backend = targets::TargetLocator::LocalBare {
475            worker_root: worker_root.clone(),
476        };
477        let reconnect = CommandSpec::new("unused", std::iter::empty::<&str>());
478        let result = bare_restart_controller()
479            .restart_worker_with_installed_binary(
480                session_id,
481                executor,
482                InstalledWorkerRestart {
483                    backend: &backend,
484                    worker_root: &worker_root,
485                    reconnect: &reconnect,
486                    launch: None,
487                    messages: &RESTART_FOR_CHECKPOINT,
488                },
489            )
490            .await;
491        match result {
492            Ok(_) => panic!("a failing executor unexpectedly restarted the worker"),
493            Err(error) => error,
494        }
495    }
496
497    #[cfg(unix)]
498    #[tokio::test]
499    async fn a_restart_that_could_not_stop_the_worker_leaves_it_running() {
500        let error = restart_error("0123456789abcdef0123456789abcdef", &FailingStop).await;
501
502        assert!(
503            !WorkerRestartLeftNoWorker::marks(&error),
504            "a failed stop may leave the old worker alive: {error:#}"
505        );
506    }
507
508    #[cfg(unix)]
509    #[tokio::test]
510    async fn a_restart_that_stopped_the_worker_and_then_failed_is_marked() {
511        let executor = StopSucceedsThenFails {
512            executed: Mutex::new(Vec::new()),
513        };
514
515        let error = restart_error("0123456789abcdef0123456789abcdef", &executor).await;
516
517        assert!(
518            WorkerRestartLeftNoWorker::marks(&error),
519            "the worker was stopped and nothing replaced it: {error:#}"
520        );
521        assert!(
522            executor.executed.lock().expect("executed commands").len() > 1,
523            "the restart should have failed after its stop, not during it"
524        );
525    }
526
527    /// The three answers hello can produce, and what each means for the
528    /// worker's binary.
529    #[test]
530    fn only_a_matching_reported_build_counts_as_current() {
531        let installed = "a".repeat(64);
532
533        assert!(worker_runs_installed_build(Some(&installed), &installed));
534        assert!(!worker_runs_installed_build(
535            Some(&"b".repeat(64)),
536            &installed
537        ));
538        assert!(
539            !worker_runs_installed_build(None, &installed),
540            "a worker too old to report a build is older than this controller"
541        );
542    }
543}