1use std::net::{Ipv4Addr, SocketAddr};
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result, bail};
11mod api;
12mod api_activity;
13
14use mj_core::config::{Config, HarnessProfile, PhoneConfig, is_bare_project_target};
15use mj_core::remote_git::{default_branch, display_url, resolve_repository};
16use mj_core::state::{MaterializedSession, ProjectSourceIdentity, SessionRecord, State};
17
18use crate::controller::Controller;
19use crate::quota::ProfileQuota;
20use crate::server::{
21 ActionOutcome, BackgroundTaskStopFailure, BackgroundTaskStopRequest, BrowserTranscript,
22 ControllerAction, ControllerRequest, MovePreparationRequest, PreflightFailure,
23 ReadReceiptRequest, ResumeQueueDisposition, ServerOptions, ViewerActivityDetails,
24 ViewerActivityKind, ViewerBackgroundTask, ViewerMoveRecovery, ViewerQueuedPrompt, ViewerQuota,
25 ViewerSnapshot, ViewerUserShell,
26};
27use crate::session_manager::{SessionManagerChannels, SessionManagerControl, new_command_id};
28use crate::tailscale::TailscaleTls;
29#[cfg(test)]
30use crate::targets::ProcessExecutor;
31use crate::targets::{CancellableProcessExecutor, CommandExecutor};
32use crate::worker_client::CredentialSyncCoordinator;
33use mj_core::relay::RelayCommand;
34use mj_core::workspace::WorkspaceRecord;
35
36use crate::controller::config_only_controller;
37use crate::daemon::{
38 CreateSessionControl, CreateSessionRequest, ResumeSessionRequest, RuntimeState,
39};
40use crate::pollers::{
41 CredentialSyncNotices, CredentialSyncSignalTracker, QUOTA_STALE_AFTER, QuotaRefreshBatch,
42 QuotaUpdate, apply_worker_record_update, credential_sync_targets, dashboard_worker_targets,
43 projected_queued_prompts, queued_prompt_projection, quota_refresh_profiles,
44 schedule_due_credential_syncs, spawn_quota_refresher,
45};
46
47#[derive(Debug, Clone)]
48pub struct ServerArgs {
49 bind: String,
50 tailscale_detect: bool,
51 tls_cert: Option<PathBuf>,
52 tls_key: Option<PathBuf>,
53}
54
55impl From<&PhoneConfig> for ServerArgs {
56 fn from(config: &PhoneConfig) -> Self {
57 Self {
58 bind: config.bind.clone(),
59 tailscale_detect: config.tailscale_detect,
60 tls_cert: config.tls_cert.clone(),
61 tls_key: config.tls_key.clone(),
62 }
63 }
64}
65
66const TAILSCALE_COMMAND_TIMEOUT: Duration = Duration::from_secs(120);
67const TAILSCALE_RENEW_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
68
69const MAX_CONCURRENT_CONVERSATION_PROJECTIONS: usize = 2;
74const CONVERSATION_PROJECTION_CHANNEL_CAPACITY: usize = 16;
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77struct ConversationProjectionKey {
78 ordinal: u64,
79 digest: String,
80}
81
82impl ConversationProjectionKey {
83 fn of(materialized: &MaterializedSession) -> Self {
84 Self {
85 ordinal: materialized.applied_event_ordinal,
86 digest: materialized.applied_event_digest.clone(),
87 }
88 }
89
90 fn is_newer_than(&self, other: &Self) -> bool {
94 self.ordinal > other.ordinal
95 || (self.ordinal == other.ordinal && self.digest != other.digest)
96 }
97}
98
99struct ConversationProjectionRequest {
100 materialized: MaterializedSession,
101 key: ConversationProjectionKey,
102 generation: u64,
103}
104
105struct ConversationProjectionResult {
106 session_id: String,
107 key: ConversationProjectionKey,
108 generation: u64,
109 result: std::result::Result<BrowserTranscript, String>,
110}
111
112struct ConversationProjectionDispatcher {
116 in_flight: std::collections::BTreeMap<String, (ConversationProjectionKey, u64)>,
117 pending: std::collections::BTreeMap<String, ConversationProjectionRequest>,
118 completed: std::collections::BTreeMap<String, ConversationProjectionKey>,
119 generations: std::collections::BTreeMap<String, u64>,
120 permits: Arc<tokio::sync::Semaphore>,
121 results: tokio::sync::mpsc::Sender<ConversationProjectionResult>,
122 shutdown: tokio_util::sync::CancellationToken,
123}
124
125impl ConversationProjectionDispatcher {
126 fn new(
127 results: tokio::sync::mpsc::Sender<ConversationProjectionResult>,
128 shutdown: tokio_util::sync::CancellationToken,
129 ) -> Self {
130 Self {
131 in_flight: std::collections::BTreeMap::new(),
132 pending: std::collections::BTreeMap::new(),
133 completed: std::collections::BTreeMap::new(),
134 generations: std::collections::BTreeMap::new(),
135 permits: Arc::new(tokio::sync::Semaphore::new(
136 MAX_CONCURRENT_CONVERSATION_PROJECTIONS,
137 )),
138 results,
139 shutdown,
140 }
141 }
142
143 #[cfg(test)]
144 fn with_permits(
145 results: tokio::sync::mpsc::Sender<ConversationProjectionResult>,
146 shutdown: tokio_util::sync::CancellationToken,
147 permits: usize,
148 ) -> Self {
149 let mut dispatcher = Self::new(results, shutdown);
150 dispatcher.permits = Arc::new(tokio::sync::Semaphore::new(permits));
151 dispatcher
152 }
153
154 fn enqueue(&mut self, materialized: MaterializedSession) {
157 let session_id = materialized.session_id.clone();
158 let key = ConversationProjectionKey::of(&materialized);
159 if self
160 .completed
161 .get(&session_id)
162 .is_some_and(|completed| !key.is_newer_than(completed))
163 {
164 return;
165 }
166 let generation = *self.generations.entry(session_id.clone()).or_default();
167 if let Some((in_flight, in_flight_generation)) = self.in_flight.get(&session_id) {
168 if generation != *in_flight_generation || key.is_newer_than(in_flight) {
169 let replace = self.pending.get(&session_id).is_none_or(|pending| {
170 pending.generation != generation || key.is_newer_than(&pending.key)
171 });
172 if replace {
173 self.pending.insert(
174 session_id,
175 ConversationProjectionRequest {
176 materialized,
177 key,
178 generation,
179 },
180 );
181 }
182 }
183 return;
184 }
185 self.in_flight
186 .insert(session_id.clone(), (key.clone(), generation));
187 self.start(ConversationProjectionRequest {
188 materialized,
189 key,
190 generation,
191 });
192 }
193
194 fn finish(
198 &mut self,
199 result: ConversationProjectionResult,
200 session_active: bool,
201 ) -> Option<(String, ConversationProjectionKey, BrowserTranscript)> {
202 let expected = self.in_flight.remove(&result.session_id);
203 if expected.as_ref() != Some(&(result.key.clone(), result.generation)) {
204 tracing::warn!(
205 session_id = %result.session_id,
206 "discarding an out-of-date browser transcript projection"
207 );
208 return None;
209 }
210 let session_id = result.session_id;
211 let key = result.key;
212 let current_generation = self
213 .generations
214 .get(&session_id)
215 .copied()
216 .unwrap_or_default();
217 let current = result.generation == current_generation;
218 let projected = match result.result {
219 Ok(transcript) if session_active && current => {
220 self.completed.insert(session_id.clone(), key.clone());
221 Some((session_id.clone(), key, transcript))
222 }
223 Ok(_) => {
224 self.completed.remove(&session_id);
228 None
229 }
230 Err(error) => {
231 tracing::warn!(
232 session_id = %session_id,
233 "browser transcript projection failed: {error}"
234 );
235 None
236 }
237 };
238 if session_active {
239 if let Some(pending) = self.pending.remove(&session_id) {
240 self.enqueue(pending.materialized);
241 }
242 } else {
243 self.pending.remove(&session_id);
244 self.completed.remove(&session_id);
245 }
246 projected
247 }
248
249 fn forget(&mut self, session_id: &str) {
253 self.pending.remove(session_id);
254 self.completed.remove(session_id);
255 let generation = self.generations.entry(session_id.to_owned()).or_default();
256 *generation = generation.wrapping_add(1);
257 }
258
259 fn session_ids(&self) -> std::collections::BTreeSet<String> {
260 self.in_flight
261 .keys()
262 .chain(self.pending.keys())
263 .chain(self.completed.keys())
264 .cloned()
265 .collect()
266 }
267
268 fn start(&self, request: ConversationProjectionRequest) {
269 let session_id = request.materialized.session_id.clone();
270 let key = request.key;
271 let generation = request.generation;
272 let permits = Arc::clone(&self.permits);
273 let results = self.results.clone();
274 let shutdown = self.shutdown.clone();
275 tokio::spawn(async move {
276 let result = match tokio::select! {
277 _ = shutdown.cancelled() => return,
278 result = permits.acquire_owned() => result,
279 } {
280 Ok(permit) => {
281 let projection = tokio::task::spawn_blocking(move || {
282 mj_client::transcript::materialized_browser_transcript(
283 &request.materialized,
284 )
285 })
286 .await;
287 drop(permit);
288 projection
289 .map_err(|error| format!("transcript projection task failed: {error}"))
290 }
291 Err(error) => Err(format!("transcript projection worker stopped: {error}")),
292 };
293 let message = ConversationProjectionResult {
294 session_id,
295 key,
296 generation,
297 result,
298 };
299 tokio::select! {
300 _ = shutdown.cancelled() => {}
301 result = results.send(message) => {
302 if let Err(error) = result {
303 tracing::debug!(%error, "browser transcript projection result dropped after server shutdown");
304 }
305 }
306 }
307 });
308 }
309}
310
311struct ResolvedServerArgs {
312 bind: SocketAddr,
313 viewer_url: String,
314 tls_files: Option<(PathBuf, PathBuf)>,
315 tailscale: Option<TailscaleTls>,
316 fallback_reason: Option<String>,
317}
318
319async fn resolve_server_args(
320 args: ServerArgs,
321 termination: tokio_util::sync::CancellationToken,
322) -> Result<ResolvedServerArgs> {
323 let configured_bind: SocketAddr = args.bind.parse().context("parse web viewer bind address")?;
324 match (args.tls_cert, args.tls_key) {
325 (Some(cert), Some(key)) => {
326 let scheme = "https";
327 return Ok(ResolvedServerArgs {
328 bind: configured_bind,
329 viewer_url: format!("{scheme}://{configured_bind}"),
330 tls_files: Some((cert, key)),
331 tailscale: None,
332 fallback_reason: None,
333 });
334 }
335 (None, None) => {}
336 _ => bail!("web viewer TLS requires both a certificate and private key"),
337 }
338
339 if !args.tailscale_detect {
340 return Ok(loopback_server_args(
341 configured_bind,
342 Some("automatic Tailscale detection is disabled".into()),
343 ));
344 }
345
346 let tls_root = mj_core::config::data_dir().join("viewer");
347 let prepared = run_tailscale_blocking(termination.clone(), move |executor| {
348 crate::tailscale::prepare_tailscale_tls(&tls_root, executor)
349 })
350 .await;
351 match prepared {
352 Ok(tailscale) => {
353 let bind = tailscale_bind(configured_bind);
354 let viewer_url = format!(
355 "https://{}:{}",
356 tailscale.cert_domain(),
357 configured_bind.port()
358 );
359 Ok(ResolvedServerArgs {
360 bind,
361 viewer_url,
362 tls_files: Some((
363 tailscale.cert_path().to_owned(),
364 tailscale.key_path().to_owned(),
365 )),
366 tailscale: Some(tailscale),
367 fallback_reason: None,
368 })
369 }
370 Err(error) if termination.is_cancelled() => Err(error),
371 Err(error) => {
372 let reason = format!("{error:#}");
373 tracing::debug!(error = reason, "Tailscale HTTPS unavailable for web viewer");
374 Ok(loopback_server_args(configured_bind, Some(reason)))
375 }
376 }
377}
378
379fn tailscale_bind(configured_bind: SocketAddr) -> SocketAddr {
380 SocketAddr::from((Ipv4Addr::UNSPECIFIED, configured_bind.port()))
381}
382
383fn loopback_server_args(bind: SocketAddr, fallback_reason: Option<String>) -> ResolvedServerArgs {
384 ResolvedServerArgs {
385 bind,
386 viewer_url: format!("http://{bind}"),
387 tls_files: None,
388 tailscale: None,
389 fallback_reason,
390 }
391}
392
393async fn run_tailscale_blocking<T>(
394 termination: tokio_util::sync::CancellationToken,
395 operation: impl FnOnce(&CancellableProcessExecutor) -> Result<T> + Send + 'static,
396) -> Result<T>
397where
398 T: Send + 'static,
399{
400 let cancelled = Arc::new(AtomicBool::new(false));
401 let executor_cancelled = cancelled.clone();
402 let mut task = tokio::task::spawn_blocking(move || {
403 let executor = CancellableProcessExecutor::new(executor_cancelled)
404 .with_deadline(TAILSCALE_COMMAND_TIMEOUT);
405 operation(&executor)
406 });
407 tokio::select! {
408 result = &mut task => result.context("Tailscale background task panicked")?,
409 _ = termination.cancelled() => {
410 cancelled.store(true, Ordering::Release);
411 let _ = task.await;
412 bail!("Tailscale operation cancelled during web viewer shutdown")
413 }
414 }
415}
416
417fn spawn_tailscale_cert_renewer(
418 tailscale: TailscaleTls,
419 rustls: axum_server::tls_rustls::RustlsConfig,
420 termination: tokio_util::sync::CancellationToken,
421) -> tokio::task::JoinHandle<()> {
422 tokio::spawn(async move {
423 let mut interval = tokio::time::interval(TAILSCALE_RENEW_INTERVAL);
424 interval.tick().await;
425 loop {
426 tokio::select! {
427 _ = termination.cancelled() => return,
428 _ = interval.tick() => {}
429 }
430 let renewing = tailscale.clone();
431 let result = run_tailscale_blocking(termination.clone(), move |executor| {
432 renewing.renew(executor)
433 })
434 .await;
435 if let Err(error) = result {
436 if !termination.is_cancelled() {
437 tracing::warn!(
438 error = format!("{error:#}"),
439 "Tailscale certificate renewal failed"
440 );
441 }
442 continue;
443 }
444 if let Err(error) = rustls
445 .reload_from_pem_file(tailscale.cert_path(), tailscale.key_path())
446 .await
447 {
448 tracing::warn!(%error, "could not activate renewed Tailscale certificate");
449 }
450 }
451 })
452}
453
454const MAX_CONCURRENT_PHONE_ACTIONS: usize = 4;
455const MAX_CONCURRENT_BUNDLE_CREATIONS: usize = 4;
456const MAX_CONCURRENT_PREFLIGHTS: usize = 4;
457
458struct PhoneActionStarted {
459 action_id: u64,
460 session: SessionRecord,
461 published: tokio::sync::oneshot::Sender<std::result::Result<(), String>>,
462}
463
464#[derive(Default)]
473struct PendingActionReplies(
474 std::collections::BTreeMap<u64, tokio::sync::oneshot::Sender<ActionOutcome>>,
475);
476
477impl PendingActionReplies {
478 fn accept(
479 &mut self,
480 action_id: u64,
481 action: &ControllerAction,
482 reply: tokio::sync::oneshot::Sender<ActionOutcome>,
483 ) {
484 if matches!(action, ControllerAction::New { .. }) {
485 self.0.insert(action_id, reply);
486 } else {
487 if reply.send(ActionOutcome::accepted()).is_err() {
488 tracing::debug!(
489 action_id,
490 "phone action acceptance reply dropped after client disconnect"
491 );
492 }
493 }
494 }
495
496 fn resolve(&mut self, action_id: u64, outcome: ActionOutcome) {
497 if let Some(reply) = self.0.remove(&action_id)
498 && reply.send(outcome).is_err()
499 {
500 tracing::debug!(
501 action_id,
502 "phone action completion reply dropped after client disconnect"
503 );
504 }
505 }
506}
507
508fn admit_phone_action(
512 action: &ControllerAction,
513 running_actions: usize,
514 active_sessions: &mut std::collections::BTreeSet<String>,
515) -> std::result::Result<Option<String>, ActionOutcome> {
516 let closing = matches!(
517 action,
518 ControllerAction::Close { .. } | ControllerAction::ForceClose { .. }
519 );
520 if !closing && !phone_action_capacity_available(running_actions) {
521 return Err(ActionOutcome::Busy);
522 }
523 let session_id = controller_action_session_id(action);
524 if let Some(session_id) = &session_id
525 && !active_sessions.insert(session_id.clone())
526 && !closing
527 {
528 return Err(ActionOutcome::SessionBusy);
529 }
530 Ok(session_id)
531}
532
533struct ReadReceiptPersisted {
534 session_id: String,
535 result: std::result::Result<u64, String>,
536 reply: tokio::sync::oneshot::Sender<std::result::Result<(), String>>,
537}
538
539struct ControllerReloaded {
540 result: std::result::Result<Controller, String>,
541}
542
543struct BundleCreated {
544 result: std::result::Result<
545 crate::controller::QuickBundleCreation,
546 crate::controller::QuickBundleFailure,
547 >,
548 reply: tokio::sync::oneshot::Sender<std::result::Result<String, crate::server::BundleFailure>>,
549}
550
551struct MovePrepared {
552 result: std::result::Result<mj_core::state::MovePreparation, String>,
553 reply:
554 tokio::sync::oneshot::Sender<std::result::Result<mj_core::state::MovePreparation, String>>,
555}
556
557fn spawn_controller_reload(completed: tokio::sync::mpsc::UnboundedSender<ControllerReloaded>) {
561 spawn_controller_reload_with(completed, Controller::load);
562}
563
564fn spawn_controller_reload_with(
565 completed: tokio::sync::mpsc::UnboundedSender<ControllerReloaded>,
566 load: impl FnOnce() -> Result<Controller> + Send + 'static,
567) {
568 tokio::spawn(async move {
569 let result = match tokio::task::spawn_blocking(load).await {
570 Ok(result) => result.map_err(|error| format!("{error:#}")),
571 Err(error) => Err(format!("controller reload task failed: {error}")),
572 };
573 if completed.send(ControllerReloaded { result }).is_err() {
574 tracing::debug!("controller reload completed after the phone control loop stopped");
575 }
576 });
577}
578
579fn request_controller_reload(
580 in_flight: &mut bool,
581 requested: &mut bool,
582 completed: &tokio::sync::mpsc::UnboundedSender<ControllerReloaded>,
583) {
584 if *in_flight {
585 *requested = true;
586 } else {
587 *in_flight = true;
588 spawn_controller_reload(completed.clone());
589 }
590}
591
592fn request_daemon_controller_reload(daemon_runtime: Arc<RuntimeState>, reason: &'static str) {
593 tokio::spawn(async move {
594 if let Err(error) = daemon_runtime.reload_controller().await {
595 tracing::warn!(
596 error = format!("{error:#}"),
597 reason,
598 "phone operation could not refresh dashboard controller state"
599 );
600 }
601 });
602}
603
604#[derive(Debug, PartialEq, Eq)]
606#[cfg(test)]
607enum ReadReceiptPlan {
608 UnknownSession,
609 AlreadyRead,
611 Persist,
613}
614
615#[cfg(test)]
616fn plan_read_receipt(state: &State, session_id: &str, through: u64) -> ReadReceiptPlan {
617 let Some(session) = state.sessions.get(session_id) else {
618 return ReadReceiptPlan::UnknownSession;
619 };
620 if through > session.viewed_through_event_ordinal {
621 ReadReceiptPlan::Persist
622 } else {
623 ReadReceiptPlan::AlreadyRead
624 }
625}
626
627#[cfg(test)]
631fn apply_read_receipt(state: &mut State, session_id: &str, receipt: u64) -> bool {
632 let Some(session) = state.sessions.get_mut(session_id) else {
633 return false;
634 };
635 if receipt <= session.viewed_through_event_ordinal {
636 return false;
637 }
638 session.viewed_through_event_ordinal = receipt;
639 true
640}
641
642#[derive(Clone)]
643struct PhoneActionControl {
644 cancelled: Arc<AtomicBool>,
645 create: Option<CreateSessionControl>,
646}
647
648impl PhoneActionControl {
649 fn for_action(action: &ControllerAction) -> Self {
650 let create =
651 matches!(action, ControllerAction::New { .. }).then(CreateSessionControl::default);
652 let cancelled = create.as_ref().map_or_else(
653 || Arc::new(AtomicBool::new(false)),
654 |control| control.cancelled.clone(),
655 );
656 Self { cancelled, create }
657 }
658
659 fn request_cancel(&self) -> bool {
660 let accepted = self.create.as_ref().map_or_else(
661 || {
662 self.cancelled
663 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
664 .is_ok()
665 },
666 |control| control.request_cancel(),
667 );
668 if accepted {
669 self.cancelled.store(true, Ordering::Release);
670 }
671 accepted
672 }
673
674 #[cfg(test)]
675 fn grant_new_commit(&self) -> bool {
676 self.create
677 .as_ref()
678 .is_some_and(|control| control.grant_commit())
679 }
680}
681
682pub async fn run_server(
683 args: ServerArgs,
684 termination: tokio_util::sync::CancellationToken,
685 worker: SessionManagerChannels,
686 daemon_runtime: Arc<RuntimeState>,
687 mut workspace_updates: tokio::sync::watch::Receiver<Vec<WorkspaceRecord>>,
688) -> Result<()> {
689 let resolved = resolve_server_args(args, termination.clone()).await?;
690 let bind = resolved.bind;
691 let mut controller = Controller::load()?;
692 let mut daemon_revisions = daemon_runtime.revisions();
693 daemon_revisions.borrow_and_update();
694 let mut phone_workspaces = workspace_updates.borrow_and_update().clone();
695 let mut quotas = std::collections::BTreeMap::new();
696 let subagent_quota_reports = Arc::new(std::sync::Mutex::new(quotas.clone()));
697 let (quota_profiles_tx, mut quota_updates_rx) = spawn_quota_refresher();
698 let mut quota_batch = QuotaRefreshBatch::default();
699 let mut published_quota_profiles = std::collections::BTreeMap::new();
700 republish_quota_profiles(
701 &controller,
702 &mut published_quota_profiles,
703 &mut quota_batch,
704 "a_profiles_tx,
705 );
706 let mut revision = daemon_runtime.allocate_revision();
707 let mut conversations = std::collections::BTreeMap::new();
708 let mut queued_prompts = projected_queued_prompts(&controller)?;
709 let mut active_user_shells = std::collections::BTreeMap::new();
710 let mut pending_elicitations = std::collections::BTreeMap::new();
711 let mut prompt_images = std::collections::BTreeSet::new();
712 let mut operational = std::collections::BTreeMap::new();
713 let mut materialized_activity = load_materialized_activity(&controller).await?;
714 let mut project_sources = PhoneProjectSources::default();
715 let (records, lifecycles) = daemon_runtime.session_projection();
716 controller.state.sessions = records;
717 let mut operations = lifecycles
718 .iter()
719 .map(|view| (view.session_id.clone(), viewer_operation(view)))
720 .collect::<std::collections::BTreeMap<_, _>>();
721 let mut move_recoveries = ViewerMoveRecoveries::new();
722 let (move_recovery_tx, mut move_recovery_rx) =
723 tokio::sync::mpsc::unbounded_channel::<Result<ViewerMoveRecoveries, String>>();
724 let mut move_recovery_load_in_flight = false;
725 let mut launch_failures = Vec::new();
726 let mut capacity_state: std::collections::BTreeMap<String, PhoneCapacity> =
730 std::collections::BTreeMap::new();
731 let (capacity_targets_tx, capacity_triggers_tx, mut capacity_updates_rx) =
732 crate::pollers::spawn_dashboard_capacity_poller();
733 let (snapshot_tx, snapshot_rx) = tokio::sync::watch::channel(viewer_snapshot(
734 &controller,
735 &phone_workspaces,
736 "as,
737 &PhoneSessionViews {
738 conversations: &conversations,
739 queued_prompts: &queued_prompts,
740 active_user_shells: &active_user_shells,
741 pending_elicitations: &pending_elicitations,
742 prompt_images: &prompt_images,
743 operational: &operational,
744 materialized_activity: &materialized_activity,
745 project_sources: &project_sources,
746 operations: &operations,
747 move_recoveries: &move_recoveries,
748 capacity: &viewer_capacity(&capacity_state),
749 launch_failures: &launch_failures,
750 reviews: &review_views(&daemon_runtime),
751 },
752 revision,
753 ));
754 let (conversation_tx, conversation_rx) = tokio::sync::watch::channel(conversations.clone());
755 let (action_tx, mut action_rx) = tokio::sync::mpsc::channel(32);
756 let (bundle_tx, mut bundle_rx) = tokio::sync::mpsc::channel(16);
757 let (receipt_tx, mut receipt_rx) = tokio::sync::mpsc::channel(32);
758 let (preflight_tx, mut preflight_rx) = tokio::sync::mpsc::channel(32);
759 let (move_preparation_tx, mut move_preparation_rx) = tokio::sync::mpsc::channel(32);
760 let (client_state_tx, mut client_state_rx) = tokio::sync::mpsc::channel(64);
761 let (dictation_tx, mut dictation_rx) =
762 tokio::sync::mpsc::channel::<crate::dictation::DictationRequest>(8);
763 let (background_task_stop_tx, mut background_task_stop_rx) =
764 tokio::sync::mpsc::channel::<BackgroundTaskStopRequest>(32);
765 let SessionManagerChannels {
766 targets: worker_targets_tx,
767 control: worker_commands_tx,
768 updates: mut worker_updates_rx,
769 shutdown: worker_shutdown,
770 } = worker;
771 worker_targets_tx.send_replace(dashboard_worker_targets(&controller));
772 publish_capacity_targets(&controller, &capacity_targets_tx, &mut capacity_state);
773 let mut credential_sync = CredentialSyncCoordinator::spawn();
774 let credential_sync_handle = credential_sync.handle();
775 credential_sync_handle.set_targets(credential_sync_targets(&controller));
776 let mut credential_sync_signals = CredentialSyncSignalTracker::default();
777 let mut credential_sync_notices = CredentialSyncNotices::default();
778 let options_session_ttl = crate::server::default_session_ttl();
780 let activity_snapshots = snapshot_rx.clone();
781 let mut options = ServerOptions::new(
782 bind,
783 snapshot_rx,
784 conversation_rx,
785 crate::server::ServerRequests {
786 action_tx,
787 bundle_tx,
788 receipt_tx,
789 preflight_tx,
790 move_preparation_tx,
791 client_state_tx,
792 dictation_tx,
793 },
794 )?;
795 options.set_background_task_stop_tx(background_task_stop_tx);
796 options.shutdown = termination.clone();
797 let cookie_key_path = crate::server::cookie_key_path();
801 options.set_cookie_key(crate::server::load_or_create_cookie_key(&cookie_key_path)?)?;
802 options.set_api_token(crate::server::load_or_create_api_token(
805 &crate::server::api_token_path(),
806 )?);
807 let api_runtime = daemon_runtime.clone();
808 let api_backend = Arc::new(
809 api::ApiBackend::new(
810 worker_commands_tx.client(),
811 Arc::new(move |session_id: &str| api_runtime.session_state(session_id)),
812 daemon_runtime.clone(),
813 )
814 .with_quota_reports(subagent_quota_reports.clone()),
815 );
816 options.set_subagent_backend(api_backend.clone());
817 let renewal_cancellation = termination.child_token();
818 let mut renewal_task = None;
819 if let Some((cert, key)) = resolved.tls_files {
820 let rustls = axum_server::tls_rustls::RustlsConfig::from_pem_file(cert, key)
821 .await
822 .context("load web viewer TLS certificate")?;
823 options.set_tls_config(rustls.clone());
824 if let Some(tailscale) = resolved.tailscale {
825 renewal_task = Some(spawn_tailscale_cert_renewer(
826 tailscale,
827 rustls,
828 renewal_cancellation.clone(),
829 ));
830 }
831 } else if bind.ip().is_loopback() {
832 options.secure_cookie = false;
833 } else {
834 anyhow::bail!("non-loopback web viewer requires TLS");
835 }
836 let fallback_reason = resolved.fallback_reason;
837 let qr_login_url = if fallback_reason.is_none() && resolved.viewer_url.starts_with("https://") {
838 let encoded = url::form_urlencoded::byte_serialize(options.login_token().as_bytes())
839 .collect::<String>();
840 Some(format!(
841 "{}/auth/login?token={encoded}",
842 resolved.viewer_url.trim_end_matches('/')
843 ))
844 } else {
845 None
846 };
847 let ready = crate::server::WebViewerAccess::Ready {
848 viewer_url: resolved.viewer_url,
849 viewer_code: options.viewer_code().to_owned(),
850 qr_login_url,
851 fallback_reason,
852 };
853
854 let serve = crate::web_viewer::serve(options, ready, &daemon_runtime.web_viewer, |access| {
855 daemon_runtime.publish_web_access(access);
856 });
857 let conversation_projection_shutdown = termination.child_token();
858 let control = async {
859 let mut credential_tick = tokio::time::interval(Duration::from_millis(250));
860 let mut prune_tick = tokio::time::interval(Duration::from_secs(60 * 60));
864 let client_state_retention = options_session_ttl;
865 let (action_done_tx, mut action_done_rx) = tokio::sync::mpsc::unbounded_channel::<(
866 u64,
867 Option<String>,
868 std::result::Result<(), String>,
869 )>();
870 let (action_started_tx, mut action_started_rx) =
871 tokio::sync::mpsc::unbounded_channel::<PhoneActionStarted>();
872 let (receipt_done_tx, mut receipt_done_rx) =
873 tokio::sync::mpsc::unbounded_channel::<ReadReceiptPersisted>();
874 let (controller_reload_tx, mut controller_reload_rx) =
875 tokio::sync::mpsc::unbounded_channel::<ControllerReloaded>();
876 let (bundle_done_tx, mut bundle_done_rx) =
877 tokio::sync::mpsc::unbounded_channel::<BundleCreated>();
878 let (move_prepared_tx, mut move_prepared_rx) =
879 tokio::sync::mpsc::unbounded_channel::<MovePrepared>();
880 let mut dictation_jobs = tokio::task::JoinSet::new();
881 let mut bundle_jobs = tokio::task::JoinSet::new();
882 let mut preflight_jobs = tokio::task::JoinSet::new();
883 let mut move_preparation_jobs = tokio::task::JoinSet::new();
884 let mut move_recovery_jobs = tokio::task::JoinSet::new();
885 let mut background_task_stop_jobs = tokio::task::JoinSet::new();
886 let mut background_task_stop_open = true;
887 let mut controller_reload_in_flight = false;
888 let mut controller_reload_requested = false;
889 let mut controller_reload_invalidated = false;
890 let mut pending_action_errors = std::collections::BTreeMap::<String, String>::new();
891 let mut active_actions = std::collections::BTreeSet::new();
892 let mut closing_actions = std::collections::BTreeMap::<String, u64>::new();
893 let mut next_action_id = 0_u64;
894 let mut action_cancellations = std::collections::BTreeMap::<u64, PhoneActionControl>::new();
895 let mut action_sessions = std::collections::BTreeMap::<u64, String>::new();
896 let mut action_replies = PendingActionReplies::default();
897 let mut launch_workspaces = std::collections::BTreeMap::new();
898 let mut subagent_jobs = tokio::task::JoinSet::new();
899 let mut subagent_completion_jobs = tokio::task::JoinSet::new();
900 let mut active_subagent_requests = std::collections::BTreeSet::new();
901 let (conversation_projection_tx, mut conversation_projection_rx) =
902 tokio::sync::mpsc::channel(CONVERSATION_PROJECTION_CHANNEL_CAPACITY);
903 let mut conversation_projections = ConversationProjectionDispatcher::new(
904 conversation_projection_tx,
905 conversation_projection_shutdown.clone(),
906 );
907 let mut quota_updates_open = true;
908 let mut failure: Option<anyhow::Error> = None;
912 request_move_recovery_reload(
913 &move_recovery_tx,
914 &mut move_recovery_load_in_flight,
915 &mut move_recovery_jobs,
916 );
917 macro_rules! publish_snapshot {
918 ($revision:expr) => {
919 let (records, lifecycles) = daemon_runtime.session_projection();
920 controller.state.sessions = records;
921 for (session_id, error) in &pending_action_errors {
922 if let Some(session) = controller.state.sessions.get_mut(session_id)
923 && session.last_error.is_none()
924 {
925 session.last_error = Some(error.clone());
926 }
927 }
928 operations = lifecycles.iter()
929 .map(|view| (view.session_id.clone(), viewer_operation(view)))
930 .collect();
931 if let Err(error) = snapshot_tx.send(viewer_snapshot(
932 &controller,
933 &phone_workspaces,
934 "as,
935 &PhoneSessionViews {
936 conversations: &conversations,
937 queued_prompts: &queued_prompts,
938 active_user_shells: &active_user_shells,
939 pending_elicitations: &pending_elicitations,
940 prompt_images: &prompt_images,
941 operational: &operational,
942 materialized_activity: &materialized_activity,
943 project_sources: &project_sources,
944 operations: &operations,
945 move_recoveries: &move_recoveries,
946 capacity: &viewer_capacity(&capacity_state),
947 launch_failures: &launch_failures,
948 reviews: &review_views(&daemon_runtime),
949 },
950 $revision,
951 )) {
952 tracing::debug!(revision = $revision, %error, "phone snapshot delivery failed; no viewer is subscribed");
953 }
954 };
955 }
956 loop {
957 project_sources.synchronize(&controller);
958 tokio::select! {
959 _ = termination.cancelled() => break,
960 request = background_task_stop_rx.recv(), if background_task_stop_open => {
961 let Some(request) = request else {
962 background_task_stop_open = false;
963 tracing::warn!("background-task stop request feed closed while the phone server was running");
964 continue;
965 };
966 let session_control = worker_commands_tx.clone();
967 background_task_stop_jobs.spawn(async move {
968 let result = match session_control.session(&request.session_id).await {
969 Ok(session) => session
970 .client()
971 .stop_background_task(request.background_task_id.clone())
972 .await
973 .map_err(|error| {
974 tracing::warn!(
975 session_id = %request.session_id,
976 background_task_id = %request.background_task_id,
977 %error,
978 "provider rejected background-task stop"
979 );
980 BackgroundTaskStopFailure::Provider
981 }),
982 Err(error) => {
983 tracing::warn!(
984 session_id = %request.session_id,
985 %error,
986 "could not resolve live session for background-task stop"
987 );
988 Err(BackgroundTaskStopFailure::SessionUnavailable)
989 }
990 };
991 if request.reply.send(result).is_err() {
992 tracing::debug!(
993 session_id = %request.session_id,
994 background_task_id = %request.background_task_id,
995 "background-task stop result dropped after viewer disconnected"
996 );
997 }
998 });
999 }
1000 completed = background_task_stop_jobs.join_next(), if !background_task_stop_jobs.is_empty() => {
1001 if let Some(Err(error)) = completed {
1002 tracing::error!(%error, "background-task stop task failed unexpectedly");
1003 }
1004 }
1005 move_reloaded = move_recovery_rx.recv() => {
1006 let Some(result) = move_reloaded else {
1007 failure = feed_stopped(
1008 termination.is_cancelled(),
1009 "the Move recovery projection stopped while the phone server was running",
1010 );
1011 break;
1012 };
1013 move_recovery_load_in_flight = false;
1014 match result {
1015 Ok(recoveries) => {
1016 move_recoveries = recoveries;
1017 revision = daemon_runtime.allocate_revision();
1018 publish_snapshot!(revision);
1019 }
1020 Err(error) => tracing::warn!(%error, "could not refresh Move recovery projection"),
1021 }
1022 }
1023 resolved = project_sources.jobs.join_next(), if !project_sources.jobs.is_empty() => {
1024 match resolved {
1025 Some(Ok(resolved)) => project_sources.complete(resolved),
1026 Some(Err(error)) => {
1027 failure = Some(anyhow::anyhow!("web project source task failed: {error}"));
1028 break;
1029 }
1030 None => unreachable!("project source jobs were not empty"),
1031 }
1032 revision = daemon_runtime.allocate_revision();
1033 publish_snapshot!(revision);
1034 }
1035 changed = daemon_revisions.changed() => {
1036 if changed.is_err() {
1037 failure = feed_stopped(
1038 termination.is_cancelled(),
1039 "the daemon stopped publishing runtime revisions to the phone server",
1040 );
1041 break;
1042 }
1043 daemon_revisions.borrow_and_update();
1044 revision = daemon_runtime.allocate_revision();
1045 publish_snapshot!(revision);
1046 request_controller_reload(
1047 &mut controller_reload_in_flight,
1048 &mut controller_reload_requested,
1049 &controller_reload_tx,
1050 );
1051 }
1052 changed = workspace_updates.changed() => {
1053 if changed.is_err() {
1054 failure = feed_stopped(
1055 termination.is_cancelled(),
1056 "the daemon stopped publishing workspaces to the phone server",
1057 );
1058 break;
1059 }
1060 phone_workspaces = workspace_updates.borrow_and_update().clone();
1061 revision = daemon_runtime.allocate_revision();
1062 publish_snapshot!(revision);
1063 }
1064 update = capacity_updates_rx.recv() => {
1065 let Some(update) = update else {
1066 failure = feed_stopped(termination.is_cancelled(), "the capacity poller stopped while the phone server was running");
1067 break;
1068 };
1069 if let Some(entry) = capacity_state.get_mut(&update.target_id) {
1070 entry.refreshing = false;
1071 entry.sampled_at_epoch_seconds = Some(update.sampled_at_epoch_seconds);
1072 match update.result {
1073 Ok(usage) => {
1074 entry.on_demand = usage.is_none();
1078 entry.usage = usage;
1079 entry.failed = false;
1080 }
1081 Err(_) => entry.failed = true,
1085 }
1086 }
1087 revision = daemon_runtime.allocate_revision();
1088 publish_snapshot!(revision);
1089 }
1090 update = quota_updates_rx.recv(), if quota_updates_open => {
1091 match update {
1092 Some(QuotaUpdate::Report(outcome)) => {
1093 if outcome.credentials_changed {
1094 credential_sync_handle
1095 .sync_profile_now(&outcome.report.profile_id, None);
1096 }
1097 quotas.insert(outcome.report.profile_id.clone(), outcome.report.clone());
1098 subagent_quota_reports
1099 .lock()
1100 .expect("sub-agent quota reports lock poisoned")
1101 .insert(outcome.report.profile_id.clone(), outcome.report);
1102 revision = daemon_runtime.allocate_revision();
1103 publish_snapshot!(revision);
1104 }
1105 Some(QuotaUpdate::Refreshing { .. } | QuotaUpdate::Finished { .. }) => {}
1106 None => {
1107 quota_updates_open = false;
1108 tracing::warn!("quota refresher stopped while the phone server is running");
1109 }
1110 }
1111 }
1112 projected = conversation_projection_rx.recv() => {
1113 let Some(projected) = projected else {
1114 failure = feed_stopped(
1115 termination.is_cancelled(),
1116 "the browser transcript projection feed stopped",
1117 );
1118 break;
1119 };
1120 let session_id = projected.session_id.clone();
1121 let session_active = controller
1122 .state
1123 .sessions
1124 .get(&session_id)
1125 .is_some_and(|session| session.state.is_active());
1126 if !session_active {
1127 conversation_projections.forget(&session_id);
1132 }
1133 if let Some((session_id, _key, transcript)) =
1134 conversation_projections.finish(projected, session_active)
1135 {
1136 conversations.insert(session_id, transcript);
1137 revision = daemon_runtime.allocate_revision();
1138 conversation_tx.send_replace(conversations.clone());
1139 publish_snapshot!(revision);
1140 } else if !session_active && conversations.remove(&session_id).is_some() {
1141 revision = daemon_runtime.allocate_revision();
1146 conversation_tx.send_replace(conversations.clone());
1147 publish_snapshot!(revision);
1148 }
1149 tokio::task::yield_now().await;
1153 }
1154 update = worker_updates_rx.recv() => {
1155 let Some(update) = update else {
1156 failure = feed_stopped(termination.is_cancelled(), "the session manager stopped; the phone server can no longer follow sessions");
1157 break;
1158 };
1159 if let Some(snapshot) = update.view.snapshot.as_ref()
1160 && let Some(session) = controller.state.sessions.get(&update.session_id)
1161 && let Some(signal) = snapshot.latest_credential_sync_signal.clone()
1162 {
1163 credential_sync_signals.observe(
1164 &update.session_id,
1165 &session.last_profile,
1166 signal,
1167 );
1168 }
1169 schedule_due_credential_syncs(
1170 &mut credential_sync_signals,
1171 &credential_sync_handle,
1172 Instant::now(),
1173 );
1174 apply_worker_record_update(&mut controller, &update);
1175 if let Some(snapshot) = update.view.snapshot {
1176 for request in snapshot.subagent_requests.iter().cloned() {
1177 let identity = (update.session_id.clone(), request.request_id.clone());
1178 if !active_subagent_requests.insert(identity.clone()) {
1179 continue;
1180 }
1181 let backend = api_backend.clone();
1182 let runtime = daemon_runtime.clone();
1183 let parent_session_id = update.session_id.clone();
1184 subagent_jobs.spawn(async move {
1185 let result = backend
1186 .execute_subagent_tool(parent_session_id.clone(), request)
1187 .await;
1188 let outcome = async {
1189 let handle = runtime
1194 .workspace_session_handle(&parent_session_id)
1195 .await?;
1196 let mut lease = handle.lease_connection().await?;
1197 lease
1198 .connection_mut()
1199 .complete_subagent_request(result)
1200 .await?;
1201 lease.release();
1202 anyhow::Ok(())
1203 }
1204 .await;
1205 (identity, outcome)
1206 });
1207 }
1208 if let Some(relation) = controller.state.subagents.get_mut(&update.session_id)
1209 && matches!(snapshot.materialized.execution, mj_core::state::MaterializedExecutionState::Idle)
1210 && let Some(outcome) = snapshot.materialized.last_turn_outcome.as_ref()
1211 && relation.noticed_turn != Some(outcome.completed_ordinal)
1212 {
1213 let turn = outcome.completed_ordinal;
1214 let child_id = relation.child_session_id.clone();
1215 let parent_id = relation.parent_session_id.clone();
1216 let task_name = relation.task_name.clone();
1217 let outcome_name = format!("{:?}", outcome.outcome).to_lowercase();
1218 relation.noticed_turn = Some(turn);
1219 let backend = api_backend.clone();
1220 subagent_completion_jobs.spawn(async move {
1221 let result = async {
1222 backend
1223 .record_subagent_completion_notice(
1224 parent_id,
1225 &child_id,
1226 &task_name,
1227 turn,
1228 &outcome_name,
1229 )
1230 .await?;
1231 tokio::task::spawn_blocking({
1232 let child_id = child_id.clone();
1233 move || crate::database::mark_subagent_turn_noticed(&child_id, turn)
1234 })
1235 .await??;
1236 anyhow::Ok(())
1237 }
1238 .await;
1239 (child_id, turn, result)
1240 });
1241 }
1242 if snapshot.operational.native_session_is_ready()
1243 && operational.get(&update.session_id).is_none_or(|old: &mj_core::relay::RelayOperationalState| old.config_options != snapshot.operational.config_options)
1244 && let Some(session) = controller.state.sessions.get(&update.session_id)
1245 && matches!(session.target, Some(mj_core::state::TargetLocator::LocalBare { .. } | mj_core::state::TargetLocator::SshBare { .. } | mj_core::state::TargetLocator::AwsEc2 { .. }))
1246 && let Some(build) = snapshot.worker_build.clone()
1247 {
1248 let profile = session.last_profile.clone();
1249 let state = snapshot.operational.clone();
1250 tokio::spawn(async move {
1251 if let Err(error) = crate::controller::profile_config::observe(profile, build, state).await {
1252 tracing::warn!(%error, "could not cache observed profile choices");
1253 }
1254 });
1255 }
1256 let materialized = snapshot.materialized;
1257 let operational_state = snapshot.operational;
1258 materialized_activity.insert(
1259 update.session_id.clone(),
1260 materialized.last_activity_at_ms,
1261 );
1262 let queued = queued_prompt_projection(&materialized);
1263 let pending = materialized.pending_elicitations.clone();
1264 let active_shells = operational_state.active_user_shells.clone();
1265 let prompt_images_supported =
1266 agent_accepts_prompt_images(&operational_state);
1267 active_user_shells.insert(
1268 update.session_id.clone(),
1269 active_shells,
1270 );
1271 conversation_projections.enqueue(materialized);
1272 queued_prompts.insert(
1273 update.session_id.clone(),
1274 queued,
1275 );
1276 pending_elicitations.insert(
1277 update.session_id.clone(),
1278 pending,
1279 );
1280 if prompt_images_supported {
1281 prompt_images.insert(update.session_id.clone());
1282 } else {
1283 prompt_images.remove(&update.session_id);
1284 }
1285 operational.insert(
1286 update.session_id.clone(),
1287 operational_state,
1288 );
1289 revision = daemon_runtime.allocate_revision();
1290 conversation_tx.send_replace(conversations.clone());
1291 publish_snapshot!(revision);
1292 }
1293 tokio::task::yield_now().await;
1298 }
1299 completed = subagent_jobs.join_next(), if !subagent_jobs.is_empty() => {
1300 match completed {
1301 Some(Ok((identity, Ok(())))) => {
1302 active_subagent_requests.remove(&identity);
1303 }
1304 Some(Ok((identity, Err(error)))) => {
1305 active_subagent_requests.remove(&identity);
1306 tracing::warn!(
1307 parent_session_id = %identity.0,
1308 request_id = %identity.1,
1309 error = %format!("{error:#}"),
1310 "sub-agent tool request failed"
1311 );
1312 }
1313 Some(Err(error)) => tracing::warn!(%error, "sub-agent tool task panicked"),
1314 None => {}
1315 }
1316 }
1317 completed = subagent_completion_jobs.join_next(), if !subagent_completion_jobs.is_empty() => {
1318 match completed {
1319 Some(Ok((_, _, Ok(())))) => {}
1320 Some(Ok((child_id, turn, Err(error)))) => {
1321 if let Some(relation) = controller.state.subagents.get_mut(&child_id)
1322 && relation.noticed_turn == Some(turn)
1323 {
1324 relation.noticed_turn = None;
1325 }
1326 tracing::warn!(%child_id, turn, error = %format!("{error:#}"), "could not record the sub-agent completion notice");
1327 }
1328 Some(Err(error)) => tracing::warn!(%error, "sub-agent completion task panicked"),
1329 None => {}
1330 }
1331 }
1332 _ = prune_tick.tick() => {
1333 tokio::spawn(async move {
1337 let pruned = tokio::task::spawn_blocking(move || {
1338 crate::database::prune_phone_client_state(client_state_retention)
1339 })
1340 .await;
1341 match pruned {
1342 Ok(Ok(0)) => {}
1343 Ok(Ok(rows)) => tracing::debug!(rows, "pruned expired phone viewer state"),
1344 Ok(Err(error)) => tracing::warn!(%error, "could not prune phone viewer state"),
1345 Err(error) => tracing::warn!(%error, "phone viewer state pruning task failed"),
1346 }
1347 });
1348 }
1349 _ = credential_tick.tick() => {
1350 schedule_due_credential_syncs(
1351 &mut credential_sync_signals,
1352 &credential_sync_handle,
1353 Instant::now(),
1354 );
1355 while let Some(result) = credential_sync.try_result() {
1356 crate::pollers::log_credential_sync_actions(&result);
1357 let harness = controller
1358 .config
1359 .profiles
1360 .get(&result.profile_id)
1361 .map(|profile| profile.kind);
1362 if let Some(notice) = credential_sync_notices.notice(&result, harness) {
1363 eprintln!("Mjolnir: {notice}");
1364 }
1365 }
1366 }
1367 request = dictation_rx.recv() => {
1368 let Some(request) = request else {
1369 failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering dictation requests");
1370 break;
1371 };
1372 let paths = controller.state.sessions.get(&request.session_id).map(|session| {
1373 crate::dictation::auth_paths(&controller.config, &session.last_profile)
1374 });
1375 dictation_jobs.spawn(crate::dictation::execute(
1376 request, paths, termination.clone(),
1377 ));
1378 }
1379 job = dictation_jobs.join_next(), if !dictation_jobs.is_empty() => {
1380 if let Some(Err(error)) = job {
1381 tracing::warn!(%error, "web dictation task failed");
1382 }
1383 }
1384 stored = client_state_rx.recv() => {
1385 let Some(stored) = stored else {
1386 failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering viewer state requests");
1387 break;
1388 };
1389 let workspace_of = |session_id: &str| {
1393 controller
1394 .state
1395 .sessions
1396 .get(session_id)
1397 .map(|session| session.workspace_id.clone())
1398 };
1399 let bundle_of = |session_id: &str| {
1400 controller
1401 .state
1402 .sessions
1403 .get(session_id)
1404 .map(|session| session.bundle_id.clone())
1405 };
1406 match stored {
1407 crate::server::ClientStateRequest::Read { client_id, session_id, reply } => {
1408 let workspace = workspace_of(&session_id);
1409 tokio::spawn(async move {
1410 let answer = tokio::task::spawn_blocking(move || {
1411 let workspace = workspace.context("unknown session")?;
1412 let state = crate::database::client_session_state(
1413 &client_id, &workspace, &session_id,
1414 )?;
1415 anyhow::Ok(crate::server::ViewerClientState {
1416 draft: state.draft,
1417 through_event_ordinal: state.through_event_ordinal,
1418 })
1419 })
1420 .await;
1421 reply.send(flatten_stored(answer)).ok();
1422 });
1423 }
1424 crate::server::ClientStateRequest::SaveDraft { client_id, session_id, draft, reply } => {
1425 let workspace = workspace_of(&session_id);
1426 tokio::spawn(async move {
1427 let answer = tokio::task::spawn_blocking(move || {
1428 let workspace = workspace.context("unknown session")?;
1429 crate::database::persist_client_draft(
1430 &client_id, &workspace, &session_id, &draft,
1431 )
1432 })
1433 .await;
1434 reply.send(flatten_stored(answer)).ok();
1435 });
1436 }
1437 crate::server::ClientStateRequest::MarkWorkspaceRead { client_id, workspace_id, reply } => {
1438 let sessions = controller
1439 .state
1440 .sessions
1441 .values()
1442 .filter(|session| session.workspace_id == workspace_id)
1443 .map(|session| (session.id.clone(), session.viewed_through_event_ordinal))
1444 .collect::<Vec<_>>();
1445 tokio::spawn(async move {
1446 let answer = tokio::task::spawn_blocking(move || {
1447 for (session_id, through) in sessions {
1448 crate::database::persist_read_receipt(
1452 &client_id, &workspace_id, &session_id, through,
1453 )
1454 .ok();
1455 }
1456 anyhow::Ok(())
1457 })
1458 .await;
1459 reply.send(flatten_stored(answer)).ok();
1460 });
1461 }
1462 crate::server::ClientStateRequest::History { session_id, query, scope, reply } => {
1463 let bundle = bundle_of(&session_id);
1464 tokio::spawn(async move {
1465 let answer = tokio::task::spawn_blocking(move || {
1466 let bundle = bundle.context("unknown session")?;
1467 let scope = match scope.as_str() {
1468 "session" => crate::database::HistoryScope::Session,
1469 "all" => crate::database::HistoryScope::All,
1470 _ => crate::database::HistoryScope::Project,
1471 };
1472 let found = crate::database::search_prompts_bounded(
1473 &session_id,
1474 &bundle,
1475 scope,
1476 &query,
1477 crate::server::MAX_HISTORY_MATCHES,
1478 )?;
1479 anyhow::Ok(crate::server::ViewerPromptHistory {
1480 entries: found
1481 .entries
1482 .into_iter()
1483 .map(|entry| entry.text)
1484 .collect(),
1485 truncated: found.truncated,
1486 })
1487 })
1488 .await;
1489 reply.send(flatten_stored(answer)).ok();
1490 });
1491 }
1492 }
1493 }
1494 bundle = bundle_rx.recv(), if bundle_jobs.len() < MAX_CONCURRENT_BUNDLE_CREATIONS => {
1495 let Some(crate::server::BundleRequest { source, reply }) = bundle else {
1496 failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering bundle requests");
1497 break;
1498 };
1499 let done = bundle_done_tx.clone();
1504 let daemon_runtime = daemon_runtime.clone();
1505 bundle_jobs.spawn(async move {
1506 let result = daemon_runtime
1507 .create_quick_bundle(source)
1508 .await;
1509 if let Err(error) = done.send(BundleCreated { result, reply }) {
1510 tracing::debug!(%error, "bundle creation finished after the server stopped");
1511 }
1512 });
1513 }
1514 bundle_done = bundle_done_rx.recv() => {
1515 let Some(BundleCreated { result, reply }) = bundle_done else {
1516 failure = feed_stopped(termination.is_cancelled(), "the bundle creation pipeline stopped while the phone server was running");
1517 break;
1518 };
1519 match result {
1520 Ok(created) => {
1521 let bundle_id = created.bundle_id;
1522 let Some(bundle) = created.config.bundles.get(&bundle_id) else {
1528 tracing::error!(%bundle_id, "bundle creation returned a config without its bundle");
1529 if reply.send(Err(crate::server::BundleFailure::Controller)).is_err() {
1530 tracing::debug!("bundle creation failure reply dropped after client disconnect");
1531 }
1532 continue;
1533 };
1534 controller
1535 .config
1536 .bundles
1537 .insert(bundle_id.clone(), bundle.clone());
1538 controller_reload_invalidated |= controller_reload_in_flight;
1542 revision = daemon_runtime.allocate_revision();
1543 publish_snapshot!(revision);
1544 request_daemon_controller_reload(
1545 daemon_runtime.clone(),
1546 "new bundle publication",
1547 );
1548 if reply.send(Ok(bundle_id)).is_err() {
1549 tracing::debug!("bundle creation reply dropped after client disconnect");
1550 }
1551 }
1552 Err(error) => {
1553 let failure = match error {
1554 crate::controller::QuickBundleFailure::InvalidSource(
1555 detail,
1556 ) => {
1557 tracing::debug!(error = %detail, "phone bundle source was invalid");
1558 crate::server::BundleFailure::InvalidSource
1559 }
1560 crate::controller::QuickBundleFailure::Persistence(
1561 detail,
1562 ) => {
1563 tracing::warn!(error = %detail, "phone bundle creation failed");
1564 crate::server::BundleFailure::Controller
1565 }
1566 };
1567 if reply.send(Err(failure)).is_err() {
1568 tracing::debug!("bundle creation failure reply dropped after client disconnect");
1569 }
1570 }
1571 }
1572 }
1573 bundle_job = bundle_jobs.join_next(), if !bundle_jobs.is_empty() => {
1574 if let Some(Err(error)) = bundle_job {
1575 tracing::warn!(%error, "bundle creation task panicked");
1576 }
1577 }
1578 preflight = preflight_rx.recv(), if preflight_jobs.len() < MAX_CONCURRENT_PREFLIGHTS => {
1579 let Some(preflight) = preflight else {
1580 failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering preflight requests");
1581 break;
1582 };
1583 let crate::server::NewPreflightRequest {
1587 bundle_id,
1588 target_id,
1589 project_directory,
1590 mut reply,
1591 remote_repairs,
1592 } = match preflight {
1593 crate::server::PreflightRequest::New(request) => request,
1594 crate::server::PreflightRequest::Resume(request) => {
1595 spawn_resume_preflight(
1596 &mut preflight_jobs,
1597 &controller,
1598 request,
1599 &termination,
1600 );
1601 continue;
1602 }
1603 };
1604 let config = controller.config.clone();
1609 let project_validation = project_directory.is_some();
1610 let task_termination = termination.clone();
1611 preflight_jobs.spawn(async move {
1612 let cancelled = Arc::new(AtomicBool::new(false));
1613 let cancellation_guard = ProcessCancellationGuard(cancelled.clone());
1614 let mut blocking = tokio::task::spawn_blocking(move || {
1615 run_new_preflight_with_cancellation(
1616 config,
1617 bundle_id,
1618 target_id,
1619 project_directory,
1620 cancelled,
1621 remote_repairs,
1622 )
1623 });
1624 let answer = tokio::select! {
1625 biased;
1626 _ = task_termination.cancelled() => None,
1627 _ = reply.closed() => None,
1628 answer = &mut blocking => Some(answer),
1629 };
1630 let Some(answer) = answer else {
1631 drop(cancellation_guard);
1632 match blocking.await {
1633 Err(error) => tracing::warn!(%error, "cancelled phone preflight task failed"),
1634 Ok(Err(error)) => tracing::debug!(%error, "phone preflight cancelled"),
1635 Ok(Ok(_)) => {}
1636 }
1637 return;
1638 };
1639 let answer = match answer {
1640 Ok(Ok(answer)) => Ok(answer),
1641 Ok(Err(error)) => {
1642 tracing::debug!(
1643 error = %error,
1644 project_validation,
1645 "phone preflight check failed"
1646 );
1647 Err(if project_validation {
1648 PreflightFailure::Validation
1649 } else {
1650 PreflightFailure::InvalidRepository(format!("{error:#}"))
1651 })
1652 }
1653 Err(error) => {
1654 tracing::warn!(%error, "phone preflight task failed");
1655 Err(PreflightFailure::Controller(format!(
1656 "preflight task failed: {error}"
1657 )))
1658 }
1659 };
1660 if reply.send(answer).is_err() {
1661 tracing::debug!("phone preflight reply dropped after client disconnect");
1662 }
1663 });
1664 }
1665 preflight_job = preflight_jobs.join_next(), if !preflight_jobs.is_empty() => {
1666 if let Some(Err(error)) = preflight_job {
1667 tracing::warn!(%error, "phone preflight task panicked");
1668 }
1669 }
1670 preparation = move_preparation_rx.recv() => {
1671 let Some(MovePreparationRequest { selection, reply }) = preparation else {
1672 failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering move preparation requests");
1673 break;
1674 };
1675 let done = move_prepared_tx.clone();
1680 let daemon_runtime = daemon_runtime.clone();
1681 move_preparation_jobs.spawn(async move {
1682 let result = daemon_runtime
1683 .prepare_move_session(selection)
1684 .await
1685 .map_err(|error| format!("{error:#}"));
1686 if let Err(error) = done.send(MovePrepared { result, reply }) {
1687 tracing::debug!(%error, "move preparation finished after the server stopped");
1688 }
1689 });
1690 }
1691 prepared = move_prepared_rx.recv() => {
1692 let Some(MovePrepared { result, reply }) = prepared else {
1693 failure = feed_stopped(termination.is_cancelled(), "the move preparation pipeline stopped while the phone server was running");
1694 break;
1695 };
1696 if reply.send(result).is_err() {
1697 tracing::debug!("move preparation reply dropped after client disconnect");
1698 }
1699 }
1700 move_preparation_job = move_preparation_jobs.join_next(), if !move_preparation_jobs.is_empty() => {
1701 if let Some(Err(error)) = move_preparation_job {
1702 tracing::warn!(%error, "move preparation task failed");
1703 }
1704 }
1705 move_recovery_job = move_recovery_jobs.join_next(), if !move_recovery_jobs.is_empty() => {
1706 if let Some(Err(error)) = move_recovery_job {
1707 move_recovery_load_in_flight = false;
1708 tracing::warn!(%error, "Move recovery projection task failed");
1709 }
1710 }
1711 receipt = receipt_rx.recv() => {
1712 let Some(ReadReceiptRequest { client_id, session_id, through, reply }) = receipt else {
1713 failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering read receipts");
1714 break;
1715 };
1716 match controller.state.sessions.get(&session_id) {
1717 None => {
1718 if reply.send(Err("unknown session".into())).is_err() {
1719 tracing::debug!(%session_id, "unknown-session read receipt reply dropped after client disconnect");
1720 }
1721 }
1722 Some(session) => {
1723 let workspace_id = session.workspace_id.clone();
1724 let done = receipt_done_tx.clone();
1725 let persisted_session_id = session_id.clone();
1726 tokio::spawn(async move {
1727 let joined = tokio::task::spawn_blocking(move || {
1728 crate::database::persist_read_receipt(
1729 &client_id,
1730 &workspace_id,
1731 &persisted_session_id,
1732 through,
1733 )
1734 })
1735 .await;
1736 let result = match joined {
1737 Ok(result) => result.map_err(|error| format!("{error:#}")),
1738 Err(error) => Err(format!("phone read receipt task failed: {error}")),
1739 };
1740 if let Err(error) = done.send(ReadReceiptPersisted { session_id, result, reply }) {
1741 tracing::debug!(%error, "phone read receipt finished after the server stopped");
1742 }
1743 });
1744 }
1745 }
1746 }
1747 persisted = receipt_done_rx.recv() => {
1748 let Some(ReadReceiptPersisted { session_id, result, reply }) = persisted else { continue };
1749 match result {
1750 Ok(receipt) => {
1751 let _ = receipt;
1752 if reply.send(Ok(())).is_err() {
1753 tracing::debug!(%session_id, "phone read receipt reply dropped after client disconnect");
1754 }
1755 }
1756 Err(error) => {
1757 tracing::warn!(%session_id, "could not persist a phone read receipt: {error}");
1758 if reply.send(Err(error)).is_err() {
1759 tracing::debug!(%session_id, "failed phone read receipt reply dropped after client disconnect");
1760 }
1761 }
1762 }
1763 }
1764 action = action_rx.recv() => {
1765 let Some(request) = action else {
1766 failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering actions");
1767 break;
1768 };
1769 match &request.action {
1773 ControllerAction::RefreshCapacity { target_id } => {
1774 let known = capacity_state.contains_key(target_id);
1775 let accepted = known && match capacity_triggers_tx.try_send(()) {
1778 Ok(()) | Err(tokio::sync::mpsc::error::TrySendError::Full(())) => true,
1779 Err(tokio::sync::mpsc::error::TrySendError::Closed(())) => {
1780 tracing::warn!("phone capacity refresh rejected: poller stopped");
1781 false
1782 }
1783 };
1784 if known {
1785 if let Some(entry) = capacity_state.get_mut(target_id) {
1786 entry.refreshing = accepted;
1787 if !accepted {
1788 entry.failed = true;
1789 }
1790 }
1791 revision = daemon_runtime.allocate_revision();
1792 publish_snapshot!(revision);
1793 }
1794 let outcome = if accepted {
1795 ActionOutcome::accepted()
1796 } else {
1797 ActionOutcome::Failed
1798 };
1799 if request.reply.send(outcome).is_err() {
1800 tracing::debug!(%target_id, "phone capacity refresh reply dropped after client disconnect");
1801 }
1802 tokio::task::yield_now().await;
1803 continue;
1804 }
1805 ControllerAction::RefreshQuota { profile_id } => {
1806 let known = controller.config.enabled_profile(profile_id).is_some();
1807 if known {
1808 quota_batch.generation = quota_batch.generation.saturating_add(1);
1812 quota_batch.profiles = quota_refresh_profiles(&controller);
1813 quota_profiles_tx.send_replace(quota_batch.clone());
1814 }
1815 let outcome = if known {
1816 ActionOutcome::accepted()
1817 } else {
1818 ActionOutcome::Failed
1819 };
1820 if request.reply.send(outcome).is_err() {
1821 tracing::debug!(%profile_id, "phone quota refresh reply dropped after client disconnect");
1822 }
1823 tokio::task::yield_now().await;
1824 continue;
1825 }
1826 _ => {}
1827 }
1828 if let ControllerAction::Cancel { session_id } = &request.action {
1829 let outcome = if request_phone_action_cancellation(
1830 session_id,
1831 &action_sessions,
1832 &action_cancellations,
1833 ) {
1834 daemon_runtime.cancel_lifecycle_if_active(session_id);
1835 ActionOutcome::accepted()
1836 } else {
1837 ActionOutcome::NotCancellable
1838 };
1839 if request.reply.send(outcome).is_err() {
1840 tracing::debug!(%session_id, "phone cancellation reply dropped after client disconnect");
1841 }
1842 tokio::task::yield_now().await;
1843 continue;
1844 }
1845 if let ControllerAction::Close { session_id } = &request.action {
1846 if closing_actions.contains_key(session_id) {
1847 if request.reply.send(ActionOutcome::accepted()).is_err() { tracing::debug!(%session_id, "repeated close reply dropped"); }
1848 continue;
1849 }
1850 request_phone_action_cancellation(session_id, &action_sessions, &action_cancellations);
1851 daemon_runtime.request_close(session_id);
1852 }
1853 if let ControllerAction::ForceClose { session_id } = &request.action {
1857 request_phone_action_cancellation(session_id, &action_sessions, &action_cancellations);
1858 daemon_runtime.request_close(session_id);
1859 }
1860 let session_id = match admit_phone_action(
1861 &request.action,
1862 action_cancellations.len(),
1863 &mut active_actions,
1864 ) {
1865 Ok(session_id) => session_id,
1866 Err(refusal) => {
1867 if request.reply.send(refusal).is_err() {
1868 tracing::debug!("phone action refusal reply dropped after client disconnect");
1869 }
1870 tokio::task::yield_now().await;
1871 continue;
1872 }
1873 };
1874 let ControllerRequest { action, reply } = request;
1875 let done = action_done_tx.clone();
1876 let session_control = worker_commands_tx.clone();
1877 let daemon_runtime = daemon_runtime.clone();
1878 let started = action_started_tx.clone();
1879 next_action_id = next_action_id.wrapping_add(1).max(1);
1880 let action_id = next_action_id;
1881 if let ControllerAction::Close { session_id } | ControllerAction::ForceClose { session_id } = &action { closing_actions.insert(session_id.clone(), action_id); }
1882 if let ControllerAction::New { workspace_id, .. } = &action {
1883 let workspace_id = if workspace_id.is_empty() && phone_workspaces.len() == 1 {
1884 phone_workspaces[0].id.clone()
1885 } else {
1886 workspace_id.clone()
1887 };
1888 launch_workspaces.insert(action_id, workspace_id);
1889 }
1890 let control = PhoneActionControl::for_action(&action);
1891 action_cancellations.insert(action_id, control.clone());
1892 if let Some(session_id) = &session_id {
1893 action_sessions.insert(action_id, session_id.clone());
1894 }
1895 action_replies.accept(action_id, &action, reply);
1896 let runtime = tokio::runtime::Handle::current();
1897 tokio::spawn(async move {
1898 let joined = tokio::task::spawn_blocking(move || {
1899 let result = (|| -> Result<()> {
1900 if control.cancelled.load(Ordering::Acquire) {
1901 bail!("phone action cancelled");
1902 }
1903 let mut operation_controller = Controller::load()?;
1904 let executor =
1905 CancellableProcessExecutor::new(control.cancelled.clone());
1906 runtime.block_on(apply_phone_action(
1907 &mut operation_controller,
1908 PhoneActionServices {
1909 sessions: &session_control,
1910 daemon_runtime: &daemon_runtime,
1911 },
1912 action,
1913 &executor,
1914 action_id,
1915 &started,
1916 &control,
1917 ))
1918 })();
1919 result.map_err(|error| format!("{error:#}"))
1920 })
1921 .await;
1922 let result = match joined {
1923 Ok(result) => result,
1924 Err(error) => Err(format!("phone action task failed: {error}")),
1925 };
1926 if let Err(error) = done.send((action_id, session_id, result)) {
1927 tracing::debug!(action_id, %error, "phone action finished after the server stopped");
1928 }
1929 });
1930 }
1931 started = action_started_rx.recv() => {
1932 let Some(started) = started else {
1933 tokio::task::yield_now().await;
1934 continue;
1935 };
1936 let started_session_id = started.session.id.clone();
1937 let publication = if !action_cancellations.contains_key(&started.action_id) {
1938 Err("phone action completed before its provisional session was published".into())
1939 } else {
1940 track_started_phone_session(
1941 &mut controller.state,
1942 &mut active_actions,
1943 &mut action_sessions,
1944 started.action_id,
1945 started.session,
1946 )
1947 };
1948 if publication.is_ok() {
1949 revision = daemon_runtime.allocate_revision();
1950 publish_snapshot!(revision);
1951 request_daemon_controller_reload(
1952 daemon_runtime.clone(),
1953 "new session publication",
1954 );
1955 };
1956 if publication.is_err()
1957 && let Some(control) = action_cancellations.get(&started.action_id)
1958 {
1959 control.request_cancel();
1960 }
1961 action_replies.resolve(
1964 started.action_id,
1965 if publication.is_ok() {
1966 ActionOutcome::Accepted {
1967 session_id: Some(started_session_id),
1968 }
1969 } else {
1970 ActionOutcome::Failed
1971 },
1972 );
1973 if started.published.send(publication).is_err() {
1974 tracing::debug!(action_id = started.action_id, "phone new-session publication reply dropped after client disconnect");
1975 }
1976 }
1977 completed = action_done_rx.recv() => {
1978 let Some((action_id, session_id, result)) = completed else {
1979 failure = feed_stopped(termination.is_cancelled(), "the phone action pipeline stopped reporting completions");
1980 break;
1981 };
1982 action_cancellations.remove(&action_id);
1983 let session_id = action_sessions.remove(&action_id).or(session_id);
1984 if closing_actions.values().any(|closing_id| *closing_id == action_id) && let Some(id) = &session_id { daemon_runtime.clear_close_request(id); }
1985 closing_actions.retain(|_, closing_id| *closing_id != action_id);
1986 if let Some(session_id) = &session_id && !action_sessions.values().any(|active| active == session_id) {
1987 active_actions.remove(session_id);
1988 }
1989 action_replies.resolve(action_id, ActionOutcome::Failed);
1993 if let Some(workspace_id) = launch_workspaces.remove(&action_id)
1994 && result.is_err()
1995 && !session_id.as_ref().is_some_and(|id| closing_actions.contains_key(id))
1996 {
1997 record_launch_failure(
1998 &mut launch_failures,
1999 action_id,
2000 workspace_id,
2001 session_id.clone(),
2002 result.as_ref().err().cloned(),
2003 );
2004 revision = daemon_runtime.allocate_revision();
2005 publish_snapshot!(revision);
2006 }
2007 if let Err(error) = &result {
2008 tracing::warn!(
2009 action_id,
2010 session_id = session_id.as_deref(),
2011 %error,
2012 "phone action failed"
2013 );
2014 }
2015 record_action_result(
2016 &mut pending_action_errors,
2017 session_id.as_deref(),
2018 &result,
2019 );
2020 request_controller_reload(
2021 &mut controller_reload_in_flight,
2022 &mut controller_reload_requested,
2023 &controller_reload_tx,
2024 );
2025 request_move_recovery_reload(
2026 &move_recovery_tx,
2027 &mut move_recovery_load_in_flight,
2028 &mut move_recovery_jobs,
2029 );
2030 request_daemon_controller_reload(
2031 daemon_runtime.clone(),
2032 "phone action completion",
2033 );
2034 }
2035 reloaded = controller_reload_rx.recv() => {
2036 let Some(ControllerReloaded { result }) = reloaded else {
2037 failure = feed_stopped(
2038 termination.is_cancelled(),
2039 "the controller reload pipeline stopped while the phone server was running",
2040 );
2041 break;
2042 };
2043 controller_reload_in_flight = false;
2044 if std::mem::take(&mut controller_reload_invalidated) {
2045 if let Err(error) = &result {
2046 tracing::warn!(%error, "superseded controller reload failed");
2047 }
2048 controller_reload_requested = false;
2049 request_controller_reload(
2050 &mut controller_reload_in_flight,
2051 &mut controller_reload_requested,
2052 &controller_reload_tx,
2053 );
2054 continue;
2055 }
2056 match result {
2057 Ok(mut reloaded) => {
2058 for (session_id, error) in &pending_action_errors {
2059 if let Some(session) = reloaded.state.sessions.get_mut(session_id)
2060 && session.last_error.is_none()
2061 {
2062 session.last_error = Some(error.clone());
2063 }
2064 }
2065 controller = reloaded;
2066 quotas.retain(|id, _| controller.config.enabled_profile(id).is_some());
2067 subagent_quota_reports
2068 .lock()
2069 .expect("sub-agent quota reports lock poisoned")
2070 .retain(|id, _| controller.config.enabled_profile(id).is_some());
2071 worker_targets_tx.send_replace(dashboard_worker_targets(&controller));
2072 publish_capacity_targets(
2073 &controller,
2074 &capacity_targets_tx,
2075 &mut capacity_state,
2076 );
2077 credential_sync_handle.set_targets(credential_sync_targets(&controller));
2078 republish_quota_profiles(
2079 &controller,
2080 &mut published_quota_profiles,
2081 &mut quota_batch,
2082 "a_profiles_tx,
2083 );
2084 queued_prompts.retain(|session_id, _| {
2085 controller.state.sessions.contains_key(session_id)
2086 });
2087 pending_elicitations.retain(|session_id, _| {
2088 controller.state.sessions.contains_key(session_id)
2089 });
2090 prompt_images.retain(|session_id| {
2091 controller.state.sessions.contains_key(session_id)
2092 });
2093 operational.retain(|session_id, _| {
2094 controller.state.sessions.contains_key(session_id)
2095 });
2096 materialized_activity.retain(|session_id, _| {
2097 controller.state.sessions.contains_key(session_id)
2098 });
2099 request_move_recovery_reload(
2100 &move_recovery_tx,
2101 &mut move_recovery_load_in_flight,
2102 &mut move_recovery_jobs,
2103 );
2104 conversations.retain(|id, _| {
2105 controller.state.sessions.get(id).is_some_and(|session| session.state.is_active())
2106 });
2107 for session_id in conversation_projections.session_ids() {
2108 if !controller
2109 .state
2110 .sessions
2111 .get(&session_id)
2112 .is_some_and(|session| session.state.is_active())
2113 {
2114 conversation_projections.forget(&session_id);
2115 }
2116 }
2117 revision = daemon_runtime.allocate_revision();
2118 conversation_tx.send_replace(conversations.clone());
2119 publish_snapshot!(revision);
2120 }
2121 Err(error) => {
2122 tracing::warn!(%error, "completed phone operation could not reload controller state");
2123 }
2124 }
2125 if controller_reload_requested {
2126 controller_reload_requested = false;
2127 controller_reload_in_flight = true;
2128 spawn_controller_reload(controller_reload_tx.clone());
2129 }
2130 }
2131 }
2132 }
2133 dictation_jobs.shutdown().await;
2135 bundle_jobs.shutdown().await;
2138 preflight_jobs.shutdown().await;
2142 move_preparation_jobs.shutdown().await;
2145 move_recovery_jobs.shutdown().await;
2146 crate::controller::profile_config::cancel_all();
2148 for control in action_cancellations.values() {
2149 control.request_cancel();
2150 }
2151 match failure {
2152 Some(failure) => Err(failure),
2153 None => Ok::<(), anyhow::Error>(()),
2154 }
2155 };
2156 let result = tokio::select! {
2157 result = api_activity::record_activity_stream(activity_snapshots) => result.context("native API activity recorder stopped"),
2158 result = serve => result,
2159 result = control => result,
2160 };
2161 conversation_projection_shutdown.cancel();
2162 renewal_cancellation.cancel();
2163 if let Some(task) = renewal_task
2164 && let Err(error) = task.await
2165 {
2166 tracing::warn!(%error, "Tailscale certificate renewal task failed");
2167 }
2168 worker_shutdown
2169 .shutdown()
2170 .await
2171 .context("shut down phone server session manager")?;
2172 result?;
2173 Ok(())
2174}
2175
2176fn feed_stopped(shutting_down: bool, reason: &'static str) -> Option<anyhow::Error> {
2182 (!shutting_down).then(|| anyhow::anyhow!(reason))
2183}
2184
2185fn agent_accepts_prompt_images(operational: &mj_core::relay::RelayOperationalState) -> bool {
2189 operational
2190 .agent_capabilities
2191 .as_ref()
2192 .is_some_and(|capabilities| capabilities.prompt_capabilities.image)
2193}
2194
2195fn controller_action_session_id(action: &ControllerAction) -> Option<String> {
2196 match action {
2197 ControllerAction::New { .. } => None,
2198 ControllerAction::Prompt { session_id, .. }
2199 | ControllerAction::RunShell { session_id, .. }
2200 | ControllerAction::CancelShell { session_id, .. }
2201 | ControllerAction::Close { session_id }
2202 | ControllerAction::ForceClose { session_id }
2203 | ControllerAction::Resume { session_id, .. }
2204 | ControllerAction::Open { session_id }
2205 | ControllerAction::Cancel { session_id }
2206 | ControllerAction::RemoveQueuedPrompt { session_id, .. }
2207 | ControllerAction::RespondElicitation { session_id, .. }
2208 | ControllerAction::Rename { session_id, .. }
2209 | ControllerAction::CancelTurn { session_id }
2210 | ControllerAction::SetConfig { session_id, .. }
2211 | ControllerAction::SetPlanMode { session_id, .. }
2212 | ControllerAction::StartReview { session_id }
2213 | ControllerAction::ResolveReview { session_id, .. } => Some(session_id.clone()),
2214 ControllerAction::Move { request } => {
2215 Some(request.preparation.selection.session_id.clone())
2216 }
2217 ControllerAction::RefreshQuota { .. } | ControllerAction::RefreshCapacity { .. } => None,
2220 }
2221}
2222
2223fn flatten_stored<T>(
2225 joined: std::result::Result<Result<T>, tokio::task::JoinError>,
2226) -> std::result::Result<T, String> {
2227 match joined {
2228 Ok(Ok(value)) => Ok(value),
2229 Ok(Err(error)) => Err(format!("{error:#}")),
2230 Err(error) => Err(format!("viewer state task failed: {error}")),
2231 }
2232}
2233
2234struct ProcessCancellationGuard(Arc<AtomicBool>);
2236
2237impl Drop for ProcessCancellationGuard {
2238 fn drop(&mut self) {
2239 self.0.store(true, Ordering::Release);
2240 }
2241}
2242
2243#[cfg(test)]
2251fn run_new_preflight(
2252 config: Config,
2253 bundle_id: String,
2254 target_id: String,
2255 project_directory: Option<PathBuf>,
2256) -> Result<crate::server::PreflightNew> {
2257 run_new_preflight_with_cancellation(
2258 config,
2259 bundle_id,
2260 target_id,
2261 project_directory,
2262 Arc::new(AtomicBool::new(false)),
2263 Vec::new(),
2264 )
2265}
2266
2267fn spawn_resume_preflight(
2271 jobs: &mut tokio::task::JoinSet<()>,
2272 controller: &Controller,
2273 request: crate::server::ResumePreflightRequest,
2274 termination: &tokio_util::sync::CancellationToken,
2275) {
2276 let crate::server::ResumePreflightRequest {
2277 session_id,
2278 target_id,
2279 mut reply,
2280 } = request;
2281 let config = controller.config.clone();
2282 let session = controller.state.sessions.get(&session_id).cloned();
2283 let termination = termination.clone();
2284 jobs.spawn(async move {
2285 let cancelled = Arc::new(AtomicBool::new(false));
2286 let cancellation_guard = ProcessCancellationGuard(cancelled.clone());
2287 let mut blocking = tokio::task::spawn_blocking(move || {
2288 run_resume_preflight(config, session, &target_id, cancelled)
2289 });
2290 let answer = tokio::select! {
2291 biased;
2292 _ = termination.cancelled() => None,
2293 _ = reply.closed() => None,
2294 answer = &mut blocking => Some(answer),
2295 };
2296 let Some(answer) = answer else {
2297 drop(cancellation_guard);
2298 if let Err(error) = blocking.await {
2299 tracing::warn!(%error, "cancelled phone resume preflight task failed");
2300 }
2301 return;
2302 };
2303 let answer = answer.map_err(|error| {
2304 tracing::warn!(%error, "phone resume preflight task failed");
2305 PreflightFailure::Controller(format!("resume preflight task failed: {error}"))
2306 });
2307 if reply.send(answer).is_err() {
2308 tracing::debug!("phone resume preflight reply dropped after client disconnect");
2309 }
2310 });
2311}
2312
2313fn run_resume_preflight(
2321 config: Config,
2322 session: Option<mj_core::state::SessionRecord>,
2323 target_id: &str,
2324 cancelled: Arc<AtomicBool>,
2325) -> crate::server::PreflightResume {
2326 let Some(session) = session else {
2327 return crate::server::PreflightResume::Unavailable {
2328 detail: "this session is no longer available".to_owned(),
2329 };
2330 };
2331 match crate::controller::resume_compatibility(&session, &config, target_id) {
2332 Err(reason) => crate::server::PreflightResume::Unavailable { detail: reason },
2333 Ok(plan) if plan != crate::controller::ResumePlan::RawToWorkspace => {
2334 crate::server::PreflightResume::Ready
2335 }
2336 Ok(_) => {
2337 let executor =
2338 CancellableProcessExecutor::new(cancelled).with_deadline(Duration::from_secs(30));
2339 match crate::controller::raw_conversion_preview_for(&session, &config, &executor) {
2340 Err(error) => crate::server::PreflightResume::Unavailable {
2341 detail: format!("{error:#}"),
2342 },
2343 Ok(mut preview) => {
2344 preview.fetch_url = display_url(&preview.fetch_url);
2347 preview.push_urls = preview
2348 .push_urls
2349 .iter()
2350 .map(|url| display_url(url))
2351 .collect();
2352 crate::server::PreflightResume::ConvertingRawCheckout {
2353 preview: Box::new(preview),
2354 }
2355 }
2356 }
2357 }
2358 }
2359}
2360
2361fn run_new_preflight_with_cancellation(
2362 config: Config,
2363 bundle_id: String,
2364 target_id: String,
2365 project_directory: Option<PathBuf>,
2366 cancelled: Arc<AtomicBool>,
2367 remote_repairs: Vec<mj_core::local_git::LocalRemoteRepair>,
2368) -> Result<crate::server::PreflightNew> {
2369 let executor =
2370 CancellableProcessExecutor::new(cancelled).with_deadline(Duration::from_secs(30));
2371 if !remote_repairs.is_empty() {
2372 let target = config.targets.get(&target_id).context("unknown target")?;
2373 anyhow::ensure!(
2374 !is_bare_project_target(target) && project_directory.is_none(),
2375 "remote repair requires an isolated target"
2376 );
2377 let bundle = config.bundles.get(&bundle_id).context("unknown bundle")?;
2378 mj_core::local_git::apply_repository_remote_repairs(bundle, &remote_repairs, &executor)?;
2379 }
2380 run_new_preflight_with_executor(config, bundle_id, target_id, project_directory, &executor)
2381}
2382
2383fn run_new_preflight_with_executor(
2384 config: Config,
2385 bundle_id: String,
2386 target_id: String,
2387 project_directory: Option<PathBuf>,
2388 executor: &impl CommandExecutor,
2389) -> Result<crate::server::PreflightNew> {
2390 let target_is_bare = config
2391 .targets
2392 .get(&target_id)
2393 .with_context(|| format!("unknown target template {target_id:?}"))
2394 .map(is_bare_project_target)?;
2395 if target_is_bare {
2396 let directory =
2397 project_directory.context("project directory is required for a bare target")?;
2398 let controller = config_only_controller(config);
2399 let directory = controller.resolve_project_directory(&target_id, &directory, executor)?;
2400 let managed_worktree =
2401 controller.managed_worktree_options(&target_id, &directory, executor)?;
2402 return Ok(crate::server::PreflightNew {
2403 managed_worktree,
2404 project_directory: Some(directory),
2405 remote_repairs: Vec::new(),
2406 dirty_repositories: Vec::new(),
2407 remote_repositories: Vec::new(),
2408 local_changes_excluded: false,
2409 });
2410 }
2411 if project_directory.is_some() {
2412 bail!("project directory is unsupported for this target");
2413 }
2414
2415 let bundle = config.bundles.get(&bundle_id).context("unknown bundle")?;
2416 let repairs = mj_core::local_git::repository_remote_repairs(bundle, executor)?;
2417 if !repairs.is_empty() {
2418 return Ok(crate::server::PreflightNew {
2419 managed_worktree: Default::default(),
2420 project_directory: None,
2421 remote_repairs: repairs,
2422 dirty_repositories: Vec::new(),
2423 remote_repositories: Vec::new(),
2424 local_changes_excluded: true,
2425 });
2426 }
2427 let remote_repositories = bundle
2428 .repositories
2429 .iter()
2430 .map(|repository| {
2431 let source = resolve_repository(repository, executor)
2432 .with_context(|| format!("repository {:?}", repository.id))?;
2433 let default_branch = default_branch(&source, executor)
2434 .with_context(|| format!("repository {:?}", repository.id))?;
2435 Ok(crate::server::PreflightRepository {
2436 id: repository.id.clone(),
2437 fetch_url: display_url(&source.fetch_url),
2438 default_branch,
2439 push_urls: source
2440 .push_urls
2441 .iter()
2442 .map(|url| display_url(url))
2443 .collect(),
2444 })
2445 })
2446 .collect::<Result<Vec<_>>>()?;
2447 Ok(crate::server::PreflightNew {
2448 managed_worktree: Default::default(),
2449 project_directory: None,
2450 remote_repairs: Vec::new(),
2451 dirty_repositories: Vec::new(),
2452 remote_repositories,
2453 local_changes_excluded: true,
2454 })
2455}
2456
2457fn phone_action_capacity_available(active_actions: usize) -> bool {
2458 active_actions < MAX_CONCURRENT_PHONE_ACTIONS
2459}
2460
2461fn republish_quota_profiles(
2471 controller: &Controller,
2472 published: &mut std::collections::BTreeMap<String, HarnessProfile>,
2473 batch: &mut QuotaRefreshBatch,
2474 profiles_tx: &tokio::sync::watch::Sender<QuotaRefreshBatch>,
2475) -> bool {
2476 if *published == controller.config.profiles {
2477 return false;
2478 }
2479 published.clone_from(&controller.config.profiles);
2480 batch.generation = batch.generation.saturating_add(1);
2481 batch.profiles = quota_refresh_profiles(controller);
2482 profiles_tx.send_replace(batch.clone());
2483 true
2484}
2485
2486fn request_phone_action_cancellation(
2487 session_id: &str,
2488 action_sessions: &std::collections::BTreeMap<u64, String>,
2489 action_cancellations: &std::collections::BTreeMap<u64, PhoneActionControl>,
2490) -> bool {
2491 let control = action_sessions
2492 .iter()
2493 .find_map(|(action_id, active_session_id)| {
2494 (active_session_id == session_id)
2495 .then(|| action_cancellations.get(action_id))
2496 .flatten()
2497 });
2498 if let Some(control) = control {
2499 return control.request_cancel();
2500 }
2501 false
2502}
2503
2504fn track_started_phone_session(
2505 state: &mut State,
2506 active_actions: &mut std::collections::BTreeSet<String>,
2507 action_sessions: &mut std::collections::BTreeMap<u64, String>,
2508 action_id: u64,
2509 session: SessionRecord,
2510) -> std::result::Result<(), String> {
2511 let session_id = session.id.clone();
2512 if !active_actions.insert(session_id.clone()) {
2513 return Err("another operation is already running for the new session".into());
2514 }
2515 action_sessions.insert(action_id, session_id.clone());
2516 state.sessions.insert(session_id, session);
2517 Ok(())
2518}
2519
2520fn record_action_result(
2530 pending_action_errors: &mut std::collections::BTreeMap<String, String>,
2531 session_id: Option<&str>,
2532 result: &std::result::Result<(), String>,
2533) {
2534 let Some(session_id) = session_id else {
2535 return;
2536 };
2537 match result {
2538 Err(error) => {
2539 pending_action_errors.insert(session_id.to_owned(), error.clone());
2540 }
2541 Ok(()) => {
2542 pending_action_errors.remove(session_id);
2543 }
2544 }
2545}
2546
2547fn record_launch_failure(
2550 failures: &mut Vec<crate::server::ViewerLaunchFailure>,
2551 action_id: u64,
2552 workspace_id: String,
2553 session_id: Option<String>,
2554 error: Option<String>,
2555) {
2556 failures.push(crate::server::ViewerLaunchFailure {
2557 id: format!("{}-{action_id}", std::process::id()),
2558 workspace_id,
2559 session_id,
2560 error,
2561 });
2562 if failures.len() > 16 {
2563 failures.remove(0);
2564 }
2565}
2566
2567struct PhoneActionServices<'a> {
2568 sessions: &'a SessionManagerControl,
2569 daemon_runtime: &'a Arc<RuntimeState>,
2570}
2571
2572async fn apply_phone_action(
2573 controller: &mut Controller,
2574 services: PhoneActionServices<'_>,
2575 action: ControllerAction,
2576 _executor: &(impl CommandExecutor + Sync),
2577 action_id: u64,
2578 started: &tokio::sync::mpsc::UnboundedSender<PhoneActionStarted>,
2579 control: &PhoneActionControl,
2580) -> Result<()> {
2581 match action {
2582 ControllerAction::New {
2583 workspace_id,
2584 profile_id,
2585 bundle_id,
2586 target_id,
2587 title,
2588 project_directory,
2589 create_managed_worktree,
2590 mjolnir_subagents,
2591 dirty_ack: _dirty_ack,
2592 } => {
2593 let workspace_id = if workspace_id.is_empty() {
2594 let workspaces = crate::database::list_workspaces()?;
2595 match workspaces.as_slice() {
2596 [workspace] => workspace.id.clone(),
2597 [] => bail!("create a workspace before starting a phone session"),
2598 _ => bail!("phone session creation requires a workspace_id"),
2599 }
2600 } else {
2601 workspace_id
2602 };
2603 let title = title.unwrap_or_else(|| {
2607 let project = project_directory
2608 .as_ref()
2609 .and_then(|path| path.file_name())
2610 .map(|name| name.to_string_lossy().into_owned())
2611 .unwrap_or_else(|| bundle_id.clone());
2612 format!("{project} via {profile_id}")
2613 });
2614 let session_title_override = Some(title.clone());
2615 let allow_dirty_local = false;
2619 let (published, publication) = tokio::sync::oneshot::channel();
2620 let registered = services
2621 .daemon_runtime
2622 .start_create_session_controlled(
2623 CreateSessionRequest {
2624 create_managed_worktree,
2625 mjolnir_subagents,
2626 initial_prompt: None,
2627 workspace_id,
2628 profile_id,
2629 bundle_id,
2630 project_directory,
2631 target_template_id: target_id,
2632 additional_mounts: Vec::new(),
2633 allow_dirty_local,
2634 resource_allocation: None,
2635 title,
2636 session_title_override,
2637 },
2638 control
2639 .create
2640 .clone()
2641 .expect("New action has a daemon create control"),
2642 publication,
2643 )
2644 .await?;
2645 let registered_session_id = registered.session.id.clone();
2646 started
2647 .send(PhoneActionStarted {
2648 action_id,
2649 session: registered.session,
2650 published,
2651 })
2652 .map_err(|_| anyhow::anyhow!("phone server stopped before publishing session"))?;
2653 services
2654 .daemon_runtime
2655 .wait_create_session(®istered_session_id)
2656 .await
2657 }
2658 ControllerAction::Prompt {
2659 session_id,
2660 text,
2661 images,
2662 } => {
2663 services
2664 .sessions
2665 .wait_for_session(&session_id, Duration::from_secs(5))
2666 .await?
2667 .submit(
2668 new_command_id("phone-prompt")?,
2669 RelayCommand::Prompt {
2670 prompt: phone_prompt_blocks(text, images),
2671 },
2672 )
2673 .await?;
2674 Ok(())
2675 }
2676 ControllerAction::RunShell {
2677 session_id,
2678 command,
2679 } => {
2680 services
2681 .sessions
2682 .wait_for_session(&session_id, Duration::from_secs(5))
2683 .await?
2684 .submit(
2685 new_command_id("phone-shell")?,
2686 RelayCommand::RunUserShell { command },
2687 )
2688 .await?;
2689 Ok(())
2690 }
2691 ControllerAction::CancelShell {
2692 session_id,
2693 shell_command_id,
2694 } => {
2695 services
2696 .sessions
2697 .wait_for_session(&session_id, Duration::from_secs(5))
2698 .await?
2699 .submit(
2700 new_command_id("phone-cancel-shell")?,
2701 RelayCommand::CancelUserShell { shell_command_id },
2702 )
2703 .await?;
2704 Ok(())
2705 }
2706 ControllerAction::Close { session_id } => {
2707 services.daemon_runtime.close_session(session_id).await
2708 }
2709 ControllerAction::ForceClose { session_id } => {
2710 services
2711 .daemon_runtime
2712 .force_destroy_session(session_id)
2713 .await
2714 }
2715 ControllerAction::Resume {
2716 session_id,
2717 workspace_id,
2718 profile_id,
2719 target_id,
2720 queue,
2721 additional_mounts,
2722 resource_allocation,
2723 } => services
2724 .daemon_runtime
2725 .resume_session(ResumeSessionRequest {
2726 session_id,
2727 workspace_id,
2728 profile_id,
2729 target_template_id: target_id,
2730 additional_mounts,
2731 resource_allocation,
2732 discard_queue: queue == ResumeQueueDisposition::Discard,
2733 repository_preflight: None,
2734 })
2735 .await
2736 .map(|_| ()),
2737 ControllerAction::Move { request } => {
2738 let outcome = services.daemon_runtime.move_session(request).await?;
2739 match outcome.outcome.as_str() {
2740 "completed" | "unchanged" => Ok(()),
2741 "cancelled" | "failed" => {
2742 let recovery = outcome.recovery.unwrap_or_else(|| {
2747 "Inspect the session and retry Move or Resume with previous settings."
2748 .into()
2749 });
2750 bail!("Move {}: {recovery}", outcome.outcome)
2751 }
2752 status => bail!("Move returned an unknown outcome: {status}"),
2753 }
2754 }
2755 ControllerAction::Open { .. } => Ok(()),
2756 ControllerAction::Cancel { .. } => {
2757 bail!("cancel actions must be handled by the phone control loop")
2758 }
2759 ControllerAction::RemoveQueuedPrompt {
2760 session_id,
2761 queue_id,
2762 } => {
2763 services
2764 .sessions
2765 .session(&session_id)
2766 .await?
2767 .submit(
2768 new_command_id("phone-remove-prompt")?,
2769 RelayCommand::RemoveQueuedPrompt {
2770 queued_command_id: queue_id,
2771 },
2772 )
2773 .await?;
2774 Ok(())
2775 }
2776 ControllerAction::RespondElicitation {
2777 session_id,
2778 elicitation_id,
2779 response,
2780 } => {
2781 services
2782 .sessions
2783 .session(&session_id)
2784 .await?
2785 .respond_elicitation(elicitation_id, response)
2786 .await
2787 }
2788 ControllerAction::Rename { session_id, title } => {
2789 controller.rename_session(&session_id, &title)?;
2790 Ok(())
2791 }
2792 ControllerAction::StartReview { session_id } => {
2793 services
2797 .daemon_runtime
2798 .review_host()
2799 .start(&session_id, true)
2800 .await
2801 .map_err(|refusal| anyhow::anyhow!("{refusal}"))?;
2802 Ok(())
2803 }
2804 ControllerAction::ResolveReview {
2805 session_id,
2806 resolution,
2807 } => {
2808 let resolution = crate::server::resolution_from_name(&resolution)
2809 .context("a review is resolved by forward, dismiss, or cancel")?;
2810 services
2811 .daemon_runtime
2812 .review_host()
2813 .resolve(&session_id, resolution)
2814 .await
2815 .map_err(|error| anyhow::anyhow!("{error}"))?;
2816 Ok(())
2817 }
2818 ControllerAction::CancelTurn { session_id } => {
2819 services
2820 .sessions
2821 .session(&session_id)
2822 .await?
2823 .submit(new_command_id("phone-cancel-turn")?, RelayCommand::Cancel)
2824 .await?;
2825 Ok(())
2826 }
2827 ControllerAction::SetConfig {
2828 session_id,
2829 key,
2830 value,
2831 } => {
2832 services
2833 .sessions
2834 .session(&session_id)
2835 .await?
2836 .submit(
2837 new_command_id("phone-set-config")?,
2838 RelayCommand::SetConfig { key, value },
2839 )
2840 .await?;
2841 Ok(())
2842 }
2843 ControllerAction::SetPlanMode { session_id, active } => {
2844 let harness_kind = controller
2848 .state
2849 .sessions
2850 .get(&session_id)
2851 .with_context(|| format!("unknown session {session_id}"))?
2852 .harness_kind;
2853 let handle = services.sessions.session(&session_id).await?;
2854 let operational = handle
2855 .view()
2856 .snapshot
2857 .map(|snapshot| snapshot.operational)
2858 .context("the session has not reported what it supports yet")?;
2859 let facts = mj_core::acp::AcpSessionFacts::from_operational(
2860 harness_kind,
2861 &operational.config,
2862 &operational.config_options,
2863 operational.modes.as_ref(),
2864 );
2865 let command = match facts.plan_control(active) {
2866 Ok(mj_core::acp::PlanControl::SetConfig { key, value }) => {
2867 RelayCommand::SetConfig { key, value }
2868 }
2869 Ok(mj_core::acp::PlanControl::SetSessionMode { mode_id }) => {
2870 RelayCommand::SetSessionMode { mode_id }
2871 }
2872 Err(reason) => bail!("{reason}"),
2873 };
2874 handle
2875 .submit(new_command_id("phone-plan-mode")?, command)
2876 .await?;
2877 Ok(())
2878 }
2879 ControllerAction::RefreshQuota { .. } | ControllerAction::RefreshCapacity { .. } => {
2882 bail!("refresh actions must be handled by the phone control loop")
2883 }
2884 }
2885}
2886
2887#[derive(Clone, PartialEq, Eq)]
2889struct ProjectSourceKey {
2890 directory: Option<PathBuf>,
2891 worktree: Option<mj_core::state::ManagedWorktree>,
2892 target: Option<mj_core::config::TargetTemplate>,
2893 fallback: ProjectSourceIdentity,
2894}
2895
2896impl ProjectSourceKey {
2897 fn of(session: &SessionRecord, config: &Config) -> Self {
2898 Self {
2899 directory: session.project_directory.clone(),
2900 worktree: session.managed_worktree.clone(),
2901 target: config.targets.get(&session.target_template_id).cloned(),
2902 fallback: session.project_source(config),
2903 }
2904 }
2905}
2906
2907struct ProjectSourceEntry {
2908 key: ProjectSourceKey,
2909 source: Option<ProjectSourceIdentity>,
2910 retry_at: Option<Instant>,
2911 cancelled: Arc<AtomicBool>,
2912}
2913
2914struct ProjectSourceResolved {
2915 cancelled: Arc<AtomicBool>,
2916 session_id: String,
2917 key: ProjectSourceKey,
2918 result: Result<ProjectSourceIdentity, String>,
2919}
2920
2921#[derive(Default)]
2924struct PhoneProjectSources {
2925 entries: std::collections::BTreeMap<String, ProjectSourceEntry>,
2926 jobs: tokio::task::JoinSet<ProjectSourceResolved>,
2927}
2928
2929impl PhoneProjectSources {
2930 fn synchronize(&mut self, controller: &Controller) {
2931 self.entries.retain(|id, entry| {
2932 let keep = controller.state.sessions.get(id).is_some_and(|session| {
2933 session.project_directory.is_some()
2934 && entry.key == ProjectSourceKey::of(session, &controller.config)
2935 });
2936 if !keep {
2937 entry.cancelled.store(true, Ordering::Release);
2938 }
2939 keep
2940 });
2941 for session in controller.state.sessions.values() {
2942 if self.jobs.len() >= 8 {
2943 break;
2944 }
2945 if session.project_directory.is_none()
2946 || self.entries.get(&session.id).is_some_and(|entry| {
2947 entry
2948 .retry_at
2949 .is_none_or(|deadline| Instant::now() < deadline)
2950 })
2951 {
2952 continue;
2953 }
2954 let key = ProjectSourceKey::of(session, &controller.config);
2955 let cancelled = Arc::new(AtomicBool::new(false));
2956 self.entries.insert(
2957 session.id.clone(),
2958 ProjectSourceEntry {
2959 key: key.clone(),
2960 source: None,
2961 retry_at: None,
2962 cancelled: cancelled.clone(),
2963 },
2964 );
2965 let source_controller = Controller {
2966 config: controller.config.clone(),
2967 state: State {
2968 sessions: [(session.id.clone(), session.clone())]
2969 .into_iter()
2970 .collect(),
2971 ..State::default()
2972 },
2973 };
2974 let session_id = session.id.clone();
2975 self.jobs.spawn_blocking(move || {
2976 let executor = CancellableProcessExecutor::new(cancelled.clone())
2977 .with_deadline(Duration::from_secs(8));
2978 let result = source_controller
2979 .resolve_session_project_source(&session_id, &executor)
2980 .map_err(|error| format!("{error:#}"));
2981 ProjectSourceResolved {
2982 cancelled,
2983 session_id,
2984 key,
2985 result,
2986 }
2987 });
2988 }
2989 }
2990
2991 fn complete(&mut self, resolved: ProjectSourceResolved) {
2992 let Some(entry) = self.entries.get_mut(&resolved.session_id) else {
2993 return;
2994 };
2995 if entry.key != resolved.key || !Arc::ptr_eq(&entry.cancelled, &resolved.cancelled) {
2996 return;
2997 }
2998 match resolved.result {
2999 Ok(source) => entry.source = Some(source),
3000 Err(error) => {
3001 tracing::warn!(session_id = %resolved.session_id, %error, "could not resolve web project source");
3002 entry.retry_at = Some(Instant::now() + Duration::from_secs(30));
3003 }
3004 }
3005 }
3006
3007 fn source(&self, session: &SessionRecord, config: &Config) -> Option<&ProjectSourceIdentity> {
3008 self.entries
3009 .get(&session.id)
3010 .filter(|entry| entry.key == ProjectSourceKey::of(session, config))
3011 .and_then(|entry| entry.source.as_ref())
3012 }
3013}
3014
3015impl Drop for PhoneProjectSources {
3016 fn drop(&mut self) {
3017 for entry in self.entries.values() {
3018 entry.cancelled.store(true, Ordering::Release);
3019 }
3020 }
3021}
3022
3023struct PhoneSessionViews<'a> {
3027 conversations: &'a std::collections::BTreeMap<String, crate::server::BrowserTranscript>,
3028 queued_prompts: &'a std::collections::BTreeMap<String, Vec<mj_core::relay::QueuedPrompt>>,
3029 active_user_shells:
3030 &'a std::collections::BTreeMap<String, Vec<mj_core::relay::ActiveUserShell>>,
3031 pending_elicitations:
3032 &'a std::collections::BTreeMap<String, Vec<mj_core::elicitation::ElicitationRequest>>,
3033 prompt_images: &'a std::collections::BTreeSet<String>,
3035 operational: &'a std::collections::BTreeMap<String, mj_core::relay::RelayOperationalState>,
3039 materialized_activity: &'a std::collections::BTreeMap<String, Option<i64>>,
3043 project_sources: &'a PhoneProjectSources,
3044 operations: &'a std::collections::BTreeMap<String, crate::server::ViewerOperation>,
3046 move_recoveries: &'a std::collections::BTreeMap<String, ViewerMoveRecovery>,
3048 capacity: &'a [crate::server::ViewerTargetCapacity],
3050 launch_failures: &'a [crate::server::ViewerLaunchFailure],
3051 reviews: &'a std::collections::BTreeMap<String, crate::review_host::RuntimeReviewView>,
3054}
3055
3056#[derive(Debug, Clone)]
3062struct PhoneCapacity {
3063 target: crate::targets::DeploymentCapacityTarget,
3064 usage: Option<crate::targets::DeploymentCapacityUsage>,
3065 on_demand: bool,
3066 sampled_at_epoch_seconds: Option<u64>,
3067 refreshing: bool,
3068 failed: bool,
3069}
3070
3071const CAPACITY_STALE_AFTER: Duration = Duration::from_secs(120);
3073
3074fn publish_capacity_targets(
3076 controller: &Controller,
3077 targets_tx: &tokio::sync::watch::Sender<Vec<crate::targets::DeploymentCapacityTarget>>,
3078 state: &mut std::collections::BTreeMap<String, PhoneCapacity>,
3079) {
3080 let targets = controller.deployment_capacity_targets();
3081 state.retain(|id, _| targets.iter().any(|target| target.id == *id));
3082 for target in &targets {
3083 state
3084 .entry(target.id.clone())
3085 .and_modify(|entry| entry.target = target.clone())
3086 .or_insert_with(|| PhoneCapacity {
3087 target: target.clone(),
3088 usage: None,
3089 on_demand: false,
3090 sampled_at_epoch_seconds: None,
3091 refreshing: true,
3093 failed: false,
3094 });
3095 }
3096 if targets_tx.borrow().as_slice() != targets.as_slice() {
3097 targets_tx.send_replace(targets);
3098 }
3099}
3100
3101fn viewer_capacity(
3103 state: &std::collections::BTreeMap<String, PhoneCapacity>,
3104) -> Vec<crate::server::ViewerTargetCapacity> {
3105 let now = std::time::SystemTime::now()
3106 .duration_since(std::time::UNIX_EPOCH)
3107 .unwrap_or_default()
3108 .as_secs();
3109 state
3110 .values()
3111 .map(|entry| {
3112 let usage = entry.usage.as_ref();
3113 crate::server::ViewerTargetCapacity {
3114 id: entry.target.id.clone(),
3115 label: entry.target.host.clone(),
3116 target_ids: entry.target.target_ids.clone(),
3117 cpu_percent: usage.and_then(|usage| usage.cpu_percent),
3118 memory_used_bytes: usage.map(|usage| usage.memory_used_bytes),
3119 memory_total_bytes: usage.map(|usage| usage.memory_total_bytes),
3120 logical_cores: usage.map(|usage| usage.logical_cores),
3121 disk_total_bytes: usage.and_then(|usage| usage.disk_total_bytes),
3122 virtual_machines: matches!(
3125 entry.target.kind,
3126 crate::targets::DeploymentCapacityKind::AwsFleet
3127 )
3128 .then(|| u64::from(!entry.on_demand)),
3129 sampled_at_epoch_seconds: entry.sampled_at_epoch_seconds,
3130 refreshing: entry.refreshing,
3131 stale: entry.sampled_at_epoch_seconds.is_some_and(|sampled| {
3132 now.saturating_sub(sampled) > CAPACITY_STALE_AFTER.as_secs()
3133 }),
3134 has_error: entry.failed,
3135 }
3136 })
3137 .collect()
3138}
3139
3140fn viewer_operation(view: &crate::daemon::RuntimeLifecycleView) -> crate::server::ViewerOperation {
3142 use crate::server::{ViewerOperationKind, ViewerOperationStage};
3143
3144 crate::server::ViewerOperation {
3145 id: view.operation_id.clone(),
3149 session_id: view.session_id.clone(),
3150 kind: match view.kind {
3151 crate::daemon::RuntimeLifecycleKind::Create => ViewerOperationKind::Create,
3152 crate::daemon::RuntimeLifecycleKind::Resume => ViewerOperationKind::Resume,
3153 crate::daemon::RuntimeLifecycleKind::Move => ViewerOperationKind::Move,
3154 crate::daemon::RuntimeLifecycleKind::Close
3157 | crate::daemon::RuntimeLifecycleKind::ForceStop => ViewerOperationKind::Stop,
3158 crate::daemon::RuntimeLifecycleKind::DestroyStopped
3159 | crate::daemon::RuntimeLifecycleKind::ForceDestroy => ViewerOperationKind::Destroy,
3160 crate::daemon::RuntimeLifecycleKind::Cleanup => ViewerOperationKind::Cleanup,
3161 },
3162 started_at_epoch_seconds: view.started_at_epoch_seconds,
3163 stages: view
3164 .active_stages
3165 .iter()
3166 .map(|(stage, started_at)| ViewerOperationStage {
3167 label: stage.label(),
3168 started_at_epoch_seconds: *started_at,
3169 })
3170 .collect(),
3171 notice: view.notice.clone(),
3172 cancellable: view.cancellable,
3173 }
3174}
3175
3176fn session_capabilities(
3182 session: &crate::server::ViewerSession,
3183 operational: Option<&mj_core::relay::RelayOperationalState>,
3184 operation: Option<&crate::server::ViewerOperation>,
3185 facts: Option<&mj_core::acp::AcpSessionFacts>,
3186) -> crate::server::ViewerSessionCapabilities {
3187 use crate::server::ViewerLifecycleCategory;
3188
3189 let live = session.lifecycle == ViewerLifecycleCategory::Live;
3190 let attached = operational.is_some();
3193 let transition_busy = session.transitioning && !session.has_error;
3198 let busy = operation.is_some() || transition_busy;
3199 let partial_move_queue = session.move_recovery.as_ref().is_some_and(|recovery| {
3203 recovery.queue_admission_started && !recovery.queue_admission_finished
3204 });
3205 let mutation_busy = busy || partial_move_queue;
3206 let idle = operational
3207 .is_some_and(|state| state.execution == mj_core::relay::RelayExecutionState::Idle);
3208 crate::server::ViewerSessionCapabilities {
3209 open: session.conversation_available
3210 && !session.transitioning
3211 && session.lifecycle == ViewerLifecycleCategory::Live,
3212 prompt: live && attached && !mutation_busy,
3213 run_shell: live && attached && !mutation_busy,
3214 cancel_turn: live
3215 && !mutation_busy
3216 && operational.is_some_and(|state| {
3217 state.active_prompt.is_some() || state.capacity_retry.is_some()
3218 }),
3219 cancel_operation: operation.is_some_and(|operation| operation.cancellable),
3220 stop: session.lifecycle.is_dashboard_visible() && !mutation_busy,
3223 rename: !session.transitioning,
3224 resume: !session.lifecycle.is_dashboard_visible() && !mutation_busy,
3225 move_session: live && !busy,
3226 set_config: live && attached && facts.is_some() && !mutation_busy,
3227 set_plan_mode: live
3230 && !mutation_busy
3231 && idle
3232 && facts.is_some_and(mj_core::acp::AcpSessionFacts::supports_plan_mode),
3233 }
3234}
3235
3236fn phone_prompt_blocks(
3239 text: String,
3240 images: Vec<crate::server::ViewerPromptImage>,
3241) -> Vec<agent_client_protocol::schema::v1::ContentBlock> {
3242 use agent_client_protocol::schema::v1::{ContentBlock, ImageContent, TextContent};
3243
3244 let mut prompt = Vec::with_capacity(images.len() + 1);
3245 if !text.is_empty() {
3246 prompt.push(ContentBlock::Text(TextContent::new(text)));
3247 }
3248 prompt.extend(images.into_iter().map(|image| match image.attachment {
3249 Some(reference) => reference.content_block(),
3250 None => ContentBlock::Image(ImageContent::new(image.data_base64, image.mime_type)),
3251 }));
3252 prompt
3253}
3254
3255fn phone_commands(
3261 session: &crate::server::ViewerSession,
3262 operational: Option<&mj_core::relay::RelayOperationalState>,
3263) -> Vec<crate::server::ViewerMjCommand> {
3264 use crate::server::ViewerCommandSource;
3265 use agent_client_protocol::schema::v1::AvailableCommandInput;
3266
3267 let command =
3268 |name: &str, description: &str, argument: Option<&str>| crate::server::ViewerMjCommand {
3269 name: name.to_owned(),
3270 description: description.to_owned(),
3271 source: ViewerCommandSource::Mj,
3272 argument: argument.map(str::to_owned),
3273 };
3274 let mut commands = vec![
3275 command("help", "show available Mjolnir and agent commands", None),
3276 command(
3277 "detach",
3278 "leave the conversation without stopping the worker",
3279 None,
3280 ),
3281 ];
3282 let option = |key: &str| {
3283 session
3284 .config_options
3285 .iter()
3286 .any(|option| option.key == key)
3287 };
3288 if option("model") {
3289 commands.push(command("model", "change the active model", Some("value")));
3290 commands.push(command("fast", "toggle Codex Fast mode", None));
3291 }
3292 if option("effort") {
3293 commands.push(command(
3294 "effort",
3295 "change the active reasoning effort",
3296 Some("value"),
3297 ));
3298 }
3299 if session.plan_mode_active.is_some() && session.capabilities.set_plan_mode {
3300 commands.push(command("plan", "toggle plan mode", Some("message")));
3301 commands.push(command(
3302 "implement",
3303 "leave plan mode and implement",
3304 Some("instruction"),
3305 ));
3306 }
3307 if session.capabilities.prompt || session.turn_review.is_some() {
3308 commands.push(command(
3309 "review",
3310 "review the finished turn now, or report how review is configured",
3311 Some("status"),
3312 ));
3313 }
3314 let reserved = [
3318 "help",
3319 "detach",
3320 "model",
3321 "fast",
3322 "effort",
3323 "plan",
3324 "implement",
3325 "review",
3326 ];
3327 for advertised in operational
3328 .into_iter()
3329 .flat_map(|state| state.available_commands.iter())
3330 {
3331 let name = advertised.name.trim();
3332 if name.is_empty()
3333 || reserved
3334 .iter()
3335 .any(|local| name.eq_ignore_ascii_case(local))
3336 || commands
3337 .iter()
3338 .any(|existing| existing.name.eq_ignore_ascii_case(name))
3339 {
3340 continue;
3341 }
3342 let argument = advertised.input.as_ref().and_then(|input| match input {
3343 AvailableCommandInput::Unstructured(input) => {
3344 let hint = input.hint.trim();
3345 (!hint.is_empty()).then(|| hint.to_owned())
3346 }
3347 _ => None,
3348 });
3349 commands.push(crate::server::ViewerMjCommand {
3350 name: name.to_owned(),
3351 description: advertised.description.trim().to_owned(),
3352 source: ViewerCommandSource::Agent,
3353 argument,
3354 });
3355 }
3356 commands
3357}
3358
3359fn review_views(
3361 daemon_runtime: &Arc<RuntimeState>,
3362) -> std::collections::BTreeMap<String, crate::review_host::RuntimeReviewView> {
3363 daemon_runtime
3364 .review_host()
3365 .views()
3366 .into_iter()
3367 .map(|review| (review.session_id.clone(), review))
3368 .collect()
3369}
3370
3371type ViewerMoveRecoveries = std::collections::BTreeMap<String, crate::server::ViewerMoveRecovery>;
3372
3373fn request_move_recovery_reload(
3377 completed: &tokio::sync::mpsc::UnboundedSender<Result<ViewerMoveRecoveries, String>>,
3378 in_flight: &mut bool,
3379 jobs: &mut tokio::task::JoinSet<()>,
3380) {
3381 if *in_flight {
3382 return;
3383 }
3384 *in_flight = true;
3385 let completed = completed.clone();
3386 jobs.spawn(async move {
3387 let result = match tokio::task::spawn_blocking(|| {
3388 crate::database::load_move_operations()
3389 .map(|operations| {
3390 operations
3391 .into_iter()
3392 .filter_map(|operation| {
3393 crate::server::ViewerMoveRecovery::from_operation(&operation)
3394 .map(|recovery| (operation.selection.session_id.clone(), recovery))
3395 })
3396 .collect()
3397 })
3398 .map_err(|error| format!("{error:#}"))
3399 })
3400 .await
3401 {
3402 Ok(result) => result,
3403 Err(error) => Err(format!("move recovery projection task failed: {error}")),
3404 };
3405 let _ = completed.send(result);
3406 });
3407}
3408
3409fn viewer_snapshot(
3410 controller: &Controller,
3411 workspaces: &[mj_core::workspace::WorkspaceRecord],
3412 quotas: &std::collections::BTreeMap<String, ProfileQuota>,
3413 views: &PhoneSessionViews<'_>,
3414 revision: u64,
3415) -> ViewerSnapshot {
3416 let PhoneSessionViews {
3417 conversations,
3418 reviews,
3419 queued_prompts,
3420 active_user_shells,
3421 pending_elicitations,
3422 prompt_images,
3423 operational,
3424 materialized_activity,
3425 project_sources,
3426 operations,
3427 move_recoveries,
3428 capacity,
3429 launch_failures,
3430 } = views;
3431 let mut snapshot =
3432 ViewerSnapshot::from_config_state(&controller.config, &controller.state, revision);
3433 snapshot.launch_failures = launch_failures.to_vec();
3434 snapshot.workspaces = workspaces
3435 .iter()
3436 .map(|workspace| crate::server::ViewerWorkspace {
3437 id: workspace.id.clone(),
3438 name: workspace.name.clone(),
3439 })
3440 .collect();
3441 let now = std::time::SystemTime::now()
3442 .duration_since(std::time::UNIX_EPOCH)
3443 .unwrap_or_default()
3444 .as_secs();
3445 for profile in &mut snapshot.profiles {
3446 let Some(quota) = quotas.get(&profile.id) else {
3447 continue;
3448 };
3449 profile.quota = Some(ViewerQuota {
3450 summary: quota.compact(),
3451 windows: quota
3452 .windows
3453 .iter()
3454 .map(|window| crate::server::ViewerQuotaWindow {
3455 label: window.label.clone(),
3456 percent_used: window
3459 .remaining_percent
3460 .map(|left| 100_u8.saturating_sub(left)),
3461 resets_at: window.resets.clone(),
3462 projects_exhaustion_before_reset: crate::quota::projects_exhaustion(
3463 window,
3464 quota.refreshed_at_epoch_seconds,
3465 ),
3466 })
3467 .collect(),
3468 resets_at: quota
3469 .windows
3470 .iter()
3471 .find_map(|window| window.resets.clone()),
3472 stale: now.saturating_sub(quota.refreshed_at_epoch_seconds)
3473 > QUOTA_STALE_AFTER.as_secs(),
3474 refreshed_at_epoch_seconds: quota.refreshed_at_epoch_seconds,
3475 has_error: quota.error.is_some(),
3476 });
3477 }
3478 for session in &mut snapshot.sessions {
3479 session.move_recovery = move_recoveries.get(&session.id).cloned();
3480 if let Some(record) = controller.state.sessions.get(&session.id)
3481 && let Some(source) = project_sources.source(record, &controller.config)
3482 {
3483 session.set_project_source(source);
3484 }
3485 session.last_activity_at_ms = materialized_activity.get(&session.id).copied().flatten();
3486 session.queued_prompts = queued_prompts
3487 .get(&session.id)
3488 .into_iter()
3489 .flatten()
3490 .map(|prompt| ViewerQueuedPrompt {
3491 id: prompt.id.clone(),
3492 text: prompt.text.clone(),
3493 created_at: prompt.created_at_ms.to_string(),
3494 })
3495 .collect();
3496 session.active_user_shells = active_user_shells
3497 .get(&session.id)
3498 .into_iter()
3499 .flatten()
3500 .map(|shell| ViewerUserShell {
3501 id: shell.command_id.clone(),
3502 command: shell.command.clone(),
3503 started_at_ms: shell.started_at_ms,
3504 })
3505 .collect();
3506 session.background_tasks = operational
3507 .get(&session.id)
3508 .map(|state| {
3509 state
3510 .background_commands
3511 .iter()
3512 .map(|task| ViewerBackgroundTask {
3513 id: task.id.clone(),
3514 command: task.command.clone(),
3515 started_at_ms: task.started_at_ms,
3516 can_stop: task.can_stop,
3517 })
3518 .collect()
3519 })
3520 .unwrap_or_default();
3521 session.pending_elicitations = pending_elicitations
3522 .get(&session.id)
3523 .cloned()
3524 .unwrap_or_default();
3525 session.prompt_images_supported = prompt_images.contains(&session.id);
3526 session.operation = operations.get(&session.id).cloned();
3527 if let Some(operation) = session.operation.as_ref()
3532 && operation.kind.transition_kind().is_some()
3533 {
3534 session.transitioning = true;
3535 }
3536 let live = operational.get(&session.id);
3537 let facts = live.map(|state| {
3538 mj_core::acp::AcpSessionFacts::from_operational(
3539 controller
3540 .state
3541 .sessions
3542 .get(&session.id)
3543 .map_or(mj_core::config::HarnessKind::Codex, |record| {
3544 record.harness_kind
3545 }),
3546 &state.config,
3547 &state.config_options,
3548 state.modes.as_ref(),
3549 )
3550 });
3551 if let Some(state) = live {
3552 session.latest_event_ordinal = state.latest_ordinal;
3553 session.chat_phase = match state.execution {
3554 mj_core::relay::RelayExecutionState::Idle => crate::server::ViewerChatPhase::Idle,
3555 mj_core::relay::RelayExecutionState::Running => {
3556 crate::server::ViewerChatPhase::Running
3557 }
3558 mj_core::relay::RelayExecutionState::Closing => {
3559 crate::server::ViewerChatPhase::Closing
3560 }
3561 mj_core::relay::RelayExecutionState::Closed => {
3562 crate::server::ViewerChatPhase::Closed
3563 }
3564 };
3565 session.config_options = crate::server::viewer_config_options(
3566 &state.config_options,
3567 facts
3568 .as_ref()
3569 .expect("live operational state always has ACP session facts"),
3570 );
3571 let turn_started_at_ms = state
3574 .active_prompt
3575 .as_ref()
3576 .map(|prompt| prompt.started_at_ms)
3577 .or_else(|| state.harness_turn.map(|turn| turn.started_at_ms));
3578 let turn_started_at = turn_started_at_ms
3579 .and_then(|started_at_ms| u64::try_from(started_at_ms).ok())
3580 .map(|started_at_ms| started_at_ms / 1_000);
3581 session.capacity_retry = state.capacity_retry.clone();
3582 let activity = mj_client::usage_format::SessionActivity::of(state);
3583 let activity_details =
3584 activity.details(turn_started_at_ms, state.current_step_started_at_ms);
3585 session.activity_details = Some(viewer_activity_details(&activity_details));
3586 session.is_idle = controller
3587 .state
3588 .sessions
3589 .get(&session.id)
3590 .is_some_and(|record| record.state == mj_core::state::SessionState::Running)
3591 && session.operation.is_none()
3592 && activity.is_idle(turn_started_at);
3593 session.activity = mj_client::usage_format::format_activity_columns(
3594 now,
3595 turn_started_at,
3596 state
3597 .current_step_started_at_ms
3598 .and_then(|value| u64::try_from(value).ok()),
3599 &activity,
3600 )
3601 .join(" ")
3602 .trim()
3603 .to_owned();
3604 }
3605 session.plan_mode_active = facts
3606 .as_ref()
3607 .filter(|facts| facts.supports_plan_mode())
3608 .map(mj_core::acp::AcpSessionFacts::plan_mode_active);
3609 session.turn_review = reviews
3610 .get(&session.id)
3611 .map(crate::server::ViewerTurnReview::from_runtime);
3612 session.capabilities =
3613 session_capabilities(session, live, operations.get(&session.id), facts.as_ref());
3614 session.available_commands = phone_commands(session, live);
3615 if let Some(transcript) = conversations.get(&session.id) {
3616 session.conversation_available = true;
3617 if !session.transitioning {
3618 let mut lines = transcript
3619 .entries
3620 .iter()
3621 .flat_map(|entry| {
3622 entry
3623 .lines
3624 .iter()
3625 .enumerate()
3626 .filter_map(move |(index, line)| {
3627 let line = line.trim();
3628 (!line.is_empty()).then(|| {
3629 if index == 0 {
3630 format!("{}: {line}", entry.label)
3631 } else {
3632 line.to_owned()
3633 }
3634 })
3635 })
3636 })
3637 .collect::<Vec<_>>();
3638 session.preview = lines.split_off(lines.len().saturating_sub(4));
3639 }
3640 }
3641 session.capabilities.open = session.conversation_available && !session.transitioning;
3644 }
3645 snapshot.capacity = capacity.to_vec();
3646 snapshot
3647}
3648
3649fn viewer_activity_details(
3650 details: &mj_client::usage_format::SessionActivityDetails,
3651) -> ViewerActivityDetails {
3652 ViewerActivityDetails {
3653 kind: match details.kind {
3654 mj_client::usage_format::SessionActivityKind::Turn => ViewerActivityKind::Turn,
3655 mj_client::usage_format::SessionActivityKind::Step => ViewerActivityKind::Step,
3656 mj_client::usage_format::SessionActivityKind::Background => {
3657 ViewerActivityKind::Background
3658 }
3659 mj_client::usage_format::SessionActivityKind::Idle => ViewerActivityKind::Idle,
3660 mj_client::usage_format::SessionActivityKind::Lifecycle
3661 | mj_client::usage_format::SessionActivityKind::Goal => ViewerActivityKind::Lifecycle,
3662 },
3663 turn_started_at_ms: details.turn_started_at_ms,
3664 step_started_at_ms: details.step_started_at_ms,
3665 background_started_at_ms: details.background_started_at_ms,
3666 idle_since_ms: details.idle_since_ms,
3667 label: details.label.clone(),
3668 }
3669}
3670
3671async fn load_materialized_activity(
3675 controller: &Controller,
3676) -> Result<std::collections::BTreeMap<String, Option<i64>>> {
3677 let session_ids = controller
3678 .state
3679 .sessions
3680 .keys()
3681 .cloned()
3682 .collect::<Vec<_>>();
3683 tokio::task::spawn_blocking(move || {
3684 session_ids
3685 .into_iter()
3686 .map(|session_id| {
3687 crate::database::load_materialized_session_summary(&session_id).map(|summary| {
3688 (
3689 session_id,
3690 summary.and_then(|summary| summary.last_activity_at_ms),
3691 )
3692 })
3693 })
3694 .collect()
3695 })
3696 .await
3697 .context("materialized activity startup task failed")?
3698}
3699
3700#[cfg(test)]
3701mod tests {
3702 use super::*;
3703 use crate::pollers::QUOTA_REFRESH_INTERVAL;
3704 use std::collections::BTreeMap;
3705
3706 use agent_client_protocol::schema::v1::{
3707 SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
3708 SessionConfigSelectOptions,
3709 };
3710 use mj_core::config::{
3711 CONFIG_VERSION, Config, HarnessKind, ProjectBundle, ProjectRepository, TargetTemplate,
3712 };
3713 use mj_core::state::SessionState;
3714
3715 #[test]
3716 fn viewer_config_options_publish_current_advertised_values() {
3717 let make_options = |model, effort| {
3718 vec![
3719 SessionConfigOption::select(
3720 "model_selector",
3721 "Model",
3722 model,
3723 SessionConfigSelectOptions::Ungrouped(vec![
3724 SessionConfigSelectOption::new("sonnet", "Claude Sonnet"),
3725 SessionConfigSelectOption::new("opus", "Claude Opus"),
3726 ]),
3727 )
3728 .category(SessionConfigOptionCategory::Model),
3729 SessionConfigOption::select(
3730 "reasoning_effort",
3731 "Effort",
3732 effort,
3733 SessionConfigSelectOptions::Ungrouped(vec![
3734 SessionConfigSelectOption::new("high", "High"),
3735 SessionConfigSelectOption::new("max", "Maximum"),
3736 ]),
3737 ),
3738 ]
3739 };
3740 let options = make_options("sonnet", "high");
3741 let defaults = mj_core::acp::AcpSessionFacts::from_operational(
3742 HarnessKind::Claude,
3743 &BTreeMap::new(),
3744 &options,
3745 None,
3746 );
3747 let projected = crate::server::viewer_config_options(&options, &defaults);
3748 assert_eq!(
3749 projected
3750 .iter()
3751 .map(|option| (option.key.as_str(), option.current.as_deref()))
3752 .collect::<Vec<_>>(),
3753 [("model", Some("sonnet")), ("effort", Some("high"))]
3754 );
3755
3756 let options = make_options("opus", "max");
3757 let updated = mj_core::acp::AcpSessionFacts::from_operational(
3758 HarnessKind::Claude,
3759 &BTreeMap::new(),
3760 &options,
3761 None,
3762 );
3763 let projected = crate::server::viewer_config_options(&options, &updated);
3764 assert_eq!(projected[0].current.as_deref(), Some("opus"));
3765 assert_eq!(projected[1].current.as_deref(), Some("max"));
3766 }
3767
3768 #[tokio::test]
3769 async fn explicit_tls_takes_precedence_over_tailscale_detection() {
3770 let resolved = resolve_server_args(
3771 ServerArgs {
3772 bind: "0.0.0.0:4443".into(),
3773 tailscale_detect: true,
3774 tls_cert: Some(PathBuf::from("configured-cert.pem")),
3775 tls_key: Some(PathBuf::from("configured-key.pem")),
3776 },
3777 tokio_util::sync::CancellationToken::new(),
3778 )
3779 .await
3780 .unwrap();
3781
3782 assert_eq!(resolved.bind, "0.0.0.0:4443".parse().unwrap());
3783 assert_eq!(resolved.viewer_url, "https://0.0.0.0:4443");
3784 assert_eq!(
3785 resolved.tls_files,
3786 Some((
3787 PathBuf::from("configured-cert.pem"),
3788 PathBuf::from("configured-key.pem")
3789 ))
3790 );
3791 assert!(resolved.tailscale.is_none());
3792 assert!(resolved.fallback_reason.is_none());
3793 }
3794
3795 #[tokio::test]
3796 async fn disabling_detection_keeps_the_viewer_on_loopback() {
3797 let resolved = resolve_server_args(
3798 ServerArgs {
3799 bind: "127.0.0.1:4765".into(),
3800 tailscale_detect: false,
3801 tls_cert: None,
3802 tls_key: None,
3803 },
3804 tokio_util::sync::CancellationToken::new(),
3805 )
3806 .await
3807 .unwrap();
3808
3809 assert_eq!(resolved.bind, "127.0.0.1:4765".parse().unwrap());
3810 assert_eq!(resolved.viewer_url, "http://127.0.0.1:4765");
3811 assert!(
3812 resolved
3813 .fallback_reason
3814 .unwrap()
3815 .contains("detection is disabled")
3816 );
3817 }
3818
3819 fn bare_preflight_config() -> Config {
3820 let mut config = Config::default();
3821 config
3822 .targets
3823 .insert("raw".into(), TargetTemplate::LocalBare);
3824 config
3825 }
3826
3827 #[test]
3828 fn move_recovery_projection_exposes_safe_retry_settings_only() {
3829 let operation = mj_core::state::MoveOperation {
3830 source_checkpoint_only: false,
3831 operation_id: "move-1".into(),
3832 selection: mj_core::state::MoveSelection {
3833 clear_resource_allocation: true,
3834 session_id: "session-1".into(),
3835 profile_id: Some("destination-profile".into()),
3836 target_template_id: Some("destination-target".into()),
3837 additional_mounts: Some(vec![crate::targets::AdditionalMount {
3838 source: "/destination/source".into(),
3839 destination: "/destination/target".into(),
3840 read_only: true,
3841 }]),
3842 resource_allocation: None,
3843 },
3844 source_profile_id: "source-profile".into(),
3845 source_target_template_id: "source-target".into(),
3846 source_target: None,
3847 source_native_session_id: Some("private-native-id".into()),
3848 source_additional_mounts: vec![crate::targets::AdditionalMount {
3849 source: "/source/source".into(),
3850 destination: "/source/target".into(),
3851 read_only: false,
3852 }],
3853 source_resource_allocation: Some(
3854 mj_core::state::SessionResourceAllocation::Container {
3855 cpus: 2,
3856 memory_bytes: 4096,
3857 },
3858 ),
3859 destination_target: None,
3860 destination_native_session_id: None,
3861 destination_store_id: None,
3862 configuration_fingerprint: "private-fingerprint".into(),
3863 checkpoint: None,
3864 recovery_session: None,
3865 queue: mj_core::state::ResumeQueueDisposition::Start,
3866 phase: mj_core::state::MovePhase::Cancelled,
3867 queue_admission_started: false,
3868 queue_admission_finished: false,
3869 cancellation_requested: true,
3870 created_at: "now".into(),
3871 updated_at: "now".into(),
3872 error: Some("private path and token".into()),
3873 };
3874 let recovery = ViewerMoveRecovery::from_operation(&operation).unwrap();
3875 assert_eq!(recovery.phase, "cancelled");
3876 assert_eq!(recovery.source_profile_id, "source-profile");
3877 assert!(recovery.clear_resource_allocation);
3878 assert_eq!(recovery.source_additional_mounts.len(), 1);
3879 assert!(recovery.source_resource_allocation.is_some());
3880 assert_eq!(recovery.destination_additional_mounts.len(), 1);
3881 assert!(recovery.destination_resource_allocation.is_none());
3882 let json = serde_json::to_string(&recovery).unwrap();
3883 assert!(!json.contains("private-native-id"));
3884 assert!(!json.contains("private-fingerprint"));
3885 assert!(!json.contains("private path and token"));
3886 }
3887
3888 #[test]
3889 fn new_preflight_rejects_a_bare_project_without_a_git_head() {
3890 let error = run_new_preflight(
3891 bare_preflight_config(),
3892 "hel".into(),
3893 "raw".into(),
3894 Some(PathBuf::from("/definitely/not/a/project")),
3895 )
3896 .expect_err("a missing project directory must fail preflight");
3897
3898 assert!(
3899 error
3900 .to_string()
3901 .contains("project directory does not exist or is not a directory")
3902 );
3903 }
3904
3905 #[test]
3906 fn new_preflight_accepts_a_git_project_for_a_bare_target() {
3907 let directory = std::env::current_dir().expect("the test has a working directory");
3908 let answer = run_new_preflight(
3909 bare_preflight_config(),
3910 "hel".into(),
3911 "raw".into(),
3912 Some(directory.clone()),
3913 )
3914 .expect("the repository running the test has a valid Git HEAD");
3915
3916 assert!(answer.dirty_repositories.is_empty());
3917 assert_eq!(answer.project_directory, Some(directory));
3918 }
3919
3920 #[test]
3921 fn new_preflight_requires_network_sources_for_isolated_targets() {
3922 let mut config = Config::default();
3923 config.targets.insert(
3924 "podman".into(),
3925 TargetTemplate::LocalPodman {
3926 container: mj_core::config::ContainerTemplate {
3927 image: "test-image".into(),
3928 pull_policy: Default::default(),
3929 platform: None,
3930 cpus: None,
3931 memory: None,
3932 environment: Default::default(),
3933 workspace_storage: Default::default(),
3934 },
3935 },
3936 );
3937 config.bundles.insert(
3938 "hel".into(),
3939 ProjectBundle {
3940 primary_repo: "hel".into(),
3941 repositories: vec![ProjectRepository {
3942 id: "hel".into(),
3943 github: None,
3944 local: Some(PathBuf::from("/definitely/not/a/repository")),
3945 destination: "hel".into(),
3946 git_ref: None,
3947 }],
3948 },
3949 );
3950 let error = run_new_preflight(config, "hel".into(), "podman".into(), None)
3951 .expect_err("an isolated bundle cannot use a local source");
3952 assert!(error.to_string().contains("repository"));
3953 }
3954
3955 #[test]
3956 fn a_phone_prompt_becomes_its_text_then_its_images() {
3957 use agent_client_protocol::schema::v1::ContentBlock;
3958
3959 let image = |data: &str| crate::server::ViewerPromptImage {
3960 attachment: None,
3961 data_base64: data.into(),
3962 mime_type: "image/png".into(),
3963 width: 32,
3964 height: 24,
3965 };
3966 let blocks = phone_prompt_blocks(
3967 "look at this".into(),
3968 vec![image("aW1hZ2U="), image("c2Vjb25k")],
3969 );
3970 let ContentBlock::Text(text) = &blocks[0] else {
3971 panic!("the prompt leads with its text");
3972 };
3973 assert_eq!(text.text, "look at this");
3974 let ContentBlock::Image(first) = &blocks[1] else {
3975 panic!("each attachment travels as an image block");
3976 };
3977 assert_eq!(first.data, "aW1hZ2U=");
3978 assert_eq!(first.mime_type, "image/png");
3979 assert!(matches!(blocks[2], ContentBlock::Image(_)));
3980 assert_eq!(blocks.len(), 3);
3981
3982 let images_only = phone_prompt_blocks(String::new(), vec![image("aW1hZ2U=")]);
3985 assert_eq!(images_only.len(), 1);
3986 assert!(matches!(images_only[0], ContentBlock::Image(_)));
3987 }
3988
3989 #[test]
3990 fn image_prompts_are_offered_only_after_the_agent_advertises_them() {
3991 use agent_client_protocol::schema::v1::AgentCapabilities;
3992 use mj_core::relay::{RelayExecutionState, RelayOperationalState};
3993
3994 let operational = |agent_capabilities| RelayOperationalState {
3995 goal: Default::default(),
3996 capacity_retry: None,
3997 activity_turn_started_at_ms: None,
3998 session_id: "session-1".into(),
3999 store_id: None,
4000 idle_since_ms: None,
4001 execution: RelayExecutionState::Idle,
4002 latest_ordinal: 0,
4003 latest_digest: String::new(),
4004 acknowledged_through: 0,
4005 acknowledged_digest: String::new(),
4006 recovery_floor_ordinal: 0,
4007 recovery_floor_digest: String::new(),
4008 native_session_id: None,
4009 native_continuity_lost: false,
4010 checkpoint_only: false,
4011 acp_ready: None,
4012 agent_capabilities,
4013 agent_info: None,
4014 steering_supported: None,
4015 config_options: Vec::new(),
4016 modes: None,
4017 available_commands: Vec::new(),
4018 config: std::collections::BTreeMap::new(),
4019 active_prompt: None,
4020 queued_prompts: Vec::new(),
4021 active_user_shells: Vec::new(),
4022 active_agent_terminals: Vec::new(),
4023 checkpoint_barrier: None,
4024 checkpoint_ready: None,
4025 last_acp_activity_at_ms: None,
4026 current_step_started_at_ms: None,
4027 foreground_tool_started_at_ms: None,
4028 harness_turn: None,
4029 last_harness_turn_started_ordinal: None,
4030 background_commands: Vec::new(),
4031 background_work_known: None,
4032 };
4033
4034 assert!(!agent_accepts_prompt_images(&operational(None)));
4037 assert!(!agent_accepts_prompt_images(&operational(Some(Box::new(
4038 AgentCapabilities::default()
4039 )))));
4040 let mut capabilities = AgentCapabilities::default();
4041 capabilities.prompt_capabilities.image = true;
4042 assert!(agent_accepts_prompt_images(&operational(Some(Box::new(
4043 capabilities
4044 )))));
4045 }
4046
4047 #[test]
4048 fn phone_snapshot_projects_capability_gated_and_agent_commands_with_provenance() {
4049 use agent_client_protocol::schema::v1::{
4050 AvailableCommand, AvailableCommandInput, SessionMode, SessionModeState,
4051 UnstructuredCommandInput,
4052 };
4053 use mj_core::relay::{RelayExecutionState, RelayOperationalState};
4054
4055 use crate::server::ViewerCommandSource;
4056
4057 let mut controller = controller_with_profiles(&["claude"]);
4058 let mut record = phone_session("session-1", 0);
4059 record.harness_kind = HarnessKind::Claude;
4060 record.last_profile = "claude".into();
4061 record.state = SessionState::Running;
4062 controller.state.sessions.insert(record.id.clone(), record);
4063 let operational = RelayOperationalState {
4064 goal: Default::default(),
4065 capacity_retry: None,
4066 activity_turn_started_at_ms: None,
4067 session_id: "session-1".into(),
4068 store_id: None,
4069 idle_since_ms: None,
4070 execution: RelayExecutionState::Idle,
4071 latest_ordinal: 0,
4072 latest_digest: String::new(),
4073 acknowledged_through: 0,
4074 acknowledged_digest: String::new(),
4075 recovery_floor_ordinal: 0,
4076 recovery_floor_digest: String::new(),
4077 native_session_id: None,
4078 native_continuity_lost: false,
4079 checkpoint_only: false,
4080 acp_ready: None,
4081 agent_capabilities: None,
4082 agent_info: None,
4083 steering_supported: None,
4084 config_options: Vec::new(),
4085 modes: Some(SessionModeState::new(
4086 "default",
4087 vec![
4088 SessionMode::new("default", "Default"),
4089 SessionMode::new("plan", "Plan"),
4090 ],
4091 )),
4092 available_commands: vec![
4093 AvailableCommand::new("inspect", " Inspect the workspace ").input(
4094 AvailableCommandInput::Unstructured(UnstructuredCommandInput::new(" query ")),
4095 ),
4096 AvailableCommand::new("Review", "agent collision"),
4097 AvailableCommand::new("INSPECT", "duplicate agent command"),
4098 ],
4099 config: std::collections::BTreeMap::new(),
4100 active_prompt: None,
4101 queued_prompts: Vec::new(),
4102 active_user_shells: Vec::new(),
4103 active_agent_terminals: Vec::new(),
4104 checkpoint_barrier: None,
4105 checkpoint_ready: None,
4106 last_acp_activity_at_ms: None,
4107 current_step_started_at_ms: None,
4108 foreground_tool_started_at_ms: None,
4109 harness_turn: None,
4110 last_harness_turn_started_ordinal: None,
4111 background_commands: Vec::new(),
4112 background_work_known: None,
4113 };
4114 let mut operational = std::collections::BTreeMap::from([("session-1".into(), operational)]);
4115 let materialized_activity =
4116 std::collections::BTreeMap::from([("session-1".into(), Some(7_777_i64))]);
4117 let project = |operational: &std::collections::BTreeMap<String, RelayOperationalState>| {
4118 viewer_snapshot(
4119 &controller,
4120 &[],
4121 &std::collections::BTreeMap::new(),
4122 &PhoneSessionViews {
4123 conversations: &std::collections::BTreeMap::new(),
4124 queued_prompts: &std::collections::BTreeMap::new(),
4125 active_user_shells: &std::collections::BTreeMap::new(),
4126 pending_elicitations: &std::collections::BTreeMap::new(),
4127 prompt_images: &std::collections::BTreeSet::new(),
4128 operational,
4129 materialized_activity: &materialized_activity,
4130 project_sources: &PhoneProjectSources::default(),
4131 operations: &std::collections::BTreeMap::new(),
4132 move_recoveries: &std::collections::BTreeMap::new(),
4133 capacity: &[],
4134 launch_failures: &[],
4135 reviews: &std::collections::BTreeMap::new(),
4136 },
4137 1,
4138 )
4139 };
4140 let snapshot = project(&operational);
4141 let session = &snapshot.sessions[0];
4142
4143 assert_eq!(session.display_location, "podman");
4144 assert_eq!(session.last_activity_at_ms, Some(7_777));
4145 assert_eq!(
4146 session.activity_details,
4147 Some(crate::server::ViewerActivityDetails {
4148 kind: ViewerActivityKind::Idle,
4149 turn_started_at_ms: None,
4150 step_started_at_ms: None,
4151 background_started_at_ms: None,
4152 idle_since_ms: None,
4153 label: None,
4154 })
4155 );
4156
4157 assert!(session.capabilities.prompt);
4158 assert!(session.capabilities.set_plan_mode);
4159 assert_eq!(
4160 session
4161 .available_commands
4162 .iter()
4163 .map(|command| (command.name.as_str(), command.source))
4164 .collect::<Vec<_>>(),
4165 vec![
4166 ("help", ViewerCommandSource::Mj),
4167 ("detach", ViewerCommandSource::Mj),
4168 ("plan", ViewerCommandSource::Mj),
4169 ("implement", ViewerCommandSource::Mj),
4170 ("review", ViewerCommandSource::Mj),
4171 ("inspect", ViewerCommandSource::Agent),
4172 ]
4173 );
4174 let inspect = session.available_commands.last().unwrap();
4175 assert_eq!(inspect.description, "Inspect the workspace");
4176 assert_eq!(inspect.argument.as_deref(), Some("query"));
4177
4178 assert!(session.is_idle);
4179 assert_eq!(session.activity, "[idle]");
4180 let state = operational.get_mut("session-1").unwrap();
4181 state
4182 .background_commands
4183 .push(mj_core::relay::BackgroundCommand {
4184 id: "background-1".into(),
4185 started_at_ms: 1_000,
4186 command: "background check".into(),
4187 can_stop: true,
4188 });
4189 let background = project(&operational);
4190 assert_eq!(
4191 background.sessions[0].background_tasks,
4192 vec![ViewerBackgroundTask {
4193 id: "background-1".into(),
4194 command: "background check".into(),
4195 started_at_ms: 1_000,
4196 can_stop: true,
4197 }]
4198 );
4199 assert!(!background.sessions[0].is_idle);
4200 assert!(background.sessions[0].activity.starts_with("BG "));
4201 let state = operational.get_mut("session-1").unwrap();
4202 state.background_commands.clear();
4203 state.execution = RelayExecutionState::Running;
4205 let stale_running = project(&operational);
4206 assert!(stale_running.sessions[0].is_idle);
4207 operational
4208 .get_mut("session-1")
4209 .unwrap()
4210 .current_step_started_at_ms = Some(1_000);
4211 let running = project(&operational);
4212 assert!(!running.sessions[0].is_idle);
4213 assert_ne!(running.sessions[0].activity, "[idle]");
4214 operational.get_mut("session-1").unwrap().execution = RelayExecutionState::Idle;
4215 let idle_again = project(&operational);
4216 assert!(idle_again.sessions[0].is_idle);
4217 assert_eq!(idle_again.sessions[0].activity, "[idle]");
4218 let unknown = project(&std::collections::BTreeMap::new());
4219 assert!(!unknown.sessions[0].is_idle);
4220 assert!(unknown.sessions[0].activity.is_empty());
4221 assert!(unknown.sessions[0].activity_details.is_none());
4222 }
4223
4224 #[test]
4225 fn tailscale_listener_preserves_the_configured_port() {
4226 assert_eq!(
4227 tailscale_bind("127.0.0.1:4765".parse().unwrap()),
4228 "0.0.0.0:4765".parse().unwrap()
4229 );
4230 }
4231
4232 fn controller_with_profiles(ids: &[&str]) -> Controller {
4233 Controller {
4234 config: Config {
4235 subagents: Default::default(),
4236 version: CONFIG_VERSION,
4237 sessions_side: Default::default(),
4238 advanced: Default::default(),
4239 show_stopped_sessions: false,
4240 newer_config_version: None,
4241 spinner: Default::default(),
4242 theme: Default::default(),
4243 phone: Default::default(),
4244 review: Default::default(),
4245 legacy_startup: (),
4246 profiles: ids
4247 .iter()
4248 .map(|id| {
4249 (
4250 (*id).to_owned(),
4251 HarnessProfile {
4252 enabled: true,
4253 context_window_bytes: None,
4254 guardian_review_model: None,
4255 kind: HarnessKind::Codex,
4256 home: PathBuf::from("/home/agent").join(id),
4257 environment: std::collections::BTreeMap::new(),
4258 },
4259 )
4260 })
4261 .collect(),
4262 bundles: std::collections::BTreeMap::new(),
4263 targets: std::collections::BTreeMap::new(),
4264 },
4265 state: State::default(),
4266 }
4267 }
4268
4269 fn snapshot_with_project_sources(
4270 controller: &Controller,
4271 sources: &PhoneProjectSources,
4272 ) -> ViewerSnapshot {
4273 viewer_snapshot(
4274 controller,
4275 &[],
4276 &Default::default(),
4277 &PhoneSessionViews {
4278 conversations: &Default::default(),
4279 queued_prompts: &Default::default(),
4280 active_user_shells: &Default::default(),
4281 pending_elicitations: &Default::default(),
4282 prompt_images: &Default::default(),
4283 operational: &Default::default(),
4284 materialized_activity: &Default::default(),
4285 project_sources: sources,
4286 operations: &Default::default(),
4287 move_recoveries: &Default::default(),
4288 capacity: &[],
4289 launch_failures: &[],
4290 reviews: &Default::default(),
4291 },
4292 1,
4293 )
4294 }
4295
4296 #[tokio::test]
4297 async fn phone_projects_resolve_origins_and_discard_results_after_location_changes() {
4298 let root = tempfile::tempdir().unwrap();
4299 let mut controller = controller_with_profiles(&["codex"]);
4300 controller
4301 .config
4302 .targets
4303 .insert("local".into(), TargetTemplate::LocalBare);
4304 for (id, origin) in [
4305 ("first-checkout", "git@github.com:BrokkAi/hel.git"),
4306 ("second-checkout", "https://github.com/BrokkAi/hel.git"),
4307 ] {
4308 let directory = root.path().join(id);
4309 std::fs::create_dir(&directory).unwrap();
4310 for args in [vec!["init"], vec!["remote", "add", "origin", origin]] {
4311 let command = crate::targets::CommandSpec::new(
4312 "git",
4313 ["-C".to_owned(), directory.to_string_lossy().into_owned()]
4314 .into_iter()
4315 .chain(args.into_iter().map(str::to_owned)),
4316 );
4317 assert_eq!(ProcessExecutor.execute(&command).unwrap().status, 0);
4318 }
4319 let mut record = phone_session(id, 0);
4320 record.project_directory = Some(directory);
4321 record.target_template_id = "local".into();
4322 controller.state.sessions.insert(id.into(), record);
4323 }
4324 let mut sources = PhoneProjectSources::default();
4325 sources.synchronize(&controller);
4326 assert_eq!(sources.jobs.len(), 2);
4327 tokio::time::timeout(Duration::from_secs(10), async {
4328 while let Some(result) = sources.jobs.join_next().await {
4329 sources.complete(result.unwrap());
4330 }
4331 })
4332 .await
4333 .unwrap();
4334 let snapshot = snapshot_with_project_sources(&controller, &sources);
4335 assert_eq!(
4336 snapshot.sessions[0].project_key,
4337 snapshot.sessions[1].project_key
4338 );
4339 assert!(
4340 snapshot
4341 .sessions
4342 .iter()
4343 .all(|session| session.project_label == "hel")
4344 );
4345 assert!(
4346 !serde_json::to_string(&snapshot)
4347 .unwrap()
4348 .contains(&root.path().to_string_lossy().to_string())
4349 );
4350 sources.synchronize(&controller);
4351 assert!(
4352 sources.jobs.is_empty(),
4353 "unchanged inputs reuse the resolved origin"
4354 );
4355
4356 let previous = &sources.entries["first-checkout"];
4357 let late = ProjectSourceResolved {
4358 cancelled: previous.cancelled.clone(),
4359 session_id: "first-checkout".into(),
4360 key: previous.key.clone(),
4361 result: Ok(ProjectSourceIdentity::git_remote("old/wrong").unwrap()),
4362 };
4363 controller
4364 .state
4365 .sessions
4366 .get_mut("first-checkout")
4367 .unwrap()
4368 .project_directory = None;
4369 let snapshot = snapshot_with_project_sources(&controller, &sources);
4370 assert_ne!(
4371 snapshot.sessions[0].project_key,
4372 snapshot.sessions[1].project_key
4373 );
4374 sources.synchronize(&controller);
4375 assert!(late.cancelled.load(Ordering::Acquire));
4376 sources.complete(late);
4377 assert!(!sources.entries.contains_key("first-checkout"));
4378 assert_eq!(snapshot.sessions[0].project_label, "project");
4379 }
4380
4381 #[test]
4382 fn capacity_target_publication_skips_unchanged_targets_and_preserves_readings() {
4383 let mut controller = controller_with_profiles(&[]);
4384 controller
4385 .config
4386 .targets
4387 .insert("raw".into(), TargetTemplate::LocalBare);
4388 let (targets_tx, mut targets_rx) = tokio::sync::watch::channel(Vec::new());
4389 let mut state = std::collections::BTreeMap::new();
4390
4391 publish_capacity_targets(&controller, &targets_tx, &mut state);
4392 assert!(targets_rx.has_changed().expect("target sender is alive"));
4393 assert_eq!(targets_rx.borrow_and_update().len(), 1);
4394
4395 let usage = crate::targets::DeploymentCapacityUsage {
4396 cpu_percent: Some(37),
4397 memory_used_bytes: 3,
4398 memory_total_bytes: 4,
4399 logical_cores: 8,
4400 disk_total_bytes: Some(5),
4401 };
4402 let local = state.get_mut("local").expect("local capacity state");
4403 local.usage = Some(usage.clone());
4404 local.on_demand = true;
4405 local.sampled_at_epoch_seconds = Some(42);
4406 local.refreshing = false;
4407
4408 publish_capacity_targets(&controller, &targets_tx, &mut state);
4409 assert!(!targets_rx.has_changed().expect("target sender is alive"));
4410 let local_capacity = viewer_capacity(&state)
4411 .into_iter()
4412 .find(|capacity| capacity.id == "local")
4413 .expect("local viewer capacity");
4414 assert_eq!(local_capacity.cpu_percent, usage.cpu_percent);
4415 assert_eq!(
4416 local_capacity.memory_used_bytes,
4417 Some(usage.memory_used_bytes)
4418 );
4419 assert_eq!(local_capacity.logical_cores, Some(usage.logical_cores));
4420 assert_eq!(local_capacity.sampled_at_epoch_seconds, Some(42));
4421
4422 controller
4423 .config
4424 .targets
4425 .insert("second-local".into(), TargetTemplate::LocalBare);
4426 publish_capacity_targets(&controller, &targets_tx, &mut state);
4427 assert!(targets_rx.has_changed().expect("target sender is alive"));
4428 assert_eq!(targets_rx.borrow_and_update().len(), 1);
4429 assert_eq!(state["local"].usage, Some(usage.clone()));
4430
4431 controller.config.targets.insert(
4432 "fleet".into(),
4433 TargetTemplate::AwsEc2 {
4434 aws_profile: None,
4435 region: "us-east-1".into(),
4436 launch_template: "hel-runson".into(),
4437 launch_template_version: None,
4438 ssh_user: "ubuntu".into(),
4439 address_source: Default::default(),
4440 identity_file: None,
4441 ssh_args: Vec::new(),
4442 },
4443 );
4444 publish_capacity_targets(&controller, &targets_tx, &mut state);
4445 assert!(targets_rx.has_changed().expect("target sender is alive"));
4446 assert_eq!(targets_rx.borrow_and_update().len(), 2);
4447 assert!(state.contains_key("aws:fleet"));
4448 assert_eq!(state["local"].usage, Some(usage.clone()));
4449
4450 controller.config.targets.remove("fleet");
4451 publish_capacity_targets(&controller, &targets_tx, &mut state);
4452 assert!(targets_rx.has_changed().expect("target sender is alive"));
4453 assert_eq!(targets_rx.borrow_and_update().len(), 1);
4454 assert!(!state.contains_key("aws:fleet"));
4455 assert_eq!(state["local"].usage, Some(usage));
4456 }
4457
4458 fn prompt_action() -> ControllerAction {
4459 ControllerAction::Prompt {
4460 session_id: "session-1".into(),
4461 text: "ship it".into(),
4462 images: Vec::new(),
4463 }
4464 }
4465
4466 fn new_action() -> ControllerAction {
4467 ControllerAction::New {
4468 mjolnir_subagents: None,
4469 create_managed_worktree: None,
4470 workspace_id: String::new(),
4471 profile_id: "codex".into(),
4472 bundle_id: "project".into(),
4473 target_id: "podman".into(),
4474 title: Some("Phone launch".into()),
4475 project_directory: None,
4476 dirty_ack: Vec::new(),
4477 }
4478 }
4479
4480 fn phone_session(id: &str, viewed_through_event_ordinal: u64) -> SessionRecord {
4481 SessionRecord {
4482 mjolnir_subagents: None,
4483 create_managed_worktree: None,
4484 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
4485 archived: false,
4486 container_cpus: None,
4487 container_memory: None,
4488 id: id.into(),
4489 title: "Phone launch".into(),
4490 harness_kind: mj_core::config::HarnessKind::Codex,
4491 last_profile: "codex".into(),
4492 bundle_id: "project".into(),
4493 project_directory: None,
4494 managed_worktree: None,
4495 target_template_id: "podman".into(),
4496 resource_allocation: None,
4497 additional_mounts: Vec::new(),
4498 state: SessionState::Provisioning,
4499 target: None,
4500 native_session_id: None,
4501 acp_session_title: None,
4502 session_title_override: Some("Phone launch".into()),
4503 created_at: "2026-08-14T00:00:00Z".into(),
4504 updated_at: "2026-08-14T00:00:00Z".into(),
4505 viewed_through_event_ordinal,
4506 draft_input: String::new(),
4507 last_error: None,
4508 last_checkpoint_error: None,
4509 checkpoint: None,
4510 }
4511 }
4512
4513 #[tokio::test]
4514 async fn controller_reload_does_not_block_the_phone_control_loop() {
4515 let (completed_tx, mut completed_rx) = tokio::sync::mpsc::unbounded_channel();
4516 let (started_tx, started_rx) = std::sync::mpsc::channel();
4517 let (release_tx, release_rx) = std::sync::mpsc::channel();
4518 let loaded = controller_with_profiles(&[]);
4519 let release = std::thread::spawn(move || {
4520 started_rx.recv().unwrap();
4521 std::thread::sleep(Duration::from_millis(250));
4522 release_tx.send(()).unwrap();
4523 });
4524
4525 let started = Instant::now();
4526 spawn_controller_reload_with(completed_tx, move || {
4527 started_tx.send(()).unwrap();
4528 release_rx.recv().unwrap();
4529 Ok(loaded)
4530 });
4531 assert!(
4532 started.elapsed() < Duration::from_millis(100),
4533 "scheduling a controller reload occupied the control loop for {:?}",
4534 started.elapsed()
4535 );
4536
4537 let completed = tokio::time::timeout(Duration::from_secs(1), completed_rx.recv())
4538 .await
4539 .expect("background reload timed out")
4540 .expect("background reload channel closed");
4541 assert!(completed.result.is_ok());
4542 release.join().unwrap();
4543 }
4544
4545 #[test]
4546 fn read_receipt_only_persists_and_refreshes_when_the_cursor_advances() {
4547 let session_id = "0123456789abcdef0123456789abcdef";
4548 let mut state = State::default();
4549 state
4550 .sessions
4551 .insert(session_id.into(), phone_session(session_id, 5));
4552
4553 assert_eq!(
4556 plan_read_receipt(&state, session_id, 5),
4557 ReadReceiptPlan::AlreadyRead
4558 );
4559 assert_eq!(
4560 plan_read_receipt(&state, session_id, 4),
4561 ReadReceiptPlan::AlreadyRead
4562 );
4563 assert_eq!(
4564 plan_read_receipt(&state, "missing", 9),
4565 ReadReceiptPlan::UnknownSession
4566 );
4567 assert_eq!(
4568 plan_read_receipt(&state, session_id, 9),
4569 ReadReceiptPlan::Persist
4570 );
4571
4572 assert!(apply_read_receipt(&mut state, session_id, 9));
4573 assert_eq!(state.sessions[session_id].viewed_through_event_ordinal, 9);
4574 assert!(!apply_read_receipt(&mut state, session_id, 9));
4575 assert!(!apply_read_receipt(&mut state, session_id, 7));
4576 assert!(!apply_read_receipt(&mut state, "missing", 9));
4577 assert_eq!(
4578 plan_read_receipt(&state, session_id, 9),
4579 ReadReceiptPlan::AlreadyRead
4580 );
4581 }
4582
4583 #[tokio::test]
4584 async fn an_admitted_action_answers_its_phone_before_the_work_runs() {
4585 let mut replies = PendingActionReplies::default();
4586 let (reply, answer) = tokio::sync::oneshot::channel();
4587
4588 replies.accept(1, &prompt_action(), reply);
4589
4590 assert_eq!(answer.await.unwrap(), ActionOutcome::accepted());
4594 }
4595
4596 #[tokio::test]
4597 async fn a_new_action_answers_once_its_provisional_session_is_published() {
4598 let mut replies = PendingActionReplies::default();
4599 let (reply, mut answer) = tokio::sync::oneshot::channel();
4600
4601 replies.accept(7, &new_action(), reply);
4602 assert!(
4603 matches!(
4604 answer.try_recv(),
4605 Err(tokio::sync::oneshot::error::TryRecvError::Empty)
4606 ),
4607 "a new session has no id to report before it is published"
4608 );
4609
4610 replies.resolve(7, ActionOutcome::accepted());
4611 assert_eq!(answer.await.unwrap(), ActionOutcome::accepted());
4612 }
4613
4614 #[tokio::test]
4615 async fn a_new_action_that_never_publishes_still_answers_its_phone() {
4616 let mut replies = PendingActionReplies::default();
4617 let (reply, answer) = tokio::sync::oneshot::channel();
4618 replies.accept(7, &new_action(), reply);
4619
4620 replies.resolve(7, ActionOutcome::Failed);
4623
4624 assert_eq!(answer.await.unwrap(), ActionOutcome::Failed);
4625 replies.resolve(7, ActionOutcome::accepted());
4628 }
4629
4630 #[test]
4631 fn force_close_is_admitted_like_close() {
4632 let mut active = std::collections::BTreeSet::from(["session-1".to_owned()]);
4633 let force_close = ControllerAction::ForceClose {
4634 session_id: "session-1".into(),
4635 };
4636 assert_eq!(
4639 admit_phone_action(&force_close, MAX_CONCURRENT_PHONE_ACTIONS, &mut active),
4640 Ok(Some("session-1".into()))
4641 );
4642 assert_eq!(
4643 controller_action_session_id(&force_close),
4644 Some("session-1".to_owned())
4645 );
4646 }
4647
4648 #[test]
4649 fn close_is_admitted_while_provisioning_occupies_a_full_action_pool() {
4650 let mut active = std::collections::BTreeSet::from(["session-1".to_owned()]);
4651 let close = ControllerAction::Close {
4652 session_id: "session-1".into(),
4653 };
4654 assert_eq!(
4655 admit_phone_action(&close, MAX_CONCURRENT_PHONE_ACTIONS, &mut active),
4656 Ok(Some("session-1".into()))
4657 );
4658 assert_eq!(
4659 admit_phone_action(&prompt_action(), 0, &mut active),
4660 Err(ActionOutcome::SessionBusy)
4661 );
4662 }
4663
4664 #[test]
4665 fn a_refused_action_reports_the_reason_the_phone_can_act_on() {
4666 let mut active = std::collections::BTreeSet::new();
4667
4668 assert_eq!(
4669 admit_phone_action(&prompt_action(), 0, &mut active),
4670 Ok(Some("session-1".to_owned()))
4671 );
4672 assert_eq!(
4673 admit_phone_action(&prompt_action(), 1, &mut active),
4674 Err(ActionOutcome::SessionBusy)
4675 );
4676 assert_eq!(
4677 admit_phone_action(&new_action(), MAX_CONCURRENT_PHONE_ACTIONS, &mut active),
4678 Err(ActionOutcome::Busy)
4679 );
4680 assert_eq!(active.len(), 1);
4682 assert_eq!(admit_phone_action(&new_action(), 1, &mut active), Ok(None));
4683 }
4684
4685 #[test]
4686 fn a_feed_that_ends_outside_shutdown_names_the_failure() {
4687 assert!(feed_stopped(true, "the session manager stopped").is_none());
4688 let failure = feed_stopped(false, "the session manager stopped").expect("named failure");
4689 assert!(failure.to_string().contains("session manager"));
4690 }
4691
4692 #[test]
4693 fn a_profile_added_while_the_server_runs_reaches_the_quota_refresher() {
4694 let (profiles_tx, profiles_rx) = tokio::sync::watch::channel(QuotaRefreshBatch::default());
4695 let mut published = std::collections::BTreeMap::new();
4696 let mut batch = QuotaRefreshBatch::default();
4697 let controller = controller_with_profiles(&["codex"]);
4698
4699 assert!(republish_quota_profiles(
4700 &controller,
4701 &mut published,
4702 &mut batch,
4703 &profiles_tx
4704 ));
4705 assert_eq!(
4706 profiles_rx
4707 .borrow()
4708 .profiles
4709 .iter()
4710 .map(|profile| profile.profile_id.clone())
4711 .collect::<Vec<_>>(),
4712 vec!["codex".to_owned()]
4713 );
4714 let first_generation = profiles_rx.borrow().generation;
4715
4716 assert!(!republish_quota_profiles(
4719 &controller,
4720 &mut published,
4721 &mut batch,
4722 &profiles_tx
4723 ));
4724 assert_eq!(profiles_rx.borrow().generation, first_generation);
4725
4726 let grown = controller_with_profiles(&["claude", "codex"]);
4727 assert!(republish_quota_profiles(
4728 &grown,
4729 &mut published,
4730 &mut batch,
4731 &profiles_tx
4732 ));
4733 assert_eq!(
4734 profiles_rx
4735 .borrow()
4736 .profiles
4737 .iter()
4738 .map(|profile| profile.profile_id.clone())
4739 .collect::<Vec<_>>(),
4740 vec!["claude".to_owned(), "codex".to_owned()]
4741 );
4742 assert!(profiles_rx.borrow().generation > first_generation);
4743 }
4744
4745 #[test]
4746 fn a_quota_reads_stale_only_once_its_next_refresh_is_overdue() {
4747 let controller = controller_with_profiles(&["codex"]);
4748 let now = std::time::SystemTime::now()
4749 .duration_since(std::time::UNIX_EPOCH)
4750 .unwrap()
4751 .as_secs();
4752 let quota_refreshed = |age: Duration| {
4753 let quotas = std::collections::BTreeMap::from([(
4754 "codex".to_owned(),
4755 ProfileQuota {
4756 profile_id: "codex".into(),
4757 harness: HarnessKind::Codex,
4758 windows: Vec::new(),
4759 extra: None,
4760 error: None,
4761 refreshed_at_epoch_seconds: now - age.as_secs(),
4762 },
4763 )]);
4764 viewer_snapshot(
4765 &controller,
4766 &[],
4767 "as,
4768 &PhoneSessionViews {
4769 conversations: &std::collections::BTreeMap::new(),
4770 queued_prompts: &std::collections::BTreeMap::new(),
4771 active_user_shells: &std::collections::BTreeMap::new(),
4772 pending_elicitations: &std::collections::BTreeMap::new(),
4773 prompt_images: &std::collections::BTreeSet::new(),
4774 operational: &std::collections::BTreeMap::new(),
4775 materialized_activity: &std::collections::BTreeMap::new(),
4776 project_sources: &PhoneProjectSources::default(),
4777 operations: &std::collections::BTreeMap::new(),
4778 move_recoveries: &std::collections::BTreeMap::new(),
4779 capacity: &[],
4780 launch_failures: &[],
4781 reviews: &std::collections::BTreeMap::new(),
4782 },
4783 1,
4784 )
4785 .profiles[0]
4786 .quota
4787 .as_ref()
4788 .expect("the profile carries its quota")
4789 .stale
4790 };
4791
4792 assert!(!quota_refreshed(QUOTA_REFRESH_INTERVAL));
4795 assert!(!quota_refreshed(QUOTA_STALE_AFTER));
4796 assert!(quota_refreshed(QUOTA_STALE_AFTER + Duration::from_secs(1)));
4797 }
4798
4799 #[test]
4800 fn phone_action_capacity_is_bounded() {
4801 assert!(phone_action_capacity_available(
4802 MAX_CONCURRENT_PHONE_ACTIONS - 1
4803 ));
4804 assert!(!phone_action_capacity_available(
4805 MAX_CONCURRENT_PHONE_ACTIONS
4806 ));
4807 }
4808
4809 #[test]
4810 fn started_phone_session_is_visible_and_mapped_before_provisioning() {
4811 let session_id = "0123456789abcdef0123456789abcdef";
4812 let session = phone_session(session_id, 0);
4813 let mut state = State::default();
4814 let mut active_actions = std::collections::BTreeSet::new();
4815 let mut action_sessions = std::collections::BTreeMap::new();
4816
4817 track_started_phone_session(
4818 &mut state,
4819 &mut active_actions,
4820 &mut action_sessions,
4821 7,
4822 session,
4823 )
4824 .unwrap();
4825
4826 assert_eq!(state.sessions[session_id].state, SessionState::Provisioning);
4827 assert_eq!(state.sessions[session_id].display_title(), "Phone launch");
4828 assert!(active_actions.contains(session_id));
4829 assert_eq!(
4830 action_sessions.get(&7).map(String::as_str),
4831 Some(session_id)
4832 );
4833 }
4834
4835 #[test]
4836 fn failed_launch_notice_survives_session_rollback_and_history_is_bounded() {
4837 let controller = controller_with_profiles(&["codex"]);
4838 let mut failures = Vec::new();
4839 for index in 0..20 {
4840 record_launch_failure(
4841 &mut failures,
4842 index,
4843 format!("workspace-{index}"),
4844 Some(format!("session-{index}")),
4845 Some(format!("worker bootstrap failed for {index}")),
4846 );
4847 }
4848 let snapshot = viewer_snapshot(
4849 &controller,
4850 &[],
4851 &std::collections::BTreeMap::new(),
4852 &PhoneSessionViews {
4853 conversations: &std::collections::BTreeMap::new(),
4854 queued_prompts: &std::collections::BTreeMap::new(),
4855 active_user_shells: &std::collections::BTreeMap::new(),
4856 pending_elicitations: &std::collections::BTreeMap::new(),
4857 prompt_images: &std::collections::BTreeSet::new(),
4858 operational: &std::collections::BTreeMap::new(),
4859 materialized_activity: &std::collections::BTreeMap::new(),
4860 project_sources: &PhoneProjectSources::default(),
4861 operations: &std::collections::BTreeMap::new(),
4862 move_recoveries: &std::collections::BTreeMap::new(),
4863 capacity: &[],
4864 launch_failures: &failures,
4865 reviews: &std::collections::BTreeMap::new(),
4866 },
4867 1,
4868 );
4869 assert!(snapshot.sessions.is_empty());
4870 assert_eq!(failures.len(), 16);
4871 assert_eq!(failures[0].workspace_id, "workspace-4");
4872 assert_eq!(failures[15].workspace_id, "workspace-19");
4873 assert_ne!(failures[0].id, failures[1].id);
4874 assert_eq!(
4875 failures[15].session_id.as_deref(),
4876 Some("session-19"),
4877 "a wait on that session has to be able to recognize its own launch failure"
4878 );
4879 let json = serde_json::to_value(snapshot).unwrap();
4880 assert_eq!(json["launch_failures"][15]["workspace_id"], "workspace-19");
4881 assert_eq!(
4882 json["launch_failures"][15]["error"], "worker bootstrap failed for 19",
4883 "the recorded failure carries its reason so a client can show it"
4884 );
4885 assert_eq!(json["launch_failures"][15].as_object().unwrap().len(), 4);
4886 }
4887
4888 #[test]
4889 fn a_later_successful_action_clears_a_session_s_recorded_failure() {
4890 let mut pending = std::collections::BTreeMap::new();
4891
4892 record_action_result(&mut pending, Some("session-1"), &Err("relay hiccup".into()));
4893 assert_eq!(
4894 pending.get("session-1").map(String::as_str),
4895 Some("relay hiccup")
4896 );
4897
4898 record_action_result(&mut pending, Some("session-2"), &Ok(()));
4899 record_action_result(&mut pending, Some("session-1"), &Ok(()));
4900 assert!(
4901 pending.is_empty(),
4902 "the overlay has no other expiry, so a stale error would badge the session forever"
4903 );
4904
4905 record_action_result(&mut pending, None, &Err("orphaned".into()));
4907 assert!(pending.is_empty());
4908 }
4909
4910 #[test]
4911 fn phone_cancel_targets_the_matching_background_action() {
4912 let first = PhoneActionControl {
4913 cancelled: Arc::new(AtomicBool::new(false)),
4914 create: None,
4915 };
4916 let second = PhoneActionControl {
4917 cancelled: Arc::new(AtomicBool::new(false)),
4918 create: None,
4919 };
4920 let action_sessions =
4921 std::collections::BTreeMap::from([(1, "session-1".into()), (2, "session-2".into())]);
4922 let cancellations =
4923 std::collections::BTreeMap::from([(1, first.clone()), (2, second.clone())]);
4924
4925 assert!(request_phone_action_cancellation(
4926 "session-2",
4927 &action_sessions,
4928 &cancellations,
4929 ));
4930 assert!(!first.cancelled.load(Ordering::Acquire));
4931 assert!(second.cancelled.load(Ordering::Acquire));
4932 assert!(!request_phone_action_cancellation(
4933 "missing",
4934 &action_sessions,
4935 &cancellations,
4936 ));
4937 }
4938
4939 #[test]
4940 fn phone_new_cancel_and_running_commit_have_one_atomic_winner() {
4941 for _ in 0..100 {
4942 let create = CreateSessionControl::default();
4943 let control = PhoneActionControl {
4944 cancelled: create.cancelled.clone(),
4945 create: Some(create),
4946 };
4947 let cancelling = control.clone();
4948 let committing = control.clone();
4949 let (cancelled, committed) = std::thread::scope(|scope| {
4950 let cancel = scope.spawn(move || cancelling.request_cancel());
4951 let commit = scope.spawn(move || committing.grant_new_commit());
4952 (cancel.join().unwrap(), commit.join().unwrap())
4953 });
4954
4955 assert_ne!(cancelled, committed);
4956 assert_eq!(control.cancelled.load(Ordering::Acquire), cancelled);
4957 assert!(!control.request_cancel());
4958 assert!(!control.grant_new_commit());
4959 }
4960 }
4961
4962 fn materialized_at(ordinal: u64) -> MaterializedSession {
4963 let mut materialized = MaterializedSession::empty("session-1");
4964 materialized.applied_event_ordinal = ordinal;
4965 materialized.applied_event_digest = format!("digest-{ordinal}");
4966 materialized
4967 }
4968
4969 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
4970 async fn browser_projection_enqueue_does_not_wait_for_a_blocking_permit() {
4971 let (results, mut completed) = tokio::sync::mpsc::channel(1);
4972 let shutdown = tokio_util::sync::CancellationToken::new();
4973 let mut dispatcher = ConversationProjectionDispatcher::with_permits(results, shutdown, 0);
4974
4975 dispatcher.enqueue(materialized_at(1));
4976 let (progress, progress_done) = tokio::sync::oneshot::channel();
4977 tokio::spawn(async move {
4978 progress.send(()).expect("control task is still alive");
4979 });
4980
4981 tokio::time::timeout(Duration::from_millis(100), progress_done)
4982 .await
4983 .expect("control progress was starved by transcript projection")
4984 .expect("progress task did not report");
4985 assert!(completed.try_recv().is_err());
4986 }
4987
4988 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
4989 async fn browser_projection_keeps_only_the_newest_pending_snapshot() {
4990 let (results, mut completed) = tokio::sync::mpsc::channel(4);
4991 let shutdown = tokio_util::sync::CancellationToken::new();
4992 let mut dispatcher = ConversationProjectionDispatcher::with_permits(results, shutdown, 0);
4993
4994 dispatcher.enqueue(materialized_at(1));
4995 dispatcher.enqueue(materialized_at(3));
4996 dispatcher.enqueue(materialized_at(2));
4997 assert_eq!(dispatcher.pending["session-1"].key.ordinal, 3);
4998
4999 dispatcher.permits.add_permits(1);
5000 let first = tokio::time::timeout(Duration::from_secs(1), completed.recv())
5001 .await
5002 .expect("first projection did not complete")
5003 .expect("projection result channel closed");
5004 assert_eq!(first.key.ordinal, 1);
5005 assert!(dispatcher.finish(first, true).is_some());
5006
5007 let second = tokio::time::timeout(Duration::from_secs(1), completed.recv())
5008 .await
5009 .expect("coalesced projection did not complete")
5010 .expect("projection result channel closed");
5011 assert_eq!(second.key.ordinal, 3);
5012 assert!(dispatcher.finish(second, true).is_some());
5013 assert!(dispatcher.pending.is_empty());
5014 assert!(dispatcher.in_flight.is_empty());
5015 }
5016
5017 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
5018 async fn forgotten_projection_cannot_repopulate_after_a_same_cursor_resume() {
5019 let (results, mut completed) = tokio::sync::mpsc::channel(4);
5020 let shutdown = tokio_util::sync::CancellationToken::new();
5021 let mut dispatcher = ConversationProjectionDispatcher::with_permits(results, shutdown, 0);
5022 let snapshot = materialized_at(7);
5023
5024 dispatcher.enqueue(snapshot.clone());
5025 dispatcher.forget("session-1");
5026 dispatcher.enqueue(snapshot);
5027 dispatcher.permits.add_permits(1);
5028
5029 let old = tokio::time::timeout(Duration::from_secs(1), completed.recv())
5030 .await
5031 .expect("old projection did not complete")
5032 .expect("projection result channel closed");
5033 assert!(dispatcher.finish(old, true).is_none());
5034
5035 let current = tokio::time::timeout(Duration::from_secs(1), completed.recv())
5036 .await
5037 .expect("resumed projection did not complete")
5038 .expect("projection result channel closed");
5039 assert!(dispatcher.finish(current, true).is_some());
5040 assert!(dispatcher.in_flight.is_empty());
5041 }
5042}