1mod 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
57const SHUTDOWN_FORCE_EXIT_TIMEOUT: Duration = Duration::from_secs(10);
67
68const FORCE_DESTROY_PREEMPT_TIMEOUT: Duration = Duration::from_secs(8);
74
75#[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 pub(crate) wait_prompt_backend:
129 std::sync::OnceLock<std::sync::Weak<crate::server_runtime::api::ApiBackend>>,
130 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 workspace_resume_admission: Mutex<BTreeMap<String, Arc<tokio::sync::RwLock<()>>>>,
145 harness_readiness: Mutex<HarnessReadinessWatch>,
148 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 notices: Mutex<VecDeque<RuntimeNotice>>,
162 next_notice_id: AtomicU64,
163 quota: Mutex<QuotaBoard>,
167 capacity: std::sync::OnceLock<capacity::CapacityFeed>,
170 review_config: Arc<Mutex<mj_core::config::ReviewConfig>>,
172 review_host: TurnReviewHost,
175 wiki: crate::sessionwiki::WikiIndexer,
177}
178
179#[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 ArchiveStopped,
249 ForceDestroy,
250 StopSubagent,
254 Park,
257 Unpark,
260 Cleanup,
261 StartupCleanup,
262}
263
264impl LifecycleKind {
265 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 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
299fn 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
327fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
349enum CloseRoute {
350 Graceful,
352 RecoverInterrupted,
354 SettleWithoutCheckpoint,
356 DeferredCleanup,
358 Done,
360}
361
362fn 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
386fn 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#[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
420struct 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 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#[derive(Debug, Clone)]
515pub(crate) struct LifecycleFailure {
516 pub(crate) detail: String,
517 pub(crate) refusal: Option<Refusal>,
518}
519
520type 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 fn internal(detail: impl Into<String>) -> Self {
536 Self {
537 detail: detail.into(),
538 refusal: None,
539 }
540 }
541
542 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 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;