1use std::future::Future;
10use std::path::PathBuf;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::time::{Duration, Instant};
14
15use anyhow::{Context, Result, bail};
16use mj_core::clock::epoch_seconds;
17use mj_core::config::Config;
18use mj_core::credentials::{
19 CredentialSyncCause, CredentialSyncHandle, CredentialSyncReason, CredentialSyncSignal,
20 CredentialSyncTarget,
21};
22use mj_core::state::{
23 ManagedSessionSnapshot, MaterializedSession, SessionRecord, SessionResourceAllocation,
24 SessionState, State,
25};
26
27use crate::controller::Controller;
28use crate::quota::{QuotaManager, QuotaRefreshOutcome, QuotaRefreshRequest};
29use crate::recovery::{RecoveryCoordinator, RecoveryResult};
30use crate::session_manager::{
31 ManagedSessionView, RelaySessionTarget, RemoteSessionRequest, SessionManagerControl,
32 SessionManagerShutdown, SessionManagerUpdate, SessionManagerUpdates, ViewError,
33 spawn_remote_session_manager,
34};
35use crate::targets::{
36 CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec,
37 DeploymentCapacityKind, DeploymentCapacityTarget, DeploymentCapacityUsage, ImageRefresh,
38 SessionResourceProbe, SessionResourceUsage,
39};
40use crate::worker_client::CredentialSyncCoordinator;
41
42use crate::daemon;
43use mj_core::state::short_id;
44
45#[cfg(test)]
46mod runtime_feed_tests;
47
48pub const QUOTA_REFRESH_INTERVAL: Duration = Duration::from_secs(10 * 60);
49pub const QUOTA_STALE_AFTER: Duration = Duration::from_secs(2 * QUOTA_REFRESH_INTERVAL.as_secs());
55pub const IMAGE_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60);
60const IMAGE_REFRESH_DELAY: Duration = Duration::from_secs(30);
63pub const RESOURCE_POLL_INTERVAL: Duration = Duration::from_secs(60);
64const RESOURCE_POLL_TIMEOUT: Duration = Duration::from_secs(15);
65pub const CAPACITY_POLL_INTERVAL: Duration = Duration::from_secs(30);
66
67pub trait FeedSource {
70 type Item;
71
72 fn wait(&mut self) -> impl Future<Output = Option<Self::Item>>;
74
75 fn poll_now(&mut self) -> Option<Self::Item>;
76}
77
78impl<T> FeedSource for tokio::sync::mpsc::Receiver<T> {
79 type Item = T;
80
81 fn wait(&mut self) -> impl Future<Output = Option<T>> {
82 self.recv()
83 }
84
85 fn poll_now(&mut self) -> Option<T> {
86 self.try_recv().ok()
87 }
88}
89
90impl<T> FeedSource for tokio::sync::mpsc::UnboundedReceiver<T> {
91 type Item = T;
92
93 fn wait(&mut self) -> impl Future<Output = Option<T>> {
94 self.recv()
95 }
96
97 fn poll_now(&mut self) -> Option<T> {
98 self.try_recv().ok()
99 }
100}
101
102impl<T: Clone> FeedSource for tokio::sync::watch::Receiver<T> {
103 type Item = T;
104
105 async fn wait(&mut self) -> Option<T> {
106 self.changed().await.ok()?;
107 Some(self.borrow_and_update().clone())
108 }
109
110 fn poll_now(&mut self) -> Option<T> {
111 self.has_changed()
112 .ok()
113 .filter(|changed| *changed)
114 .map(|_| self.borrow_and_update().clone())
115 }
116}
117
118impl FeedSource for SessionManagerUpdates {
119 type Item = SessionManagerUpdate;
120
121 fn wait(&mut self) -> impl Future<Output = Option<SessionManagerUpdate>> {
122 self.recv()
123 }
124
125 fn poll_now(&mut self) -> Option<SessionManagerUpdate> {
126 self.try_recv().ok()
127 }
128}
129
130impl FeedSource for RecoveryCoordinator {
131 type Item = RecoveryResult;
132
133 fn wait(&mut self) -> impl Future<Output = Option<RecoveryResult>> {
134 self.result()
135 }
136
137 fn poll_now(&mut self) -> Option<RecoveryResult> {
138 self.try_result()
139 }
140}
141
142impl FeedSource for CredentialSyncCoordinator {
143 type Item = mj_core::credentials::CredentialSyncResult;
144
145 fn wait(&mut self) -> impl Future<Output = Option<Self::Item>> {
146 self.result()
147 }
148
149 fn poll_now(&mut self) -> Option<Self::Item> {
150 self.try_result()
151 }
152}
153
154pub struct Feed<S: FeedSource> {
162 source: S,
163 pending: Option<S::Item>,
164 open: bool,
165}
166
167impl<S: FeedSource> Feed<S> {
168 pub fn new(source: S) -> Self {
169 Self {
170 source,
171 pending: None,
172 open: true,
173 }
174 }
175
176 pub fn is_open(&self) -> bool {
177 self.open
178 }
179
180 pub fn wait(&mut self) -> impl Future<Output = Option<S::Item>> {
181 self.source.wait()
182 }
183
184 pub fn accept(&mut self, message: Option<S::Item>) -> bool {
187 match message {
188 Some(message) => {
189 self.pending = Some(message);
190 true
191 }
192 None => {
193 self.open = false;
194 false
195 }
196 }
197 }
198
199 pub fn next_ready(&mut self) -> Option<S::Item> {
202 self.pending.take().or_else(|| self.source.poll_now())
203 }
204}
205
206#[derive(Debug, Clone, Default)]
207pub struct QuotaRefreshBatch {
208 pub generation: u64,
209 pub profiles: Vec<QuotaRefreshRequest>,
210}
211
212#[derive(Debug)]
213pub enum QuotaUpdate {
214 Refreshing { profile_ids: Vec<String> },
215 Report(QuotaRefreshOutcome),
216 Finished { generation: u64 },
217}
218
219pub type WorkerPollTarget = RelaySessionTarget;
220pub type WorkerPollUpdate = SessionManagerUpdate;
221
222#[derive(Debug)]
223struct WorkerDiagnosisEpisode {
224 id: u64,
225 error: String,
226 diagnosed: bool,
227}
228
229#[derive(Debug, Default)]
230pub struct WorkerDiagnosisTracker {
231 next_episode: u64,
232 current: std::collections::BTreeMap<String, WorkerDiagnosisEpisode>,
233 pending: std::collections::BTreeMap<String, u64>,
234}
235
236#[derive(Debug, Default, PartialEq, Eq)]
237pub struct WorkerDiagnosisCompletion {
238 pub display_error: Option<String>,
239 pub restart_episode: Option<u64>,
240}
241
242impl WorkerDiagnosisTracker {
243 pub fn observe(
244 &mut self,
245 session_id: &str,
246 connected: bool,
247 error: Option<String>,
248 ) -> Option<u64> {
249 if connected || error.is_none() {
250 self.current.remove(session_id);
251 }
252 let error = error?;
253 let episode = self
254 .current
255 .entry(session_id.to_owned())
256 .or_insert_with(|| {
257 self.next_episode = self.next_episode.wrapping_add(1).max(1);
258 WorkerDiagnosisEpisode {
259 id: self.next_episode,
260 error: error.clone(),
261 diagnosed: false,
262 }
263 });
264 episode.error = error;
265 if episode.diagnosed || self.pending.contains_key(session_id) {
266 return None;
267 }
268 self.pending.insert(session_id.to_owned(), episode.id);
269 Some(episode.id)
270 }
271
272 pub fn finish(&mut self, session_id: &str, episode_id: u64) -> WorkerDiagnosisCompletion {
273 if self.pending.get(session_id) != Some(&episode_id) {
274 return WorkerDiagnosisCompletion::default();
275 }
276 self.pending.remove(session_id);
277 let Some(current) = self.current.get_mut(session_id) else {
278 return WorkerDiagnosisCompletion::default();
279 };
280 if current.id == episode_id {
281 current.diagnosed = true;
282 return WorkerDiagnosisCompletion {
283 display_error: Some(current.error.clone()),
284 restart_episode: None,
285 };
286 }
287 if !current.diagnosed {
288 self.pending.insert(session_id.to_owned(), current.id);
289 return WorkerDiagnosisCompletion {
290 display_error: None,
291 restart_episode: Some(current.id),
292 };
293 }
294 WorkerDiagnosisCompletion::default()
295 }
296}
297
298#[derive(Debug, Clone)]
299pub struct ResourcePollTarget {
300 session_id: String,
301 probe: SessionResourceProbe,
302}
303
304#[derive(Debug)]
305pub struct ResourcePollUpdate {
306 pub session_id: String,
307 pub usage: SessionResourceUsage,
308}
309
310#[derive(Debug)]
311pub struct CapacityPollUpdate {
312 pub target_id: String,
313 pub result: std::result::Result<Option<DeploymentCapacityUsage>, String>,
314 pub sampled_at_epoch_seconds: u64,
315}
316
317pub fn projected_queued_prompts(
318 controller: &Controller,
319) -> Result<std::collections::BTreeMap<String, Vec<mj_core::relay::QueuedPrompt>>> {
320 let queues = crate::database::load_materialized_queued_prompts()?;
321 Ok(controller
322 .state
323 .sessions
324 .keys()
325 .filter_map(|session_id| {
326 queues
327 .get(session_id)
328 .map(|queue| (session_id.clone(), queued_prompt_entries(queue)))
329 })
330 .collect())
331}
332
333pub fn quota_refresh_profiles(controller: &Controller) -> Vec<QuotaRefreshRequest> {
334 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
335 controller
336 .config
337 .enabled_profiles()
338 .map(|(id, profile)| {
339 let mut environment = profile.environment.clone();
340 profile
341 .kind
342 .configure_home_environment(&profile.home, &mut environment);
343 QuotaRefreshRequest {
344 profile_id: id.to_owned(),
345 harness: profile.kind,
346 source_home: profile.home.clone(),
347 environment,
348 cwd: cwd.clone(),
349 }
350 })
351 .collect()
352}
353
354pub fn spawn_quota_refresher() -> (
355 tokio::sync::watch::Sender<QuotaRefreshBatch>,
356 tokio::sync::mpsc::Receiver<QuotaUpdate>,
357) {
358 let (profiles_tx, mut profiles_rx) = tokio::sync::watch::channel(QuotaRefreshBatch::default());
359 let (updates_tx, updates_rx) = tokio::sync::mpsc::channel(32);
360 tokio::spawn(async move {
361 let mut quotas = QuotaManager::default();
362 let mut batch = QuotaRefreshBatch::default();
363 let mut interval = tokio::time::interval(QUOTA_REFRESH_INTERVAL);
364 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
365 interval.tick().await;
366 loop {
367 tokio::select! {
368 _ = interval.tick(), if !batch.profiles.is_empty() => {
369 if !refresh_profile_quotas(
370 &mut quotas,
371 batch.generation,
372 &batch.profiles,
373 &updates_tx,
374 ).await {
375 break;
376 }
377 }
378 changed = profiles_rx.changed() => {
379 if changed.is_err() {
380 tracing::debug!("quota profile target feed closed; stopping quota refresher");
381 break;
382 }
383 batch = profiles_rx.borrow_and_update().clone();
384 if !refresh_profile_quotas(
385 &mut quotas,
386 batch.generation,
387 &batch.profiles,
388 &updates_tx,
389 ).await {
390 break;
391 }
392 }
393 }
394 }
395 quotas.shutdown().await;
396 });
397 (profiles_tx, updates_rx)
398}
399
400async fn refresh_profile_quotas(
401 quotas: &mut QuotaManager,
402 generation: u64,
403 profiles: &[QuotaRefreshRequest],
404 updates: &tokio::sync::mpsc::Sender<QuotaUpdate>,
405) -> bool {
406 let ids = profiles
407 .iter()
408 .map(|profile| profile.profile_id.clone())
409 .collect::<Vec<_>>();
410 if updates
411 .send(QuotaUpdate::Refreshing { profile_ids: ids })
412 .await
413 .is_err()
414 {
415 tracing::debug!("quota update consumer closed before refresh started");
416 return false;
417 }
418 let delivered = AtomicBool::new(true);
421 quotas
422 .refresh_profiles(profiles.to_vec(), |quota| {
423 let delivered = &delivered;
424 async move {
425 if delivered.load(Ordering::Acquire)
426 && updates.send(QuotaUpdate::Report(quota)).await.is_err()
427 {
428 tracing::debug!("quota update consumer closed while reporting a profile");
429 delivered.store(false, Ordering::Release);
430 }
431 }
432 })
433 .await;
434 if !delivered.into_inner() {
435 return false;
436 }
437 if updates
438 .send(QuotaUpdate::Finished { generation })
439 .await
440 .is_err()
441 {
442 tracing::debug!(
443 generation,
444 "quota update consumer closed before refresh completed"
445 );
446 false
447 } else {
448 true
449 }
450}
451
452pub fn spawn_image_refresher(
459 plan: impl Fn() -> Vec<ImageRefresh> + Send + 'static,
460 cancellation: tokio_util::sync::CancellationToken,
461) -> tokio::task::JoinHandle<()> {
462 tokio::spawn(async move {
463 let mut interval = tokio::time::interval_at(
464 tokio::time::Instant::now() + IMAGE_REFRESH_DELAY,
465 IMAGE_REFRESH_INTERVAL,
466 );
467 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
470 loop {
471 tokio::select! {
472 biased;
475 _ = cancellation.cancelled() => return,
476 _ = interval.tick() => refresh_images(plan(), &cancellation).await,
477 }
478 }
479 })
480}
481
482async fn refresh_images(
483 plan: Vec<ImageRefresh>,
484 cancellation: &tokio_util::sync::CancellationToken,
485) {
486 if plan.is_empty() {
487 return;
488 }
489 let cancelled = Arc::new(AtomicBool::new(false));
492 let mut hosts = tokio::task::JoinSet::new();
493 for refresh in plan {
494 let executor = CancellableProcessExecutor::new(cancelled.clone());
497 hosts.spawn_blocking(move || {
498 let Err(error) = refresh_host_image(&refresh, &executor) else {
499 return;
500 };
501 if executor.is_cancelled() {
502 tracing::debug!(
504 host = refresh.host.label(),
505 image = refresh.image,
506 "container image refresh cancelled"
507 );
508 return;
509 }
510 tracing::warn!(
511 host = refresh.host.label(),
512 image = refresh.image,
513 error = format!("{error:#}"),
514 "could not refresh a container image"
515 );
516 });
517 }
518 let mut cancelling = false;
519 loop {
520 tokio::select! {
521 biased;
522 _ = cancellation.cancelled(), if !cancelling => {
523 cancelling = true;
524 cancelled.store(true, Ordering::Release);
525 }
526 joined = hosts.join_next() => match joined {
527 None => return,
528 Some(Ok(())) => {}
529 Some(Err(error)) => {
530 tracing::warn!(%error, "container image refresh task failed");
531 }
532 },
533 }
534 }
535}
536
537fn refresh_host_image(refresh: &ImageRefresh, executor: &impl CommandExecutor) -> Result<()> {
542 let cached = image_id(&refresh.image_id, executor);
543 run_refresh_command(&refresh.pull, executor)?;
544 let pulled = image_id(&refresh.image_id, executor);
545 if pulled.is_some() && pulled != cached {
546 tracing::info!(
547 host = refresh.host.label(),
548 image = refresh.image,
549 id = pulled.unwrap_or_default(),
550 "pulled a newer container image"
551 );
552 } else {
553 tracing::debug!(
554 host = refresh.host.label(),
555 image = refresh.image,
556 "container image is already current"
557 );
558 }
559 run_refresh_command(&refresh.prune, executor)?;
560 Ok(())
561}
562
563fn image_id(command: &CommandSpec, executor: &impl CommandExecutor) -> Option<String> {
566 let output = executor.execute(command).ok()?;
567 if output.status != 0 {
568 return None;
569 }
570 let id = String::from_utf8_lossy(&output.stdout).trim().to_owned();
571 (!id.is_empty()).then_some(id)
572}
573
574fn run_refresh_command(command: &CommandSpec, executor: &impl CommandExecutor) -> Result<()> {
575 let output = executor.execute(command)?;
576 if output.status != 0 {
577 bail!(
578 "{} failed with status {}: {}",
579 command.purpose,
580 output.status,
581 String::from_utf8_lossy(&output.stderr).trim()
582 );
583 }
584 Ok(())
585}
586
587pub fn complete_manual_quota_refresh(
588 pending_generation: &mut Option<u64>,
589 completed_generation: u64,
590) -> bool {
591 if *pending_generation != Some(completed_generation) {
592 return false;
593 }
594 *pending_generation = None;
595 true
596}
597
598pub fn dashboard_worker_targets(controller: &Controller) -> Vec<WorkerPollTarget> {
599 controller
600 .state
601 .sessions
602 .values()
603 .filter(|session| session_target_is_pollable(session))
604 .filter_map(|session| {
605 let spec = match controller.reconnect_command(&session.id) {
606 Ok(spec) => spec,
607 Err(error) => {
608 tracing::warn!(session_id = %session.id, "could not build worker poll target: {error:#}");
609 return None;
610 }
611 };
612 Some(WorkerPollTarget {
613 session_id: session.id.clone(),
614 spec,
615 worker_recovery: match controller.worker_recovery_plan(&session.id) {
616 Ok(plan) => Some(plan),
617 Err(error) => {
618 tracing::debug!(session_id = %session.id, "worker recovery target unavailable: {error:#}");
619 None
620 }
621 },
622 project_memory: match controller.project_memory_sync_target(&session.id) {
623 Ok(target) => Some(target),
624 Err(error) => {
625 tracing::debug!(session_id = %session.id, "project memory target unavailable: {error:#}");
626 None
627 }
628 },
629 })
630 })
631 .collect()
632}
633
634pub fn dashboard_worker_targets_excluding(
635 controller: &Controller,
636 excluded_sessions: &std::collections::BTreeSet<String>,
637) -> Vec<WorkerPollTarget> {
638 let mut targets = dashboard_worker_targets(controller);
639 targets.retain(|target| !excluded_sessions.contains(&target.session_id));
640 targets
641}
642
643pub fn credential_sync_targets(controller: &Controller) -> Vec<CredentialSyncTarget> {
647 controller
648 .state
649 .sessions
650 .values()
651 .filter(|session| {
652 matches!(
653 session.state,
654 SessionState::Running | SessionState::Checkpointing
655 ) && session.target.is_some()
656 })
657 .filter_map(|session| {
658 let profile = controller.config.profiles.get(&session.last_profile)?;
659 let spec = match controller.reconnect_command(&session.id) {
660 Ok(spec) => spec,
661 Err(error) => {
662 tracing::warn!(session_id = %session.id, "could not build credential sync target: {error:#}");
663 return None;
664 }
665 };
666 let sync_github_token = target_syncs_github_token(session.target.as_ref());
667 Some(CredentialSyncTarget {
668 session_id: session.id.clone(),
669 profile_id: session.last_profile.clone(),
670 harness: profile.kind,
671 profile_home: profile.home.clone(),
672 sync_github_token,
673 spec,
674 })
675 })
676 .collect()
677}
678
679fn target_syncs_github_token(target: Option<&mj_core::state::TargetLocator>) -> bool {
680 target.is_some()
681 && !matches!(
682 target,
683 Some(mj_core::state::TargetLocator::LocalBare { .. })
684 )
685}
686
687pub const IMMEDIATE_CREDENTIAL_SYNC_COOLDOWN: Duration = Duration::from_secs(5 * 60);
690
691#[derive(Debug, Clone, PartialEq, Eq)]
692struct PendingCredentialSync {
693 signal: CredentialSyncSignal,
694 profile_id: String,
695}
696
697#[derive(Debug, Default)]
700pub struct CredentialSyncSignalTracker {
701 handled_ordinals: std::collections::BTreeMap<String, u64>,
702 last_attempts: std::collections::BTreeMap<String, Instant>,
703 pending: std::collections::BTreeMap<String, PendingCredentialSync>,
704}
705
706impl CredentialSyncSignalTracker {
707 pub fn observe(&mut self, session_id: &str, profile_id: &str, signal: CredentialSyncSignal) {
708 if self
709 .handled_ordinals
710 .get(session_id)
711 .is_some_and(|handled| *handled >= signal.ordinal)
712 {
713 return;
714 }
715 let pending = PendingCredentialSync {
716 signal,
717 profile_id: profile_id.to_owned(),
718 };
719 match self.pending.entry(session_id.to_owned()) {
720 std::collections::btree_map::Entry::Vacant(entry) => {
721 entry.insert(pending);
722 }
723 std::collections::btree_map::Entry::Occupied(mut entry)
724 if entry.get().signal.ordinal <= pending.signal.ordinal =>
725 {
726 entry.insert(pending);
727 }
728 std::collections::btree_map::Entry::Occupied(_) => {}
729 }
730 }
731
732 fn drain_due(&mut self, now: Instant) -> Vec<(String, String, CredentialSyncReason)> {
733 let due = self
734 .pending
735 .keys()
736 .filter(|session_id| {
737 self.last_attempts.get(*session_id).is_none_or(|previous| {
738 now.saturating_duration_since(*previous) >= IMMEDIATE_CREDENTIAL_SYNC_COOLDOWN
739 })
740 })
741 .cloned()
742 .collect::<Vec<_>>();
743 due.into_iter()
744 .map(|session_id| {
745 let pending = self
746 .pending
747 .remove(&session_id)
748 .expect("due credential sync signal disappeared");
749 self.handled_ordinals
750 .insert(session_id.clone(), pending.signal.ordinal);
751 self.last_attempts.insert(session_id.clone(), now);
752 (session_id, pending.profile_id, pending.signal.reason)
753 })
754 .collect()
755 }
756}
757
758pub fn schedule_due_credential_syncs(
759 tracker: &mut CredentialSyncSignalTracker,
760 credential_sync: &CredentialSyncHandle,
761 now: Instant,
762) {
763 for (session_id, profile_id, reason) in tracker.drain_due(now) {
764 credential_sync.sync_profile_now(
765 &profile_id,
766 Some(CredentialSyncCause { session_id, reason }),
767 );
768 }
769}
770
771#[derive(Debug, Default)]
779pub struct CredentialSyncNotices {
780 last_failures: std::collections::BTreeMap<(String, Option<String>), String>,
781}
782
783pub fn log_credential_sync_actions(result: &mj_core::credentials::CredentialSyncResult) {
784 let sessions = result.credential_sessions();
785 if sessions > 0 {
786 tracing::info!(
787 profile_id = %result.profile_id,
788 sessions,
789 "refreshed harness credentials"
790 );
791 }
792}
793
794fn setup_token_advice(profile_id: &str, harness: Option<mj_core::config::HarnessKind>) -> String {
800 if harness == Some(mj_core::config::HarnessKind::Claude) {
801 format!(
802 ", or store a long-lived token with `mj login --profile {profile_id} --setup-token`"
803 )
804 } else {
805 String::new()
806 }
807}
808
809impl CredentialSyncNotices {
810 pub fn notice(
813 &mut self,
814 result: &mj_core::credentials::CredentialSyncResult,
815 harness: Option<mj_core::config::HarnessKind>,
816 ) -> Option<String> {
817 let advice = setup_token_advice(&result.profile_id, harness);
818 if let Some(trigger) = &result.trigger {
821 let session_id = &trigger.session_id;
822 let sync_failure = result.failure.as_deref().or_else(|| {
823 result.failures().find_map(|(failed_session, detail)| {
824 (failed_session == session_id).then_some(detail)
825 })
826 });
827 if let Some(detail) = sync_failure {
828 return Some(match trigger.reason {
829 CredentialSyncReason::AuthenticationFailure => format!(
830 "Auth failure on profile {} (session {}); credential reconciliation failed: {detail}. Run `mj login --profile {}`{advice}.",
831 result.profile_id,
832 short_id(session_id),
833 result.profile_id
834 ),
835 CredentialSyncReason::EmptyPromptResponse => format!(
836 "Session {} returned no response; credential reconciliation for profile {} failed: {detail}. The failure is recorded in the transcript.",
837 short_id(session_id),
838 result.profile_id
839 ),
840 });
841 }
842 return Some(match (trigger.reason, result.pushed_to(session_id)) {
845 (CredentialSyncReason::AuthenticationFailure, true) => format!(
846 "Auth failure on profile {} (session {}); refreshed credentials were pushed. Retry the prompt, and if it repeats run `mj login --profile {}`{advice}.",
847 result.profile_id,
848 short_id(session_id),
849 result.profile_id
850 ),
851 (CredentialSyncReason::AuthenticationFailure, false) => format!(
852 "Auth failure on profile {} (session {}); nothing fresher to push. Run `mj login --profile {}`{advice}.",
853 result.profile_id,
854 short_id(session_id),
855 result.profile_id
856 ),
857 (CredentialSyncReason::EmptyPromptResponse, true) => format!(
858 "Session {} returned no response; fresher credentials from profile {} were pushed. Retry the prompt.",
859 short_id(session_id),
860 result.profile_id
861 ),
862 (CredentialSyncReason::EmptyPromptResponse, false) => format!(
863 "Session {} returned no response; profile {} had no newer credentials to push. The failure is recorded in the transcript.",
864 short_id(session_id),
865 result.profile_id
866 ),
867 });
868 }
869
870 let mut failures = std::collections::BTreeMap::new();
871 if let Some(detail) = &result.failure {
872 failures.insert(
873 (result.profile_id.clone(), None),
874 format!(
875 "Credential sync for profile {} failed: {detail}",
876 result.profile_id
877 ),
878 );
879 }
880 for (session_id, detail) in result.failures() {
881 failures.insert(
882 (result.profile_id.clone(), Some(session_id.to_owned())),
883 format!(
884 "Credential sync for profile {} (session {}) failed: {detail}",
885 result.profile_id,
886 short_id(session_id)
887 ),
888 );
889 }
890 self.last_failures
893 .retain(|key, _| key.0 != result.profile_id || failures.contains_key(key));
894 let mut notice = None;
895 for (key, message) in failures {
896 if self.last_failures.get(&key) != Some(&message) {
897 notice.get_or_insert_with(|| message.clone());
898 }
899 self.last_failures.insert(key, message);
900 }
901 if notice.is_some() {
902 return notice;
903 }
904
905 let mut parts = Vec::new();
906 let skills = result.skills_sessions();
907 if skills > 0 {
908 parts.push(format!(
909 "Synced skills for profile {} to {skills} session(s).",
910 result.profile_id
911 ));
912 }
913 let github_pushed = result.github_token_pushed_sessions();
914 if github_pushed > 0 {
915 parts.push(format!(
916 "Synced the GitHub CLI token to {github_pushed} session(s)."
917 ));
918 }
919 let github_removed = result.github_token_removed_sessions();
920 if github_removed > 0 {
921 parts.push(format!(
922 "Removed the GitHub CLI token from {github_removed} session(s)."
923 ));
924 }
925 (!parts.is_empty()).then(|| parts.join(" "))
926 }
927}
928
929fn dashboard_resource_targets(controller: &Controller) -> Vec<ResourcePollTarget> {
930 controller
931 .state
932 .sessions
933 .values()
934 .filter(|session| session_target_is_pollable(session))
935 .filter_map(|session| {
936 match controller.resource_probe(&session.id) {
937 Ok(probe) => Some(ResourcePollTarget {
938 session_id: session.id.clone(),
939 probe,
940 }),
941 Err(error) => {
942 tracing::warn!(session_id = %session.id, "could not build resource poll target: {error:#}");
943 None
944 }
945 }
946 })
947 .collect()
948}
949
950pub fn session_target_is_pollable(session: &mj_core::state::SessionRecord) -> bool {
965 session.state.is_active()
966 && !matches!(
967 session.state,
968 SessionState::Error | SessionState::Provisioning | SessionState::Destroying
969 )
970 && session.target.is_some()
971}
972
973pub fn refresh_dashboard_poll_targets(
974 controller: &Controller,
975 worker_targets_tx: &tokio::sync::watch::Sender<Vec<WorkerPollTarget>>,
976 resource_targets_tx: &tokio::sync::watch::Sender<Vec<ResourcePollTarget>>,
977 credential_sync: &CredentialSyncHandle,
978 excluded_sessions: &std::collections::BTreeSet<String>,
979) {
980 let worker_targets = dashboard_worker_targets_excluding(controller, excluded_sessions);
981 worker_targets_tx.send_replace(worker_targets);
982 let mut resource_targets = dashboard_resource_targets(controller);
983 resource_targets.retain(|target| !excluded_sessions.contains(&target.session_id));
984 resource_targets_tx.send_replace(resource_targets);
985 let mut credential_targets = credential_sync_targets(controller);
986 credential_targets.retain(|target| !excluded_sessions.contains(&target.session_id));
987 credential_sync.set_targets(credential_targets);
988}
989
990pub fn spawn_aws_resource_options_resolution(
991 config: Config,
992 target_id: String,
993 updates: tokio::sync::mpsc::UnboundedSender<(
994 String,
995 std::result::Result<Vec<SessionResourceAllocation>, String>,
996 )>,
997 tracker: mj_client::operations::CriticalOperationTracker,
998) {
999 let cancelled = Arc::new(AtomicBool::new(false));
1000 let guard = tracker.begin_cancellable(
1001 format!("resolving resources for {target_id}"),
1002 cancelled.clone(),
1003 );
1004 let _task = tokio::task::spawn_blocking(move || {
1005 let controller = Controller {
1006 config,
1007 state: State::default(),
1008 };
1009 let result = controller
1010 .resolve_aws_resource_options(&target_id, &CancellableProcessExecutor::new(cancelled))
1011 .map_err(|error| format!("{error:#}"));
1012 if let Err(error) = updates.send((target_id.clone(), result)) {
1013 tracing::debug!(target_id, %error, "AWS resource options result dropped after dashboard shutdown");
1014 }
1015 drop(guard);
1016 });
1017}
1018
1019pub fn spawn_dashboard_resource_poller() -> (
1020 tokio::sync::watch::Sender<Vec<ResourcePollTarget>>,
1021 tokio::sync::mpsc::Sender<String>,
1022 tokio::sync::mpsc::Receiver<ResourcePollUpdate>,
1023) {
1024 let (targets_tx, mut targets_rx) =
1025 tokio::sync::watch::channel(Vec::<ResourcePollTarget>::new());
1026 let (triggers_tx, mut triggers_rx) = tokio::sync::mpsc::channel(64);
1027 let (updates_tx, updates_rx) = tokio::sync::mpsc::channel(64);
1028 tokio::spawn(async move {
1029 let mut targets = std::collections::BTreeMap::new();
1030 let mut last_started = std::collections::BTreeMap::new();
1031 let mut interval = tokio::time::interval(RESOURCE_POLL_INTERVAL);
1032 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1033 loop {
1034 tokio::select! {
1035 _ = interval.tick() => {
1036 let due = targets.values().cloned().collect::<Vec<_>>();
1037 for target in due {
1038 schedule_resource_sample(target, &mut last_started, &updates_tx);
1039 }
1040 }
1041 changed = targets_rx.changed() => {
1042 if changed.is_err() {
1043 tracing::debug!("resource poll target feed closed; stopping resource poller");
1044 break;
1045 }
1046 targets = targets_rx
1047 .borrow_and_update()
1048 .iter()
1049 .cloned()
1050 .map(|target| (target.session_id.clone(), target))
1051 .collect();
1052 last_started.retain(|session_id, _| targets.contains_key(session_id));
1053 let due = targets.values().cloned().collect::<Vec<_>>();
1054 for target in due {
1055 schedule_resource_sample(target, &mut last_started, &updates_tx);
1056 }
1057 }
1058 session_id = triggers_rx.recv() => {
1059 let Some(session_id) = session_id else {
1060 break;
1061 };
1062 if let Some(target) = targets.get(&session_id).cloned() {
1063 schedule_resource_sample(target, &mut last_started, &updates_tx);
1064 }
1065 }
1066 }
1067 }
1068 });
1069 (targets_tx, triggers_tx, updates_rx)
1070}
1071
1072fn resource_sample_is_due(
1073 last_started: Option<&tokio::time::Instant>,
1074 now: tokio::time::Instant,
1075) -> bool {
1076 last_started.is_none_or(|started| now.duration_since(*started) >= RESOURCE_POLL_INTERVAL)
1077}
1078
1079fn schedule_resource_sample(
1080 target: ResourcePollTarget,
1081 last_started: &mut std::collections::BTreeMap<String, tokio::time::Instant>,
1082 updates: &tokio::sync::mpsc::Sender<ResourcePollUpdate>,
1083) {
1084 let now = tokio::time::Instant::now();
1085 if !resource_sample_is_due(last_started.get(&target.session_id), now) {
1086 return;
1087 }
1088 last_started.insert(target.session_id.clone(), now);
1089 let updates = updates.clone();
1090 tokio::spawn(async move {
1091 let usage = match tokio::time::timeout(
1092 RESOURCE_POLL_TIMEOUT,
1093 collect_session_resource_usage(&target.probe),
1094 )
1095 .await
1096 {
1097 Ok(Ok(usage)) => Some(usage),
1098 Ok(Err(error)) => {
1099 tracing::warn!(session_id = %target.session_id, "resource probe failed: {error:#}");
1100 None
1101 }
1102 Err(_) => {
1103 tracing::warn!(session_id = %target.session_id, "resource probe timed out");
1104 None
1105 }
1106 };
1107 let Some(usage) = usage else {
1108 return;
1109 };
1110 if let Err(error) = updates
1111 .send(ResourcePollUpdate {
1112 session_id: target.session_id.clone(),
1113 usage,
1114 })
1115 .await
1116 {
1117 tracing::debug!(session_id = %target.session_id, %error, "resource probe result dropped after dashboard shutdown");
1118 }
1119 });
1120}
1121
1122async fn collect_session_resource_usage(
1123 probe: &SessionResourceProbe,
1124) -> Result<SessionResourceUsage> {
1125 let memory = execute_resource_command(&probe.memory).await?;
1126 let disk = match &probe.disk {
1127 Some(command) => match execute_resource_command(command).await {
1128 Ok(output) => Some(output),
1129 Err(error) => {
1130 tracing::debug!(purpose = %command.purpose, "optional disk resource probe failed: {error:#}");
1131 None
1132 }
1133 },
1134 None => None,
1135 };
1136 crate::targets::parse_resource_usage(
1137 &memory.stdout,
1138 disk.as_ref().map(|output| output.stdout.as_slice()),
1139 )
1140}
1141
1142pub fn spawn_dashboard_capacity_poller() -> (
1143 tokio::sync::watch::Sender<Vec<DeploymentCapacityTarget>>,
1144 tokio::sync::mpsc::Sender<()>,
1145 tokio::sync::mpsc::Receiver<CapacityPollUpdate>,
1146) {
1147 spawn_capacity_poller_with(|target| async move {
1148 if let Some(error) = &target.probe_error {
1149 bail!("capacity probe is unavailable: {error}");
1150 }
1151 if target.local {
1152 return collect_local_capacity_with(collect_local_capacity)
1153 .await
1154 .map(Some);
1155 }
1156 tokio::time::timeout(RESOURCE_POLL_TIMEOUT, collect_capacity(&target))
1157 .await
1158 .context("capacity probe timed out")?
1159 })
1160}
1161
1162fn spawn_capacity_poller_with<F, Fut>(
1163 collect: F,
1164) -> (
1165 tokio::sync::watch::Sender<Vec<DeploymentCapacityTarget>>,
1166 tokio::sync::mpsc::Sender<()>,
1167 tokio::sync::mpsc::Receiver<CapacityPollUpdate>,
1168)
1169where
1170 F: Fn(DeploymentCapacityTarget) -> Fut + Send + Sync + 'static,
1171 Fut: Future<Output = Result<Option<DeploymentCapacityUsage>>> + Send + 'static,
1172{
1173 let (targets_tx, mut targets_rx) =
1174 tokio::sync::watch::channel(Vec::<DeploymentCapacityTarget>::new());
1175 let (updates_tx, updates_rx) = tokio::sync::mpsc::channel(64);
1176 let (triggers_tx, mut triggers_rx) = tokio::sync::mpsc::channel(1);
1177 tokio::spawn(async move {
1178 let mut targets = Vec::new();
1179 let collect = Arc::new(collect);
1180 let mut samples = CapacitySamples::default();
1181 let mut interval = tokio::time::interval_at(
1182 tokio::time::Instant::now() + CAPACITY_POLL_INTERVAL,
1183 CAPACITY_POLL_INTERVAL,
1184 );
1185 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1186 loop {
1187 tokio::select! {
1188 _ = updates_tx.closed() => break,
1189 _ = interval.tick() => {
1190 samples.schedule(targets.iter().cloned(), &collect);
1191 }
1192 changed = targets_rx.changed() => {
1193 if changed.is_err() {
1194 tracing::debug!("capacity poll target feed closed; stopping capacity poller");
1195 break;
1196 }
1197 let updated = targets_rx.borrow_and_update().clone();
1198 samples.schedule(
1199 updated.iter().filter(|target| !targets.contains(target)).cloned(),
1200 &collect,
1201 );
1202 targets = updated;
1203 }
1204 trigger = triggers_rx.recv() => {
1205 if trigger.is_none() {
1206 break;
1207 }
1208 samples.schedule(targets.iter().cloned(), &collect);
1209 }
1210 completed = samples.tasks.join_next_with_id(), if !samples.tasks.is_empty() => {
1211 let (id, result) = match completed.expect("capacity task exists") {
1212 Ok((id, result)) => (id, result.map_err(|error| format!("{error:#}"))),
1213 Err(error) => (error.id(), Err(format!("capacity probe task failed: {error}"))),
1214 };
1215 let sampled = samples.targets.remove(&id).expect("capacity task retains its target");
1216 if let Err(error) = &result {
1217 tracing::warn!(target_id = %sampled.id, %error, "capacity probe failed");
1218 }
1219 let Ok(permit) = updates_tx.reserve().await else {
1220 break;
1221 };
1222 let current = targets_rx.borrow().iter().find(|target| target.id == sampled.id).cloned();
1226 let Some(current) = current else {
1227 continue;
1228 };
1229 if current != sampled {
1230 if targets.contains(¤t) {
1234 samples.schedule(std::iter::once(current), &collect);
1235 }
1236 continue;
1237 }
1238 permit.send(CapacityPollUpdate {
1239 target_id: sampled.id,
1240 result,
1241 sampled_at_epoch_seconds: epoch_seconds(),
1242 });
1243 }
1244 }
1245 }
1246 samples.tasks.abort_all();
1247 while let Some(completed) = samples.tasks.join_next().await {
1248 match completed {
1249 Ok(Err(error)) => tracing::warn!(%error, "capacity probe failed during shutdown"),
1250 Err(error) if !error.is_cancelled() => {
1251 tracing::error!(%error, "capacity probe task failed during shutdown");
1252 }
1253 _ => {}
1254 }
1255 }
1256 });
1257 (targets_tx, triggers_tx, updates_rx)
1258}
1259
1260#[derive(Default)]
1261struct CapacitySamples {
1262 tasks: tokio::task::JoinSet<Result<Option<DeploymentCapacityUsage>>>,
1263 targets: std::collections::HashMap<tokio::task::Id, DeploymentCapacityTarget>,
1264}
1265
1266impl CapacitySamples {
1267 fn schedule<F, Fut>(
1268 &mut self,
1269 targets: impl IntoIterator<Item = DeploymentCapacityTarget>,
1270 collect: &Arc<F>,
1271 ) where
1272 F: Fn(DeploymentCapacityTarget) -> Fut + Send + Sync + 'static,
1273 Fut: Future<Output = Result<Option<DeploymentCapacityUsage>>> + Send + 'static,
1274 {
1275 for target in targets {
1276 if self.targets.values().any(|running| running.id == target.id) {
1277 continue;
1278 }
1279 let collect = collect.clone();
1280 let sampled = target.clone();
1281 let task = self.tasks.spawn(async move {
1282 let started = Instant::now();
1283 let target_id = sampled.id.clone();
1284 let result = collect(sampled).await;
1285 tracing::debug!(
1286 %target_id,
1287 elapsed_ms = started.elapsed().as_millis() as u64,
1288 success = result.is_ok(),
1289 "capacity probe completed",
1290 );
1291 result
1292 });
1293 self.targets.insert(task.id(), target);
1294 }
1295 }
1296}
1297
1298async fn collect_capacity(
1299 target: &DeploymentCapacityTarget,
1300) -> Result<Option<DeploymentCapacityUsage>> {
1301 if let Some(error) = &target.probe_error {
1302 anyhow::bail!("capacity probe is unavailable: {error}");
1303 }
1304 match target.kind {
1305 DeploymentCapacityKind::Host => {
1306 let mut last_error = None;
1307 for command in &target.probes {
1308 match execute_resource_command(command).await {
1309 Ok(output) => {
1310 return crate::targets::parse_host_capacity(&output.stdout).map(Some);
1311 }
1312 Err(error) => last_error = Some(error),
1313 }
1314 }
1315 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("no host probe is configured")))
1316 }
1317 DeploymentCapacityKind::AwsFleet => {
1318 if target.probes.is_empty() {
1319 return Ok(None);
1320 }
1321 let mut tasks = tokio::task::JoinSet::new();
1322 for command in target.probes.clone() {
1323 tasks.spawn(async move {
1324 let output = execute_resource_command(&command).await?;
1325 crate::targets::parse_aws_allocated_capacity(&output.stdout)
1326 });
1327 }
1328 let mut usages = Vec::new();
1329 while let Some(result) = tasks.join_next().await {
1330 usages.push(result.context("join EC2 capacity probe")??);
1331 }
1332 aggregate_aws_capacity(&usages).map(Some)
1333 }
1334 }
1335}
1336
1337pub fn aggregate_aws_capacity(
1338 usages: &[DeploymentCapacityUsage],
1339) -> Result<DeploymentCapacityUsage> {
1340 let mut total = DeploymentCapacityUsage {
1341 cpu_percent: None,
1342 memory_used_bytes: 0,
1343 memory_total_bytes: 0,
1344 logical_cores: 0,
1345 disk_total_bytes: Some(0),
1346 };
1347 for usage in usages {
1348 total.memory_total_bytes = total
1349 .memory_total_bytes
1350 .checked_add(usage.memory_total_bytes)
1351 .context("aggregate EC2 RAM overflow")?;
1352 total.logical_cores = total
1353 .logical_cores
1354 .checked_add(usage.logical_cores)
1355 .context("aggregate EC2 core count overflow")?;
1356 total.disk_total_bytes = Some(
1357 total
1358 .disk_total_bytes
1359 .unwrap_or(0)
1360 .checked_add(usage.disk_total_bytes.unwrap_or(0))
1361 .context("aggregate EC2 disk overflow")?,
1362 );
1363 }
1364 Ok(total)
1365}
1366
1367fn collect_local_capacity() -> Result<DeploymentCapacityUsage> {
1368 let mut system = sysinfo::System::new();
1369 system.refresh_memory();
1370 system.refresh_cpu_usage();
1372 std::thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL);
1373 system.refresh_cpu_usage();
1374 Ok(DeploymentCapacityUsage {
1375 cpu_percent: Some(system.global_cpu_usage().round().clamp(0.0, 100.0) as u8),
1376 memory_used_bytes: system
1377 .total_memory()
1378 .saturating_sub(system.available_memory()),
1379 memory_total_bytes: system.total_memory(),
1380 logical_cores: system
1381 .cpus()
1382 .len()
1383 .try_into()
1384 .context("logical CPU count overflow")?,
1385 disk_total_bytes: None,
1386 })
1387}
1388
1389async fn collect_local_capacity_with(
1390 collect: impl FnOnce() -> Result<DeploymentCapacityUsage> + Send + 'static,
1391) -> Result<DeploymentCapacityUsage> {
1392 let mut sample = tokio::task::spawn_blocking(move || {
1395 let result = collect();
1396 if let Err(error) = &result {
1398 tracing::warn!(%error, "local capacity sample failed");
1399 }
1400 result
1401 });
1402 match tokio::time::timeout(RESOURCE_POLL_TIMEOUT, &mut sample).await {
1403 Ok(result) => result.context("join local capacity probe")?,
1404 Err(_) => {
1405 match sample.await {
1406 Ok(Ok(_)) => {}
1407 Ok(Err(error)) => tracing::warn!(%error, "timed-out capacity probe failed"),
1408 Err(error) => tracing::error!(%error, "timed-out capacity probe task failed"),
1409 }
1410 bail!("capacity probe timed out")
1411 }
1412 }
1413}
1414
1415async fn execute_resource_command(command: &CommandSpec) -> Result<CommandOutput> {
1416 let mut process = tokio::process::Command::new(&command.program);
1417 process
1418 .args(&command.args)
1419 .envs(&command.env)
1420 .stdin(std::process::Stdio::null())
1421 .stdout(std::process::Stdio::piped())
1422 .stderr(std::process::Stdio::piped())
1423 .kill_on_drop(true);
1424 let child = process
1425 .spawn()
1426 .with_context(|| format!("start {} for {}", command.program, command.purpose))?;
1427 #[allow(clippy::disallowed_methods)]
1430 let output = child
1431 .wait_with_output()
1432 .await
1433 .with_context(|| format!("wait for {}", command.purpose))?;
1434 let command_output = CommandOutput {
1435 status: output.status.code().unwrap_or(-1),
1436 stdout: output.stdout,
1437 stderr: output.stderr,
1438 };
1439 if command_output.status != 0 {
1440 bail!(
1441 "{} failed with status {}: {}",
1442 command.purpose,
1443 command_output.status,
1444 String::from_utf8_lossy(&command_output.stderr).trim()
1445 );
1446 }
1447 Ok(command_output)
1448}
1449
1450pub struct RemoteDashboardWorkerPoller {
1451 pub targets: tokio::sync::watch::Sender<Vec<WorkerPollTarget>>,
1452 pub updates: SessionManagerUpdates,
1453 pub control: SessionManagerControl,
1454 pub shutdown: SessionManagerShutdown,
1455 pub state: tokio::sync::watch::Receiver<RuntimeStateUpdate>,
1456 pub reviews: tokio::sync::watch::Receiver<Vec<crate::review_host::RuntimeReviewView>>,
1458 pub notices: tokio::sync::watch::Receiver<Vec<daemon::RuntimeNotice>>,
1460 pub config: tokio::sync::watch::Receiver<mj_core::config::Config>,
1461}
1462
1463#[derive(Debug, Clone, Default)]
1465pub struct RuntimeStateUpdate {
1466 pub workspace_names: std::collections::BTreeMap<String, String>,
1467 pub revision: u64,
1468 pub records: Vec<SessionRecord>,
1469 pub lifecycles: Vec<daemon::RuntimeLifecycleView>,
1470 pub moves: Vec<mj_core::state::MoveOperation>,
1471}
1472
1473#[derive(Debug, Clone, PartialEq)]
1481struct PublishedView {
1482 projection_ordinal: u64,
1483 projection_digest: String,
1484 operational: Option<mj_core::relay::RelayOperationalState>,
1485 connected: bool,
1486 error: Option<String>,
1487}
1488
1489impl PublishedView {
1490 fn of(runtime: &crate::daemon::RuntimeSessionView) -> Self {
1491 Self {
1492 projection_ordinal: runtime.projection_ordinal,
1493 projection_digest: runtime.projection_digest.clone(),
1494 operational: runtime.operational.clone(),
1495 connected: runtime.connected,
1496 error: runtime.error.as_ref().map(|error| format!("{error:?}")),
1497 }
1498 }
1499
1500 fn matches(&self, runtime: &crate::daemon::RuntimeSessionView) -> bool {
1501 *self == Self::of(runtime)
1502 }
1503}
1504
1505const PROJECTION_CONVERGENCE_RETRIES: u8 = 20;
1506const PROJECTION_CONVERGENCE_RETRY_DELAY: Duration = Duration::from_millis(50);
1507
1508#[derive(Debug, Clone, PartialEq, Eq)]
1509struct ProjectionMismatch {
1510 published_ordinal: u64,
1511 published_digest: String,
1512 durable_ordinal: u64,
1513 durable_digest: String,
1514}
1515
1516#[derive(Default)]
1517struct ProjectionConvergence {
1518 attempts: std::collections::BTreeMap<String, (ProjectionMismatch, u8)>,
1519}
1520
1521impl ProjectionConvergence {
1522 fn converged(&mut self, session_id: &str) {
1523 self.attempts.remove(session_id);
1524 }
1525
1526 fn should_retry(&mut self, session_id: &str, mismatch: ProjectionMismatch) -> bool {
1530 let entry = self
1531 .attempts
1532 .entry(session_id.to_owned())
1533 .or_insert_with(|| (mismatch.clone(), 0));
1534 if entry.0 != mismatch {
1535 *entry = (mismatch, 0);
1536 }
1537 entry.1 = entry.1.saturating_add(1);
1538 entry.1 <= PROJECTION_CONVERGENCE_RETRIES
1539 }
1540}
1541
1542pub enum RuntimeFeedUpdate {
1545 Snapshot(Box<daemon::RuntimeSnapshot>),
1546 Session {
1547 session_id: String,
1548 view: Box<ManagedSessionView>,
1549 },
1550 Error(String),
1551}
1552
1553pub struct RuntimeFeed {
1556 pub updates: tokio::sync::mpsc::Receiver<RuntimeFeedUpdate>,
1557 task: tokio::task::JoinHandle<()>,
1558}
1559
1560impl Drop for RuntimeFeed {
1561 fn drop(&mut self) {
1562 self.task.abort();
1563 }
1564}
1565
1566type StoredProjection = Option<(MaterializedSession, mj_core::state::ProjectionWindow)>;
1567
1568async fn load_runtime_projection(session_id: String) -> Result<StoredProjection> {
1569 static READERS: std::sync::LazyLock<Arc<tokio::sync::Semaphore>> =
1570 std::sync::LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(4)));
1571 let permit = Arc::clone(&READERS)
1572 .acquire_owned()
1573 .await
1574 .context("projection readers stopped")?;
1575 tokio::task::spawn_blocking(move || {
1576 let _permit = permit;
1577 let result = crate::database::load_materialized_projection_tail(
1578 &session_id,
1579 crate::database::PROJECTION_TAIL_ITEMS,
1580 );
1581 if let Err(error) = &result {
1583 tracing::warn!(%session_id, %error, "could not load runtime projection");
1584 }
1585 result
1586 })
1587 .await
1588 .context("projection load task failed")?
1589}
1590
1591fn spawn_runtime_feed_with<P, PF, L, LF>(workspace_id: String, poll: P, load: L) -> RuntimeFeed
1592where
1593 P: Fn(String, u64) -> PF + Send + 'static,
1594 PF: Future<Output = Result<daemon::RuntimeSnapshot>> + Send,
1595 L: Fn(String) -> LF + Clone + Send + 'static,
1596 LF: Future<Output = Result<StoredProjection>> + Send + 'static,
1597{
1598 let (tx, updates) = tokio::sync::mpsc::channel(32);
1599 let task = tokio::spawn(async move {
1600 let result = run_runtime_feed(workspace_id, poll, load, &tx).await;
1601 if let Err(error) = result {
1602 let message = format!("Runtime feed stopped: {error:#}");
1603 tracing::error!(%message);
1604 let _ = tx.send(RuntimeFeedUpdate::Error(message)).await;
1605 }
1606 });
1607 RuntimeFeed { updates, task }
1608}
1609
1610async fn run_runtime_feed<P, PF, L, LF>(
1611 workspace_id: String,
1612 poll: P,
1613 load: L,
1614 tx: &tokio::sync::mpsc::Sender<RuntimeFeedUpdate>,
1615) -> Result<()>
1616where
1617 P: Fn(String, u64) -> PF,
1618 PF: Future<Output = Result<daemon::RuntimeSnapshot>>,
1619 L: Fn(String) -> LF + Clone + Send + 'static,
1620 LF: Future<Output = Result<StoredProjection>> + Send + 'static,
1621{
1622 let mut revision = 0;
1623 let mut convergence = ProjectionConvergence::default();
1624 let mut published = std::collections::BTreeMap::<String, PublishedView>::new();
1625 loop {
1626 let mut snapshot = match poll(workspace_id.clone(), revision).await {
1627 Ok(snapshot) => snapshot,
1628 Err(error) => {
1629 if tx
1630 .send(RuntimeFeedUpdate::Error(format!(
1631 "Could not refresh sessions: {error:#}"
1632 )))
1633 .await
1634 .is_err()
1635 {
1636 return Ok(());
1637 }
1638 tokio::time::sleep(Duration::from_millis(250)).await;
1639 continue;
1640 }
1641 };
1642 let snapshot_revision = snapshot.revision;
1643 let sessions = std::mem::take(&mut snapshot.sessions);
1644 published.retain(|id, _| sessions.iter().any(|session| &session.session_id == id));
1645 convergence
1646 .attempts
1647 .retain(|id, _| sessions.iter().any(|session| &session.session_id == id));
1648 if tx
1649 .send(RuntimeFeedUpdate::Snapshot(Box::new(snapshot)))
1650 .await
1651 .is_err()
1652 {
1653 return Ok(());
1654 }
1655 let mut pending = sessions
1656 .into_iter()
1657 .filter(|runtime| {
1658 !published
1659 .get(&runtime.session_id)
1660 .is_some_and(|last| last.matches(runtime))
1661 })
1662 .collect::<std::collections::VecDeque<_>>();
1663 let mut tasks = tokio::task::JoinSet::new();
1664 let mut retry = false;
1665 while !pending.is_empty() || !tasks.is_empty() {
1666 while tasks.len() < 4 {
1669 let Some(runtime) = pending.pop_front() else {
1670 break;
1671 };
1672 let load = load.clone();
1673 tasks.spawn(async move {
1674 let stored = if runtime.operational.is_some() {
1675 load(runtime.session_id.clone()).await
1676 } else {
1677 Ok(None)
1678 };
1679 (runtime, stored)
1680 });
1681 }
1682 let Some(result) = tasks.join_next().await else {
1683 break;
1684 };
1685 let (runtime, stored) = result.context("join runtime projection reader")?;
1686 let session_id = runtime.session_id.clone();
1687 let fingerprint = PublishedView::of(&runtime);
1688 let Some(view) = runtime_projection_view(runtime, stored, &mut convergence) else {
1689 retry = true;
1690 continue;
1691 };
1692 if view.snapshot.is_some() {
1693 published.insert(session_id.clone(), fingerprint);
1694 } else {
1695 published.remove(&session_id);
1696 }
1697 if tx
1698 .send(RuntimeFeedUpdate::Session {
1699 session_id,
1700 view: Box::new(view),
1701 })
1702 .await
1703 .is_err()
1704 {
1705 return Ok(());
1706 }
1707 }
1708 if retry {
1709 tokio::time::sleep(PROJECTION_CONVERGENCE_RETRY_DELAY).await;
1710 } else {
1711 revision = revision.max(snapshot_revision);
1712 }
1713 }
1714}
1715
1716fn runtime_projection_view(
1717 runtime: daemon::RuntimeSessionView,
1718 stored: Result<StoredProjection>,
1719 convergence: &mut ProjectionConvergence,
1720) -> Option<ManagedSessionView> {
1721 let Some(operational) = runtime.operational else {
1722 return Some(ManagedSessionView {
1723 snapshot: None,
1724 connected: runtime.connected,
1725 error: runtime.error,
1726 });
1727 };
1728 let detail = match stored {
1729 Ok(Some((materialized, window)))
1730 if materialized.applied_event_ordinal > runtime.projection_ordinal
1731 || (materialized.applied_event_ordinal == runtime.projection_ordinal
1732 && materialized.applied_event_digest == runtime.projection_digest) =>
1733 {
1734 convergence.converged(&runtime.session_id);
1735 return Some(ManagedSessionView {
1736 snapshot: Some(ManagedSessionSnapshot {
1737 materialized,
1738 window,
1739 operational,
1740 latest_credential_sync_signal: runtime.latest_credential_sync_signal,
1741 worker_build: None,
1742 subagent_requests: Vec::new(),
1743 subagent_results: Vec::new(),
1744 }),
1745 connected: runtime.connected,
1746 error: runtime.error,
1747 });
1748 }
1749 Ok(Some((materialized, _))) => {
1750 let mismatch = ProjectionMismatch {
1751 published_ordinal: runtime.projection_ordinal,
1752 published_digest: runtime.projection_digest,
1753 durable_ordinal: materialized.applied_event_ordinal,
1754 durable_digest: materialized.applied_event_digest.clone(),
1755 };
1756 if convergence.should_retry(&runtime.session_id, mismatch) {
1757 return None;
1758 }
1759 if materialized.applied_event_ordinal < runtime.projection_ordinal {
1760 format!(
1761 "daemon published projection {} but SQLite contains only {} after a bounded convergence retry",
1762 runtime.projection_ordinal, materialized.applied_event_ordinal
1763 )
1764 } else {
1765 format!(
1766 "daemon and SQLite projection digests differ at ordinal {} after a bounded convergence retry",
1767 runtime.projection_ordinal
1768 )
1769 }
1770 }
1771 Ok(None) => "daemon published a session with no durable projection".into(),
1772 Err(error) => format!("load daemon-owned projection: {error:#}"),
1773 };
1774 Some(ManagedSessionView {
1775 snapshot: None,
1776 connected: false,
1777 error: Some(ViewError::ProjectionIntegrity(detail)),
1778 })
1779}
1780
1781pub fn spawn_remote_dashboard_worker_poller(
1782 workspace_id: String,
1783) -> Result<RemoteDashboardWorkerPoller> {
1784 let channels = spawn_remote_session_manager()?;
1785 let crate::session_manager::RemoteSessionManagerChannels {
1786 targets,
1787 control,
1788 updates,
1789 shutdown,
1790 publisher,
1791 mut requests,
1792 } = channels;
1793 let (state_tx, state_rx) = tokio::sync::watch::channel(RuntimeStateUpdate::default());
1794 let (reviews_tx, reviews_rx) = tokio::sync::watch::channel(Vec::new());
1795 let (notices_tx, notices_rx) = tokio::sync::watch::channel(Vec::new());
1796 let (config_tx, config_rx) = tokio::sync::watch::channel(mj_core::config::Config::default());
1797 tokio::spawn(async move {
1798 let mut feed = spawn_runtime_feed_with(
1799 workspace_id,
1800 |workspace, revision| poll_daemon_runtime(workspace, revision, true),
1801 load_runtime_projection,
1802 );
1803 let mut request_order = crate::session_manager::SessionRequestOrder::new();
1804 loop {
1805 tokio::select! {
1806 request = requests.recv() => {
1807 let Some(request) = request else { return; };
1808 request_order.dispatch(request, forward_remote_session_request);
1809 }
1810 update = feed.updates.recv() => {
1811 match update {
1812 Some(RuntimeFeedUpdate::Snapshot(snapshot)) => {
1813 config_tx.send_if_modified(|config| {
1814 if *config == snapshot.config { false }
1815 else { *config = snapshot.config.clone(); true }
1816 });
1817 state_tx.send_replace(RuntimeStateUpdate {
1818 workspace_names: snapshot.workspace_names,
1819 revision: snapshot.revision,
1820 records: snapshot.records,
1821 lifecycles: snapshot.lifecycles,
1822 moves: snapshot.moves,
1823 });
1824 reviews_tx.send_replace(snapshot.reviews);
1825 notices_tx.send_replace(snapshot.notices);
1826 }
1827 Some(RuntimeFeedUpdate::Session { session_id, view }) => {
1828 if publisher.publish(session_id, *view).await.is_err() { return; }
1829 }
1830 Some(RuntimeFeedUpdate::Error(error)) => {
1831 tracing::warn!(%error, "could not refresh sessions from controller daemon");
1832 }
1833 None => return,
1834 }
1835 }
1836 }
1837 }
1838 });
1839 Ok(RemoteDashboardWorkerPoller {
1840 targets,
1841 updates,
1842 control,
1843 shutdown,
1844 state: state_rx,
1845 reviews: reviews_rx,
1846 notices: notices_rx,
1847 config: config_rx,
1848 })
1849}
1850
1851async fn poll_daemon_runtime(
1852 workspace_id: String,
1853 after_revision: u64,
1854 all_workspaces: bool,
1855) -> Result<daemon::RuntimeSnapshot> {
1856 let mut daemon = mj_client::daemon::connect_existing().await?;
1857 daemon
1858 .runtime_snapshot(workspace_id, after_revision, all_workspaces)
1859 .await
1860}
1861
1862async fn forward_remote_session_request(request: RemoteSessionRequest) {
1863 match request {
1864 RemoteSessionRequest::Submit {
1865 session_id,
1866 command_id,
1867 command,
1868 admission,
1869 reply,
1870 } => {
1871 if admission.is_some() {
1872 let _ = reply.send(Err(
1873 "review delivery admissions cannot cross the daemon request bridge".into(),
1874 ));
1875 return;
1876 }
1877 let result = async {
1878 mj_client::daemon::connect_existing()
1879 .await?
1880 .submit_session_command(session_id, command_id, command, None)
1881 .await
1882 }
1883 .await
1884 .map_err(|error| format!("{error:#}"));
1885 let _ = reply.send(result);
1886 }
1887 RemoteSessionRequest::Sync { session_id, reply } => {
1888 let result = async {
1889 mj_client::daemon::connect_existing()
1890 .await?
1891 .sync_session(session_id)
1892 .await
1893 }
1894 .await
1895 .map_err(|error| format!("{error:#}"));
1896 let _ = reply.send(result);
1897 }
1898 RemoteSessionRequest::RespondElicitation {
1899 session_id,
1900 elicitation_id,
1901 response,
1902 reply,
1903 } => {
1904 let result = async {
1905 mj_client::daemon::connect_existing()
1906 .await?
1907 .respond_elicitation(session_id, elicitation_id, response)
1908 .await
1909 }
1910 .await
1911 .map_err(|error| format!("{error:#}"));
1912 let _ = reply.send(result);
1913 }
1914 RemoteSessionRequest::StopBackgroundTask {
1915 session_id,
1916 background_task_id,
1917 reply,
1918 } => {
1919 let result = async {
1920 mj_client::daemon::connect_existing()
1921 .await?
1922 .stop_background_task(session_id, background_task_id)
1923 .await
1924 }
1925 .await
1926 .map_err(|error| format!("{error:#}"));
1927 let _ = reply.send(result);
1928 }
1929 RemoteSessionRequest::Reviewer {
1930 session_id,
1931 role,
1932 action,
1933 mut reply,
1934 } => {
1935 let result = tokio::select! {
1936 _ = reply.closed() => return,
1937 result = async {
1938 mj_client::daemon::connect_existing()
1939 .await?
1940 .reviewer_action(session_id, role, action)
1941 .await
1942 } => result,
1943 }
1944 .map_err(|error| format!("{error:#}"));
1945 let _ = reply.send(result);
1946 }
1947 }
1948}
1949
1950pub fn queued_prompt_projection(
1951 session: &MaterializedSession,
1952) -> Vec<mj_core::relay::QueuedPrompt> {
1953 queued_prompt_entries(&session.queued_prompts)
1954}
1955
1956fn queued_prompt_entries(
1957 prompts: &[mj_core::state::MaterializedQueuedPrompt],
1958) -> Vec<mj_core::relay::QueuedPrompt> {
1959 prompts
1960 .iter()
1961 .map(|prompt| mj_core::relay::QueuedPrompt {
1962 id: prompt.command_id.clone(),
1963 text: mj_core::transcript::materialized_content_text(&prompt.content),
1964 attachments: Vec::new(),
1965 created_at_ms: prompt.queued_at_ms,
1966 })
1967 .collect()
1968}
1969
1970pub enum LifecycleSuccess {
1971 Created,
1972 Resumed {
1973 profile_id: String,
1974 target_id: String,
1975 },
1976 Moved(mj_core::state::MoveOutcome),
1977 Closed,
1978 ForceStopped,
1979 DestroyedStopped,
1980 ForceDestroyed,
1981}
1982
1983pub struct LifecycleUpdate {
1984 pub session_id: String,
1985 pub result: std::result::Result<LifecycleSuccess, String>,
1986 pub deferred_cleanup: bool,
1987}
1988
1989pub fn is_interrupted_close(session: &SessionRecord) -> bool {
1993 matches!(
1994 session.state,
1995 SessionState::Closing | SessionState::Destroying
1996 ) && session.target.is_some()
1997}
1998
1999pub fn interrupted_close_session_ids(controller: &Controller) -> Vec<String> {
2000 controller
2001 .state
2002 .sessions
2003 .values()
2004 .filter(|session| is_interrupted_close(session))
2005 .map(|session| session.id.clone())
2006 .collect()
2007}
2008
2009pub fn spawn_interrupted_close_recovery(
2010 session_id: String,
2011 session_manager: SessionManagerControl,
2012 recovery_observer: crate::recovery_gate::RecoveryObserver,
2013 cancelled: Arc<AtomicBool>,
2014 updates: tokio::sync::mpsc::UnboundedSender<LifecycleUpdate>,
2015 tracker: Option<mj_client::operations::CriticalOperationTracker>,
2016) -> tokio::task::JoinHandle<()> {
2017 let guard = tracker.map(|tracker| {
2018 tracker.begin_cancellable(
2019 format!(
2020 "recovering session {}",
2021 mj_core::state::short_id(&session_id)
2022 ),
2023 cancelled.clone(),
2024 )
2025 });
2026 let runtime = tokio::runtime::Handle::current();
2027 tokio::spawn(async move {
2028 let operation_session_id = session_id.clone();
2029 let joined = tokio::task::spawn_blocking(move || {
2030 (|| -> Result<bool> {
2031 let _recovery_reservation = reserve_recovery_or_cancel(
2032 &recovery_observer,
2033 &operation_session_id,
2034 &cancelled,
2035 )?;
2036 let mut controller = Controller::load()?;
2037 let executor = CancellableProcessExecutor::new(cancelled);
2038 runtime.block_on(controller.recover_interrupted_close_managed(
2039 &operation_session_id,
2040 &executor,
2041 &session_manager,
2042 ))
2043 })()
2044 .map_err(|error| format!("{error:#}"))
2045 })
2046 .await;
2047 let (result, deferred_cleanup) = match joined {
2048 Ok(Ok(deferred_cleanup)) => (Ok(LifecycleSuccess::Closed), deferred_cleanup),
2049 Ok(Err(error)) => (Err(error), false),
2050 Err(error) => (
2051 Err(format!("interrupted close recovery task failed: {error}")),
2052 false,
2053 ),
2054 };
2055 if let Err(error) = updates.send(LifecycleUpdate {
2056 session_id: session_id.clone(),
2057 result,
2058 deferred_cleanup,
2059 }) {
2060 tracing::debug!(%session_id, %error, "interrupted close result dropped after dashboard shutdown");
2061 }
2062 drop(guard);
2063 })
2064}
2065
2066pub fn reserve_recovery_or_cancel(
2067 observer: &crate::recovery_gate::RecoveryObserver,
2068 session_id: &str,
2069 cancelled: &AtomicBool,
2070) -> Result<crate::recovery_gate::RecoveryReservation> {
2071 let reservation = observer.reserve(session_id);
2072 observer.cancel_busy(session_id);
2076 while observer.is_busy(session_id) {
2077 if cancelled.load(Ordering::Acquire) {
2078 bail!("operation cancelled while waiting for recovery copy");
2079 }
2080 std::thread::sleep(Duration::from_millis(25));
2081 }
2082 Ok(reservation)
2083}
2084
2085pub fn project_worker_title(
2086 controller: &mut Controller,
2087 update: &WorkerPollUpdate,
2088) -> Option<Option<String>> {
2089 let snapshot = update.view.snapshot.as_ref()?;
2090 let session = controller.state.sessions.get_mut(&update.session_id)?;
2091 let title = snapshot.resolved_title();
2092 if session.acp_session_title == title {
2093 return None;
2094 }
2095 session.acp_session_title = title.clone();
2096 Some(title)
2097}
2098
2099pub fn apply_worker_record_update(controller: &mut Controller, update: &WorkerPollUpdate) {
2100 let Some(title) = project_worker_title(controller, update) else {
2101 return;
2102 };
2103 let session_id = update.session_id.clone();
2104 tokio::spawn(async move {
2105 let result = tokio::task::spawn_blocking(move || {
2106 crate::database::set_session_acp_title(&session_id, title.as_deref())
2107 })
2108 .await;
2109 match result {
2110 Ok(Ok(())) => {}
2111 Ok(Err(error)) => tracing::warn!(%error, "could not persist relay title"),
2112 Err(error) => tracing::warn!(%error, "relay title persistence task failed"),
2113 }
2114 });
2115}
2116
2117#[cfg(test)]
2118mod tests {
2119
2120 #[test]
2125 fn an_unchanged_session_is_recognised_without_reading_its_transcript() {
2126 let runtime = runtime_view("session-1", 42, "digest-42");
2127 let published = PublishedView::of(&runtime);
2128
2129 assert!(
2130 published.matches(&runtime),
2131 "an identical snapshot was treated as a change, so it would be re-read"
2132 );
2133
2134 let advanced = runtime_view("session-1", 43, "digest-43");
2136 assert!(
2137 !published.matches(&advanced),
2138 "a moved projection was mistaken for an unchanged one"
2139 );
2140
2141 let rewritten = runtime_view("session-1", 42, "digest-other");
2144 assert!(
2145 !published.matches(&rewritten),
2146 "a rewritten projection at the same ordinal was skipped"
2147 );
2148
2149 let mut busy = runtime_view("session-1", 42, "digest-42");
2152 busy.connected = false;
2153 assert!(
2154 !published.matches(&busy),
2155 "a disconnect was skipped as unchanged"
2156 );
2157 }
2158
2159 fn runtime_view(
2160 session_id: &str,
2161 projection_ordinal: u64,
2162 projection_digest: &str,
2163 ) -> crate::daemon::RuntimeSessionView {
2164 crate::daemon::RuntimeSessionView {
2165 session_id: session_id.to_owned(),
2166 projection_ordinal,
2167 projection_digest: projection_digest.to_owned(),
2168 operational: None,
2169 latest_credential_sync_signal: None,
2170 connected: true,
2171 error: None,
2172 }
2173 }
2174 use super::*;
2175
2176 fn podman_controller(state: SessionState) -> Controller {
2177 let session_id = "0123456789abcdef0123456789abcdef";
2178 let mut config = Config::default();
2179 config.profiles.insert(
2180 "codex".into(),
2181 mj_core::config::HarnessProfile {
2182 enabled: true,
2183 kind: mj_core::config::HarnessKind::Codex,
2184 home: PathBuf::from("/home/dev/.codex"),
2185 environment: Default::default(),
2186 context_window_bytes: None,
2187 },
2188 );
2189 config.targets.insert(
2190 "podman".into(),
2191 mj_core::config::TargetTemplate::LocalPodman {
2192 container: mj_core::config::ContainerTemplate {
2193 image: "ubuntu:24.04".into(),
2194 pull_policy: Default::default(),
2195 platform: None,
2196 cpus: None,
2197 memory: None,
2198 environment: std::collections::BTreeMap::new(),
2199 workspace_storage: Default::default(),
2200 },
2201 },
2202 );
2203 config.bundles.insert(
2204 "project".into(),
2205 mj_core::config::ProjectBundle {
2206 primary_repo: "project".into(),
2207 repositories: vec![mj_core::config::ProjectRepository {
2208 id: "project".into(),
2209 github: Some("owner/project".into()),
2210 local: None,
2211 destination: "project".into(),
2212 git_ref: None,
2213 }],
2214 },
2215 );
2216 let mut app_state = State::default();
2217 app_state.sessions.insert(
2218 session_id.into(),
2219 mj_core::state::SessionRecord {
2220 mjolnir_subagents: None,
2221 create_managed_worktree: None,
2222 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2223 archived: false,
2224 container_cpus: None,
2225 container_memory: None,
2226 id: session_id.into(),
2227 title: "poll target".into(),
2228 harness_kind: mj_core::config::HarnessKind::Codex,
2229 last_profile: "codex".into(),
2230 bundle_id: "project".into(),
2231 project_directory: None,
2232 managed_worktree: None,
2233 target_template_id: "podman".into(),
2234 resource_allocation: None,
2235 additional_mounts: Vec::new(),
2236 state,
2237 target: Some(mj_core::state::TargetLocator::LocalPodman {
2238 container_id: "a".repeat(64),
2239 workspace_storage: Default::default(),
2240 }),
2241 native_session_id: None,
2242 acp_session_title: None,
2243 session_title_override: None,
2244 created_at: "2026-08-27T00:00:00Z".into(),
2245 updated_at: "2026-08-27T00:00:00Z".into(),
2246 viewed_through_event_ordinal: 0,
2247 draft_input: String::new(),
2248 last_error: None,
2249 last_checkpoint_error: None,
2250 checkpoint: None,
2251 },
2252 );
2253 Controller {
2254 config,
2255 state: app_state,
2256 }
2257 }
2258
2259 #[test]
2260 fn recoverable_error_session_stays_out_of_live_target_pollers() {
2261 let running = podman_controller(SessionState::Running);
2262 assert_eq!(dashboard_worker_targets(&running).len(), 1);
2263 assert_eq!(dashboard_resource_targets(&running).len(), 1);
2264 assert_eq!(credential_sync_targets(&running).len(), 1);
2265
2266 let recoverable_error = podman_controller(SessionState::Error);
2267 assert!(
2268 recoverable_error
2269 .state
2270 .sessions
2271 .values()
2272 .all(|session| session.target.is_some()),
2273 "the test session keeps its target so the exclusion is about its state"
2274 );
2275 assert!(
2276 !recoverable_error
2277 .state
2278 .sessions
2279 .values()
2280 .any(session_target_is_pollable),
2281 "an errored session is not dialed even while its target exists"
2282 );
2283 assert!(dashboard_worker_targets(&recoverable_error).is_empty());
2284 assert!(dashboard_resource_targets(&recoverable_error).is_empty());
2285 assert!(credential_sync_targets(&recoverable_error).is_empty());
2286 }
2287
2288 #[test]
2294 fn a_provisioning_session_is_not_polled_before_its_worker_exists() {
2295 let provisioning = podman_controller(SessionState::Provisioning);
2296 assert!(
2297 provisioning
2298 .state
2299 .sessions
2300 .values()
2301 .all(|session| session.target.is_some())
2302 );
2303
2304 assert!(dashboard_worker_targets(&provisioning).is_empty());
2305 assert!(dashboard_resource_targets(&provisioning).is_empty());
2306
2307 let running = podman_controller(SessionState::Running);
2310 assert_eq!(dashboard_worker_targets(&running).len(), 1);
2311 assert_eq!(dashboard_resource_targets(&running).len(), 1);
2312 }
2313
2314 #[test]
2315 fn failed_destruction_stays_out_of_pollers_without_an_active_lifecycle() {
2316 let mut controller = podman_controller(SessionState::Destroying);
2317 for session in controller.state.sessions.values_mut() {
2318 session.last_error =
2319 Some("verified checkpoint retained; cleanup is safely retryable".into());
2320 }
2321 let excluded = std::collections::BTreeSet::new();
2323 for _ in 0..3 {
2324 assert!(dashboard_worker_targets_excluding(&controller, &excluded).is_empty());
2325 assert!(dashboard_resource_targets(&controller).is_empty());
2326 assert!(credential_sync_targets(&controller).is_empty());
2327 }
2328 let closing = podman_controller(SessionState::Closing);
2329 assert_eq!(dashboard_worker_targets(&closing).len(), 1);
2330 }
2331
2332 #[test]
2333 fn lifecycle_owned_session_stays_out_of_worker_targets() {
2334 let controller = podman_controller(SessionState::Running);
2335 assert_eq!(dashboard_worker_targets(&controller).len(), 1);
2336
2337 let excluded = controller
2338 .state
2339 .sessions
2340 .keys()
2341 .cloned()
2342 .collect::<std::collections::BTreeSet<_>>();
2343
2344 assert!(dashboard_worker_targets_excluding(&controller, &excluded).is_empty());
2345 }
2346
2347 #[test]
2348 fn projection_rollback_race_retries_before_reporting_integrity_failure() {
2349 let mismatch = ProjectionMismatch {
2350 published_ordinal: 39,
2351 published_digest: "published".into(),
2352 durable_ordinal: 36,
2353 durable_digest: "durable".into(),
2354 };
2355 let mut convergence = ProjectionConvergence::default();
2356
2357 for _ in 0..PROJECTION_CONVERGENCE_RETRIES {
2358 assert!(convergence.should_retry("session-1", mismatch.clone()));
2359 }
2360 assert!(
2361 !convergence.should_retry("session-1", mismatch),
2362 "a persistent mismatch must still become an integrity error"
2363 );
2364
2365 convergence.converged("session-1");
2366 assert!(convergence.attempts.is_empty());
2367 }
2368
2369 #[test]
2370 fn a_changed_projection_mismatch_gets_its_own_convergence_window() {
2371 let mut convergence = ProjectionConvergence::default();
2372 let stale_lineage = ProjectionMismatch {
2373 published_ordinal: 39,
2374 published_digest: "old-lineage".into(),
2375 durable_ordinal: 36,
2376 durable_digest: "checkpoint".into(),
2377 };
2378 for _ in 0..=PROJECTION_CONVERGENCE_RETRIES {
2379 convergence.should_retry("session-1", stale_lineage.clone());
2380 }
2381 let equal_frontier_different_lineage = ProjectionMismatch {
2382 published_ordinal: 39,
2383 published_digest: "old-lineage".into(),
2384 durable_ordinal: 39,
2385 durable_digest: "new-lineage".into(),
2386 };
2387
2388 assert!(convergence.should_retry("session-1", equal_frontier_different_lineage));
2389 }
2390
2391 #[test]
2392 fn worker_diagnosis_is_coalesced_for_one_unreachable_episode() {
2393 let mut tracker = WorkerDiagnosisTracker::default();
2394 let episode = tracker
2395 .observe("session-1", false, Some("connection refused".into()))
2396 .unwrap();
2397
2398 assert_eq!(
2399 tracker.observe("session-1", false, Some("still unreachable".into())),
2400 None
2401 );
2402 assert_eq!(
2403 tracker.finish("session-1", episode),
2404 WorkerDiagnosisCompletion {
2405 display_error: Some("still unreachable".into()),
2406 restart_episode: None,
2407 }
2408 );
2409 assert_eq!(
2410 tracker.observe("session-1", false, Some("third poll".into())),
2411 None
2412 );
2413 }
2414
2415 #[test]
2416 fn stale_worker_diagnosis_is_not_published_after_reconnect() {
2417 let mut tracker = WorkerDiagnosisTracker::default();
2418 let first = tracker
2419 .observe("session-1", false, Some("first outage".into()))
2420 .unwrap();
2421 assert_eq!(tracker.observe("session-1", true, None), None);
2422 assert_eq!(
2423 tracker.observe("session-1", false, Some("new outage".into())),
2424 None
2425 );
2426
2427 let completion = tracker.finish("session-1", first);
2428 assert_eq!(completion.display_error, None);
2429 let second = completion.restart_episode.unwrap();
2430 assert_eq!(
2431 tracker.finish("session-1", second).display_error.as_deref(),
2432 Some("new outage")
2433 );
2434 }
2435
2436 #[test]
2437 fn stale_worker_diagnosis_is_not_published_after_a_terminal_poll_error() {
2438 let mut tracker = WorkerDiagnosisTracker::default();
2439 let episode = tracker
2440 .observe("session-1", false, Some("relay failed".into()))
2441 .unwrap();
2442
2443 assert_eq!(tracker.observe("session-1", false, None), None);
2444 assert_eq!(
2445 tracker.finish("session-1", episode),
2446 WorkerDiagnosisCompletion::default()
2447 );
2448 }
2449
2450 #[tokio::test]
2451 async fn quota_refresh_completion_keeps_its_generation() {
2452 let mut quotas = QuotaManager::default();
2453 let (updates, mut received) = tokio::sync::mpsc::channel(4);
2454 assert!(refresh_profile_quotas(&mut quotas, 42, &[], &updates).await);
2455 assert!(matches!(
2456 received.recv().await,
2457 Some(QuotaUpdate::Refreshing {
2458 profile_ids,
2459 }) if profile_ids.is_empty()
2460 ));
2461 assert!(matches!(
2462 received.recv().await,
2463 Some(QuotaUpdate::Finished { generation: 42 })
2464 ));
2465
2466 let mut pending = Some(43);
2467 assert!(!complete_manual_quota_refresh(&mut pending, 42));
2468 assert_eq!(pending, Some(43));
2469 assert!(complete_manual_quota_refresh(&mut pending, 43));
2470 assert_eq!(pending, None);
2471 quotas.shutdown().await;
2472 }
2473
2474 #[test]
2475 fn quota_refresh_requests_exclude_disabled_profiles() {
2476 let mut controller = podman_controller(SessionState::Stopped);
2477 let mut disabled = controller.config.profiles["codex"].clone();
2478 disabled.enabled = false;
2479 controller
2480 .config
2481 .profiles
2482 .insert("reserve".into(), disabled);
2483
2484 let requests = quota_refresh_profiles(&controller);
2485
2486 assert_eq!(
2487 requests
2488 .iter()
2489 .map(|request| request.profile_id.as_str())
2490 .collect::<Vec<_>>(),
2491 ["codex"]
2492 );
2493 }
2494
2495 #[test]
2496 fn resource_samples_are_throttled_to_one_per_minute() {
2497 let started = tokio::time::Instant::now();
2498 assert!(!resource_sample_is_due(
2499 Some(&started),
2500 started + Duration::from_secs(59),
2501 ));
2502 assert!(resource_sample_is_due(
2503 Some(&started),
2504 started + RESOURCE_POLL_INTERVAL,
2505 ));
2506 }
2507
2508 struct PendingCapacityProbe {
2509 target: DeploymentCapacityTarget,
2510 finish: tokio::sync::oneshot::Sender<Result<Option<DeploymentCapacityUsage>>>,
2511 }
2512
2513 struct CapacityPollerFixture {
2514 targets: tokio::sync::watch::Sender<Vec<DeploymentCapacityTarget>>,
2515 triggers: tokio::sync::mpsc::Sender<()>,
2516 updates: tokio::sync::mpsc::Receiver<CapacityPollUpdate>,
2517 started: tokio::sync::mpsc::UnboundedReceiver<PendingCapacityProbe>,
2518 }
2519
2520 impl CapacityPollerFixture {
2521 fn new() -> Self {
2522 let (started_tx, started) = tokio::sync::mpsc::unbounded_channel();
2523 let (targets, triggers, updates) = spawn_capacity_poller_with(move |target| {
2524 let started_tx = started_tx.clone();
2525 async move {
2526 let (finish, result) = tokio::sync::oneshot::channel();
2527 started_tx
2528 .send(PendingCapacityProbe { target, finish })
2529 .unwrap();
2530 result.await.context("test probe completion dropped")?
2531 }
2532 });
2533 Self {
2534 targets,
2535 triggers,
2536 updates,
2537 started,
2538 }
2539 }
2540
2541 async fn assert_no_start(&mut self) {
2542 assert!(
2543 tokio::time::timeout(Duration::from_millis(1), self.started.recv())
2544 .await
2545 .is_err()
2546 );
2547 }
2548 }
2549
2550 fn capacity_target(id: &str) -> DeploymentCapacityTarget {
2551 DeploymentCapacityTarget {
2552 id: id.into(),
2553 host: id.into(),
2554 target_ids: vec![id.into()],
2555 kind: DeploymentCapacityKind::Host,
2556 local: true,
2557 probes: Vec::new(),
2558 probe_error: None,
2559 }
2560 }
2561
2562 #[tokio::test(start_paused = true)]
2563 async fn capacity_samples_follow_timer_and_manual_refresh_not_unchanged_publications() {
2564 let mut fixture = CapacityPollerFixture::new();
2565 let targets = vec![capacity_target("local")];
2566 fixture.targets.send_replace(targets.clone());
2567 fixture
2568 .started
2569 .recv()
2570 .await
2571 .unwrap()
2572 .finish
2573 .send(Ok(None))
2574 .unwrap();
2575 assert!(fixture.updates.recv().await.unwrap().result.is_ok());
2576
2577 fixture.targets.send_replace(targets);
2578 fixture.assert_no_start().await;
2579 tokio::time::advance(Duration::from_secs(29)).await;
2580 fixture.assert_no_start().await;
2581 tokio::time::advance(Duration::from_secs(1)).await;
2582 fixture
2583 .started
2584 .recv()
2585 .await
2586 .unwrap()
2587 .finish
2588 .send(Ok(None))
2589 .unwrap();
2590 assert!(fixture.updates.recv().await.unwrap().result.is_ok());
2591
2592 fixture.triggers.send(()).await.unwrap();
2593 fixture
2594 .started
2595 .recv()
2596 .await
2597 .unwrap()
2598 .finish
2599 .send(Ok(None))
2600 .unwrap();
2601 assert!(fixture.updates.recv().await.unwrap().result.is_ok());
2602 fixture.assert_no_start().await;
2603 }
2604
2605 #[tokio::test(start_paused = true)]
2606 async fn capacity_busy_targets_coalesce_requests_without_blocking_other_targets() {
2607 let mut fixture = CapacityPollerFixture::new();
2608 let first_target = capacity_target("first");
2609 fixture.targets.send_replace(vec![first_target.clone()]);
2610 let first = fixture.started.recv().await.unwrap();
2611 fixture
2612 .targets
2613 .send_replace(vec![first_target, capacity_target("second")]);
2614 let second = fixture.started.recv().await.unwrap();
2615 assert_eq!(second.target.id, "second");
2616
2617 fixture.triggers.send(()).await.unwrap();
2618 fixture.assert_no_start().await;
2619 tokio::time::advance(CAPACITY_POLL_INTERVAL).await;
2620 fixture.assert_no_start().await;
2621 first.finish.send(Ok(None)).unwrap();
2622 second.finish.send(Ok(None)).unwrap();
2623 assert!(fixture.updates.recv().await.unwrap().result.is_ok());
2624 assert!(fixture.updates.recv().await.unwrap().result.is_ok());
2625 fixture.assert_no_start().await;
2626 }
2627
2628 #[tokio::test(start_paused = true)]
2629 async fn capacity_changed_targets_get_one_follow_up_and_removed_results_are_discarded() {
2630 let mut fixture = CapacityPollerFixture::new();
2631 let mut target = capacity_target("local");
2632 fixture.targets.send_replace(vec![target.clone()]);
2633 let first = fixture.started.recv().await.unwrap();
2634 target.host = "new-host".into();
2635 fixture.targets.send_replace(vec![target.clone()]);
2636 fixture.assert_no_start().await;
2637 first.finish.send(Ok(None)).unwrap();
2638 let changed = fixture.started.recv().await.unwrap();
2639 assert_eq!(changed.target, target);
2640 assert!(
2641 fixture.updates.try_recv().is_err(),
2642 "old configuration result escaped"
2643 );
2644 changed
2645 .finish
2646 .send(Err(anyhow::anyhow!("new host unavailable")))
2647 .unwrap();
2648 assert!(
2649 fixture
2650 .updates
2651 .recv()
2652 .await
2653 .unwrap()
2654 .result
2655 .unwrap_err()
2656 .contains("new host unavailable")
2657 );
2658
2659 fixture.triggers.send(()).await.unwrap();
2660 let removed = fixture.started.recv().await.unwrap();
2661 fixture.targets.send_replace(Vec::new());
2662 fixture.assert_no_start().await;
2663 removed.finish.send(Ok(None)).unwrap();
2664 assert!(
2665 tokio::time::timeout(Duration::from_millis(1), fixture.updates.recv())
2666 .await
2667 .is_err()
2668 );
2669 fixture.targets.send_replace(vec![target]);
2670 let mut last = fixture.started.recv().await.unwrap();
2671 drop(fixture.updates);
2672 last.finish.closed().await;
2673 }
2674
2675 #[tokio::test]
2676 async fn capacity_probe_panics_are_reported_and_do_not_prevent_retry() {
2677 let first = AtomicBool::new(true);
2678 let (targets, triggers, mut updates) = spawn_capacity_poller_with(move |_| {
2679 if first.swap(false, Ordering::SeqCst) {
2680 panic!("test capacity probe panic");
2681 }
2682 async { Ok(None) }
2683 });
2684 targets.send_replace(vec![capacity_target("local")]);
2685 let failure = updates.recv().await.unwrap();
2686 assert_eq!(failure.target_id, "local");
2687 assert!(
2688 failure
2689 .result
2690 .unwrap_err()
2691 .contains("test capacity probe panic")
2692 );
2693 triggers.send(()).await.unwrap();
2694 assert!(updates.recv().await.unwrap().result.is_ok());
2695 }
2696
2697 #[tokio::test(start_paused = true)]
2698 async fn capacity_results_are_revalidated_after_output_backpressure() {
2699 let mut fixture = CapacityPollerFixture::new();
2700 let mut targets: Vec<_> = (0..65).map(|id| capacity_target(&id.to_string())).collect();
2701 fixture.targets.send_replace(targets.clone());
2702 let mut pending = Vec::new();
2703 for _ in 0..65 {
2704 pending.push(fixture.started.recv().await.unwrap());
2705 }
2706 let last = pending.pop().unwrap();
2707 let last_id = last.target.id;
2708 for probe in pending {
2709 probe.finish.send(Ok(None)).unwrap();
2710 }
2711 fixture.assert_no_start().await;
2712 assert_eq!(fixture.updates.len(), 64);
2713 last.finish.send(Ok(None)).unwrap();
2714 fixture.assert_no_start().await;
2715
2716 targets
2717 .iter_mut()
2718 .find(|target| target.id == last_id)
2719 .unwrap()
2720 .host = "changed".into();
2721 fixture.targets.send_replace(targets);
2722 for _ in 0..64 {
2723 let update = fixture.updates.recv().await.unwrap();
2724 assert_ne!(update.target_id, last_id);
2725 }
2726 let changed = fixture.started.recv().await.unwrap();
2727 assert_eq!(changed.target.id, last_id);
2728 assert_eq!(changed.target.host, "changed");
2729 assert!(
2730 fixture.updates.try_recv().is_err(),
2731 "stale blocked result escaped"
2732 );
2733 changed.finish.send(Ok(None)).unwrap();
2734 assert_eq!(fixture.updates.recv().await.unwrap().target_id, last_id);
2735 fixture.assert_no_start().await;
2736 }
2737
2738 #[tokio::test(start_paused = true)]
2739 async fn capacity_timeout_retains_blocking_sample_until_it_exits() {
2740 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
2741 let (finish_tx, finish_rx) = std::sync::mpsc::channel();
2742 let sample = tokio::spawn(collect_local_capacity_with(move || {
2743 started_tx.send(()).unwrap();
2744 finish_rx.recv().context("test sample was cancelled")?;
2746 Ok(DeploymentCapacityUsage {
2747 cpu_percent: Some(10),
2748 memory_used_bytes: 1,
2749 memory_total_bytes: 2,
2750 logical_cores: 4,
2751 disk_total_bytes: None,
2752 })
2753 }));
2754 started_rx.await.unwrap();
2755 tokio::time::advance(RESOURCE_POLL_TIMEOUT + Duration::from_secs(1)).await;
2756 tokio::task::yield_now().await;
2757 assert!(
2758 !sample.is_finished(),
2759 "timeout released a still-running blocking sample"
2760 );
2761 finish_tx.send(()).unwrap();
2762 assert!(
2763 sample
2764 .await
2765 .unwrap()
2766 .unwrap_err()
2767 .to_string()
2768 .contains("timed out")
2769 );
2770 }
2771
2772 #[test]
2773 fn a_new_credential_signal_waits_out_the_cooldown_without_being_lost() {
2774 let signal = |ordinal, reason| CredentialSyncSignal { ordinal, reason };
2775 let mut tracker = CredentialSyncSignalTracker::default();
2776 let started = Instant::now();
2777 tracker.observe(
2778 "session",
2779 "work",
2780 signal(41, CredentialSyncReason::AuthenticationFailure),
2781 );
2782 assert_eq!(
2783 tracker.drain_due(started),
2784 vec![(
2785 "session".into(),
2786 "work".into(),
2787 CredentialSyncReason::AuthenticationFailure
2788 )]
2789 );
2790
2791 tracker.observe(
2792 "session",
2793 "work",
2794 signal(42, CredentialSyncReason::AuthenticationFailure),
2795 );
2796 assert!(
2797 tracker
2798 .drain_due(started + Duration::from_secs(60))
2799 .is_empty()
2800 );
2801 tracker.observe(
2802 "session",
2803 "new-profile",
2804 signal(43, CredentialSyncReason::EmptyPromptResponse),
2805 );
2806 assert_eq!(tracker.pending["session"].signal.ordinal, 43);
2807
2808 assert_eq!(
2811 tracker.drain_due(started + IMMEDIATE_CREDENTIAL_SYNC_COOLDOWN),
2812 vec![(
2813 "session".into(),
2814 "new-profile".into(),
2815 CredentialSyncReason::EmptyPromptResponse
2816 )]
2817 );
2818 tracker.observe(
2819 "session",
2820 "new-profile",
2821 signal(43, CredentialSyncReason::EmptyPromptResponse),
2822 );
2823 assert!(
2824 tracker
2825 .drain_due(started + (IMMEDIATE_CREDENTIAL_SYNC_COOLDOWN * 2))
2826 .is_empty()
2827 );
2828
2829 tracker.observe(
2830 "other",
2831 "personal",
2832 signal(1, CredentialSyncReason::AuthenticationFailure),
2833 );
2834 assert_eq!(
2835 tracker.drain_due(started + Duration::from_secs(60)),
2836 vec![(
2837 "other".into(),
2838 "personal".into(),
2839 CredentialSyncReason::AuthenticationFailure
2840 )]
2841 );
2842 }
2843
2844 #[test]
2845 fn a_healthy_credential_cycle_stays_out_of_the_ui() {
2846 let result = mj_core::credentials::CredentialSyncResult {
2847 profile_id: "work".into(),
2848 trigger: None,
2849 failure: None,
2850 outcomes: Vec::new(),
2851 };
2852 assert_eq!(CredentialSyncNotices::default().notice(&result, None), None);
2853 }
2854
2855 #[test]
2856 fn github_tokens_sync_to_every_remote_target_but_raw_localhost() {
2857 use mj_core::state::TargetLocator;
2858
2859 let remotes = [
2860 TargetLocator::LocalPodman {
2861 container_id: "podman".into(),
2862 workspace_storage: Default::default(),
2863 },
2864 TargetLocator::AppleContainer {
2865 container_id: "apple".into(),
2866 },
2867 TargetLocator::AwsEc2 {
2868 instance_id: "i-123".into(),
2869 address: Some("example.invalid".into()),
2870 },
2871 TargetLocator::SshBare {
2872 host: "ssh.example".into(),
2873 workspace: "/workspace".into(),
2874 worker_id: None,
2875 },
2876 TargetLocator::SshPodman {
2877 host: "ssh.example".into(),
2878 container_id: "remote-podman".into(),
2879 workspace_storage: Default::default(),
2880 },
2881 TargetLocator::SshDocker {
2882 host: "ssh.example".into(),
2883 container_id: "remote-docker".into(),
2884 },
2885 ];
2886 for target in &remotes {
2887 assert!(target_syncs_github_token(Some(target)), "{target:?}");
2888 }
2889 assert!(!target_syncs_github_token(Some(
2890 &TargetLocator::LocalBare {
2891 worker_root: "/tmp/worker".into(),
2892 }
2893 )));
2894 assert!(!target_syncs_github_token(None));
2895 }
2896
2897 #[test]
2898 fn an_authentication_failure_notice_says_whether_anything_was_pushed() {
2899 use mj_core::credentials::{
2900 CredentialSyncAction, CredentialSyncOutcome, CredentialSyncResult,
2901 };
2902
2903 let mut notices = CredentialSyncNotices::default();
2904 let pushed = CredentialSyncResult {
2905 profile_id: "work".into(),
2906 trigger: Some(CredentialSyncCause {
2907 session_id: "018f9dd2-a3b4".into(),
2908 reason: CredentialSyncReason::AuthenticationFailure,
2909 }),
2910 failure: None,
2911 outcomes: vec![CredentialSyncOutcome {
2912 session_id: "018f9dd2-a3b4".into(),
2913 outcome: Ok(vec![CredentialSyncAction::Pushed]),
2914 }],
2915 };
2916 let notice = notices.notice(&pushed, None).unwrap();
2917 assert!(notice.contains("were pushed"), "{notice}");
2918 assert!(notice.contains("mj login --profile work"), "{notice}");
2919
2920 let nothing_to_push = CredentialSyncResult {
2921 trigger: Some(CredentialSyncCause {
2922 session_id: "018f9dd2-a3b4".into(),
2923 reason: CredentialSyncReason::AuthenticationFailure,
2924 }),
2925 outcomes: Vec::new(),
2926 ..pushed
2927 };
2928 let notice = notices.notice(¬hing_to_push, None).unwrap();
2929 assert!(notice.contains("nothing fresher"), "{notice}");
2930 assert!(notice.contains("mj login --profile work"), "{notice}");
2931 assert_eq!(notices.notice(¬hing_to_push, None), Some(notice));
2933 }
2934
2935 #[test]
2936 fn a_claude_authentication_failure_offers_the_long_lived_token() {
2937 use mj_core::config::HarnessKind;
2938 use mj_core::credentials::{CredentialSyncOutcome, CredentialSyncResult};
2939
2940 let result = CredentialSyncResult {
2941 profile_id: "claude-max".into(),
2942 trigger: Some(CredentialSyncCause {
2943 session_id: "018f9dd2-a3b4".into(),
2944 reason: CredentialSyncReason::AuthenticationFailure,
2945 }),
2946 failure: None,
2947 outcomes: Vec::new(),
2948 };
2949
2950 let claude = CredentialSyncNotices::default()
2951 .notice(&result, Some(HarnessKind::Claude))
2952 .unwrap();
2953 assert!(
2954 claude.ends_with(
2955 "Run `mj login --profile claude-max`, or store a long-lived token with `mj login --profile claude-max --setup-token`."
2956 ),
2957 "{claude}"
2958 );
2959
2960 let codex = CredentialSyncNotices::default()
2962 .notice(&result, Some(HarnessKind::Codex))
2963 .unwrap();
2964 assert!(
2965 codex.ends_with("Run `mj login --profile claude-max`."),
2966 "{codex}"
2967 );
2968
2969 let failed = CredentialSyncResult {
2971 outcomes: vec![CredentialSyncOutcome {
2972 session_id: "018f9dd2-a3b4".into(),
2973 outcome: Err("worker proxy disconnected".into()),
2974 }],
2975 ..result
2976 };
2977 let claude_failure = CredentialSyncNotices::default()
2978 .notice(&failed, Some(HarnessKind::Claude))
2979 .unwrap();
2980 assert!(
2981 claude_failure.contains("--setup-token`."),
2982 "{claude_failure}"
2983 );
2984 }
2985
2986 #[test]
2987 fn an_empty_prompt_notice_does_not_claim_authentication_failed() {
2988 use mj_core::credentials::{
2989 CredentialSyncAction, CredentialSyncOutcome, CredentialSyncResult,
2990 };
2991
2992 let result = CredentialSyncResult {
2993 profile_id: "work".into(),
2994 trigger: Some(CredentialSyncCause {
2995 session_id: "018f9dd2-a3b4".into(),
2996 reason: CredentialSyncReason::EmptyPromptResponse,
2997 }),
2998 failure: None,
2999 outcomes: vec![CredentialSyncOutcome {
3000 session_id: "018f9dd2-a3b4".into(),
3001 outcome: Ok(vec![CredentialSyncAction::Pushed]),
3002 }],
3003 };
3004 let notice = CredentialSyncNotices::default()
3005 .notice(&result, None)
3006 .unwrap();
3007 assert!(notice.contains("returned no response"), "{notice}");
3008 assert!(notice.contains("were pushed"), "{notice}");
3009 assert!(!notice.contains("Auth failure"), "{notice}");
3010 }
3011
3012 #[test]
3013 fn an_immediate_sync_failure_is_not_reported_as_no_new_credentials() {
3014 use mj_core::credentials::CredentialSyncResult;
3015
3016 let result = CredentialSyncResult {
3017 profile_id: "work".into(),
3018 trigger: Some(CredentialSyncCause {
3019 session_id: "018f9dd2-a3b4".into(),
3020 reason: CredentialSyncReason::AuthenticationFailure,
3021 }),
3022 failure: Some("controller credential file is unreadable".into()),
3023 outcomes: Vec::new(),
3024 };
3025 let notice = CredentialSyncNotices::default()
3026 .notice(&result, None)
3027 .unwrap();
3028 assert!(notice.contains("reconciliation failed"), "{notice}");
3029 assert!(notice.contains("credential file is unreadable"), "{notice}");
3030 assert!(!notice.contains("nothing fresher"), "{notice}");
3031 }
3032
3033 #[test]
3034 fn a_failed_credential_sync_is_reported() {
3035 use mj_core::credentials::{CredentialSyncOutcome, CredentialSyncResult};
3036
3037 let result = CredentialSyncResult {
3038 profile_id: "work".into(),
3039 trigger: None,
3040 failure: None,
3041 outcomes: vec![CredentialSyncOutcome {
3042 session_id: "018f9dd2-a3b4".into(),
3043 outcome: Err("worker proxy disconnected".into()),
3044 }],
3045 };
3046 let notice = CredentialSyncNotices::default()
3047 .notice(&result, None)
3048 .unwrap();
3049 assert!(notice.contains("worker proxy disconnected"), "{notice}");
3050 }
3051
3052 #[test]
3053 fn a_repeated_credential_failure_is_reported_once_until_it_changes() {
3054 use mj_core::credentials::{
3055 CredentialSyncAction, CredentialSyncOutcome, CredentialSyncResult,
3056 };
3057
3058 let failed = |detail: &str| CredentialSyncResult {
3059 profile_id: "work".into(),
3060 trigger: None,
3061 failure: None,
3062 outcomes: vec![CredentialSyncOutcome {
3063 session_id: "018f9dd2-a3b4".into(),
3064 outcome: Err(detail.to_owned()),
3065 }],
3066 };
3067 let mut notices = CredentialSyncNotices::default();
3068
3069 assert!(
3070 notices
3071 .notice(&failed("worker proxy disconnected"), None)
3072 .is_some()
3073 );
3074 assert_eq!(
3075 notices.notice(&failed("worker proxy disconnected"), None),
3076 None
3077 );
3078
3079 let changed = notices.notice(&failed("container is gone"), None).unwrap();
3080 assert!(changed.contains("container is gone"), "{changed}");
3081 assert_eq!(notices.notice(&failed("container is gone"), None), None);
3082
3083 let healthy = CredentialSyncResult {
3085 profile_id: "work".into(),
3086 trigger: None,
3087 failure: None,
3088 outcomes: vec![CredentialSyncOutcome {
3089 session_id: "018f9dd2-a3b4".into(),
3090 outcome: Ok(vec![CredentialSyncAction::Pushed]),
3091 }],
3092 };
3093 assert_eq!(notices.notice(&healthy, None), None);
3094 assert!(notices.notice(&failed("container is gone"), None).is_some());
3095 }
3096
3097 #[test]
3098 fn a_repeated_whole_sync_failure_is_reported_once_per_profile() {
3099 use mj_core::credentials::CredentialSyncResult;
3100
3101 let failed = |profile_id: &str| CredentialSyncResult {
3102 profile_id: profile_id.to_owned(),
3103 trigger: None,
3104 failure: Some("controller home is unreadable".into()),
3105 outcomes: Vec::new(),
3106 };
3107 let mut notices = CredentialSyncNotices::default();
3108
3109 let notice = notices.notice(&failed("work"), None).unwrap();
3110 assert!(notice.contains("profile work"), "{notice}");
3111 assert_eq!(notices.notice(&failed("work"), None), None);
3112 assert!(notices.notice(&failed("personal"), None).is_some());
3114 assert_eq!(notices.notice(&failed("work"), None), None);
3115 }
3116
3117 #[test]
3118 fn skills_and_github_syncs_speak_while_harness_credentials_stay_out_of_the_notice() {
3119 use mj_core::credentials::{
3120 CredentialSyncAction, CredentialSyncOutcome, CredentialSyncResult,
3121 };
3122
3123 let result = CredentialSyncResult {
3124 profile_id: "work".into(),
3125 trigger: None,
3126 failure: None,
3127 outcomes: vec![
3128 CredentialSyncOutcome {
3129 session_id: "018f9dd2-a3b4".into(),
3130 outcome: Ok(vec![
3131 CredentialSyncAction::Pushed,
3132 CredentialSyncAction::SkillsPushed,
3133 CredentialSyncAction::GithubTokenPushed,
3134 ]),
3135 },
3136 CredentialSyncOutcome {
3137 session_id: "018f9dd2-bbbb".into(),
3138 outcome: Ok(vec![
3139 CredentialSyncAction::SkillsPushed,
3140 CredentialSyncAction::GithubTokenRemoved,
3141 ]),
3142 },
3143 ],
3144 };
3145 let notice = CredentialSyncNotices::default()
3146 .notice(&result, None)
3147 .unwrap();
3148 assert!(!notice.contains("harness credentials"), "{notice}");
3149 assert!(
3150 notice.contains("Synced skills for profile work to 2 session(s)."),
3151 "{notice}"
3152 );
3153 assert!(
3154 notice.contains("Synced the GitHub CLI token to 1 session(s)."),
3155 "{notice}"
3156 );
3157 assert!(
3158 notice.contains("Removed the GitHub CLI token from 1 session(s)."),
3159 "{notice}"
3160 );
3161 }
3162
3163 #[test]
3164 fn aws_capacity_sums_live_instance_allocations() {
3165 let total = aggregate_aws_capacity(&[
3166 DeploymentCapacityUsage {
3167 cpu_percent: None,
3168 memory_used_bytes: 0,
3169 memory_total_bytes: 8,
3170 logical_cores: 2,
3171 disk_total_bytes: Some(100),
3172 },
3173 DeploymentCapacityUsage {
3174 cpu_percent: None,
3175 memory_used_bytes: 0,
3176 memory_total_bytes: 16,
3177 logical_cores: 4,
3178 disk_total_bytes: Some(200),
3179 },
3180 ])
3181 .unwrap();
3182
3183 assert_eq!(total.memory_total_bytes, 24);
3184 assert_eq!(total.logical_cores, 6);
3185 assert_eq!(total.disk_total_bytes, Some(300));
3186 }
3187
3188 #[test]
3194 fn a_failed_pull_is_reported_and_leaves_the_other_host_alone() {
3195 struct FailingPullExecutor {
3196 failing_image: String,
3197 commands: std::sync::Mutex<Vec<(String, Vec<String>)>>,
3198 }
3199
3200 impl CommandExecutor for FailingPullExecutor {
3201 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3202 self.commands
3203 .lock()
3204 .unwrap()
3205 .push((command.program.clone(), command.args.clone()));
3206 if command.args.contains(&"pull".to_owned())
3207 && command.args.contains(&self.failing_image)
3208 {
3209 return Ok(CommandOutput {
3210 status: 125,
3211 stdout: Vec::new(),
3212 stderr: b"short-name resolution failed".to_vec(),
3213 });
3214 }
3215 Ok(CommandOutput {
3216 status: 0,
3217 stdout: b"sha256:1111\n".to_vec(),
3218 stderr: Vec::new(),
3219 })
3220 }
3221 }
3222
3223 let failing_image = "ghcr.io/example/broken:latest";
3224 let broken = crate::targets::image_refresh(
3225 crate::targets::ImageHost::LocalPodman,
3226 failing_image,
3227 None,
3228 mj_core::config::ImagePullPolicy::Auto,
3229 )
3230 .expect("a remote latest image is refreshed");
3231 let healthy = crate::targets::image_refresh(
3232 crate::targets::ImageHost::LocalDocker,
3233 "ghcr.io/example/dev:latest",
3234 None,
3235 mj_core::config::ImagePullPolicy::Auto,
3236 )
3237 .expect("a remote latest image is refreshed");
3238 let executor = FailingPullExecutor {
3239 failing_image: failing_image.to_owned(),
3240 commands: std::sync::Mutex::new(Vec::new()),
3241 };
3242
3243 let reported = refresh_host_image(&broken, &executor)
3244 .expect_err("a failed pull has to reach the caller");
3245 let reported = format!("{reported:#}");
3246 assert!(
3247 reported.contains("short-name resolution failed"),
3248 "{reported}"
3249 );
3250 assert!(reported.contains(failing_image), "{reported}");
3251
3252 refresh_host_image(&healthy, &executor).expect("the second host still refreshes");
3253
3254 let commands = executor.commands.lock().unwrap();
3255 let ran = |program: &str, args: &[&str]| {
3256 commands
3257 .iter()
3258 .any(|(command, arguments)| command == program && arguments == args)
3259 };
3260 assert!(ran("podman", &["pull", failing_image]), "{commands:?}");
3261 assert!(
3262 !ran("podman", &["image", "prune", "-f"]),
3263 "a host that could not pull has nothing to prune: {commands:?}"
3264 );
3265 assert!(
3266 ran("docker", &["pull", "ghcr.io/example/dev:latest"]),
3267 "{commands:?}"
3268 );
3269 assert!(ran("docker", &["image", "prune", "-f"]), "{commands:?}");
3270 }
3271}