Skip to main content

mj_controller/
server_runtime.rs

1//! The phone-oriented remote-control server: its HTTP surface, the controller
2//! actions phones request, and the concurrency limits that keep them safe.
3
4use 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
69/// Transcript projection parses every stored ACP content chunk. Keep that work
70/// off the control task, and allow only a small number of projections to use
71/// the blocking pool at once so a burst of active sessions cannot turn the
72/// pool into an unbounded queue.
73const 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    /// Event ordinals are monotonic. A changed digest at one ordinal is also a
91    /// new projection, which preserves the integrity-repair path without
92    /// relying on string ordering for digests.
93    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
112/// Owns the one-at-a-time/latest-state scheduling for each session. The
113/// control loop remains the sole owner of these maps; background tasks only
114/// return completed browser projections through `results`.
115struct 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    /// Queue a session's newest durable view. At most one request is running
155    /// and one newer request is retained for any given session.
156    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    /// Finish one task and, when the session is still active, immediately
195    /// launch the newest coalesced request. Returning `None` means the task
196    /// failed or no longer belongs to the current in-flight request.
197    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                // An inactive session, or a result from an earlier lifecycle
225                // generation, must not resurrect a conversation. Its next
226                // active update gets a fresh generation.
227                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    /// Drop queued/completed state after a controller reload removes a
250    /// session. An in-flight task is allowed to finish; `finish` receives the
251    /// current active-state guard and discards its result.
252    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/// The phone replies the control loop still owes.
465///
466/// A phone is answered as soon as its action is admitted, because provisioning,
467/// resume and close run for minutes and a request held open that long dies on a
468/// mobile network. `new` is the one action whose acceptance means more than
469/// admission: the phone has no session id until the provisional session is
470/// published, so its reply is parked here until the loop publishes it — or
471/// until the action ends without ever getting that far.
472#[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
508/// Admission control for one phone action, run before any work starts so the
509/// answer to the phone never waits on the operation itself. Reports the session
510/// the action occupies, or the outcome that refuses it.
511fn 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
557/// Loads durable controller state without occupying the phone control loop.
558/// The outer task observes blocking-task panics and reports a closed result
559/// channel instead of silently abandoning the refresh.
560fn 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/// What one phone read receipt actually needs.
605#[derive(Debug, PartialEq, Eq)]
606#[cfg(test)]
607enum ReadReceiptPlan {
608    UnknownSession,
609    /// The cursor has not advanced, so the receipt needs no work at all.
610    AlreadyRead,
611    /// The cursor advanced: persist it, then refresh the snapshot.
612    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/// Record a persisted receipt in the in-memory projection, reporting whether
628/// the cursor moved. That is exactly when the snapshot revision has to move,
629/// so surfaces showing unread state refresh and nothing else does.
630#[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        &quota_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    // What the capacity poller last said, per probe target. The projection is
727    // built from this on every publish rather than being accumulated, so a
728    // target that disappears from the configuration disappears from the page.
729    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        &quotas,
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    // Captured before `options` is moved into the server.
779    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    // Session cookies are stateless, so a per-process key would sign every
798    // phone out on every restart. Delete the key file to sign them out on
799    // purpose.
800    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    // The documented `/api/v1` surface authenticates with a persisted bearer
803    // token and drives sessions through the daemon-side backend.
804    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        // Stored viewer state expires with the authentication that created it.
861        // The sweep is hourly rather than on every request, because it is
862        // housekeeping and nothing waits for it.
863        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        // A feed that ends is not a reason to exit quietly: the phone server
909        // exists to follow sessions, so losing that feed is a named failure
910        // rather than a silent success.
911        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                    &quotas,
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                                // A fleet with nothing running reports no
1075                                // figures, and that is an answer rather than a
1076                                // failure.
1077                                entry.on_demand = usage.is_none();
1078                                entry.usage = usage;
1079                                entry.failed = false;
1080                            }
1081                            // The last good reading stays on screen beside the
1082                            // failure: one failed probe is not a reason to
1083                            // forget what the machine was doing.
1084                            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                        // Invalidate a late result before it can be applied or
1128                        // launch another queued projection. A session that is
1129                        // resumed later gets a new generation from its next
1130                        // worker snapshot.
1131                        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                        // Controller reload normally removes inactive rows
1142                        // first, but this also covers a worker result racing
1143                        // that reload and keeps the viewer from seeing a
1144                        // conversation for a dead session.
1145                        revision = daemon_runtime.allocate_revision();
1146                        conversation_tx.send_replace(conversations.clone());
1147                        publish_snapshot!(revision);
1148                    }
1149                    // SessionManagerUpdates has a synchronous pending fast
1150                    // path. Yield after each completion so a hot stream of
1151                    // updates cannot monopolize this runtime worker.
1152                    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                                    // The result reaches the model as the tool
1190                                    // call's own answer: completing the request
1191                                    // unblocks the worker socket the harness is
1192                                    // waiting on. It is not injected as a turn.
1193                                    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                    // The session update receiver can return pending entries
1294                    // without touching Tokio's budgeted receive operation.
1295                    // Give HTTP/TLS tasks a scheduling opportunity after each
1296                    // update even when the worker is publishing continuously.
1297                    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                    // Only rows whose client id names a phone are considered:
1334                    // a terminal client's place in a conversation is not the
1335                    // phone's to expire.
1336                    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                    // Every one of these touches SQLite, so each runs on its
1390                    // own task. A composer autosaving on a debounce must never
1391                    // be able to stall the loop that follows sessions.
1392                    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                                        // A receipt that would move backwards
1449                                        // is not an error; it is a session this
1450                                        // viewer had already read past.
1451                                        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                    // Repository canonicalization and config persistence both
1500                    // touch the filesystem. Keep them off this loop, and
1501                    // report a panic as a failed request rather than dropping
1502                    // the browser's reply.
1503                    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                            // Other config sections may have changed while
1523                            // this request was in flight. Publish only the
1524                            // bundle this transaction created; a full fresh
1525                            // config is requested below and must not make a
1526                            // later completion hide another completed bundle.
1527                            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                            // A reload started before this save may still be
1539                            // queued. It must not hide a bundle after we have
1540                            // acknowledged it as available to the browser.
1541                            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                    // A resume preflight asks a different question about the
1584                    // same disk, so it runs on the same supervised task set
1585                    // and under the same cap as a new-session preflight.
1586                    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                    // Reading a working tree's status or validating a project
1605                    // directory touches the disk, so it runs on its own task
1606                    // rather than on the loop that has to stay responsive to
1607                    // every other feed.
1608                    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                    // Preparation can inspect archives, target prerequisites,
1676                    // and harness capabilities. Keep it supervised and away
1677                    // from this feed loop so another browser can still read
1678                    // snapshots while a move form is open.
1679                    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                    // A refresh nudges a poller this loop owns. It takes no
1770                    // session slot and starts no lifecycle work, so it is
1771                    // answered here rather than admitted as an action.
1772                    match &request.action {
1773                        ControllerAction::RefreshCapacity { target_id } => {
1774                            let known = capacity_state.contains_key(target_id);
1775                            // One queued nudge refreshes every target. Do not
1776                            // block the consumer while readings wait for it.
1777                            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                                // The refresher works from a generation-stamped
1809                                // batch, so a new generation is how one is asked
1810                                // for again rather than a per-profile trigger.
1811                                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                    // A force close runs even while a graceful close for the
1854                    // same session is still in flight; that stuck close is
1855                    // exactly what it is meant to take over.
1856                    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                    // The phone asked for a session, and now there is one to
1962                    // point at: that is what its request was waiting for.
1963                    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                    // A `new` that failed before publishing a session never
1990                    // reached the arm that answers it, so its phone is still
1991                    // waiting for a reply it can act on.
1992                    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                                &quota_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        // Stop provider requests before the HTTP request channels disappear.
2134        dictation_jobs.shutdown().await;
2135        // Bundle jobs are supervised so shutdown never leaves a detached
2136        // request task behind holding the config mutation lock.
2137        bundle_jobs.shutdown().await;
2138        // Preflight jobs own cancellation guards for their blocking Git
2139        // probes. Aborting them here signals those probes before the server's
2140        // request channels disappear.
2141        preflight_jobs.shutdown().await;
2142        // Preparation tasks may be inspecting an archive or probing a target;
2143        // abort and drain them before the HTTP server's channels disappear.
2144        move_preparation_jobs.shutdown().await;
2145        move_recovery_jobs.shutdown().await;
2146        // Every exit stops in-flight work, whether it was asked for or forced.
2147        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
2176/// Why the control loop is stopping because one of its feeds ended.
2177///
2178/// During shutdown every feed ends, and that is the plan. At any other time it
2179/// means the phone server has lost the machinery it exists to drive, so it
2180/// says which feed and exits non-zero instead of reporting success.
2181fn feed_stopped(shutting_down: bool, reason: &'static str) -> Option<anyhow::Error> {
2182    (!shutting_down).then(|| anyhow::anyhow!(reason))
2183}
2184
2185/// Whether a session's agent said it accepts image content in prompts. An
2186/// agent that has not answered `initialize` yet has advertised nothing, so the
2187/// phone is not offered controls the agent may refuse.
2188fn 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        // A refresh belongs to a profile or a target rather than a session, so
2218        // it takes no session slot and cannot be refused as session-busy.
2219        ControllerAction::RefreshQuota { .. } | ControllerAction::RefreshCapacity { .. } => None,
2220    }
2221}
2222
2223/// Flatten a joined blocking result into the answer a phone channel carries.
2224fn 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
2234/// Cancel the shared subprocess executor when its request owner goes away.
2235struct ProcessCancellationGuard(Arc<AtomicBool>);
2236
2237impl Drop for ProcessCancellationGuard {
2238    fn drop(&mut self) {
2239        self.0.store(true, Ordering::Release);
2240    }
2241}
2242
2243/// Perform the preflight for a new session.
2244///
2245/// This runs on the blocking task owned by the phone server. Bare targets
2246/// need the same directory-and-Git-HEAD validation as the dashboard. Isolated
2247/// targets resolve every bundle repository to a network source and report the
2248/// exact fetch branch and publication destinations. Local working-tree
2249/// contents are never copied for this path.
2250#[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
2267/// Run one resume preflight on its own task, like a new-session preflight:
2268/// the disk work stays off the feed loop, a disconnected browser cancels it,
2269/// and the task is supervised by the same `JoinSet`.
2270fn 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
2313/// What resuming this session on this target does to its repository content.
2314///
2315/// Anything but a local-checkout conversion is `Ready` and reads nothing:
2316/// the compatibility gate is a pure function, so a browser may ask about
2317/// every destination it offers. A conversion that cannot be planned reports
2318/// the plan's own message, which is what tells a person to add a remote or
2319/// commit a submodule.
2320fn 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                    // Credentials never reach a browser, here as everywhere
2345                    // else a repository URL is published.
2346                    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
2461/// Point the quota refresher at the profiles the configuration currently
2462/// defines, alongside the worker-poll and credential-sync targets that are
2463/// rebuilt from the same reload. A profile added to `config.toml` while the
2464/// server runs otherwise reaches the snapshot but never the refresher, and
2465/// reads "quota unavailable" until the next restart.
2466///
2467/// Sending a batch restarts every profile's refresh, which spawns a harness
2468/// process per profile, so the batch travels only when the profiles changed.
2469/// Reports whether it did.
2470fn 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
2520/// Carry a finished action's failure into the session projection, and clear it
2521/// once a later action for the same session succeeds.
2522///
2523/// Nothing is waiting on the request any more, so a failure the action itself
2524/// did not record would reach no one but this process's stderr; the overlay
2525/// keeps it visible through every later durable reload, where the snapshot's
2526/// `has_error` takes it to the phone. Clearing on success matters just as
2527/// much: the overlay has no other expiry, so one transient failure would
2528/// otherwise badge the session as errored for the daemon's whole lifetime.
2529fn 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
2547/// Retain safe notices even if provisioning removed its provisional session.
2548/// This bounded, daemon-lifetime history survives durable controller reloads.
2549fn 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            // A phone that supplies no title gets the one the terminal would
2604            // have derived, so a session started from either surface reads the
2605            // same way in both.
2606            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            // Isolated creation always starts from the network default branch;
2616            // the legacy field remains accepted on the wire for old phones but
2617            // cannot opt local commits or dirty files into a new session.
2618            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(&registered_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                    // The daemon keeps detailed diagnostics in its durable
2743                    // operation record. Only its safe recovery guidance is
2744                    // copied into the phone action error, where the normal
2745                    // failed-action path records a visible session error.
2746                    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            // The refusal is a sentence for the person holding the phone --
2794            // "prompts are queued", "set [review] profile in config.toml" --
2795            // so it travels as the error text of this action.
2796            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            // Which call turns plan mode on is a fact about the harness, so it
2845            // is asked of the shared decision rather than decided here or, far
2846            // worse, in the browser.
2847            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        // Refreshes are handled by the phone control loop, which owns the
2880        // pollers they nudge.
2881        ControllerAction::RefreshQuota { .. } | ControllerAction::RefreshCapacity { .. } => {
2882            bail!("refresh actions must be handled by the phone control loop")
2883        }
2884    }
2885}
2886
2887/// Inputs that can change a session's source without changing its ID.
2888#[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/// Git/SSH probes run independently of snapshot publication and are bounded
2922/// and cancelled when their inputs disappear or the server shuts down.
2923#[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
3023/// The live, per-session projections the phone snapshot layers on top of the
3024/// controller's durable state. They arrive from relay snapshots rather than
3025/// from disk, so they travel together instead of as separate arguments.
3026struct 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    /// Sessions whose agent advertised image support in prompts.
3034    prompt_images: &'a std::collections::BTreeSet<String>,
3035    /// What each managed session's relay last reported. This is where the
3036    /// projection learns what the agent can do, rather than guessing from the
3037    /// durable record, which knows only what was configured.
3038    operational: &'a std::collections::BTreeMap<String, mj_core::relay::RelayOperationalState>,
3039    /// Durable activity watermarks delivered with the materialized worker
3040    /// snapshots. Keeping this in the control-loop cache avoids a database
3041    /// read while rendering each viewer snapshot.
3042    materialized_activity: &'a std::collections::BTreeMap<String, Option<i64>>,
3043    project_sources: &'a PhoneProjectSources,
3044    /// Lifecycle operations running now, keyed by session.
3045    operations: &'a std::collections::BTreeMap<String, crate::server::ViewerOperation>,
3046    /// Durable Move records, projected without diagnostics or checkpoint paths.
3047    move_recoveries: &'a std::collections::BTreeMap<String, ViewerMoveRecovery>,
3048    /// The most recent capacity reading per probe target.
3049    capacity: &'a [crate::server::ViewerTargetCapacity],
3050    launch_failures: &'a [crate::server::ViewerLaunchFailure],
3051    /// Reviews the daemon is running, keyed by session. The phone renders the
3052    /// same review the terminal does, from the same host.
3053    reviews: &'a std::collections::BTreeMap<String, crate::review_host::RuntimeReviewView>,
3054}
3055
3056/// What the phone server remembers about one probe target between readings.
3057///
3058/// The last good reading is kept beside any failure, because one failed probe
3059/// is not a reason to forget what a machine was doing a minute ago; the phone
3060/// is told both, and says so.
3061#[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
3071/// How old a reading may be before the page says so.
3072const CAPACITY_STALE_AFTER: Duration = Duration::from_secs(120);
3073
3074/// Tell the poller which targets to probe, and keep the state map in step.
3075fn 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                // A target with no reading yet is loading, not idle.
3092                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
3101/// Project the capacity readings for the phone.
3102fn 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                // A fleet reports how many machines it is running; a plain host
3123                // has no such count and says nothing rather than zero.
3124                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
3140/// Turn one lifecycle operation into the projection a phone follows.
3141fn viewer_operation(view: &crate::daemon::RuntimeLifecycleView) -> crate::server::ViewerOperation {
3142    use crate::server::{ViewerOperationKind, ViewerOperationStage};
3143
3144    crate::server::ViewerOperation {
3145        // RuntimeLifecycle owns the identity. A session can have consecutive
3146        // operations, and a client must be able to retire a late response from
3147        // the previous one without mistaking it for the current operation.
3148        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            // Stop, destroy, and retained cleanup remain distinct so a phone
3155            // can describe which part of teardown owns the session.
3156            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
3176/// What the phone may do with one session.
3177///
3178/// Everything here is a fact the controller holds and the browser cannot:
3179/// whether the session manager is driving this session, what the agent said it
3180/// supports, and whether a lifecycle operation already owns it.
3181fn 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    // A session the manager is not driving cannot be talked to, whatever its
3191    // durable state says.
3192    let attached = operational.is_some();
3193    // A failed close/destroy can leave its durable record in an intermediate
3194    // state after the lifecycle owner has gone away. Keep the status card
3195    // selectable, but do not turn the failure into an endless mutation lock:
3196    // the published Stop capability is the recovery action.
3197    let transition_busy = session.transitioning && !session.has_error;
3198    let busy = operation.is_some() || transition_busy;
3199    // A failed move can retain a live destination while queue admission is
3200    // incomplete. The durable move hold owns all mutating session controls in
3201    // that interval; retry Move is the one intentional exception.
3202    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        // Stopping a session that is already stopping asks for something that
3221        // is happening; resuming one that is running asks for a second copy.
3222        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        // Plan mode is a turn boundary: the terminal offers it only while the
3228        // agent is idle, and the phone must not be looser.
3229        set_plan_mode: live
3230            && !mutation_busy
3231            && idle
3232            && facts.is_some_and(mj_core::acp::AcpSessionFacts::supports_plan_mode),
3233    }
3234}
3235
3236/// The ACP content blocks one phone prompt becomes: its text, then each
3237/// attached image as the image block the prompt path already carries.
3238fn 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
3255/// The Mjolnir commands a phone may offer for one session.
3256///
3257/// The list is built here, from what this session can actually do, and
3258/// published: the browser used to keep its own copy, which is how `/review`
3259/// was missing from the phone while the terminal had it.
3260fn 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    // These names are handled by Mjolnir even when the corresponding control
3315    // is unavailable for this session. An agent cannot claim one and turn a
3316    // locally interpreted slash command into a misleading palette entry.
3317    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
3359/// The open reviews, keyed by session, for one snapshot.
3360fn 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
3373/// Refresh durable Move records away from the phone event loop. A Move can
3374/// finish after its initiating request disconnects, so active lifecycle views
3375/// alone are not enough to render Retry move or Resume with previous settings.
3376fn 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                    // The controller reports headroom; a bar fills as a limit
3457                    // is consumed, so the phone is given the complement.
3458                    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        // Runtime ownership takes precedence over the durable record. Move
3528        // can still look Running while its old conversation is no longer a
3529        // valid destination, and stopped cleanup/destroy operations have no
3530        // live lifecycle category of their own.
3531        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            // Share activity classification with the terminal. The browser
3572            // retains its detailed turn/step/background clock presentation.
3573            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        // `conversation_available` is only known after the transcript loop
3642        // above, so the capability that depends on it is settled here.
3643        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
3671/// Seed the viewer's in-memory activity cache from the durable projection
3672/// before publishing its first snapshot. Later worker snapshots update this
3673/// cache without adding a database read to the render path.
3674async 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        // An image needs no words with it, and an empty text block would be a
3983        // message the user never wrote.
3984        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        // A session whose agent has not answered `initialize` has advertised
4035        // nothing, so the phone is not offered a control the agent may refuse.
4036        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        // A phase flag alone can be stale; a current SDK step proves work.
4204        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        // The viewer re-posts its cursor after every refresh; a repeat must
4554        // not reach the database and must not move the revision.
4555        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        // No completion has been reported, and the phone already has its
4591        // answer: holding it until the action finished is what mobile
4592        // networks time out on.
4593        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        // Registration failed before the session reached the loop, which is
4621        // the completion path rather than the publication path.
4622        replies.resolve(7, ActionOutcome::Failed);
4623
4624        assert_eq!(answer.await.unwrap(), ActionOutcome::Failed);
4625        // A second resolution is a no-op, so a completion after a publication
4626        // cannot overwrite the answer already sent.
4627        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        // A full action pool and a session already busy with a stuck close
4637        // are both exactly when a force close has to get through.
4638        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        // A refusal must not consume the session slot it did not take.
4681        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        // Every finished action reloads the configuration; an unchanged one
4717        // must not restart a harness process per profile.
4718        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                &quotas,
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        // A reading taken one refresh interval ago is exactly what a healthy
4793        // refresher produces, so it must not be labelled stale.
4794        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        // A completion with no session cannot clear or record anything.
4906        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}