Skip to main content

mj_controller/server_runtime/
run.rs

1use super::*;
2
3pub async fn run_server(
4    args: ServerArgs,
5    termination: tokio_util::sync::CancellationToken,
6    worker: SessionManagerChannels,
7    daemon_runtime: Arc<RuntimeState>,
8    mut workspace_updates: tokio::sync::watch::Receiver<Vec<WorkspaceRecord>>,
9) -> Result<()> {
10    let resolved = resolve_server_args(args, termination.clone()).await?;
11    let bind = resolved.bind;
12    let mut controller = Controller::load()?;
13    // `list_profiles` is called in the middle of a model's turn, so the
14    // catalogue discovers the profiles' capabilities in the background and
15    // the call only waits for what is already under way.
16    let profile_catalog = profile_catalog::ProfileCatalog::new(termination.child_token());
17    profile_catalog.sync(&controller.config);
18    let mut daemon_revisions = daemon_runtime.revisions();
19    daemon_revisions.borrow_and_update();
20    let mut phone_workspaces = workspace_updates.borrow_and_update().clone();
21    let mut quotas = std::collections::BTreeMap::new();
22    let subagent_quota_reports = Arc::new(std::sync::Mutex::new(quotas.clone()));
23    let (quota_profiles_tx, mut quota_updates_rx) = spawn_quota_refresher();
24    let mut quota_batch = QuotaRefreshBatch::default();
25    let mut published_quota_profiles = std::collections::BTreeMap::new();
26    republish_quota_profiles(
27        &controller,
28        &mut published_quota_profiles,
29        &mut quota_batch,
30        &quota_profiles_tx,
31    );
32    let mut revision = daemon_runtime.allocate_revision();
33    let mut conversations = std::collections::BTreeMap::new();
34    let mut queued_prompts = projected_queued_prompts(&controller)?;
35    let mut active_user_shells = std::collections::BTreeMap::new();
36    let mut pending_elicitations = std::collections::BTreeMap::new();
37    let mut prompt_images = std::collections::BTreeSet::new();
38    let mut operational = std::collections::BTreeMap::new();
39    let mut materialized_activity = load_materialized_activity(&controller).await?;
40    let mut project_sources = PhoneProjectSources::default();
41    let (records, lifecycles) = daemon_runtime.session_projection();
42    controller.state.sessions = records;
43    let mut operations = lifecycles
44        .iter()
45        .map(|view| (view.session_id.clone(), viewer_operation(view)))
46        .collect::<std::collections::BTreeMap<_, _>>();
47    let mut move_recoveries = ViewerMoveRecoveries::new();
48    let (move_recovery_tx, mut move_recovery_rx) =
49        tokio::sync::mpsc::unbounded_channel::<Result<ViewerMoveRecoveries, String>>();
50    let mut move_recovery_load_in_flight = false;
51    let mut launch_failures = Vec::new();
52    // What the capacity poller last said, per probe target. The projection is
53    // built from this on every publish rather than being accumulated, so a
54    // target that disappears from the configuration disappears from the page.
55    let mut capacity_state: std::collections::BTreeMap<String, PhoneCapacity> =
56        std::collections::BTreeMap::new();
57    let (capacity_targets_tx, capacity_triggers_tx, mut capacity_updates_rx) =
58        crate::pollers::spawn_dashboard_capacity_poller();
59    let (snapshot_tx, snapshot_rx) = tokio::sync::watch::channel(viewer_snapshot(
60        &controller,
61        &phone_workspaces,
62        &quotas,
63        &PhoneSessionViews {
64            conversations: &conversations,
65            queued_prompts: &queued_prompts,
66            active_user_shells: &active_user_shells,
67            pending_elicitations: &pending_elicitations,
68            prompt_images: &prompt_images,
69            operational: &operational,
70            materialized_activity: &materialized_activity,
71            project_sources: &project_sources,
72            operations: &operations,
73            move_recoveries: &move_recoveries,
74            capacity: &viewer_capacity(&capacity_state),
75            launch_failures: &launch_failures,
76            reviews: &review_views(&daemon_runtime),
77        },
78        revision,
79    ));
80    let (conversation_tx, conversation_rx) = tokio::sync::watch::channel(conversations.clone());
81    let (action_tx, mut action_rx) = tokio::sync::mpsc::channel(32);
82    let (bundle_tx, mut bundle_rx) = tokio::sync::mpsc::channel(16);
83    let (receipt_tx, mut receipt_rx) = tokio::sync::mpsc::channel(32);
84    let (preflight_tx, mut preflight_rx) = tokio::sync::mpsc::channel(32);
85    let (move_preparation_tx, mut move_preparation_rx) = tokio::sync::mpsc::channel(32);
86    let (client_state_tx, mut client_state_rx) = tokio::sync::mpsc::channel(64);
87    let (dictation_tx, mut dictation_rx) =
88        tokio::sync::mpsc::channel::<crate::dictation::DictationRequest>(8);
89    let (background_task_stop_tx, mut background_task_stop_rx) =
90        tokio::sync::mpsc::channel::<BackgroundTaskStopRequest>(32);
91    let SessionManagerChannels {
92        targets: worker_targets_tx,
93        control: worker_commands_tx,
94        updates: mut worker_updates_rx,
95        shutdown: worker_shutdown,
96    } = worker;
97    worker_targets_tx.send_replace(dashboard_worker_targets(&controller));
98    publish_capacity_targets(&controller, &capacity_targets_tx, &mut capacity_state);
99    let mut credential_sync = CredentialSyncCoordinator::spawn();
100    let credential_sync_handle = credential_sync.handle();
101    credential_sync_handle.set_targets(credential_sync_targets(&controller));
102    let mut credential_sync_signals = CredentialSyncSignalTracker::default();
103    let mut credential_sync_notices = CredentialSyncNotices::default();
104    // Captured before `options` is moved into the server.
105    let options_session_ttl = crate::server::default_session_ttl();
106    let activity_snapshots = snapshot_rx.clone();
107    let mut options = ServerOptions::new(
108        bind,
109        snapshot_rx,
110        conversation_rx,
111        crate::server::ServerRequests {
112            action_tx,
113            bundle_tx,
114            receipt_tx,
115            preflight_tx,
116            move_preparation_tx,
117            client_state_tx,
118            dictation_tx,
119        },
120    )?;
121    options.set_background_task_stop_tx(background_task_stop_tx);
122    options.shutdown = termination.clone();
123    // Session cookies are stateless, so a per-process key would sign every
124    // phone out on every restart. Delete the key file to sign them out on
125    // purpose.
126    let cookie_key_path = crate::server::cookie_key_path();
127    options.set_cookie_key(crate::server::load_or_create_cookie_key(&cookie_key_path)?)?;
128    // The documented `/api/v1` surface authenticates with a persisted bearer
129    // token and drives sessions through the daemon-side backend.
130    options.set_api_token(crate::server::load_or_create_api_token(
131        &crate::server::api_token_path(),
132    )?);
133    let api_runtime = daemon_runtime.clone();
134    let api_backend = Arc::new(
135        api::ApiBackend::new(
136            worker_commands_tx.client(),
137            Arc::new(move |session_id: &str| api_runtime.session_state(session_id)),
138            daemon_runtime.clone(),
139        )
140        .with_quota_reports(subagent_quota_reports.clone())
141        .with_profile_catalog(profile_catalog.clone()),
142    );
143    options.set_subagent_backend(api_backend.clone());
144    let renewal_cancellation = termination.child_token();
145    let mut renewal_task = None;
146    if let Some((cert, key)) = resolved.tls_files {
147        let rustls = axum_server::tls_rustls::RustlsConfig::from_pem_file(cert, key)
148            .await
149            .context("load web viewer TLS certificate")?;
150        options.set_tls_config(rustls.clone());
151        if let Some(tailscale) = resolved.tailscale {
152            renewal_task = Some(spawn_tailscale_cert_renewer(
153                tailscale,
154                rustls,
155                renewal_cancellation.clone(),
156            ));
157        }
158    } else if bind.ip().is_loopback() {
159        options.secure_cookie = false;
160    } else {
161        anyhow::bail!("non-loopback web viewer requires TLS");
162    }
163    let fallback_reason = resolved.fallback_reason;
164    let qr_login_url = if fallback_reason.is_none() && resolved.viewer_url.starts_with("https://") {
165        let encoded = url::form_urlencoded::byte_serialize(options.login_token().as_bytes())
166            .collect::<String>();
167        Some(format!(
168            "{}/auth/login?token={encoded}",
169            resolved.viewer_url.trim_end_matches('/')
170        ))
171    } else {
172        None
173    };
174    let ready = crate::server::WebViewerAccess::Ready {
175        viewer_url: resolved.viewer_url,
176        viewer_code: options.viewer_code().to_owned(),
177        qr_login_url,
178        fallback_reason,
179    };
180
181    let serve = crate::web_viewer::serve(options, ready, &daemon_runtime.web_viewer, |access| {
182        daemon_runtime.publish_web_access(access);
183    });
184    let conversation_projection_shutdown = termination.child_token();
185    let control = async {
186        let mut credential_tick = tokio::time::interval(Duration::from_millis(250));
187        // Stored viewer state expires with the authentication that created it.
188        // The sweep is hourly rather than on every request, because it is
189        // housekeeping and nothing waits for it.
190        let mut prune_tick = tokio::time::interval(prune_tick_interval());
191        // The first index build can take many minutes on a large corpus, and
192        // nothing else would start it until the first hourly tick. Ask for it
193        // here, explicitly, rather than leaning on the interval's immediate
194        // first tick: that tick may run the archive job instead.
195        daemon_runtime.wiki().request_sync(true);
196        let client_state_retention = options_session_ttl;
197        let (action_done_tx, mut action_done_rx) = tokio::sync::mpsc::unbounded_channel::<(
198            u64,
199            Option<String>,
200            std::result::Result<(), PhoneActionFailure>,
201        )>();
202        let (action_started_tx, mut action_started_rx) =
203            tokio::sync::mpsc::unbounded_channel::<PhoneActionStarted>();
204        let (receipt_done_tx, mut receipt_done_rx) =
205            tokio::sync::mpsc::unbounded_channel::<ReadReceiptPersisted>();
206        let (controller_reload_tx, mut controller_reload_rx) =
207            tokio::sync::mpsc::unbounded_channel::<ControllerReloaded>();
208        let (bundle_done_tx, mut bundle_done_rx) =
209            tokio::sync::mpsc::unbounded_channel::<BundleCreated>();
210        let (move_prepared_tx, mut move_prepared_rx) =
211            tokio::sync::mpsc::unbounded_channel::<MovePrepared>();
212        let mut dictation_jobs = tokio::task::JoinSet::new();
213        let mut archive_jobs = tokio::task::JoinSet::new();
214        let mut bundle_jobs = tokio::task::JoinSet::new();
215        let mut preflight_jobs = tokio::task::JoinSet::new();
216        let mut move_preparation_jobs = tokio::task::JoinSet::new();
217        let mut move_recovery_jobs = tokio::task::JoinSet::new();
218        let mut background_task_stop_jobs = tokio::task::JoinSet::new();
219        let mut background_task_stop_open = true;
220        let mut controller_reload_in_flight = false;
221        let mut controller_reload_requested = false;
222        let mut controller_reload_invalidated = false;
223        let mut pending_action_errors = std::collections::BTreeMap::<String, String>::new();
224        let mut active_actions = std::collections::BTreeSet::new();
225        let mut closing_actions = std::collections::BTreeMap::<String, u64>::new();
226        let mut next_action_id = 0_u64;
227        let mut action_cancellations = std::collections::BTreeMap::<u64, PhoneActionControl>::new();
228        let mut action_sessions = std::collections::BTreeMap::<u64, String>::new();
229        let mut action_replies = PendingActionReplies::default();
230        let mut launch_workspaces = std::collections::BTreeMap::new();
231        let mut subagent_jobs = tokio::task::JoinSet::new();
232        let mut subagent_completion_jobs = tokio::task::JoinSet::new();
233        let mut active_subagent_requests = std::collections::BTreeSet::new();
234        let (conversation_projection_tx, mut conversation_projection_rx) =
235            tokio::sync::mpsc::channel(CONVERSATION_PROJECTION_CHANNEL_CAPACITY);
236        let mut conversation_projections = ConversationProjectionDispatcher::new(
237            conversation_projection_tx,
238            conversation_projection_shutdown.clone(),
239        );
240        let mut quota_updates_open = true;
241        // A feed that ends is not a reason to exit quietly: the phone server
242        // exists to follow sessions, so losing that feed is a named failure
243        // rather than a silent success.
244        let mut failure: Option<anyhow::Error> = None;
245        request_move_recovery_reload(
246            &move_recovery_tx,
247            &mut move_recovery_load_in_flight,
248            &mut move_recovery_jobs,
249        );
250        macro_rules! publish_snapshot {
251            ($revision:expr) => {
252                let (records, lifecycles) = daemon_runtime.session_projection();
253                controller.state.sessions = records;
254                for (session_id, error) in &pending_action_errors {
255                    if let Some(session) = controller.state.sessions.get_mut(session_id)
256                        && session.last_error.is_none()
257                    {
258                        session.last_error = Some(error.clone());
259                    }
260                }
261                operations = lifecycles.iter()
262                    .map(|view| (view.session_id.clone(), viewer_operation(view)))
263                    .collect();
264                let snapshot = viewer_snapshot(
265                    &controller,
266                    &phone_workspaces,
267                    &quotas,
268                    &PhoneSessionViews {
269                        conversations: &conversations,
270                        queued_prompts: &queued_prompts,
271                        active_user_shells: &active_user_shells,
272                        pending_elicitations: &pending_elicitations,
273                        prompt_images: &prompt_images,
274                        operational: &operational,
275                        materialized_activity: &materialized_activity,
276                        project_sources: &project_sources,
277                        operations: &operations,
278                        move_recoveries: &move_recoveries,
279                        capacity: &viewer_capacity(&capacity_state),
280                        launch_failures: &launch_failures,
281                        reviews: &review_views(&daemon_runtime),
282                    },
283                    $revision,
284                );
285                if let Err(error) = snapshot_tx.send(snapshot) {
286                    tracing::debug!(revision = $revision, %error, "phone snapshot delivery failed; no viewer is subscribed");
287                }
288            };
289        }
290        loop {
291            project_sources.synchronize(&controller);
292            tokio::select! {
293                _ = termination.cancelled() => break,
294                request = background_task_stop_rx.recv(), if background_task_stop_open => {
295                    let Some(request) = request else {
296                        background_task_stop_open = false;
297                        tracing::warn!("background-task stop request feed closed while the phone server was running");
298                        continue;
299                    };
300                    let session_control = worker_commands_tx.clone();
301                    background_task_stop_jobs.spawn(async move {
302                        let result = match session_control.session(&request.session_id).await {
303                            Ok(session) => session
304                                .client()
305                                .stop_background_task(request.background_task_id.clone())
306                                .await
307                                .map_err(|error| {
308                                    tracing::warn!(
309                                        session_id = %request.session_id,
310                                        background_task_id = %request.background_task_id,
311                                        %error,
312                                        "provider rejected background-task stop"
313                                    );
314                                    BackgroundTaskStopFailure::Provider
315                                }),
316                            Err(error) => {
317                                tracing::warn!(
318                                    session_id = %request.session_id,
319                                    %error,
320                                    "could not resolve live session for background-task stop"
321                                );
322                                Err(BackgroundTaskStopFailure::SessionUnavailable)
323                            }
324                        };
325                        if request.reply.send(result).is_err() {
326                            tracing::debug!(
327                                session_id = %request.session_id,
328                                background_task_id = %request.background_task_id,
329                                "background-task stop result dropped after viewer disconnected"
330                            );
331                        }
332                    });
333                }
334                completed = background_task_stop_jobs.join_next(), if !background_task_stop_jobs.is_empty() => {
335                    if let Some(Err(error)) = completed {
336                        tracing::error!(%error, "background-task stop task failed unexpectedly");
337                    }
338                }
339                move_reloaded = move_recovery_rx.recv() => {
340                    let Some(result) = move_reloaded else {
341                        failure = feed_stopped(
342                            termination.is_cancelled(),
343                            "the Move recovery projection stopped while the phone server was running",
344                        );
345                        break;
346                    };
347                    move_recovery_load_in_flight = false;
348                    match result {
349                        Ok(recoveries) => {
350                            move_recoveries = recoveries;
351                            revision = daemon_runtime.allocate_revision();
352                            publish_snapshot!(revision);
353                        }
354                        Err(error) => tracing::warn!(%error, "could not refresh Move recovery projection"),
355                    }
356                }
357                resolved = project_sources.jobs.join_next(), if !project_sources.jobs.is_empty() => {
358                    match resolved {
359                        Some(Ok(resolved)) => project_sources.complete(resolved),
360                        Some(Err(error)) => {
361                            failure = Some(anyhow::anyhow!("web project source task failed: {error}"));
362                            break;
363                        }
364                        None => unreachable!("project source jobs were not empty"),
365                    }
366                    revision = daemon_runtime.allocate_revision();
367                    publish_snapshot!(revision);
368                }
369                changed = daemon_revisions.changed() => {
370                    if changed.is_err() {
371                        failure = feed_stopped(
372                            termination.is_cancelled(),
373                            "the daemon stopped publishing runtime revisions to the phone server",
374                        );
375                        break;
376                    }
377                    daemon_revisions.borrow_and_update();
378                    revision = daemon_runtime.allocate_revision();
379                    publish_snapshot!(revision);
380                    request_controller_reload(
381                        &mut controller_reload_in_flight,
382                        &mut controller_reload_requested,
383                        &controller_reload_tx,
384                    );
385                }
386                changed = workspace_updates.changed() => {
387                    if changed.is_err() {
388                        failure = feed_stopped(
389                            termination.is_cancelled(),
390                            "the daemon stopped publishing workspaces to the phone server",
391                        );
392                        break;
393                    }
394                    phone_workspaces = workspace_updates.borrow_and_update().clone();
395                    revision = daemon_runtime.allocate_revision();
396                    publish_snapshot!(revision);
397                }
398                update = capacity_updates_rx.recv() => {
399                    let Some(update) = update else {
400                        failure = feed_stopped(termination.is_cancelled(), "the capacity poller stopped while the phone server was running");
401                        break;
402                    };
403                    if let Some(entry) = capacity_state.get_mut(&update.target_id) {
404                        entry.refreshing = false;
405                        entry.sampled_at_epoch_seconds = Some(update.sampled_at_epoch_seconds);
406                        match update.result {
407                            Ok(usage) => {
408                                // A fleet with nothing running reports no
409                                // figures, and that is an answer rather than a
410                                // failure.
411                                entry.on_demand = usage.is_none();
412                                entry.usage = usage;
413                                entry.failed = false;
414                            }
415                            // The last good reading stays on screen beside the
416                            // failure: one failed probe is not a reason to
417                            // forget what the machine was doing.
418                            Err(_) => entry.failed = true,
419                        }
420                    }
421                    revision = daemon_runtime.allocate_revision();
422                    publish_snapshot!(revision);
423                }
424                update = quota_updates_rx.recv(), if quota_updates_open => {
425                    match update {
426                        Some(QuotaUpdate::Report(outcome)) => {
427                            if outcome.credentials_changed {
428                                credential_sync_handle
429                                    .sync_profile_now(&outcome.report.profile_id, None);
430                            }
431                            quotas.insert(outcome.report.profile_id.clone(), outcome.report.clone());
432                            subagent_quota_reports
433                                .lock()
434                                .expect("sub-agent quota reports lock poisoned")
435                                .insert(outcome.report.profile_id.clone(), outcome.report);
436                            revision = daemon_runtime.allocate_revision();
437                            publish_snapshot!(revision);
438                        }
439                        Some(QuotaUpdate::Refreshing { .. } | QuotaUpdate::Finished { .. }) => {}
440                        None => {
441                            quota_updates_open = false;
442                            tracing::warn!("quota refresher stopped while the phone server is running");
443                        }
444                    }
445                }
446                projected = conversation_projection_rx.recv() => {
447                    let Some(projected) = projected else {
448                        failure = feed_stopped(
449                            termination.is_cancelled(),
450                            "the browser transcript projection feed stopped",
451                        );
452                        break;
453                    };
454                    let session_id = projected.session_id.clone();
455                    let session_active = controller
456                        .state
457                        .sessions
458                        .get(&session_id)
459                        .is_some_and(|session| session.state.is_active());
460                    if !session_active {
461                        // Invalidate a late result before it can be applied or
462                        // launch another queued projection. A session that is
463                        // resumed later gets a new generation from its next
464                        // worker snapshot.
465                        conversation_projections.forget(&session_id);
466                    }
467                    if let Some((session_id, _key, transcript)) =
468                        conversation_projections.finish(projected, session_active)
469                    {
470                        conversations.insert(session_id, transcript);
471                        revision = daemon_runtime.allocate_revision();
472                        conversation_tx.send_replace(conversations.clone());
473                        publish_snapshot!(revision);
474                    } else if !session_active && conversations.remove(&session_id).is_some() {
475                        // Controller reload normally removes inactive rows
476                        // first, but this also covers a worker result racing
477                        // that reload and keeps the viewer from seeing a
478                        // conversation for a dead session.
479                        revision = daemon_runtime.allocate_revision();
480                        conversation_tx.send_replace(conversations.clone());
481                        publish_snapshot!(revision);
482                    }
483                    // SessionManagerUpdates has a synchronous pending fast
484                    // path. Yield after each completion so a hot stream of
485                    // updates cannot monopolize this runtime worker.
486                    tokio::task::yield_now().await;
487                }
488                update = worker_updates_rx.recv() => {
489                    let Some(update) = update else {
490                        failure = feed_stopped(termination.is_cancelled(), "the session manager stopped; the phone server can no longer follow sessions");
491                        break;
492                    };
493                    if let Some(snapshot) = update.view.snapshot.as_ref()
494                        && let Some(session) = controller.state.sessions.get(&update.session_id)
495                        && let Some(signal) = snapshot.latest_credential_sync_signal.clone()
496                    {
497                        credential_sync_signals.observe(
498                            &update.session_id,
499                            &session.last_profile,
500                            signal,
501                        );
502                    }
503                    schedule_due_credential_syncs(
504                        &mut credential_sync_signals,
505                        &credential_sync_handle,
506                        Instant::now(),
507                    );
508                    apply_worker_record_update(&mut controller, &update);
509                    if let Some(snapshot) = update.view.snapshot {
510                        for request in snapshot.subagent_requests.iter().cloned() {
511                            let identity = (update.session_id.clone(), request.request_id.clone());
512                            if !active_subagent_requests.insert(identity.clone()) {
513                                continue;
514                            }
515                            let backend = api_backend.clone();
516                            let runtime = daemon_runtime.clone();
517                            let parent_session_id = update.session_id.clone();
518                            subagent_jobs.spawn(async move {
519                                let result = backend
520                                    .execute_subagent_tool(parent_session_id.clone(), request)
521                                    .await;
522                                let outcome = async {
523                                    // The result reaches the model as the tool
524                                    // call's own answer: completing the request
525                                    // unblocks the worker socket the harness is
526                                    // waiting on. It is not injected as a turn.
527                                    let handle = runtime
528                                        .workspace_session_handle(&parent_session_id)
529                                        .await?;
530                                    let mut lease = handle.lease_connection().await?;
531                                    lease
532                                        .connection_mut()
533                                        .complete_subagent_request(result)
534                                        .await?;
535                                    lease.release();
536                                    anyhow::Ok(())
537                                }
538                                .await;
539                                (identity, outcome)
540                            });
541                        }
542                        if let Some(relation) = controller.state.subagents.get_mut(&update.session_id)
543                            && matches!(snapshot.materialized.execution, mj_core::state::MaterializedExecutionState::Idle)
544                            && let Some(outcome) = snapshot.materialized.last_turn_outcome.as_ref()
545                            && relation.noticed_turn != Some(outcome.completed_ordinal)
546                        {
547                            let turn = outcome.completed_ordinal;
548                            let child_id = relation.child_session_id.clone();
549                            let parent_id = relation.parent_session_id.clone();
550                            let task_name = relation.task_name.clone();
551                            let outcome_name = format!("{:?}", outcome.outcome).to_lowercase();
552                            relation.noticed_turn = Some(turn);
553                            let backend = api_backend.clone();
554                            subagent_completion_jobs.spawn(async move {
555                                let result = async {
556                                    backend
557                                        .record_subagent_completion_notice(
558                                            parent_id,
559                                            &child_id,
560                                            &task_name,
561                                            turn,
562                                            &outcome_name,
563                                        )
564                                        .await?;
565                                    tokio::task::spawn_blocking({
566                                        let child_id = child_id.clone();
567                                        move || crate::database::mark_subagent_turn_noticed(&child_id, turn)
568                                    })
569                                    .await??;
570                                    anyhow::Ok(())
571                                }
572                                .await;
573                                (child_id, turn, result)
574                            });
575                        }
576                        if snapshot.operational.native_session_is_ready()
577                            && operational.get(&update.session_id).is_none_or(|old: &mj_core::relay::RelayOperationalState| old.config_options != snapshot.operational.config_options)
578                            && let Some(session) = controller.state.sessions.get(&update.session_id)
579                            && matches!(session.target, Some(mj_core::state::TargetLocator::LocalBare { .. } | mj_core::state::TargetLocator::SshBare { .. } | mj_core::state::TargetLocator::AwsEc2 { .. }))
580                            && let Some(build) = snapshot.worker_build.clone()
581                        {
582                            let profile = session.last_profile.clone();
583                            let state = snapshot.operational.clone();
584                            tokio::spawn(async move {
585                                if let Err(error) = crate::controller::profile_config::observe(profile, build, state).await {
586                                    tracing::warn!(%error, "could not cache observed profile choices");
587                                }
588                            });
589                        }
590                        let materialized = snapshot.materialized;
591                        let operational_state = snapshot.operational;
592                        materialized_activity.insert(
593                            update.session_id.clone(),
594                            crate::server_runtime::snapshot::MaterializedActivity {
595                                last_activity_at_ms: materialized.last_activity_at_ms,
596                                execution: materialized.execution,
597                            },
598                        );
599                        let queued = queued_prompt_projection(&materialized);
600                        let pending = materialized.pending_elicitations.clone();
601                        let active_shells = operational_state.active_user_shells.clone();
602                        let prompt_images_supported =
603                            agent_accepts_prompt_images(&operational_state);
604                        active_user_shells.insert(
605                            update.session_id.clone(),
606                            active_shells,
607                        );
608                        conversation_projections.enqueue(materialized);
609                        queued_prompts.insert(
610                            update.session_id.clone(),
611                            queued,
612                        );
613                        pending_elicitations.insert(
614                            update.session_id.clone(),
615                            pending,
616                        );
617                        if prompt_images_supported {
618                            prompt_images.insert(update.session_id.clone());
619                        } else {
620                            prompt_images.remove(&update.session_id);
621                        }
622                        operational.insert(
623                            update.session_id.clone(),
624                            operational_state,
625                        );
626                        revision = daemon_runtime.allocate_revision();
627                        conversation_tx.send_replace(conversations.clone());
628                        publish_snapshot!(revision);
629                    }
630                    // The session update receiver can return pending entries
631                    // without touching Tokio's budgeted receive operation.
632                    // Give HTTP/TLS tasks a scheduling opportunity after each
633                    // update even when the worker is publishing continuously.
634                    tokio::task::yield_now().await;
635                }
636                completed = subagent_jobs.join_next(), if !subagent_jobs.is_empty() => {
637                    match completed {
638                        Some(Ok((identity, Ok(())))) => {
639                            active_subagent_requests.remove(&identity);
640                        }
641                        Some(Ok((identity, Err(error)))) => {
642                            active_subagent_requests.remove(&identity);
643                            tracing::warn!(
644                                parent_session_id = %identity.0,
645                                request_id = %identity.1,
646                                error = %format!("{error:#}"),
647                                "sub-agent tool request failed"
648                            );
649                        }
650                        Some(Err(error)) => tracing::warn!(%error, "sub-agent tool task panicked"),
651                        None => {}
652                    }
653                }
654                completed = subagent_completion_jobs.join_next(), if !subagent_completion_jobs.is_empty() => {
655                    match completed {
656                        Some(Ok((_, _, Ok(())))) => {}
657                        Some(Ok((child_id, turn, Err(error)))) => {
658                            if let Some(relation) = controller.state.subagents.get_mut(&child_id)
659                                && relation.noticed_turn == Some(turn)
660                            {
661                                relation.noticed_turn = None;
662                            }
663                            tracing::warn!(%child_id, turn, error = %format!("{error:#}"), "could not record the sub-agent completion notice");
664                        }
665                        Some(Err(error)) => tracing::warn!(%error, "sub-agent completion task panicked"),
666                        None => {}
667                    }
668                }
669                _ = prune_tick.tick() => {
670                    // The hourly full SessionWiki sync. Session closes drive
671                    // bounded syncs; this one also reconciles sessions deleted
672                    // outside the daemon and picks up any bounded run that
673                    // failed.
674                    //
675                    // With `archive_after_days` set, the same tick runs the
676                    // archive job instead, because that job starts with the
677                    // full sync itself. It runs as a background task, and only
678                    // when no earlier one is still running: a pass over a large
679                    // corpus can outlast the tick.
680                    let archive_after_days = controller.config.sessionwiki.archive_after_days;
681                    match archive_after_days {
682                        Some(days) if archive_jobs.is_empty() => {
683                            let runtime = daemon_runtime.clone();
684                            archive_jobs.spawn(async move {
685                                runtime.archive_aged_sessions(days).await
686                            });
687                        }
688                        Some(_) => tracing::debug!(
689                            "the previous SessionWiki archive pass is still running; skipping this tick"
690                        ),
691                        None => daemon_runtime.wiki().request_sync(true),
692                    }
693                    // Only rows whose client id names a phone are considered:
694                    // a terminal client's place in a conversation is not the
695                    // phone's to expire.
696                    tokio::spawn(async move {
697                        let pruned = tokio::task::spawn_blocking(move || {
698                            crate::database::prune_phone_client_state(client_state_retention)
699                        })
700                        .await;
701                        match pruned {
702                            Ok(Ok(0)) => {}
703                            Ok(Ok(rows)) => tracing::debug!(rows, "pruned expired phone viewer state"),
704                            Ok(Err(error)) => tracing::warn!(%error, "could not prune phone viewer state"),
705                            Err(error) => tracing::warn!(%error, "phone viewer state pruning task failed"),
706                        }
707                    });
708                }
709                _ = credential_tick.tick() => {
710                    schedule_due_credential_syncs(
711                        &mut credential_sync_signals,
712                        &credential_sync_handle,
713                        Instant::now(),
714                    );
715                    while let Some(result) = credential_sync.try_result() {
716                        crate::pollers::log_credential_sync_actions(&result);
717                        let harness = controller
718                            .config
719                            .profiles
720                            .get(&result.profile_id)
721                            .map(|profile| profile.kind);
722                        if let Some(notice) = credential_sync_notices.notice(&result, harness) {
723                            eprintln!("Mjolnir: {notice}");
724                        }
725                    }
726                }
727                request = dictation_rx.recv() => {
728                    let Some(request) = request else {
729                        failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering dictation requests");
730                        break;
731                    };
732                    let paths = controller.state.sessions.get(&request.session_id).map(|session| {
733                        crate::dictation::auth_paths(&controller.config, &session.last_profile)
734                    });
735                    dictation_jobs.spawn(crate::dictation::execute(
736                        request, paths, termination.clone(),
737                    ));
738                }
739                job = dictation_jobs.join_next(), if !dictation_jobs.is_empty() => {
740                    if let Some(Err(error)) = job {
741                        tracing::warn!(%error, "web dictation task failed");
742                    }
743                }
744                job = archive_jobs.join_next(), if !archive_jobs.is_empty() => {
745                    match job {
746                        Some(Ok(Ok(0))) | None => {}
747                        Some(Ok(Ok(archived))) => tracing::debug!(
748                            archived, "the SessionWiki archive pass finished"
749                        ),
750                        Some(Ok(Err(error))) => tracing::warn!(
751                            error = %format!("{error:#}"),
752                            "the SessionWiki archive pass failed"
753                        ),
754                        Some(Err(error)) => tracing::warn!(
755                            %error, "the SessionWiki archive task panicked"
756                        ),
757                    }
758                }
759                stored = client_state_rx.recv() => {
760                    let Some(stored) = stored else {
761                        failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering viewer state requests");
762                        break;
763                    };
764                    // Every one of these touches SQLite, so each runs on its
765                    // own task. A composer autosaving on a debounce must never
766                    // be able to stall the loop that follows sessions.
767                    let workspace_of = |session_id: &str| {
768                        controller
769                            .state
770                            .sessions
771                            .get(session_id)
772                            .map(|session| session.workspace_id.clone())
773                    };
774                    let bundle_of = |session_id: &str| {
775                        controller
776                            .state
777                            .sessions
778                            .get(session_id)
779                            .map(|session| session.bundle_id.clone())
780                    };
781                    match stored {
782                        crate::server::ClientStateRequest::Read { client_id, session_id, reply } => {
783                            let workspace = workspace_of(&session_id);
784                            tokio::spawn(async move {
785                                let answer = tokio::task::spawn_blocking(move || {
786                                    let workspace = workspace.context("unknown session")?;
787                                    let state = crate::database::client_session_state(
788                                        &client_id, &workspace, &session_id,
789                                    )?;
790                                    anyhow::Ok(crate::server::ViewerClientState {
791                                        draft: state.draft,
792                                        through_event_ordinal: state.through_event_ordinal,
793                                    })
794                                })
795                                .await;
796                                reply.send(flatten_stored(answer)).ok();
797                            });
798                        }
799                        crate::server::ClientStateRequest::SaveDraft { client_id, session_id, draft, reply } => {
800                            let workspace = workspace_of(&session_id);
801                            tokio::spawn(async move {
802                                let answer = tokio::task::spawn_blocking(move || {
803                                    let workspace = workspace.context("unknown session")?;
804                                    crate::database::persist_client_draft(
805                                        &client_id, &workspace, &session_id, &draft,
806                                    )
807                                })
808                                .await;
809                                reply.send(flatten_stored(answer)).ok();
810                            });
811                        }
812                        crate::server::ClientStateRequest::MarkWorkspaceRead { client_id, workspace_id, reply } => {
813                            let sessions = controller
814                                .state
815                                .sessions
816                                .values()
817                                .filter(|session| session.workspace_id == workspace_id)
818                                .map(|session| (session.id.clone(), session.viewed_through_event_ordinal))
819                                .collect::<Vec<_>>();
820                            tokio::spawn(async move {
821                                let answer = tokio::task::spawn_blocking(move || {
822                                    for (session_id, through) in sessions {
823                                        // A receipt that would move backwards
824                                        // is not an error; it is a session this
825                                        // viewer had already read past.
826                                        crate::database::persist_read_receipt(
827                                            &client_id, &workspace_id, &session_id, through,
828                                        )
829                                        .ok();
830                                    }
831                                    anyhow::Ok(())
832                                })
833                                .await;
834                                reply.send(flatten_stored(answer)).ok();
835                            });
836                        }
837                        crate::server::ClientStateRequest::History { session_id, query, scope, reply } => {
838                            let bundle = bundle_of(&session_id);
839                            tokio::spawn(async move {
840                                let answer = tokio::task::spawn_blocking(move || {
841                                    let bundle = bundle.context("unknown session")?;
842                                    let scope = match scope.as_str() {
843                                        "session" => crate::database::HistoryScope::Session,
844                                        "all" => crate::database::HistoryScope::All,
845                                        _ => crate::database::HistoryScope::Project,
846                                    };
847                                    let found = crate::database::search_prompts_bounded(
848                                        &session_id,
849                                        &bundle,
850                                        scope,
851                                        &query,
852                                        crate::server::MAX_HISTORY_MATCHES,
853                                    )?;
854                                    anyhow::Ok(crate::server::ViewerPromptHistory {
855                                        entries: found
856                                            .entries
857                                            .into_iter()
858                                            .map(|entry| entry.text)
859                                            .collect(),
860                                        truncated: found.truncated,
861                                    })
862                                })
863                                .await;
864                                reply.send(flatten_stored(answer)).ok();
865                            });
866                        }
867                    }
868                }
869                bundle = bundle_rx.recv(), if bundle_jobs.len() < MAX_CONCURRENT_BUNDLE_CREATIONS => {
870                    let Some(crate::server::BundleRequest { source, reply }) = bundle else {
871                        failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering bundle requests");
872                        break;
873                    };
874                    // Repository canonicalization and config persistence both
875                    // touch the filesystem. Keep them off this loop, and
876                    // report a panic as a failed request rather than dropping
877                    // the browser's reply.
878                    let done = bundle_done_tx.clone();
879                    let daemon_runtime = daemon_runtime.clone();
880                    bundle_jobs.spawn(async move {
881                        let result = daemon_runtime
882                            .create_quick_bundle(source)
883                            .await;
884                        if let Err(error) = done.send(BundleCreated { result, reply }) {
885                            tracing::debug!(%error, "bundle creation finished after the server stopped");
886                        }
887                    });
888                }
889                bundle_done = bundle_done_rx.recv() => {
890                    let Some(BundleCreated { result, reply }) = bundle_done else {
891                        failure = feed_stopped(termination.is_cancelled(), "the bundle creation pipeline stopped while the phone server was running");
892                        break;
893                    };
894                    match result {
895                        Ok(created) => {
896                            let bundle_id = created.bundle_id;
897                            // Other config sections may have changed while
898                            // this request was in flight. Publish only the
899                            // bundle this transaction created; a full fresh
900                            // config is requested below and must not make a
901                            // later completion hide another completed bundle.
902                            let Some(bundle) = created.config.bundles.get(&bundle_id) else {
903                                tracing::error!(%bundle_id, "bundle creation returned a config without its bundle");
904                                if reply.send(Err(crate::server::BundleFailure::Controller)).is_err() {
905                                    tracing::debug!("bundle creation failure reply dropped after client disconnect");
906                                }
907                                continue;
908                            };
909                            controller
910                                .config
911                                .bundles
912                                .insert(bundle_id.clone(), bundle.clone());
913                            // A reload started before this save may still be
914                            // queued. It must not hide a bundle after we have
915                            // acknowledged it as available to the browser.
916                            controller_reload_invalidated |= controller_reload_in_flight;
917                            revision = daemon_runtime.allocate_revision();
918                            publish_snapshot!(revision);
919                            request_daemon_controller_reload(
920                                daemon_runtime.clone(),
921                                "new bundle publication",
922                            );
923                            if reply.send(Ok(bundle_id)).is_err() {
924                                tracing::debug!("bundle creation reply dropped after client disconnect");
925                            }
926                        }
927                        Err(error) => {
928                            let failure = match error {
929                                crate::controller::QuickBundleFailure::InvalidSource(
930                                    detail,
931                                ) => {
932                                    tracing::debug!(error = %detail, "phone bundle source was invalid");
933                                    crate::server::BundleFailure::InvalidSource
934                                }
935                                crate::controller::QuickBundleFailure::Persistence(
936                                    detail,
937                                ) => {
938                                    tracing::warn!(error = %detail, "phone bundle creation failed");
939                                    crate::server::BundleFailure::Controller
940                                }
941                            };
942                            if reply.send(Err(failure)).is_err() {
943                                tracing::debug!("bundle creation failure reply dropped after client disconnect");
944                            }
945                        }
946                    }
947                }
948                bundle_job = bundle_jobs.join_next(), if !bundle_jobs.is_empty() => {
949                    if let Some(Err(error)) = bundle_job {
950                        tracing::warn!(%error, "bundle creation task panicked");
951                    }
952                }
953                preflight = preflight_rx.recv(), if preflight_jobs.len() < MAX_CONCURRENT_PREFLIGHTS => {
954                    let Some(preflight) = preflight else {
955                        failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering preflight requests");
956                        break;
957                    };
958                    // A resume preflight asks a different question about the
959                    // same disk, so it runs on the same supervised task set
960                    // and under the same cap as a new-session preflight.
961                    let crate::server::NewPreflightRequest {
962                        bundle_id,
963                        target_id,
964                        project_directory,
965                        mut reply,
966                        remote_repairs,
967                    } = match preflight {
968                        crate::server::PreflightRequest::New(request) => request,
969                        crate::server::PreflightRequest::Resume(request) => {
970                            spawn_resume_preflight(
971                                &mut preflight_jobs,
972                                &controller,
973                                request,
974                                &termination,
975                            );
976                            continue;
977                        }
978                        crate::server::PreflightRequest::CompletePath(request) => {
979                            spawn_path_completion(
980                                &mut preflight_jobs,
981                                &controller.config,
982                                request,
983                                &termination,
984                            );
985                            continue;
986                        }
987                    };
988                    // Reading a working tree's status or validating a project
989                    // directory touches the disk, so it runs on its own task
990                    // rather than on the loop that has to stay responsive to
991                    // every other feed.
992                    let config = controller.config.clone();
993                    let project_validation = project_directory.is_some();
994                    let task_termination = termination.clone();
995                    preflight_jobs.spawn(async move {
996                        let cancelled = Arc::new(AtomicBool::new(false));
997                        let cancellation_guard = ProcessCancellationGuard(cancelled.clone());
998                        let mut blocking = tokio::task::spawn_blocking(move || {
999                            run_new_preflight_with_cancellation(
1000                                config,
1001                                bundle_id,
1002                                target_id,
1003                                project_directory,
1004                                cancelled,
1005                                remote_repairs,
1006                            )
1007                        });
1008                        let answer = tokio::select! {
1009                            biased;
1010                            _ = task_termination.cancelled() => None,
1011                            _ = reply.closed() => None,
1012                            answer = &mut blocking => Some(answer),
1013                        };
1014                        let Some(answer) = answer else {
1015                            drop(cancellation_guard);
1016                            match blocking.await {
1017                                Err(error) => tracing::warn!(%error, "cancelled phone preflight task failed"),
1018                                Ok(Err(error)) => tracing::debug!(%error, "phone preflight cancelled"),
1019                                Ok(Ok(_)) => {}
1020                            }
1021                            return;
1022                        };
1023                        let answer = match answer {
1024                            Ok(Ok(answer)) => Ok(answer),
1025                            Ok(Err(error)) => {
1026                                tracing::debug!(
1027                                    error = %error,
1028                                    project_validation,
1029                                    "phone preflight check failed"
1030                                );
1031                                Err(if project_validation {
1032                                    PreflightFailure::Validation
1033                                } else {
1034                                    PreflightFailure::InvalidRepository(format!("{error:#}"))
1035                                })
1036                            }
1037                            Err(error) => {
1038                                tracing::warn!(%error, "phone preflight task failed");
1039                                Err(PreflightFailure::Controller(format!(
1040                                    "preflight task failed: {error}"
1041                                )))
1042                            }
1043                        };
1044                        if reply.send(answer).is_err() {
1045                            tracing::debug!("phone preflight reply dropped after client disconnect");
1046                        }
1047                    });
1048                }
1049                preflight_job = preflight_jobs.join_next(), if !preflight_jobs.is_empty() => {
1050                    if let Some(Err(error)) = preflight_job {
1051                        tracing::warn!(%error, "phone preflight task panicked");
1052                    }
1053                }
1054                preparation = move_preparation_rx.recv() => {
1055                    let Some(MovePreparationRequest { selection, reply }) = preparation else {
1056                        failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering move preparation requests");
1057                        break;
1058                    };
1059                    // Preparation can inspect archives, target prerequisites,
1060                    // and harness capabilities. Keep it supervised and away
1061                    // from this feed loop so another browser can still read
1062                    // snapshots while a move form is open.
1063                    let done = move_prepared_tx.clone();
1064                    let daemon_runtime = daemon_runtime.clone();
1065                    move_preparation_jobs.spawn(async move {
1066                        let result = daemon_runtime
1067                            .prepare_move_session(selection)
1068                            .await
1069                            .map_err(|error| format!("{error:#}"));
1070                        if let Err(error) = done.send(MovePrepared { result, reply }) {
1071                            tracing::debug!(%error, "move preparation finished after the server stopped");
1072                        }
1073                    });
1074                }
1075                prepared = move_prepared_rx.recv() => {
1076                    let Some(MovePrepared { result, reply }) = prepared else {
1077                        failure = feed_stopped(termination.is_cancelled(), "the move preparation pipeline stopped while the phone server was running");
1078                        break;
1079                    };
1080                    if reply.send(result).is_err() {
1081                        tracing::debug!("move preparation reply dropped after client disconnect");
1082                    }
1083                }
1084                move_preparation_job = move_preparation_jobs.join_next(), if !move_preparation_jobs.is_empty() => {
1085                    if let Some(Err(error)) = move_preparation_job {
1086                        tracing::warn!(%error, "move preparation task failed");
1087                    }
1088                }
1089                move_recovery_job = move_recovery_jobs.join_next(), if !move_recovery_jobs.is_empty() => {
1090                    if let Some(Err(error)) = move_recovery_job {
1091                        move_recovery_load_in_flight = false;
1092                        tracing::warn!(%error, "Move recovery projection task failed");
1093                    }
1094                }
1095                receipt = receipt_rx.recv() => {
1096                    let Some(ReadReceiptRequest { client_id, session_id, through, reply }) = receipt else {
1097                        failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering read receipts");
1098                        break;
1099                    };
1100                    match controller.state.sessions.get(&session_id) {
1101                        None => {
1102                            if reply.send(Err("unknown session".into())).is_err() {
1103                                tracing::debug!(%session_id, "unknown-session read receipt reply dropped after client disconnect");
1104                            }
1105                        }
1106                        Some(session) => {
1107                            let workspace_id = session.workspace_id.clone();
1108                            let done = receipt_done_tx.clone();
1109                            let persisted_session_id = session_id.clone();
1110                            tokio::spawn(async move {
1111                                let joined = tokio::task::spawn_blocking(move || {
1112                                    crate::database::persist_read_receipt(
1113                                        &client_id,
1114                                        &workspace_id,
1115                                        &persisted_session_id,
1116                                        through,
1117                                    )
1118                                })
1119                                .await;
1120                                let result = match joined {
1121                                    Ok(result) => result.map_err(|error| format!("{error:#}")),
1122                                    Err(error) => Err(format!("phone read receipt task failed: {error}")),
1123                                };
1124                                if let Err(error) = done.send(ReadReceiptPersisted { session_id, result, reply }) {
1125                                    tracing::debug!(%error, "phone read receipt finished after the server stopped");
1126                                }
1127                            });
1128                        }
1129                    }
1130                }
1131                persisted = receipt_done_rx.recv() => {
1132                    let Some(ReadReceiptPersisted { session_id, result, reply }) = persisted else { continue };
1133                    match result {
1134                        Ok(receipt) => {
1135                            let _ = receipt;
1136                            if reply.send(Ok(())).is_err() {
1137                                tracing::debug!(%session_id, "phone read receipt reply dropped after client disconnect");
1138                            }
1139                        }
1140                        Err(error) => {
1141                            tracing::warn!(%session_id, "could not persist a phone read receipt: {error}");
1142                            if reply.send(Err(error)).is_err() {
1143                                tracing::debug!(%session_id, "failed phone read receipt reply dropped after client disconnect");
1144                            }
1145                        }
1146                    }
1147                }
1148                action = action_rx.recv() => {
1149                    let Some(request) = action else {
1150                        failure = feed_stopped(termination.is_cancelled(), "the phone HTTP server stopped delivering actions");
1151                        break;
1152                    };
1153                    // A refresh nudges a poller this loop owns. It takes no
1154                    // session slot and starts no lifecycle work, so it is
1155                    // answered here rather than admitted as an action.
1156                    match &request.action {
1157                        ControllerAction::RefreshCapacity { target_id } => {
1158                            let known = capacity_state.contains_key(target_id);
1159                            // One queued nudge refreshes every target. Do not
1160                            // block the consumer while readings wait for it.
1161                            let accepted = known && match capacity_triggers_tx.try_send(()) {
1162                                Ok(()) | Err(tokio::sync::mpsc::error::TrySendError::Full(())) => true,
1163                                Err(tokio::sync::mpsc::error::TrySendError::Closed(())) => {
1164                                    tracing::warn!("phone capacity refresh rejected: poller stopped");
1165                                    false
1166                                }
1167                            };
1168                            if known {
1169                                if let Some(entry) = capacity_state.get_mut(target_id) {
1170                                    entry.refreshing = accepted;
1171                                    if !accepted {
1172                                        entry.failed = true;
1173                                    }
1174                                }
1175                                revision = daemon_runtime.allocate_revision();
1176                                publish_snapshot!(revision);
1177                            }
1178                            let outcome = match (known, accepted) {
1179                                (_, true) => ActionOutcome::accepted(),
1180                                (false, _) => ActionOutcome::Refused(Refusal::unusable(format!(
1181                                    "no target named {target_id} is configured"
1182                                ))),
1183                                (true, false) => ActionOutcome::Failed {
1184                                    reference: "capacity-poller".to_owned(),
1185                                },
1186                            };
1187                            if request.reply.send(outcome).is_err() {
1188                                tracing::debug!(%target_id, "phone capacity refresh reply dropped after client disconnect");
1189                            }
1190                            tokio::task::yield_now().await;
1191                            continue;
1192                        }
1193                        ControllerAction::RefreshQuota { profile_id } => {
1194                            let known = controller.config.enabled_profile(profile_id).is_some();
1195                            if known {
1196                                // The refresher works from a generation-stamped
1197                                // batch, so a new generation is how one is asked
1198                                // for again rather than a per-profile trigger.
1199                                quota_batch.generation = quota_batch.generation.saturating_add(1);
1200                                quota_batch.profiles = quota_refresh_profiles(&controller);
1201                                quota_profiles_tx.send_replace(quota_batch.clone());
1202                            }
1203                            let outcome = if known {
1204                                ActionOutcome::accepted()
1205                            } else {
1206                                ActionOutcome::Refused(Refusal::unusable(format!(
1207                                    "no enabled profile named {profile_id} is configured"
1208                                )))
1209                            };
1210                            if request.reply.send(outcome).is_err() {
1211                                tracing::debug!(%profile_id, "phone quota refresh reply dropped after client disconnect");
1212                            }
1213                            tokio::task::yield_now().await;
1214                            continue;
1215                        }
1216                        _ => {}
1217                    }
1218                    if let ControllerAction::Cancel { session_id } = &request.action {
1219                        let outcome = if request_phone_action_cancellation(
1220                            session_id,
1221                            &action_sessions,
1222                            &action_cancellations,
1223                        ) {
1224                            daemon_runtime.cancel_lifecycle_if_active(session_id);
1225                            ActionOutcome::accepted()
1226                        } else {
1227                            ActionOutcome::NotCancellable
1228                        };
1229                        if request.reply.send(outcome).is_err() {
1230                            tracing::debug!(%session_id, "phone cancellation reply dropped after client disconnect");
1231                        }
1232                        tokio::task::yield_now().await;
1233                        continue;
1234                    }
1235                    if let ControllerAction::Close { session_id } = &request.action {
1236                        if closing_actions.contains_key(session_id) {
1237                            if request.reply.send(ActionOutcome::accepted()).is_err() { tracing::debug!(%session_id, "repeated close reply dropped"); }
1238                            continue;
1239                        }
1240                        request_phone_action_cancellation(session_id, &action_sessions, &action_cancellations);
1241                        daemon_runtime.request_close(session_id);
1242                    }
1243                    // A force close runs even while a graceful close for the
1244                    // same session is still in flight; that stuck close is
1245                    // exactly what it is meant to take over.
1246                    if let ControllerAction::ForceClose { session_id, .. } = &request.action {
1247                        request_phone_action_cancellation(session_id, &action_sessions, &action_cancellations);
1248                        daemon_runtime.request_close(session_id);
1249                    }
1250                    let session_id = match admit_phone_action(
1251                        &request.action,
1252                        action_cancellations.len(),
1253                        &mut active_actions,
1254                    ) {
1255                        Ok(session_id) => session_id,
1256                        Err(refusal) => {
1257                            if request.reply.send(refusal).is_err() {
1258                                tracing::debug!("phone action refusal reply dropped after client disconnect");
1259                            }
1260                            tokio::task::yield_now().await;
1261                            continue;
1262                        }
1263                    };
1264                    let ControllerRequest { action, reply } = request;
1265                    let done = action_done_tx.clone();
1266                    let session_control = worker_commands_tx.clone();
1267                    let daemon_runtime = daemon_runtime.clone();
1268                    let started = action_started_tx.clone();
1269                    next_action_id = next_action_id.wrapping_add(1).max(1);
1270                    let action_id = next_action_id;
1271                    if let ControllerAction::Close { session_id } | ControllerAction::ForceClose { session_id, .. } = &action { closing_actions.insert(session_id.clone(), action_id); }
1272                    if let ControllerAction::New { workspace_id, .. } = &action {
1273                        let workspace_id = if workspace_id.is_empty() && phone_workspaces.len() == 1 {
1274                            phone_workspaces[0].id.clone()
1275                        } else {
1276                            workspace_id.clone()
1277                        };
1278                        launch_workspaces.insert(action_id, workspace_id);
1279                    }
1280                    let control = PhoneActionControl::for_action(&action);
1281                    action_cancellations.insert(action_id, control.clone());
1282                    if let Some(session_id) = &session_id {
1283                        action_sessions.insert(action_id, session_id.clone());
1284                    }
1285                    action_replies.accept(action_id, &action, reply);
1286                    let runtime = tokio::runtime::Handle::current();
1287                    tokio::spawn(async move {
1288                        let joined = tokio::task::spawn_blocking(move || {
1289                            let result = (|| -> Result<()> {
1290                                if control.cancelled.load(Ordering::Acquire) {
1291                                    bail!("phone action cancelled");
1292                                }
1293                                let mut operation_controller = Controller::load()?;
1294                                let executor =
1295                                    CancellableProcessExecutor::new(control.cancelled.clone());
1296                                runtime.block_on(apply_phone_action(
1297                                    &mut operation_controller,
1298                                    PhoneActionServices {
1299                                        sessions: &session_control,
1300                                        daemon_runtime: &daemon_runtime,
1301                                    },
1302                                    action,
1303                                    &executor,
1304                                    action_id,
1305                                    &started,
1306                                    &control,
1307                                ))
1308                            })();
1309                            result.map_err(|error| PhoneActionFailure::of(&error))
1310                        })
1311                        .await;
1312                        let result = match joined {
1313                            Ok(result) => result,
1314                            Err(error) => Err(PhoneActionFailure::internal(format!(
1315                                "phone action task failed: {error}"
1316                            ))),
1317                        };
1318                        if let Err(error) = done.send((action_id, session_id, result)) {
1319                            tracing::debug!(action_id, %error, "phone action finished after the server stopped");
1320                        }
1321                    });
1322                }
1323                started = action_started_rx.recv() => {
1324                    let Some(started) = started else {
1325                        tokio::task::yield_now().await;
1326                        continue;
1327                    };
1328                    let started_session_id = started.session.id.clone();
1329                    let publication = if !action_cancellations.contains_key(&started.action_id) {
1330                        Err("phone action completed before its provisional session was published".into())
1331                    } else {
1332                        track_started_phone_session(
1333                            &mut controller.state,
1334                            &mut active_actions,
1335                            &mut action_sessions,
1336                            started.action_id,
1337                            started.session,
1338                        )
1339                    };
1340                    if publication.is_ok() {
1341                        revision = daemon_runtime.allocate_revision();
1342                        publish_snapshot!(revision);
1343                        request_daemon_controller_reload(
1344                            daemon_runtime.clone(),
1345                            "new session publication",
1346                        );
1347                    };
1348                    if publication.is_err()
1349                        && let Some(control) = action_cancellations.get(&started.action_id)
1350                    {
1351                        control.request_cancel();
1352                    }
1353                    // The phone asked for a session, and now there is one to
1354                    // point at: that is what its request was waiting for.
1355                    action_replies.resolve(
1356                        started.action_id,
1357                        match &publication {
1358                            Ok(()) => ActionOutcome::Accepted {
1359                                session_id: Some(started_session_id),
1360                            },
1361                            // Publication fails only for reasons this loop
1362                            // words itself -- a race for the new session, or
1363                            // an action that ended first -- so the text is
1364                            // already safe and specific enough to send.
1365                            Err(reason) => ActionOutcome::Refused(Refusal::precondition(
1366                                reason.clone(),
1367                            )),
1368                        },
1369                    );
1370                    if started.published.send(publication).is_err() {
1371                        tracing::debug!(action_id = started.action_id, "phone new-session publication reply dropped after client disconnect");
1372                    }
1373                }
1374                completed = action_done_rx.recv() => {
1375                    let Some((action_id, session_id, result)) = completed else {
1376                        failure = feed_stopped(termination.is_cancelled(), "the phone action pipeline stopped reporting completions");
1377                        break;
1378                    };
1379                    action_cancellations.remove(&action_id);
1380                    let session_id = action_sessions.remove(&action_id).or(session_id);
1381                    if closing_actions.values().any(|closing_id| *closing_id == action_id) && let Some(id) = &session_id { daemon_runtime.clear_close_request(id); }
1382                    closing_actions.retain(|_, closing_id| *closing_id != action_id);
1383                    if let Some(session_id) = &session_id && !action_sessions.values().any(|active| active == session_id) {
1384                        active_actions.remove(session_id);
1385                    }
1386                    // A `new` that failed before publishing a session never
1387                    // reached the arm that answers it, so its phone is still
1388                    // waiting for a reply it can act on. A failure that named
1389                    // a reason the caller can fix answers with that reason;
1390                    // every other one answers generically and points at the
1391                    // log entry below.
1392                    let reference = action_reference(action_id);
1393                    action_replies.resolve(
1394                        action_id,
1395                        match &result {
1396                            Ok(()) => ActionOutcome::Accepted { session_id: session_id.clone() },
1397                            Err(failure) => failure.outcome(&reference),
1398                        },
1399                    );
1400                    if let Some(workspace_id) = launch_workspaces.remove(&action_id)
1401                        && result.is_err()
1402                        && !session_id.as_ref().is_some_and(|id| closing_actions.contains_key(id))
1403                    {
1404                        record_launch_failure(
1405                            &mut launch_failures,
1406                            action_id,
1407                            workspace_id,
1408                            session_id.clone(),
1409                            result.as_ref().err().map(|failure| failure.detail.clone()),
1410                        );
1411                        revision = daemon_runtime.allocate_revision();
1412                        publish_snapshot!(revision);
1413                    }
1414                    if let Err(failure) = &result {
1415                        tracing::warn!(
1416                            action_id,
1417                            reference,
1418                            session_id = session_id.as_deref(),
1419                            error = %failure.detail,
1420                            "phone action failed"
1421                        );
1422                    }
1423                    record_action_result(
1424                        &mut pending_action_errors,
1425                        session_id.as_deref(),
1426                        &result,
1427                    );
1428                    // A session that is taking work again has recovered from
1429                    // whatever its last close did, so it stops reporting it.
1430                    if result.is_ok() && let Some(session_id) = session_id.clone() {
1431                        let daemon_runtime = daemon_runtime.clone();
1432                        tokio::spawn(async move {
1433                            daemon_runtime.clear_recorded_close_failure(&session_id).await;
1434                        });
1435                    }
1436                    request_controller_reload(
1437                        &mut controller_reload_in_flight,
1438                        &mut controller_reload_requested,
1439                        &controller_reload_tx,
1440                    );
1441                    request_move_recovery_reload(
1442                        &move_recovery_tx,
1443                        &mut move_recovery_load_in_flight,
1444                        &mut move_recovery_jobs,
1445                    );
1446                    request_daemon_controller_reload(
1447                        daemon_runtime.clone(),
1448                        "phone action completion",
1449                    );
1450                }
1451                reloaded = controller_reload_rx.recv() => {
1452                    let Some(ControllerReloaded { result }) = reloaded else {
1453                        failure = feed_stopped(
1454                            termination.is_cancelled(),
1455                            "the controller reload pipeline stopped while the phone server was running",
1456                        );
1457                        break;
1458                    };
1459                    controller_reload_in_flight = false;
1460                    if std::mem::take(&mut controller_reload_invalidated) {
1461                        if let Err(error) = &result {
1462                            tracing::warn!(%error, "superseded controller reload failed");
1463                        }
1464                        controller_reload_requested = false;
1465                        request_controller_reload(
1466                            &mut controller_reload_in_flight,
1467                            &mut controller_reload_requested,
1468                            &controller_reload_tx,
1469                        );
1470                        continue;
1471                    }
1472                    match result {
1473                        Ok(mut reloaded) => {
1474                            for (session_id, error) in &pending_action_errors {
1475                                if let Some(session) = reloaded.state.sessions.get_mut(session_id)
1476                                    && session.last_error.is_none()
1477                                {
1478                                    session.last_error = Some(error.clone());
1479                                }
1480                            }
1481                            controller = reloaded;
1482                            quotas.retain(|id, _| controller.config.enabled_profile(id).is_some());
1483                            subagent_quota_reports
1484                                .lock()
1485                                .expect("sub-agent quota reports lock poisoned")
1486                                .retain(|id, _| controller.config.enabled_profile(id).is_some());
1487                            worker_targets_tx.send_replace(dashboard_worker_targets(&controller));
1488                            publish_capacity_targets(
1489                                &controller,
1490                                &capacity_targets_tx,
1491                                &mut capacity_state,
1492                            );
1493                            credential_sync_handle.set_targets(credential_sync_targets(&controller));
1494                            republish_quota_profiles(
1495                                &controller,
1496                                &mut published_quota_profiles,
1497                                &mut quota_batch,
1498                                &quota_profiles_tx,
1499                            );
1500                            // A changed profile set or sub-agent policy makes
1501                            // the catalogue's answers wrong, so it drops them,
1502                            // adopts the configuration it is given here, and
1503                            // discovers the new one in the background. A
1504                            // `list_profiles` call that arrives first waits on
1505                            // that pass's discoveries rather than starting its
1506                            // own.
1507                            profile_catalog.sync(&controller.config);
1508                            queued_prompts.retain(|session_id, _| {
1509                                controller.state.sessions.contains_key(session_id)
1510                            });
1511                            pending_elicitations.retain(|session_id, _| {
1512                                controller.state.sessions.contains_key(session_id)
1513                            });
1514                            prompt_images.retain(|session_id| {
1515                                controller.state.sessions.contains_key(session_id)
1516                            });
1517                            operational.retain(|session_id, _| {
1518                                controller.state.sessions.contains_key(session_id)
1519                            });
1520                            materialized_activity.retain(|session_id, _| {
1521                                controller.state.sessions.contains_key(session_id)
1522                            });
1523                            request_move_recovery_reload(
1524                                &move_recovery_tx,
1525                                &mut move_recovery_load_in_flight,
1526                                &mut move_recovery_jobs,
1527                            );
1528                            conversations.retain(|id, _| {
1529                                controller.state.sessions.get(id).is_some_and(|session| session.state.is_active())
1530                            });
1531                            for session_id in conversation_projections.session_ids() {
1532                                if !controller
1533                                    .state
1534                                    .sessions
1535                                    .get(&session_id)
1536                                    .is_some_and(|session| session.state.is_active())
1537                                {
1538                                    conversation_projections.forget(&session_id);
1539                                }
1540                            }
1541                            revision = daemon_runtime.allocate_revision();
1542                            conversation_tx.send_replace(conversations.clone());
1543                            publish_snapshot!(revision);
1544                        }
1545                        Err(error) => {
1546                            tracing::warn!(%error, "completed phone operation could not reload controller state");
1547                        }
1548                    }
1549                    if controller_reload_requested {
1550                        controller_reload_requested = false;
1551                        controller_reload_in_flight = true;
1552                        spawn_controller_reload(controller_reload_tx.clone());
1553                    }
1554                }
1555            }
1556        }
1557        // Stop provider requests before the HTTP request channels disappear.
1558        dictation_jobs.shutdown().await;
1559        // Bundle jobs are supervised so shutdown never leaves a detached
1560        // request task behind holding the config mutation lock.
1561        bundle_jobs.shutdown().await;
1562        // Preflight jobs own cancellation guards for their blocking Git
1563        // probes. Aborting them here signals those probes before the server's
1564        // request channels disappear.
1565        preflight_jobs.shutdown().await;
1566        // Preparation tasks may be inspecting an archive or probing a target;
1567        // abort and drain them before the HTTP server's channels disappear.
1568        move_preparation_jobs.shutdown().await;
1569        move_recovery_jobs.shutdown().await;
1570        // Every exit stops in-flight work, whether it was asked for or forced.
1571        crate::controller::profile_config::cancel_all();
1572        for control in action_cancellations.values() {
1573            control.request_cancel();
1574        }
1575        match failure {
1576            Some(failure) => Err(failure),
1577            None => Ok::<(), anyhow::Error>(()),
1578        }
1579    };
1580    let result = tokio::select! {
1581        result = api_activity::record_activity_stream(activity_snapshots) => result.context("native API activity recorder stopped"),
1582        result = serve => result,
1583        result = control => result,
1584    };
1585    conversation_projection_shutdown.cancel();
1586    renewal_cancellation.cancel();
1587    if let Some(task) = renewal_task
1588        && let Err(error) = task.await
1589    {
1590        tracing::warn!(%error, "Tailscale certificate renewal task failed");
1591    }
1592    worker_shutdown
1593        .shutdown()
1594        .await
1595        .context("shut down phone server session manager")?;
1596    result?;
1597    Ok(())
1598}