Skip to main content

mj_controller/
daemon.rs

1//! Persistent per-user controller daemon and its authenticated local protocol.
2
3mod session_move;
4use crate::controller::move_session::{
5    MoveMutationGuard, MoveOutcome, MovePreparation, MoveSelection, MoveSessionRequest,
6};
7pub use mj_client::daemon::*;
8
9use std::collections::{BTreeMap, BTreeSet, VecDeque};
10use std::fs::{self, OpenOptions};
11use std::io::Write;
12use std::net::{IpAddr, Ipv4Addr, SocketAddr};
13use std::path::Path;
14use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
15use std::sync::{Arc, Mutex, PoisonError};
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18use crate::database::StoreSchemaMismatch;
19use crate::recovery_gate::RecoveryObserver;
20use crate::targets::{
21    CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor,
22    ProvisionStage, ProvisionStageGuard,
23};
24use agent_client_protocol::schema::v1::{ContentBlock, TextContent};
25use anyhow::{Context, Result, anyhow, bail, ensure};
26use mj_core::config::Config;
27use mj_core::refusal::Refusal;
28use mj_core::relay::RelayCommand;
29use mj_core::state::{RecoveryObservation, SessionRecord, SessionState};
30use mj_core::subagent::SubagentRecord;
31
32use crate::controller::{
33    BranchDisposition, Controller, ControllerStoreGuard, SessionLaunchOptions, SessionResumeOptions,
34};
35use crate::review_host::TurnReviewHost;
36use crate::session_manager::{
37    ManagedSessionView, RemoteSessionPublisher, RemoteSessionRequest, SessionManagerChannels,
38    SessionManagerControl, ViewError, new_command_id, spawn_remote_session_manager,
39    spawn_session_manager,
40};
41#[cfg(test)]
42use crate::session_manager::{RelaySessionTarget, RemoteSessionRequests, SessionManagerShutdown};
43use crate::worker_upgrade::{WorkerUpgradeObservation, WorkerUpgradeObserver};
44use mj_core::workspace::WorkspaceRecord;
45use tokio::net::{TcpListener, TcpStream};
46use tokio_util::sync::CancellationToken;
47
48use crate::pollers::{
49    dashboard_worker_targets, dashboard_worker_targets_excluding, interrupted_close_session_ids,
50    reserve_recovery_or_cancel, spawn_image_refresher, spawn_interrupted_close_recovery,
51    unowned_interrupted_lifecycles,
52};
53
54// Move preparation now reports whether source state must be recovered without its harness.
55
56/// How long the epilogue is given before the process leaves anyway.
57///
58/// Every daemon exit -- stop, SIGTERM, idle, a store that moved underneath it
59/// -- unwinds through the same epilogue, and every step of it is bounded in
60/// practice. This makes "the daemon did not stop" impossible rather than
61/// unlikely, and it must stay well inside [`STOP_TIMEOUT`] so a client waiting
62/// on a stop sees the exit rather than its own deadline.
63const SHUTDOWN_FORCE_EXIT_TIMEOUT: Duration = Duration::from_secs(10);
64
65/// How long force destruction waits for a cancelled lifecycle to actually
66/// stop before refusing to destroy under it. Cancellation kills the
67/// operation's child process groups and unwinds its persistence, which is
68/// fast in practice; an operation that outlives this bound is wedged in a
69/// way destruction must not paper over.
70const FORCE_DESTROY_PREEMPT_TIMEOUT: Duration = Duration::from_secs(8);
71
72/// Cancellation and committing a newly started session are one atomic decision.
73#[derive(Clone, Default)]
74pub struct CreateSessionControl {
75    state: Arc<AtomicU8>,
76    pub cancelled: Arc<AtomicBool>,
77}
78
79impl CreateSessionControl {
80    pub fn request_cancel(&self) -> bool {
81        let accepted = self
82            .state
83            .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
84            .is_ok();
85        if accepted {
86            self.cancelled.store(true, Ordering::Release);
87        }
88        accepted
89    }
90
91    pub fn grant_commit(&self) -> bool {
92        self.state
93            .compare_exchange(0, 2, Ordering::AcqRel, Ordering::Acquire)
94            .is_ok()
95    }
96
97    fn is_cancellable(&self) -> bool {
98        self.state.load(Ordering::Acquire) == 0
99    }
100}
101
102#[derive(Debug, Clone)]
103struct Attachment {
104    pid: u32,
105}
106
107pub struct RuntimeState {
108    attachments: Mutex<BTreeMap<String, Attachment>>,
109    phone_status: Mutex<WebViewerStatus>,
110    pub web_viewer: crate::web_viewer::ViewerControl,
111    ever_attached: AtomicBool,
112    sessions: Mutex<BTreeMap<String, RuntimeSessionView>>,
113    revisions: RuntimeRevisions,
114    workspaces_tx: tokio::sync::watch::Sender<Vec<WorkspaceRecord>>,
115    session_manager: SessionManagerControl,
116    lifecycle: Mutex<BTreeMap<String, ActiveLifecycle>>,
117    /// Work waiting for one session's harness to become ready: the prompts a
118    /// person typed while it started, and the hand-off a restored session
119    /// carries. One ordered queue per session, each drained by one task.
120    startup_prompts: Mutex<BTreeMap<String, StartupQueue>>,
121    close_requested: Mutex<BTreeSet<String>>,
122    controller: Mutex<Controller>,
123    controller_loader: fn() -> Result<Controller>,
124    config_mutation: tokio::sync::Mutex<()>,
125    recovery_observer: RecoveryObserver,
126    worker_upgrade_observer: WorkerUpgradeObserver,
127    /// Recent background notices, newest last, with the id of the next one.
128    /// Bounded: a surface that never attaches must not make this grow.
129    notices: Mutex<VecDeque<RuntimeNotice>>,
130    next_notice_id: AtomicU64,
131    /// What `[review]` last said, republished by the target refresher.
132    review_config: Arc<Mutex<mj_core::config::ReviewConfig>>,
133    /// Turn review runs here, in the process that owns every session, so a
134    /// review happens whether the terminal, the phone, or nobody is attached.
135    review_host: TurnReviewHost,
136    /// Publishes checkpointed sessions into the user's SessionWiki index.
137    wiki: crate::sessionwiki::WikiIndexer,
138}
139
140/// One monotonic cursor shared by daemon snapshots and their wake-up feed.
141///
142/// Allocations can come from independent UI and daemon tasks. Publishing an
143/// older allocation after a newer one must not move the watch channel
144/// backwards, so publication compares against the last visible cursor.
145#[derive(Clone)]
146struct RuntimeRevisions {
147    allocated: Arc<std::sync::atomic::AtomicU64>,
148    published: tokio::sync::watch::Sender<u64>,
149}
150
151impl RuntimeRevisions {
152    fn new(initial: u64) -> Self {
153        let (published, _) = tokio::sync::watch::channel(initial);
154        Self {
155            allocated: Arc::new(std::sync::atomic::AtomicU64::new(initial)),
156            published,
157        }
158    }
159
160    fn allocate(&self) -> u64 {
161        self.allocated.fetch_add(1, Ordering::AcqRel) + 1
162    }
163
164    fn publish(&self) -> u64 {
165        let revision = self.allocate();
166        self.publish_allocated(revision);
167        revision
168    }
169
170    fn publish_allocated(&self, revision: u64) {
171        self.published.send_if_modified(|visible| {
172            if revision > *visible {
173                *visible = revision;
174                true
175            } else {
176                false
177            }
178        });
179    }
180
181    fn notifier(&self) -> Arc<dyn Fn() + Send + Sync> {
182        let revisions = self.clone();
183        Arc::new(move || {
184            revisions.publish();
185        })
186    }
187
188    fn subscribe(&self) -> tokio::sync::watch::Receiver<u64> {
189        self.published.subscribe()
190    }
191
192    fn current(&self) -> u64 {
193        self.allocated.load(Ordering::Acquire)
194    }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198enum LifecycleKind {
199    Create,
200    Close,
201    Resume,
202    Move,
203    ForceStop,
204    DestroyStopped,
205    /// The archive job's destruction: the same teardown as `DestroyStopped`,
206    /// with the session's git branch kept unless another branch already
207    /// contains every one of its commits. Surfaces see it as a destroy.
208    ArchiveStopped,
209    ForceDestroy,
210    Cleanup,
211}
212
213/// Whether a lifecycle has exclusive ownership of the worker target, so the
214/// session manager must stop polling it. A graceful close needs the manager's
215/// relay lease through checkpointing and sealing; once the durable state says
216/// `Destroying`, that lease has been released and target teardown is exclusive.
217fn lifecycle_owns_worker_target(kind: LifecycleKind, state: Option<SessionState>) -> bool {
218    match kind {
219        LifecycleKind::Close => state == Some(SessionState::Destroying),
220        LifecycleKind::Move => !matches!(
221            state,
222            Some(
223                SessionState::Running
224                    | SessionState::Disconnected
225                    | SessionState::Checkpointing
226                    | SessionState::Closing
227            )
228        ),
229        _ => true,
230    }
231}
232
233/// Whether a running lifecycle can still be cancelled. A graceful close has a
234/// point of no return: once the durable state says `Destroying`, the verified
235/// checkpoint is sealed and the record has already committed to losing its
236/// target, so stopping the teardown only strands the target. Every other
237/// lifecycle stays cancellable while it runs.
238fn lifecycle_cancellable(kind: LifecycleKind, state: Option<SessionState>) -> bool {
239    !(kind == LifecycleKind::Close && state == Some(SessionState::Destroying))
240}
241
242/// How a stop request has to be carried out, given the durable record.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244enum CloseRoute {
245    /// Run the graceful close from the start.
246    Graceful,
247    /// A previous close stopped partway; finish it from its checkpoint.
248    RecoverInterrupted,
249    /// Nothing to checkpoint: tear down whatever target is left and settle.
250    SettleWithoutCheckpoint,
251    /// Already stopped, but the target still has to be removed.
252    DeferredCleanup,
253    /// Already stopped with nothing left to do.
254    Done,
255}
256
257/// A record mid-close with a live target cannot be closed again from the start:
258/// its worker socket is gone, so a fresh checkpoint attempt only fails on
259/// connect. Recovery finishes it from the checkpoint the first close verified.
260fn close_route(session: Option<&SessionRecord>) -> CloseRoute {
261    let Some(session) = session else {
262        return CloseRoute::Graceful;
263    };
264    if crate::pollers::is_interrupted_close(session) {
265        CloseRoute::RecoverInterrupted
266    } else if session.state == SessionState::Stopped {
267        if session.target.is_some() {
268            CloseRoute::DeferredCleanup
269        } else {
270            CloseRoute::Done
271        }
272    } else if crate::controller::has_nothing_to_checkpoint(session) {
273        CloseRoute::SettleWithoutCheckpoint
274    } else {
275        CloseRoute::Graceful
276    }
277}
278
279/// The durable state of one record as the locked controller holds it.
280fn durable_session_state(controller: &Controller, session_id: &str) -> Option<SessionState> {
281    controller
282        .state
283        .sessions
284        .get(session_id)
285        .map(|session| session.state)
286}
287
288/// One piece of work that waits for a starting session's harness.
289///
290/// Both kinds are ordered against each other on purpose: a restored session's
291/// hand-off is the hidden context its first prompt reads, so it has to be
292/// installed before any queued prompt is submitted.
293pub(crate) enum StartupStep {
294    InstallHandoff(Box<mj_core::archive::CanonicalSessionSnapshot>),
295    Prompt {
296        text: String,
297        inherited_draft: Option<String>,
298    },
299}
300
301/// The steps waiting for one session, and the task draining them.
302///
303/// The entry exists only while a drain task owns it. `in_flight` marks a step
304/// that has been popped and is running, so the queue is never treated as empty
305/// while its last step is still being carried out.
306struct StartupQueue {
307    pending: VecDeque<StartupStep>,
308    in_flight: bool,
309    cancel: CancellationToken,
310    task: Option<tokio::task::JoinHandle<()>>,
311}
312
313struct ActiveLifecycle {
314    operation_id: String,
315    create_control: Option<CreateSessionControl>,
316    kind: LifecycleKind,
317    cancelled: Arc<AtomicBool>,
318    started_at_epoch_seconds: u64,
319    active_stages: BTreeMap<ProvisionStage, (usize, u64)>,
320    /// The workspace a resume is claiming before its durable record changes.
321    /// Workspace deletion consults this so it cannot race the claim.
322    resume_workspace_id: Option<String>,
323    resume_destination: Option<(String, String)>,
324    notice: Option<String>,
325    request_key: Option<String>,
326    _move_guard: Option<MoveMutationGuard>,
327    move_source_closed: bool,
328    result: LifecycleWatch,
329}
330
331impl ActiveLifecycle {
332    fn is_visible(&self) -> bool {
333        let result = self.result.borrow();
334        result.is_none()
335            || matches!(
336                result.as_ref(),
337                Some(Ok(DaemonLifecycleResult::DeferredCleanup))
338            )
339    }
340
341    fn request_cancel(&self) -> bool {
342        if let Some(control) = &self.create_control {
343            control.request_cancel()
344        } else {
345            !self.cancelled.swap(true, Ordering::AcqRel)
346        }
347    }
348
349    fn is_cancellable(&self) -> bool {
350        self.result.borrow().is_none()
351            && self.create_control.as_ref().map_or_else(
352                || !self.cancelled.load(Ordering::Acquire),
353                CreateSessionControl::is_cancellable,
354            )
355    }
356}
357
358#[derive(Debug, Clone)]
359enum DaemonLifecycleResult {
360    Done,
361    DeferredCleanup,
362    Move(MoveOutcome),
363}
364
365/// How one lifecycle operation ended when it failed.
366///
367/// The result is broadcast to every waiter, which is why it cannot simply be
368/// the `anyhow::Error`: that is not clonable. Keeping the refusal beside the
369/// text is what lets a reason written for the caller survive the crossing; a
370/// failure rebuilt from a string alone would arrive as an internal fault.
371#[derive(Debug, Clone)]
372pub(crate) struct LifecycleFailure {
373    detail: String,
374    refusal: Option<Refusal>,
375}
376
377/// One lifecycle operation's outcome, and the channel every waiter reads it
378/// from. `None` means the operation is still running.
379type LifecycleResult = std::result::Result<DaemonLifecycleResult, LifecycleFailure>;
380type LifecycleWatch = tokio::sync::watch::Receiver<Option<LifecycleResult>>;
381
382impl LifecycleFailure {
383    fn of(error: &anyhow::Error) -> Self {
384        Self {
385            detail: format!("{error:#}"),
386            refusal: Refusal::of(error),
387        }
388    }
389
390    /// A failure with no reason written for a caller, such as a task that died
391    /// before the operation could say anything about itself.
392    fn internal(detail: impl Into<String>) -> Self {
393        Self {
394            detail: detail.into(),
395            refusal: None,
396        }
397    }
398
399    /// Rebuild the error a waiter sees, with the refusal still attached.
400    fn into_error(self) -> anyhow::Error {
401        match self.refusal {
402            Some(refusal) => anyhow::Error::new(refusal).context(self.detail),
403            None => anyhow::Error::msg(self.detail),
404        }
405    }
406}
407
408impl std::fmt::Display for LifecycleFailure {
409    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410        formatter.write_str(&self.detail)
411    }
412}
413
414impl From<LifecycleKind> for RuntimeLifecycleKind {
415    fn from(kind: LifecycleKind) -> Self {
416        match kind {
417            LifecycleKind::Create => Self::Create,
418            LifecycleKind::Close => Self::Close,
419            LifecycleKind::Resume => Self::Resume,
420            LifecycleKind::Move => Self::Move,
421            LifecycleKind::ForceStop => Self::ForceStop,
422            LifecycleKind::DestroyStopped | LifecycleKind::ArchiveStopped => Self::DestroyStopped,
423            LifecycleKind::ForceDestroy => Self::ForceDestroy,
424            LifecycleKind::Cleanup => Self::Cleanup,
425        }
426    }
427}
428
429mod close;
430mod create;
431mod lifecycle;
432mod resume;
433mod snapshot;
434mod state;
435mod support;
436mod views;
437use support::*;
438mod process;
439pub use process::*;
440mod serve;
441use serve::*;
442mod actions;
443use actions::*;
444mod guards;
445pub(crate) use guards::*;
446
447#[cfg(test)]
448mod tests;