Skip to main content

mj_controller/
worker_upgrade.rs

1//! Background worker-binary upgrade policy and coordination.
2//!
3//! A running session keeps the worker it started with, so a session that is
4//! never stopped never gains anything a newer daemon's worker learned. This
5//! coordinator watches the same session views the recovery coordinator does
6//! and, when a session is quiet and its worker is a different build from the
7//! one this controller would install, replaces it in place.
8//!
9//! Quiet is the whole safety argument: stopping a worker tears down the ACP
10//! bridge with it, so an upgrade may only run when nothing would be lost.
11
12use std::collections::BTreeMap;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::time::Duration;
16
17use chrono::{DateTime, Utc};
18use tokio::sync::mpsc;
19
20use crate::controller::{Controller, WorkerUpgradeOutcome};
21use crate::recovery::{backoff_delay, elapsed_at_least};
22use crate::recovery_gate::{RecoveryGate, RecoveryObserver};
23use crate::session_manager::SessionManagerControl;
24use crate::targets::CancellableProcessExecutor;
25use mj_core::config::Config;
26use mj_core::state::{SessionRecord, SessionState, State};
27
28/// How long a failed upgrade waits before it is tried again, doubling per
29/// consecutive failure. A session that is quiet produces an observation every
30/// sync tick, so without this one broken target would be retried forever.
31const WORKER_UPGRADE_RETRY_INTERVAL: Duration = Duration::from_secs(10 * 60);
32
33/// Ceiling on the widening retry delay, so a target that is broken rather than
34/// blipping is still probed, just rarely.
35const MAX_WORKER_UPGRADE_RETRY_INTERVAL: Duration = Duration::from_secs(2 * 60 * 60);
36
37/// Upper bound on one upgrade. Stopping, installing, starting and waiting for
38/// a recovered journal all happen inside it; past this the attempt is a
39/// reported failure rather than a session whose upgrade never ends.
40const WORKER_UPGRADE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
41
42/// One session view, reduced to what the upgrade decision reads.
43#[derive(Debug, Clone)]
44pub struct WorkerUpgradeObservation {
45    pub session: SessionRecord,
46    pub config: Config,
47    /// Content address the connected worker reported in hello, or `None` when
48    /// it is too old to report one. `None` counts as outdated.
49    pub worker_build: Option<String>,
50    /// Whether replacing the worker now would destroy nothing. See
51    /// [`mj_core::relay::RelayOperationalState::is_quiet`].
52    ///
53    /// An observer may skip reporting a session that is working - the daemon
54    /// does, to keep the config clone off the streaming path - but the rule
55    /// lives here, so a busy observation that does arrive is still refused.
56    pub quiet: bool,
57}
58
59/// Reports session activity to the upgrade coordinator.
60///
61/// Like the recovery observer, this is a queued hand-off: the caller is an
62/// event loop and must never wait on an upgrade decision.
63#[derive(Clone)]
64pub struct WorkerUpgradeObserver {
65    observations: mpsc::UnboundedSender<WorkerUpgradeObservation>,
66}
67
68impl WorkerUpgradeObserver {
69    pub fn observe(&self, observation: WorkerUpgradeObservation) {
70        let session_id = observation.session.id.clone();
71        if let Err(error) = self.observations.send(observation) {
72            tracing::debug!(
73                %session_id,
74                %error,
75                "worker upgrade observation dropped because the coordinator stopped"
76            );
77        }
78    }
79}
80
81#[derive(Debug, Clone)]
82pub struct WorkerUpgradeResult {
83    pub session_id: String,
84    pub outcome: Result<WorkerUpgradeOutcome, String>,
85    /// The attempt was preempted by a lifecycle operation or by coordinator
86    /// shutdown. It judged nothing, so it is neither a success nor a failure.
87    pub cancelled: bool,
88}
89
90pub struct WorkerUpgradeCoordinator {
91    observer: WorkerUpgradeObserver,
92    results: mpsc::UnboundedReceiver<WorkerUpgradeResult>,
93    cancelled: Arc<AtomicBool>,
94    gate: Arc<RecoveryGate>,
95}
96
97impl Drop for WorkerUpgradeCoordinator {
98    fn drop(&mut self) {
99        // Stop the coordinator loop, then cancel every attempt already
100        // running. The gate is shared, so this also stops recovery copies -
101        // which is what dropping either coordinator means: the process that
102        // owns both is going away.
103        self.cancelled.store(true, Ordering::Release);
104        self.gate.cancel_all();
105    }
106}
107
108impl WorkerUpgradeCoordinator {
109    /// Start the coordinator, sharing the recovery observer's gate so a
110    /// recovery copy and an upgrade never touch one session at the same time.
111    pub fn spawn(session_manager: SessionManagerControl, recovery: &RecoveryObserver) -> Self {
112        let (observations_tx, mut observations_rx) =
113            mpsc::unbounded_channel::<WorkerUpgradeObservation>();
114        let (completed_tx, mut completed_rx) = mpsc::unbounded_channel::<WorkerUpgradeResult>();
115        let (results_tx, results_rx) = mpsc::unbounded_channel();
116        let gate = recovery.gate.clone();
117        let coordinator_gate = gate.clone();
118        let cancelled = Arc::new(AtomicBool::new(false));
119        let coordinator_cancelled = cancelled.clone();
120        tokio::spawn(async move {
121            let mut policies = BTreeMap::<String, PolicyState>::new();
122            loop {
123                tokio::select! {
124                    observed = observations_rx.recv() => {
125                        let Some(observation) = observed else { break };
126                        if coordinator_cancelled.load(Ordering::Acquire) {
127                            break;
128                        }
129                        let session_id = observation.session.id.clone();
130                        let policy = policies.entry(session_id.clone()).or_default();
131                        policy.observe(&observation);
132                        if !policy.due(&observation, Utc::now()) {
133                            continue;
134                        }
135                        let Some(upgrade_cancelled) = coordinator_gate.try_start(&session_id)
136                        else {
137                            continue;
138                        };
139                        policy.attempt_started();
140                        let completed_tx = completed_tx.clone();
141                        let session_manager = session_manager.clone();
142                        let task_cancelled = upgrade_cancelled.clone();
143                        let handle = tokio::runtime::Handle::current();
144                        let task_session_id = session_id.clone();
145                        tokio::spawn(async move {
146                            let joined = tokio::task::spawn_blocking(move || {
147                                let mut state = State::default();
148                                state
149                                    .sessions
150                                    .insert(task_session_id.clone(), observation.session);
151                                let controller = Controller {
152                                    config: observation.config,
153                                    state,
154                                };
155                                let executor = CancellableProcessExecutor::new(task_cancelled)
156                                    .with_deadline(WORKER_UPGRADE_TIMEOUT);
157                                handle
158                                    .block_on(controller.upgrade_session_worker(
159                                        &task_session_id,
160                                        &executor,
161                                        &session_manager,
162                                        observation.worker_build.as_deref(),
163                                    ))
164                                    .map_err(|error| format!("{error:#}"))
165                            })
166                            .await;
167                            let outcome = match joined {
168                                Ok(outcome) => outcome,
169                                Err(error) => Err(format!("worker upgrade task failed: {error}")),
170                            };
171                            let result = WorkerUpgradeResult {
172                                session_id,
173                                outcome,
174                                cancelled: upgrade_cancelled.load(Ordering::Acquire),
175                            };
176                            let result_session_id = result.session_id.clone();
177                            if let Err(error) = completed_tx.send(result) {
178                                tracing::debug!(
179                                    session_id = %result_session_id,
180                                    %error,
181                                    "worker upgrade result dropped because the coordinator stopped"
182                                );
183                            }
184                        });
185                    }
186                    completed = completed_rx.recv() => {
187                        let Some(result) = completed else { break };
188                        coordinator_gate.finish(&result.session_id);
189                        let policy = policies.entry(result.session_id.clone()).or_default();
190                        policy.record(&result, Utc::now());
191                        let result_session_id = result.session_id.clone();
192                        if let Err(error) = results_tx.send(result) {
193                            tracing::debug!(
194                                session_id = %result_session_id,
195                                %error,
196                                "worker upgrade result dropped because its consumer stopped"
197                            );
198                        }
199                    }
200                }
201            }
202        });
203        Self {
204            observer: WorkerUpgradeObserver {
205                observations: observations_tx,
206            },
207            results: results_rx,
208            cancelled,
209            gate,
210        }
211    }
212
213    pub fn observer(&self) -> WorkerUpgradeObserver {
214        self.observer.clone()
215    }
216
217    pub fn try_result(&mut self) -> Option<WorkerUpgradeResult> {
218        self.results.try_recv().ok()
219    }
220}
221
222/// What the coordinator remembers about one session between observations.
223#[derive(Debug, Default, PartialEq, Eq)]
224struct PolicyState {
225    /// The build proved current for this coordinator. While the observed
226    /// build still matches it, no attempt is needed and nothing is hashed.
227    current_build: Option<String>,
228    /// An attempt is running. The gate enforces this too, but it is shared, so
229    /// the policy keeps its own record of what it started.
230    attempt_in_flight: bool,
231    failed_at: Option<DateTime<Utc>>,
232    consecutive_failures: u32,
233}
234
235impl PolicyState {
236    /// Whether an upgrade attempt should start for this observation.
237    fn due(&self, observation: &WorkerUpgradeObservation, now: DateTime<Utc>) -> bool {
238        // Killing a worker mid-turn destroys the turn, and a session that is
239        // shutting down or already stopped has no worker worth replacing.
240        if !observation.quiet
241            || observation.session.state != SessionState::Running
242            || self.attempt_in_flight
243        {
244            return false;
245        }
246        if self.worker_is_known_current(observation.worker_build.as_deref()) {
247            return false;
248        }
249        self.failed_at.is_none_or(|failed_at| {
250            elapsed_at_least(
251                failed_at,
252                now,
253                backoff_delay(
254                    WORKER_UPGRADE_RETRY_INTERVAL,
255                    MAX_WORKER_UPGRADE_RETRY_INTERVAL,
256                    self.consecutive_failures,
257                ),
258            )
259        })
260    }
261
262    /// Whether a previous attempt proved this exact build current. A worker
263    /// reporting no build is never current: it predates the field, so it
264    /// predates this controller.
265    fn worker_is_known_current(&self, worker_build: Option<&str>) -> bool {
266        let (Some(observed), Some(current)) = (worker_build, self.current_build.as_deref()) else {
267            return false;
268        };
269        observed == current
270    }
271
272    /// Fold in what one observation proves, before deciding whether to act.
273    ///
274    /// The one thing it can prove is that the last upgrade took: the worker
275    /// now reports the build that upgrade installed. That releases the
276    /// cooldown an upgrade leaves behind.
277    fn observe(&mut self, observation: &WorkerUpgradeObservation) {
278        if self.current_build.is_some()
279            && observation.worker_build.as_deref() == self.current_build.as_deref()
280        {
281            self.failed_at = None;
282            self.consecutive_failures = 0;
283        }
284    }
285
286    fn attempt_started(&mut self) {
287        self.attempt_in_flight = true;
288    }
289
290    fn record(&mut self, result: &WorkerUpgradeResult, now: DateTime<Utc>) {
291        self.attempt_in_flight = false;
292        if result.cancelled {
293            // A preempted attempt judged nothing: it must neither be counted
294            // as a failure nor suppress the next observation.
295            return;
296        }
297        match &result.outcome {
298            Ok(WorkerUpgradeOutcome::Deferred) => {
299                // The session started working between the observation and the
300                // attempt. That is ordinary, and the next quiet observation
301                // tries again.
302                self.failed_at = None;
303                self.consecutive_failures = 0;
304            }
305            Ok(outcome @ WorkerUpgradeOutcome::AlreadyCurrent { .. }) => {
306                self.failed_at = None;
307                self.consecutive_failures = 0;
308                self.current_build = outcome.build().map(str::to_owned);
309            }
310            Ok(outcome @ WorkerUpgradeOutcome::Upgraded { .. }) => {
311                // The worker this session runs now, so the next observation
312                // reporting it needs no attempt - and, through `observe`,
313                // clears the cooldown below.
314                self.current_build = outcome.build().map(str::to_owned);
315                // An upgrade that did not take would otherwise restart this
316                // worker on every sync tick, forever. The same widening
317                // cooldown a failure gets bounds that; a worker that comes
318                // back reporting the installed build releases it immediately,
319                // so a healthy upgrade pays nothing.
320                self.consecutive_failures = self.consecutive_failures.saturating_add(1);
321                self.failed_at = Some(now);
322            }
323            Err(_) => {
324                self.consecutive_failures = self.consecutive_failures.saturating_add(1);
325                self.failed_at = Some(now);
326            }
327        }
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    fn session_record(state: SessionState) -> SessionRecord {
336        SessionRecord {
337            build_cache: None,
338            container_workspace: None,
339            mjolnir_subagents: None,
340            create_managed_worktree: None,
341            workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
342            archived: false,
343            container_cpus: None,
344            container_memory: None,
345            id: "session-1".to_owned(),
346            title: "work".into(),
347            harness_kind: mj_core::config::HarnessKind::Codex,
348            last_profile: "codex-1".into(),
349            bundle_id: "hel".into(),
350            project_directory: None,
351            managed_worktree: None,
352            target_template_id: "podman".into(),
353            resource_allocation: None,
354            additional_mounts: Vec::new(),
355            state,
356            target: None,
357            native_session_id: None,
358            acp_session_title: None,
359            session_title_override: None,
360            created_at: "2026-08-09T12:00:00Z".into(),
361            updated_at: "2026-08-09T12:01:00Z".into(),
362            viewed_through_event_ordinal: 0,
363            draft_input: String::new(),
364            last_error: None,
365            last_checkpoint_error: None,
366            checkpoint: None,
367        }
368    }
369
370    fn observation(worker_build: Option<&str>, quiet: bool) -> WorkerUpgradeObservation {
371        WorkerUpgradeObservation {
372            session: session_record(SessionState::Running),
373            config: Config::default(),
374            worker_build: worker_build.map(str::to_owned),
375            quiet,
376        }
377    }
378
379    fn failure(detail: &str) -> WorkerUpgradeResult {
380        WorkerUpgradeResult {
381            session_id: "session-1".into(),
382            outcome: Err(detail.into()),
383            cancelled: false,
384        }
385    }
386
387    fn current(build: &str) -> WorkerUpgradeOutcome {
388        WorkerUpgradeOutcome::AlreadyCurrent {
389            build: build.to_owned(),
390        }
391    }
392
393    fn upgraded(build: &str) -> WorkerUpgradeOutcome {
394        WorkerUpgradeOutcome::Upgraded {
395            build: build.to_owned(),
396        }
397    }
398
399    fn success(outcome: WorkerUpgradeOutcome) -> WorkerUpgradeResult {
400        WorkerUpgradeResult {
401            session_id: "session-1".into(),
402            outcome: Ok(outcome),
403            cancelled: false,
404        }
405    }
406
407    /// The two facts that make an upgrade due, each on its own.
408    #[test]
409    fn only_a_quiet_session_with_an_unknown_build_is_due() {
410        let now = Utc::now();
411        let policy = PolicyState::default();
412
413        assert!(policy.due(&observation(Some("build-a"), true), now));
414        assert!(
415            !policy.due(&observation(Some("build-a"), false), now),
416            "a working session must not have its worker killed"
417        );
418        assert!(
419            policy.due(&observation(None, true), now),
420            "a worker too old to report a build is outdated"
421        );
422    }
423
424    #[test]
425    fn a_busy_turn_is_never_upgraded_no_matter_how_long_it_runs() {
426        let started = Utc::now();
427        let policy = PolicyState::default();
428        let two_days_later = started + chrono::Duration::days(2);
429
430        assert!(!policy.due(&observation(Some("old-build"), false), two_days_later));
431        assert!(
432            policy.due(&observation(Some("old-build"), true), two_days_later),
433            "the next quiet observation may upgrade without an age-based busy timeout"
434        );
435    }
436
437    /// A session that is closing, checkpointing or already stopped is not a
438    /// session whose worker may be replaced underneath it.
439    #[test]
440    fn only_a_running_session_is_due() {
441        let now = Utc::now();
442        let policy = PolicyState::default();
443        for state in [
444            SessionState::Provisioning,
445            SessionState::Disconnected,
446            SessionState::Checkpointing,
447            SessionState::Closing,
448            SessionState::Destroying,
449            SessionState::Stopped,
450            SessionState::Lost,
451            SessionState::Error,
452            SessionState::DestroyedWithDataLoss,
453        ] {
454            let mut observation = observation(Some("build-a"), true);
455            observation.session.state = state;
456            assert!(!policy.due(&observation, now), "{state:?}");
457        }
458    }
459
460    /// One attempt per session at a time: further observations of the same
461    /// quiet session must not pile up restarts on the same worker.
462    #[test]
463    fn an_attempt_in_flight_suppresses_further_observations() {
464        let now = Utc::now();
465        let mut policy = PolicyState::default();
466        policy.attempt_started();
467
468        assert!(!policy.due(&observation(Some("build-a"), true), now));
469    }
470
471    /// A worker an attempt proved current stays trusted for the coordinator's
472    /// lifetime, while a different observed build is checked immediately.
473    #[test]
474    fn a_worker_proved_current_stays_trusted_for_coordinator_lifetime() {
475        let now = Utc::now();
476        let mut policy = PolicyState::default();
477        policy.record(&success(current("build-a")), now);
478
479        assert!(!policy.due(&observation(Some("build-a"), true), now));
480        assert!(
481            !policy.due(
482                &observation(Some("build-a"), true),
483                now + chrono::Duration::days(2)
484            ),
485            "the launched build remains trusted for the coordinator lifetime"
486        );
487        assert!(
488            policy.due(&observation(Some("build-b"), true), now),
489            "a different build is outdated however recently the last one was checked"
490        );
491    }
492
493    /// A failed upgrade waits, and waits longer each time, so a broken target
494    /// is not restarted on every sync tick.
495    #[test]
496    fn a_failed_upgrade_backs_off_and_widens() {
497        let now = Utc::now();
498        let mut policy = PolicyState::default();
499        policy.attempt_started();
500        policy.record(&failure("install the current Mjolnir worker binary"), now);
501
502        let interval = chrono::Duration::from_std(WORKER_UPGRADE_RETRY_INTERVAL).unwrap();
503        assert!(!policy.due(&observation(Some("build-a"), true), now));
504        assert!(!policy.due(
505            &observation(Some("build-a"), true),
506            now + interval - chrono::Duration::seconds(1)
507        ));
508        assert!(policy.due(&observation(Some("build-a"), true), now + interval));
509
510        policy.attempt_started();
511        policy.record(&failure("install the current Mjolnir worker binary"), now);
512        assert!(!policy.due(
513            &observation(Some("build-a"), true),
514            now + interval * 2 - chrono::Duration::seconds(1)
515        ));
516        assert!(policy.due(&observation(Some("build-a"), true), now + interval * 2));
517    }
518
519    /// A successful upgrade stops the session being probed again: the worker
520    /// that answers next is the new one, and confirming it clears the failure
521    /// run the upgrade itself was guarded by.
522    #[test]
523    fn a_successful_upgrade_stops_the_probing_and_confirming_it_clears_the_backoff() {
524        let now = Utc::now();
525        let mut policy = PolicyState::default();
526        policy.attempt_started();
527        policy.record(&failure("install the current Mjolnir worker binary"), now);
528        policy.attempt_started();
529        policy.record(&success(upgraded("build-b")), now);
530
531        let confirmed = observation(Some("build-b"), true);
532        policy.observe(&confirmed);
533        assert_eq!(policy.consecutive_failures, 0);
534        assert_eq!(policy.failed_at, None);
535        assert!(
536            !policy.due(&confirmed, now),
537            "the worker now runs the installed build, so nothing is due"
538        );
539    }
540
541    /// An upgrade that does not take - the worker comes back reporting a build
542    /// that is still not the installed one - must not restart that worker on
543    /// every freshness window forever.
544    #[test]
545    fn an_upgrade_that_does_not_take_backs_off_instead_of_looping() {
546        let now = Utc::now();
547        let mut policy = PolicyState::default();
548        policy.attempt_started();
549        policy.record(&success(upgraded("build-b")), now);
550
551        // The worker came back as something else, so nothing confirms the
552        // upgrade and the cooldown stands.
553        let unchanged = observation(Some("build-a"), true);
554        policy.observe(&unchanged);
555        let interval = chrono::Duration::from_std(WORKER_UPGRADE_RETRY_INTERVAL).unwrap();
556        assert!(!policy.due(&unchanged, now));
557        assert!(policy.due(&unchanged, now + interval));
558
559        policy.attempt_started();
560        policy.record(&success(upgraded("build-b")), now + interval);
561        policy.observe(&unchanged);
562        assert!(!policy.due(&unchanged, now + interval * 2));
563        assert!(policy.due(&unchanged, now + interval * 3));
564    }
565
566    /// A session that started working again judged nothing about the target,
567    /// so the next quiet observation tries straight away.
568    #[test]
569    fn a_deferred_upgrade_is_retried_at_the_next_quiet_observation() {
570        let now = Utc::now();
571        let mut policy = PolicyState::default();
572        policy.attempt_started();
573        policy.record(&success(WorkerUpgradeOutcome::Deferred), now);
574
575        assert!(policy.due(&observation(Some("build-a"), true), now));
576    }
577
578    /// A preempted attempt says nothing either way: it must not count as a
579    /// failure and must not delay the next attempt.
580    #[test]
581    fn a_preempted_attempt_is_neither_a_success_nor_a_failure() {
582        let now = Utc::now();
583        let mut policy = PolicyState::default();
584        policy.attempt_started();
585        policy.record(
586            &WorkerUpgradeResult {
587                session_id: "session-1".into(),
588                outcome: Err("operation cancelled".into()),
589                cancelled: true,
590            },
591            now,
592        );
593
594        assert_eq!(policy.consecutive_failures, 0);
595        assert!(policy.due(&observation(Some("build-a"), true), now));
596    }
597
598    /// Recovery holds the same per-session slot, so an upgrade cannot start
599    /// while a recovery copy is running for that session.
600    #[test]
601    fn the_shared_gate_keeps_an_upgrade_and_a_recovery_copy_apart() {
602        let gate = Arc::new(RecoveryGate::default());
603        let recovery_copy = gate.try_start("session-1").expect("the slot starts free");
604
605        assert!(gate.try_start("session-1").is_none());
606
607        gate.finish("session-1");
608        assert!(gate.try_start("session-1").is_some());
609        drop(recovery_copy);
610    }
611
612    /// Observing must hand off and return: the daemon reports from its event
613    /// loop, and no upgrade decision may hold that loop up.
614    #[test]
615    fn observing_hands_off_without_waiting() {
616        let (observations, mut queued) = mpsc::unbounded_channel();
617        let observer = WorkerUpgradeObserver { observations };
618
619        for _ in 0..64 {
620            observer.observe(observation(Some("build-a"), true));
621        }
622
623        let received = std::iter::from_fn(|| queued.try_recv().ok()).count();
624        assert_eq!(received, 64);
625    }
626
627    /// A stopped coordinator leaves observing harmless.
628    #[test]
629    fn observing_a_stopped_coordinator_is_a_no_op() {
630        let (observations, queued) = mpsc::unbounded_channel();
631        let observer = WorkerUpgradeObserver { observations };
632        drop(queued);
633
634        observer.observe(observation(Some("build-a"), true));
635    }
636}