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