Skip to main content

mj_controller/
daemon.rs

1//! Persistent per-user controller daemon and its authenticated local protocol.
2
3mod feed;
4mod owner;
5mod record_index;
6mod restart;
7mod session_move;
8use crate::controller::move_session::{
9    MoveMutationGuard, MoveOutcome, MovePreparation, MoveSelection, MoveSessionRequest,
10};
11pub use mj_client::daemon::*;
12use owner::RuntimeStateOwner;
13
14use std::collections::{BTreeMap, BTreeSet, VecDeque};
15use std::fs::{self, OpenOptions};
16use std::io::Write;
17use std::net::{IpAddr, Ipv4Addr, SocketAddr};
18use std::path::{Path, PathBuf};
19use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
20use std::sync::{Arc, Mutex, PoisonError};
21use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
22
23use crate::database::StoreSchemaMismatch;
24use crate::recovery_gate::RecoveryObserver;
25use crate::targets::{
26    CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor,
27    ProvisionStage, ProvisionStageGuard,
28};
29use agent_client_protocol::schema::v1::{ContentBlock, TextContent};
30use anyhow::{Context, Result, anyhow, bail, ensure};
31use mj_core::config::Config;
32use mj_core::refusal::Refusal;
33use mj_core::relay::RelayCommand;
34use mj_core::state::{RecoveryObservation, SessionRecord, SessionState};
35
36use crate::controller::{
37    BeforeClose, BranchDisposition, CheckoutDisposition, Controller, ControllerStoreGuard,
38    SessionLaunchOptions, SessionResumeOptions,
39};
40use crate::review_host::TurnReviewHost;
41use crate::session_manager::{
42    ManagedSessionView, RemoteSessionPublisher, RemoteSessionRequest, SessionManagerChannels,
43    SessionManagerControl, ViewError, new_command_id, spawn_remote_session_manager,
44};
45#[cfg(test)]
46use crate::session_manager::{RelaySessionTarget, RemoteSessionRequests, SessionManagerShutdown};
47use crate::worker_upgrade::{WorkerUpgradeObservation, WorkerUpgradeObserver};
48use mj_core::workspace::WorkspaceRecord;
49use tokio::net::{TcpListener, TcpStream};
50use tokio_util::sync::CancellationToken;
51
52use crate::pollers::{
53    dashboard_worker_targets, interrupted_destroy_session_ids, interrupted_suspend_session_ids,
54    reserve_recovery_or_cancel, spawn_image_refresher, unowned_interrupted_lifecycles,
55};
56
57// Move preparation now reports whether source state must be recovered without its harness.
58
59/// How long the epilogue is given before the process leaves anyway.
60///
61/// Every daemon exit -- stop, SIGTERM, idle, a store that moved underneath it
62/// -- unwinds through the same epilogue, and every step of it is bounded in
63/// practice. This makes "the daemon did not stop" impossible rather than
64/// unlikely, and it must stay well inside [`STOP_TIMEOUT`] so a client waiting
65/// on a stop sees the exit rather than its own deadline.
66const SHUTDOWN_FORCE_EXIT_TIMEOUT: Duration = Duration::from_secs(10);
67
68/// How long force destruction waits for a cancelled lifecycle to actually
69/// stop before refusing to destroy under it. Cancellation kills the
70/// operation's child process groups and unwinds its persistence, which is
71/// fast in practice; an operation that outlives this bound is wedged in a
72/// way destruction must not paper over.
73const FORCE_DESTROY_PREEMPT_TIMEOUT: Duration = Duration::from_secs(8);
74
75/// Cancellation and committing a newly started session are one atomic decision.
76#[derive(Clone, Default)]
77pub struct CreateSessionControl {
78    state: Arc<AtomicU8>,
79    pub cancelled: Arc<AtomicBool>,
80}
81
82impl CreateSessionControl {
83    pub fn request_cancel(&self) -> bool {
84        let accepted = self
85            .state
86            .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
87            .is_ok();
88        if accepted {
89            self.cancelled.store(true, Ordering::Release);
90        }
91        accepted
92    }
93
94    pub fn grant_commit(&self) -> bool {
95        self.state
96            .compare_exchange(0, 2, Ordering::AcqRel, Ordering::Acquire)
97            .is_ok()
98    }
99
100    fn is_cancellable(&self) -> bool {
101        self.state.load(Ordering::Acquire) == 0
102    }
103}
104
105#[derive(Debug, Clone)]
106struct Attachment {
107    pid: u32,
108}
109
110#[derive(Default)]
111struct QuotaBoard {
112    snapshot: mj_client::quota::QuotaSnapshot,
113    refresh: Option<tokio::sync::mpsc::Sender<()>>,
114}
115
116pub struct RuntimeState {
117    attachments: Mutex<BTreeMap<String, Attachment>>,
118    phone_status: Mutex<WebViewerStatus>,
119    pub web_viewer: crate::web_viewer::ViewerControl,
120    ever_attached: AtomicBool,
121    revisions: RuntimeRevisions,
122    workspaces_tx: tokio::sync::watch::Sender<Vec<WorkspaceRecord>>,
123    workspace_refresh: tokio::sync::Mutex<()>,
124    session_manager: SessionManagerControl,
125    owner: Mutex<RuntimeStateOwner>,
126    /// Rebuilt by the delegation service at daemon startup; weak so the
127    /// backend's ExportRuntime reference does not form an Arc cycle.
128    pub(crate) wait_prompt_backend:
129        std::sync::OnceLock<std::sync::Weak<crate::server_runtime::api::ApiBackend>>,
130    /// Parents whose level-triggered wait-prompt reconcile failed. The
131    /// delegation coordinator retries these; startup also rebuilds the set
132    /// from child records after a daemon restart.
133    pub(crate) wait_prompt_retries: Mutex<BTreeMap<String, Instant>>,
134    pub(crate) credential_targets:
135        Arc<tokio::sync::watch::Sender<Vec<mj_core::credentials::CredentialSyncTarget>>>,
136    feed: Mutex<feed::RuntimeHistory>,
137    committed: Option<
138        tokio::sync::watch::Receiver<
139            std::result::Result<Arc<crate::database::CommittedState>, Arc<str>>,
140        >,
141    >,
142    workspace_closes: Mutex<BTreeMap<String, Arc<AtomicBool>>>,
143    /// Resume ownership can precede its durable workspace assignment.
144    workspace_resume_admission: Mutex<BTreeMap<String, Arc<tokio::sync::RwLock<()>>>>,
145    /// The bounded wait for each live session's harness to become usable.
146    /// Driven only by the daemon's background readiness sweep.
147    harness_readiness: Mutex<HarnessReadinessWatch>,
148    /// Work waiting for one session's harness to become ready: the prompts a
149    /// person typed while it started, and the hand-off a restored session
150    /// carries. One ordered queue per session, each drained by one task.
151    startup_prompts: Mutex<BTreeMap<String, StartupQueue>>,
152    startup_enqueue: tokio::sync::Mutex<()>,
153    controller_loader: fn() -> Result<Controller>,
154    config_mutation: tokio::sync::Mutex<()>,
155    projects: Arc<crate::project_catalog::Catalog>,
156    profile_catalog: crate::review_host::SharedProfileCatalog,
157    recovery_observer: RecoveryObserver,
158    worker_upgrade_observer: WorkerUpgradeObserver,
159    /// Recent background notices, newest last, with the id of the next one.
160    /// Bounded: a surface that never attaches must not make this grow.
161    notices: Mutex<VecDeque<RuntimeNotice>>,
162    next_notice_id: AtomicU64,
163    /// What the quota poller last published, and how to wake it. The poller
164    /// is the only process that asks a provider for quota; everything else
165    /// reads this.
166    quota: Mutex<QuotaBoard>,
167    /// The one capacity poller and what it shares; set once the daemon
168    /// starts it.
169    capacity: std::sync::OnceLock<capacity::CapacityFeed>,
170    /// What `[review]` last said, republished by the target refresher.
171    review_config: Arc<Mutex<mj_core::config::ReviewConfig>>,
172    /// Turn review runs here, in the process that owns every session, so a
173    /// review happens whether the terminal, the phone, or nobody is attached.
174    review_host: TurnReviewHost,
175    /// Publishes checkpointed sessions into the user's SessionWiki index.
176    wiki: crate::sessionwiki::WikiIndexer,
177}
178
179/// One monotonic cursor shared by daemon snapshots and their wake-up feed.
180///
181/// Allocations can come from independent UI and daemon tasks. Publishing an
182/// older allocation after a newer one must not move the watch channel
183/// backwards, so publication compares against the last visible cursor.
184#[derive(Clone)]
185struct RuntimeRevisions {
186    allocated: Arc<std::sync::atomic::AtomicU64>,
187    published: tokio::sync::watch::Sender<u64>,
188}
189
190impl RuntimeRevisions {
191    fn new(initial: u64) -> Self {
192        let (published, _) = tokio::sync::watch::channel(initial);
193        Self {
194            allocated: Arc::new(std::sync::atomic::AtomicU64::new(initial)),
195            published,
196        }
197    }
198
199    fn allocate(&self) -> u64 {
200        self.allocated.fetch_add(1, Ordering::AcqRel) + 1
201    }
202
203    fn publish(&self) -> u64 {
204        let revision = self.allocate();
205        self.publish_allocated(revision);
206        revision
207    }
208
209    fn publish_allocated(&self, revision: u64) {
210        self.published.send_if_modified(|visible| {
211            if revision > *visible {
212                *visible = revision;
213                true
214            } else {
215                false
216            }
217        });
218    }
219
220    fn notifier(&self) -> Arc<dyn Fn() + Send + Sync> {
221        let revisions = self.clone();
222        Arc::new(move || {
223            revisions.publish();
224        })
225    }
226
227    fn subscribe(&self) -> tokio::sync::watch::Receiver<u64> {
228        self.published.subscribe()
229    }
230
231    fn current(&self) -> u64 {
232        self.allocated.load(Ordering::Acquire)
233    }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237enum LifecycleKind {
238    Create,
239    Suspend,
240    Resume,
241    Restart,
242    Move,
243    ForceStop,
244    DestroyStopped,
245    /// The archive job's destruction: the same teardown as `DestroyStopped`,
246    /// with the session's git branch kept unless another branch already
247    /// contains every one of its commits. Surfaces see it as a destroy.
248    ArchiveStopped,
249    ForceDestroy,
250    /// A sub-agent stopped because its parent is being suspended: the
251    /// teardown of `ForceDestroy`, owned by the parent's suspend, so it is
252    /// shown as a stop and cannot be cancelled on its own.
253    StopSubagent,
254    /// A sub-agent whose turn ended and whose parent was told is having its
255    /// worker stopped, keeping everything else (#1161).
256    Park,
257    /// A parked sub-agent's worker is being started again for its parent's
258    /// `send_message`.
259    Unpark,
260    Cleanup,
261    StartupCleanup,
262}
263
264impl LifecycleKind {
265    /// Whether this operation is a destroy's teardown of the session, which a
266    /// second destroy of the same session waits for instead of cancelling.
267    fn is_teardown(self) -> bool {
268        matches!(
269            self,
270            LifecycleKind::ForceDestroy
271                | LifecycleKind::DestroyStopped
272                | LifecycleKind::ArchiveStopped
273                | LifecycleKind::StopSubagent
274        )
275    }
276
277    /// What a refusal calls this operation, so a person told that a session is
278    /// busy learns which operation is holding it (#1010).
279    fn label(self) -> &'static str {
280        match self {
281            LifecycleKind::Create => "create",
282            LifecycleKind::Suspend => "suspend",
283            LifecycleKind::Resume => "resume",
284            LifecycleKind::Restart => "restart",
285            LifecycleKind::Move => "move",
286            LifecycleKind::ForceStop => "force stop",
287            LifecycleKind::DestroyStopped => "destroy",
288            LifecycleKind::ArchiveStopped => "archive",
289            LifecycleKind::ForceDestroy => "force destroy",
290            LifecycleKind::StopSubagent => "stop",
291            LifecycleKind::Park => "park",
292            LifecycleKind::Unpark => "restart",
293            LifecycleKind::Cleanup => "cleanup",
294            LifecycleKind::StartupCleanup => "failed startup cleanup",
295        }
296    }
297}
298
299/// Whether a lifecycle has exclusive ownership of the worker target, so the
300/// session manager must stop polling it. A graceful close needs the manager's
301/// relay lease through checkpointing and sealing; once the durable state says
302/// `Destroying`, that lease has been released and target teardown is exclusive.
303///
304/// A park needs the session actor: it takes the actor's connection to admit
305/// the park only while the worker is idle. Once the record says `Parked` the
306/// manager drops the session on its own. Once unpark commits an active record
307/// and boot intent, the actor can observe startup; WorkerPermit still prevents
308/// it from racing a process mutation by the wake owner.
309fn lifecycle_owns_worker_target(kind: LifecycleKind, state: Option<SessionState>) -> bool {
310    match kind {
311        LifecycleKind::Suspend => state == Some(SessionState::Destroying),
312        LifecycleKind::Park => false,
313        LifecycleKind::Unpark => state == Some(SessionState::Parked),
314        LifecycleKind::Move | LifecycleKind::Restart => !matches!(
315            state,
316            Some(
317                SessionState::Running
318                    | SessionState::Disconnected
319                    | SessionState::Checkpointing
320                    | SessionState::Closing
321            )
322        ),
323        _ => true,
324    }
325}
326
327/// Whether a running lifecycle can still be cancelled. A graceful close has a
328/// point of no return: once the durable state says `Destroying`, the verified
329/// checkpoint is sealed and the record has already committed to losing its
330/// target, so stopping the teardown only strands the target. A sub-agent's
331/// stop belongs to its parent's suspend, which is what a person cancels.
332/// Every other lifecycle stays cancellable while it runs.
333///
334/// A park and an unpark belong to the parent's sub-agent tools: a person ends
335/// either by closing the child, which waits for them.
336fn lifecycle_cancellable(kind: LifecycleKind, state: Option<SessionState>) -> bool {
337    !(state == Some(SessionState::StartupCleanup)
338        || kind == LifecycleKind::StartupCleanup
339        || matches!(kind, LifecycleKind::Suspend | LifecycleKind::Restart)
340            && state == Some(SessionState::Destroying)
341        || matches!(
342            kind,
343            LifecycleKind::StopSubagent | LifecycleKind::Park | LifecycleKind::Unpark
344        ))
345}
346
347/// How a stop request has to be carried out, given the durable record.
348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
349enum CloseRoute {
350    /// Run the graceful close from the start.
351    Graceful,
352    /// A previous close stopped partway; finish it from its checkpoint.
353    RecoverInterrupted,
354    /// Nothing to checkpoint: tear down whatever target is left and settle.
355    SettleWithoutCheckpoint,
356    /// Already stopped, but the target still has to be removed.
357    DeferredCleanup,
358    /// Already stopped with nothing left to do.
359    Done,
360}
361
362/// A record mid-close with a live target cannot be closed again from the start:
363/// its worker socket is gone, so a fresh checkpoint attempt only fails on
364/// connect. Recovery finishes it from the checkpoint the first close verified.
365/// `subagent` says the session is a Mjolnir sub-agent; see
366/// [`crate::controller::has_nothing_to_checkpoint`].
367fn close_route(session: Option<&SessionRecord>, subagent: bool) -> CloseRoute {
368    let Some(session) = session else {
369        return CloseRoute::Graceful;
370    };
371    if crate::pollers::is_interrupted_close(session) {
372        CloseRoute::RecoverInterrupted
373    } else if session.state == SessionState::Stopped {
374        if session.target.is_some() {
375            CloseRoute::DeferredCleanup
376        } else {
377            CloseRoute::Done
378        }
379    } else if crate::controller::has_nothing_to_checkpoint(session, subagent) {
380        CloseRoute::SettleWithoutCheckpoint
381    } else {
382        CloseRoute::Graceful
383    }
384}
385
386/// The durable state of one record as the locked controller holds it.
387fn durable_session_state(controller: &Controller, session_id: &str) -> Option<SessionState> {
388    controller
389        .state
390        .sessions
391        .get(session_id)
392        .map(|session| session.state)
393}
394
395/// One piece of work that waits for a starting session's harness.
396///
397/// Both kinds are ordered against each other on purpose: a restored session's
398/// hand-off is the hidden context its first prompt reads, so it has to be
399/// installed before any queued prompt is submitted.
400#[derive(serde::Serialize, serde::Deserialize)]
401pub(crate) enum StartupStep {
402    InstallHandoff(Box<mj_core::archive::CanonicalSessionSnapshot>),
403    PreparedHandoff {
404        text: String,
405    },
406    Prompt {
407        text: String,
408        inherited_draft: Option<String>,
409    },
410    Configure {
411        key: String,
412        value: String,
413        optional: bool,
414    },
415    ApiPrompt {
416        text: String,
417    },
418}
419
420/// One supervised drain per session. Pending payloads stay in SQLite.
421struct StartupQueue {
422    identity: Arc<()>,
423    last_error: Option<String>,
424    cancel: CancellationToken,
425    task: Option<tokio::task::JoinHandle<()>>,
426}
427
428struct ActiveLifecycle {
429    upgrade_work: Arc<Mutex<Option<crate::upgrade::Work>>>,
430    phase: LifecyclePhase,
431    operation_id: String,
432    create_control: Option<CreateSessionControl>,
433    kind: LifecycleKind,
434    cancelled: Arc<AtomicBool>,
435    started_at_epoch_seconds: u64,
436    active_stages: BTreeMap<ProvisionStage, (usize, u64)>,
437    /// The workspace a resume is claiming before its durable record changes.
438    /// Workspace deletion consults this so it cannot race the claim.
439    resume_workspace_id: Option<String>,
440    resume_destination: Option<(String, String)>,
441    notice: Option<String>,
442    request_key: Option<String>,
443    _move_guard: Option<MoveMutationGuard>,
444    result: LifecycleWatch,
445}
446
447enum LifecyclePhase {
448    Executing,
449    MovingDestination,
450    Cancelling,
451    CancellingMoveDestination,
452    Completed(LifecycleResult),
453}
454
455impl ActiveLifecycle {
456    fn is_running(&self) -> bool {
457        !matches!(self.phase, LifecyclePhase::Completed(_))
458    }
459
460    fn is_visible(&self) -> bool {
461        self.is_running()
462            || matches!(
463                self.phase,
464                LifecyclePhase::Completed(Ok(DaemonLifecycleResult::DeferredCleanup))
465            )
466    }
467
468    fn request_cancel(&mut self) -> bool {
469        if !matches!(
470            self.phase,
471            LifecyclePhase::Executing | LifecyclePhase::MovingDestination
472        ) {
473            return false;
474        }
475        let accepted = if let Some(control) = &self.create_control {
476            control.request_cancel()
477        } else {
478            !self.cancelled.swap(true, Ordering::AcqRel)
479        };
480        if accepted {
481            self.phase = match self.phase {
482                LifecyclePhase::MovingDestination => LifecyclePhase::CancellingMoveDestination,
483                _ => LifecyclePhase::Cancelling,
484            };
485        }
486        accepted
487    }
488
489    fn is_cancellable(&self) -> bool {
490        matches!(
491            self.phase,
492            LifecyclePhase::Executing | LifecyclePhase::MovingDestination
493        ) && self.create_control.as_ref().map_or_else(
494            || !self.cancelled.load(Ordering::Acquire),
495            CreateSessionControl::is_cancellable,
496        )
497    }
498}
499
500#[derive(Debug, Clone)]
501enum DaemonLifecycleResult {
502    Done,
503    DeferredCleanup,
504    Move(MoveOutcome),
505    Park(crate::controller::ParkOutcome),
506}
507
508/// How one lifecycle operation ended when it failed.
509///
510/// The result is broadcast to every waiter, which is why it cannot simply be
511/// the `anyhow::Error`: that is not clonable. Keeping the refusal beside the
512/// text is what lets a reason written for the caller survive the crossing; a
513/// failure rebuilt from a string alone would arrive as an internal fault.
514#[derive(Debug, Clone)]
515pub(crate) struct LifecycleFailure {
516    pub(crate) detail: String,
517    pub(crate) refusal: Option<Refusal>,
518}
519
520/// One lifecycle operation's outcome, and the channel every waiter reads it
521/// from. `None` means the operation is still running.
522type LifecycleResult = std::result::Result<DaemonLifecycleResult, LifecycleFailure>;
523type LifecycleWatch = tokio::sync::watch::Receiver<Option<LifecycleResult>>;
524
525impl LifecycleFailure {
526    fn of(error: &anyhow::Error) -> Self {
527        Self {
528            detail: format!("{error:#}"),
529            refusal: Refusal::of(error),
530        }
531    }
532
533    /// A failure with no reason written for a caller, such as a task that died
534    /// before the operation could say anything about itself.
535    fn internal(detail: impl Into<String>) -> Self {
536        Self {
537            detail: detail.into(),
538            refusal: None,
539        }
540    }
541
542    /// Rebuild the error a waiter sees, with the refusal still attached.
543    fn into_error(self) -> anyhow::Error {
544        match self.refusal {
545            Some(refusal) => anyhow::Error::new(refusal).context(self.detail),
546            None => anyhow::Error::msg(self.detail),
547        }
548    }
549}
550
551impl std::fmt::Display for LifecycleFailure {
552    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
553        formatter.write_str(&self.detail)
554    }
555}
556
557impl From<LifecycleKind> for RuntimeLifecycleKind {
558    fn from(kind: LifecycleKind) -> Self {
559        match kind {
560            LifecycleKind::Create => Self::Create,
561            LifecycleKind::Suspend => Self::Suspend,
562            LifecycleKind::Resume => Self::Resume,
563            LifecycleKind::Restart => Self::Resume,
564            LifecycleKind::Move => Self::Move,
565            LifecycleKind::ForceStop => Self::ForceStop,
566            LifecycleKind::DestroyStopped | LifecycleKind::ArchiveStopped => Self::DestroyStopped,
567            LifecycleKind::ForceDestroy => Self::ForceDestroy,
568            // A park is shown as the sub-agent stop it is, and an unpark as
569            // the resume it is; neither needs a wire kind of its own.
570            LifecycleKind::StopSubagent | LifecycleKind::Park => Self::StopSubagent,
571            LifecycleKind::Unpark => Self::Resume,
572            LifecycleKind::Cleanup | LifecycleKind::StartupCleanup => Self::Cleanup,
573        }
574    }
575}
576
577mod capacity;
578mod close;
579mod close_workspace;
580mod create;
581mod readiness;
582use readiness::{HarnessReadinessWatch, ReadinessObservation, UnreadySession};
583mod lifecycle;
584mod resume;
585mod snapshot;
586pub(crate) mod startup_followup;
587mod state;
588mod subagent_park;
589mod support;
590mod views;
591use support::*;
592pub(crate) mod delegation;
593mod diagnostics;
594mod process;
595pub use process::*;
596mod serve;
597use serve::*;
598mod actions;
599use actions::*;
600mod guards;
601pub(crate) use guards::*;
602
603#[cfg(test)]
604mod tests;
605
606mod continuation;