1use 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
28const WORKER_UPGRADE_RETRY_INTERVAL: Duration = Duration::from_secs(10 * 60);
32
33const MAX_WORKER_UPGRADE_RETRY_INTERVAL: Duration = Duration::from_secs(2 * 60 * 60);
36
37const WORKER_UPGRADE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
41
42#[derive(Debug, Clone)]
44pub struct WorkerUpgradeObservation {
45 pub session: SessionRecord,
46 pub config: Config,
47 pub worker_build: Option<String>,
50 pub quiet: bool,
57}
58
59#[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 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 self.cancelled.store(true, Ordering::Release);
104 self.gate.cancel_all();
105 }
106}
107
108impl WorkerUpgradeCoordinator {
109 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#[derive(Debug, Default, PartialEq, Eq)]
224struct PolicyState {
225 current_build: Option<String>,
228 attempt_in_flight: bool,
231 failed_at: Option<DateTime<Utc>>,
232 consecutive_failures: u32,
233}
234
235impl PolicyState {
236 fn due(&self, observation: &WorkerUpgradeObservation, now: DateTime<Utc>) -> bool {
238 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 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 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 return;
296 }
297 match &result.outcome {
298 Ok(WorkerUpgradeOutcome::Deferred) => {
299 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 self.current_build = outcome.build().map(str::to_owned);
315 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 mjolnir_subagents: None,
338 create_managed_worktree: None,
339 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
340 archived: false,
341 container_cpus: None,
342 container_memory: None,
343 id: "session-1".to_owned(),
344 title: "work".into(),
345 harness_kind: mj_core::config::HarnessKind::Codex,
346 last_profile: "codex-1".into(),
347 bundle_id: "hel".into(),
348 project_directory: None,
349 managed_worktree: None,
350 target_template_id: "podman".into(),
351 resource_allocation: None,
352 additional_mounts: Vec::new(),
353 state,
354 target: None,
355 native_session_id: None,
356 acp_session_title: None,
357 session_title_override: None,
358 created_at: "2026-08-09T12:00:00Z".into(),
359 updated_at: "2026-08-09T12:01:00Z".into(),
360 viewed_through_event_ordinal: 0,
361 draft_input: String::new(),
362 last_error: None,
363 last_checkpoint_error: None,
364 checkpoint: None,
365 }
366 }
367
368 fn observation(worker_build: Option<&str>, quiet: bool) -> WorkerUpgradeObservation {
369 WorkerUpgradeObservation {
370 session: session_record(SessionState::Running),
371 config: Config::default(),
372 worker_build: worker_build.map(str::to_owned),
373 quiet,
374 }
375 }
376
377 fn failure(detail: &str) -> WorkerUpgradeResult {
378 WorkerUpgradeResult {
379 session_id: "session-1".into(),
380 outcome: Err(detail.into()),
381 cancelled: false,
382 }
383 }
384
385 fn current(build: &str) -> WorkerUpgradeOutcome {
386 WorkerUpgradeOutcome::AlreadyCurrent {
387 build: build.to_owned(),
388 }
389 }
390
391 fn upgraded(build: &str) -> WorkerUpgradeOutcome {
392 WorkerUpgradeOutcome::Upgraded {
393 build: build.to_owned(),
394 }
395 }
396
397 fn success(outcome: WorkerUpgradeOutcome) -> WorkerUpgradeResult {
398 WorkerUpgradeResult {
399 session_id: "session-1".into(),
400 outcome: Ok(outcome),
401 cancelled: false,
402 }
403 }
404
405 #[test]
407 fn only_a_quiet_session_with_an_unknown_build_is_due() {
408 let now = Utc::now();
409 let policy = PolicyState::default();
410
411 assert!(policy.due(&observation(Some("build-a"), true), now));
412 assert!(
413 !policy.due(&observation(Some("build-a"), false), now),
414 "a working session must not have its worker killed"
415 );
416 assert!(
417 policy.due(&observation(None, true), now),
418 "a worker too old to report a build is outdated"
419 );
420 }
421
422 #[test]
423 fn a_busy_turn_is_never_upgraded_no_matter_how_long_it_runs() {
424 let started = Utc::now();
425 let policy = PolicyState::default();
426 let two_days_later = started + chrono::Duration::days(2);
427
428 assert!(!policy.due(&observation(Some("old-build"), false), two_days_later));
429 assert!(
430 policy.due(&observation(Some("old-build"), true), two_days_later),
431 "the next quiet observation may upgrade without an age-based busy timeout"
432 );
433 }
434
435 #[test]
438 fn only_a_running_session_is_due() {
439 let now = Utc::now();
440 let policy = PolicyState::default();
441 for state in [
442 SessionState::Provisioning,
443 SessionState::Disconnected,
444 SessionState::Checkpointing,
445 SessionState::Closing,
446 SessionState::Destroying,
447 SessionState::Stopped,
448 SessionState::Lost,
449 SessionState::Error,
450 SessionState::DestroyedWithDataLoss,
451 ] {
452 let mut observation = observation(Some("build-a"), true);
453 observation.session.state = state;
454 assert!(!policy.due(&observation, now), "{state:?}");
455 }
456 }
457
458 #[test]
461 fn an_attempt_in_flight_suppresses_further_observations() {
462 let now = Utc::now();
463 let mut policy = PolicyState::default();
464 policy.attempt_started();
465
466 assert!(!policy.due(&observation(Some("build-a"), true), now));
467 }
468
469 #[test]
472 fn a_worker_proved_current_stays_trusted_for_coordinator_lifetime() {
473 let now = Utc::now();
474 let mut policy = PolicyState::default();
475 policy.record(&success(current("build-a")), now);
476
477 assert!(!policy.due(&observation(Some("build-a"), true), now));
478 assert!(
479 !policy.due(
480 &observation(Some("build-a"), true),
481 now + chrono::Duration::days(2)
482 ),
483 "the launched build remains trusted for the coordinator lifetime"
484 );
485 assert!(
486 policy.due(&observation(Some("build-b"), true), now),
487 "a different build is outdated however recently the last one was checked"
488 );
489 }
490
491 #[test]
494 fn a_failed_upgrade_backs_off_and_widens() {
495 let now = Utc::now();
496 let mut policy = PolicyState::default();
497 policy.attempt_started();
498 policy.record(&failure("install the current Mjolnir worker binary"), now);
499
500 let interval = chrono::Duration::from_std(WORKER_UPGRADE_RETRY_INTERVAL).unwrap();
501 assert!(!policy.due(&observation(Some("build-a"), true), now));
502 assert!(!policy.due(
503 &observation(Some("build-a"), true),
504 now + interval - chrono::Duration::seconds(1)
505 ));
506 assert!(policy.due(&observation(Some("build-a"), true), now + interval));
507
508 policy.attempt_started();
509 policy.record(&failure("install the current Mjolnir worker binary"), now);
510 assert!(!policy.due(
511 &observation(Some("build-a"), true),
512 now + interval * 2 - chrono::Duration::seconds(1)
513 ));
514 assert!(policy.due(&observation(Some("build-a"), true), now + interval * 2));
515 }
516
517 #[test]
521 fn a_successful_upgrade_stops_the_probing_and_confirming_it_clears_the_backoff() {
522 let now = Utc::now();
523 let mut policy = PolicyState::default();
524 policy.attempt_started();
525 policy.record(&failure("install the current Mjolnir worker binary"), now);
526 policy.attempt_started();
527 policy.record(&success(upgraded("build-b")), now);
528
529 let confirmed = observation(Some("build-b"), true);
530 policy.observe(&confirmed);
531 assert_eq!(policy.consecutive_failures, 0);
532 assert_eq!(policy.failed_at, None);
533 assert!(
534 !policy.due(&confirmed, now),
535 "the worker now runs the installed build, so nothing is due"
536 );
537 }
538
539 #[test]
543 fn an_upgrade_that_does_not_take_backs_off_instead_of_looping() {
544 let now = Utc::now();
545 let mut policy = PolicyState::default();
546 policy.attempt_started();
547 policy.record(&success(upgraded("build-b")), now);
548
549 let unchanged = observation(Some("build-a"), true);
552 policy.observe(&unchanged);
553 let interval = chrono::Duration::from_std(WORKER_UPGRADE_RETRY_INTERVAL).unwrap();
554 assert!(!policy.due(&unchanged, now));
555 assert!(policy.due(&unchanged, now + interval));
556
557 policy.attempt_started();
558 policy.record(&success(upgraded("build-b")), now + interval);
559 policy.observe(&unchanged);
560 assert!(!policy.due(&unchanged, now + interval * 2));
561 assert!(policy.due(&unchanged, now + interval * 3));
562 }
563
564 #[test]
567 fn a_deferred_upgrade_is_retried_at_the_next_quiet_observation() {
568 let now = Utc::now();
569 let mut policy = PolicyState::default();
570 policy.attempt_started();
571 policy.record(&success(WorkerUpgradeOutcome::Deferred), now);
572
573 assert!(policy.due(&observation(Some("build-a"), true), now));
574 }
575
576 #[test]
579 fn a_preempted_attempt_is_neither_a_success_nor_a_failure() {
580 let now = Utc::now();
581 let mut policy = PolicyState::default();
582 policy.attempt_started();
583 policy.record(
584 &WorkerUpgradeResult {
585 session_id: "session-1".into(),
586 outcome: Err("operation cancelled".into()),
587 cancelled: true,
588 },
589 now,
590 );
591
592 assert_eq!(policy.consecutive_failures, 0);
593 assert!(policy.due(&observation(Some("build-a"), true), now));
594 }
595
596 #[test]
599 fn the_shared_gate_keeps_an_upgrade_and_a_recovery_copy_apart() {
600 let gate = Arc::new(RecoveryGate::default());
601 let recovery_copy = gate.try_start("session-1").expect("the slot starts free");
602
603 assert!(gate.try_start("session-1").is_none());
604
605 gate.finish("session-1");
606 assert!(gate.try_start("session-1").is_some());
607 drop(recovery_copy);
608 }
609
610 #[test]
613 fn observing_hands_off_without_waiting() {
614 let (observations, mut queued) = mpsc::unbounded_channel();
615 let observer = WorkerUpgradeObserver { observations };
616
617 for _ in 0..64 {
618 observer.observe(observation(Some("build-a"), true));
619 }
620
621 let received = std::iter::from_fn(|| queued.try_recv().ok()).count();
622 assert_eq!(received, 64);
623 }
624
625 #[test]
627 fn observing_a_stopped_coordinator_is_a_no_op() {
628 let (observations, queued) = mpsc::unbounded_channel();
629 let observer = WorkerUpgradeObserver { observations };
630 drop(queued);
631
632 observer.observe(observation(Some("build-a"), true));
633 }
634}