1use super::*;
2
3impl RuntimeState {
4 fn request_lifecycle_cancel(&self, session_id: &str) -> Result<Option<bool>> {
5 let mut controller_owner = self.owner();
6 let durable = durable_session_state(controller_owner.controller(), session_id);
7 let Some(active) = controller_owner.lifecycle.get_mut(session_id) else {
8 return Ok(None);
9 };
10 ensure!(
11 lifecycle_cancellable(active.kind, durable),
12 "stop of {session_id} has passed its verified checkpoint and is removing the target; \
13 it cannot be cancelled"
14 );
15 ensure!(
16 active.request_cancel(),
17 "lifecycle operation is no longer cancellable"
18 );
19 let restart = active.kind == LifecycleKind::Restart;
20 drop(controller_owner);
21 self.publish_revision();
22 Ok(Some(restart))
23 }
24
25 pub async fn cancel_lifecycle_with_intent(&self, session_id: &str) -> Result<()> {
26 let restart = self
27 .request_lifecycle_cancel(session_id)?
28 .with_context(|| {
29 format!("no lifecycle operation is running for session {session_id}")
30 })?;
31 if restart {
32 blocking({
33 let session_id = session_id.to_owned();
34 move || crate::database::cancel_session_restart(&session_id)
35 })
36 .await?;
37 }
38 Ok(())
39 }
40
41 pub async fn cancel_lifecycle_if_active(&self, session_id: &str) -> Result<bool> {
42 let Some(restart) = self.request_lifecycle_cancel(session_id)? else {
43 return Ok(false);
44 };
45 if restart {
46 blocking({
47 let session_id = session_id.to_owned();
48 move || crate::database::cancel_session_restart(&session_id)
49 })
50 .await?;
51 }
52 Ok(true)
53 }
54
55 pub(super) async fn cancel_and_wait_lifecycles(&self) -> Result<()> {
60 let mut pending = {
61 let mut lifecycle_owner = self.owner();
62 let lifecycle = &mut lifecycle_owner.lifecycle;
63 lifecycle
64 .iter_mut()
65 .filter(|(_, active)| active.is_running())
66 .map(|(session_id, active)| {
67 if active.kind != LifecycleKind::Cleanup {
68 active.request_cancel();
69 }
70 let stage = active
71 .active_stages
72 .keys()
73 .next_back()
74 .map(|stage| stage.label())
75 .unwrap_or_else(|| "container cleanup".to_owned());
76 (
77 session_id.clone(),
78 active.operation_id.clone(),
79 active.kind,
80 stage,
81 active.result.clone(),
82 )
83 })
84 .collect::<Vec<_>>()
85 };
86 let cleanup_deadline = tokio::time::Instant::now() + Duration::from_secs(8);
87 for (session_id, operation_id, kind, stage, result) in &mut pending {
88 if *kind != LifecycleKind::Cleanup || result.borrow().is_some() {
89 continue;
90 }
91 tracing::info!(%session_id, %stage, "daemon shutdown is waiting for deferred cleanup");
92 self.set_lifecycle_notice(
93 session_id,
94 operation_id,
95 &format!("Daemon shutdown is waiting for {stage}"),
96 );
97 let finished = tokio::time::timeout_at(cleanup_deadline, async {
98 while result.borrow_and_update().is_none() {
99 result.changed().await.with_context(|| {
100 format!("cleanup owner stopped without a result for session {session_id}")
101 })?;
102 }
103 Ok::<_, anyhow::Error>(())
104 })
105 .await;
106 match finished {
107 Ok(result) => result?,
108 Err(_) => {
109 tracing::warn!(%session_id, %stage, "deferred cleanup exceeded the daemon shutdown drain deadline");
110 self.cancel_operation(session_id, operation_id);
111 }
112 }
113 }
114 let join_started = tokio::time::Instant::now();
115 let join_deadline = join_started + Duration::from_secs(1);
116 let startup_cleanup_deadline = join_started
119 + crate::controller::FAILED_STARTUP_CLEANUP_TIMEOUT
120 + Duration::from_secs(1);
121 for (session_id, operation_id, kind, stage, mut result) in pending {
122 let join_deadline = if matches!(
123 kind,
124 LifecycleKind::Create
125 | LifecycleKind::Resume
126 | LifecycleKind::Restart
127 | LifecycleKind::StartupCleanup
128 ) {
129 startup_cleanup_deadline
130 } else {
131 join_deadline
132 };
133 self.cancel_operation(&session_id, &operation_id);
134 let joined = tokio::time::timeout_at(join_deadline, async {
135 while result.borrow_and_update().is_none() {
136 result.changed().await.with_context(|| {
137 format!("lifecycle owner stopped without a result for session {session_id}")
138 })?;
139 }
140 Ok::<_, anyhow::Error>(())
141 })
142 .await;
143 if joined.is_err() {
144 bail!(
145 "timed out cancelling lifecycle owner for session {session_id} while {stage}"
146 );
147 }
148 joined.expect("checked timeout")?;
149 }
150 Ok(())
151 }
152
153 pub fn active_lifecycles(&self) -> Vec<RuntimeLifecycleView> {
162 let controller_owner = self.owner();
163 Self::active_lifecycles_with(&controller_owner)
164 }
165
166 pub(super) fn active_lifecycles_with(owner: &RuntimeStateOwner) -> Vec<RuntimeLifecycleView> {
168 let controller = owner.controller();
169 owner
170 .lifecycle
171 .iter()
172 .filter(|(_, active)| active.is_visible())
173 .map(|(session_id, active)| RuntimeLifecycleView {
174 operation_id: active.operation_id.clone(),
175 cancellable: active.is_cancellable()
176 && lifecycle_cancellable(
177 active.kind,
178 durable_session_state(controller, session_id),
179 ),
180 session_id: session_id.clone(),
181 kind: active.kind.into(),
182 started_at_epoch_seconds: active.started_at_epoch_seconds,
183 active_stages: active
184 .active_stages
185 .iter()
186 .map(|(stage, (_, started_at))| (stage.clone(), *started_at))
187 .collect(),
188 resume_destination: active.resume_destination.clone(),
189 notice: active.notice.clone(),
190 })
191 .collect()
192 }
193
194 pub fn session_state(&self, session_id: &str) -> Option<mj_core::state::SessionState> {
198 let owner = self.owner();
199 if owner.close_requested.contains(session_id) {
200 return Some(SessionState::Closing);
201 }
202 owner
203 .controller()
204 .state
205 .sessions
206 .get(session_id)
207 .map(|record| record.state)
208 }
209
210 pub fn session_record(&self, session_id: &str) -> Option<SessionRecord> {
212 self.owner()
213 .controller()
214 .state
215 .sessions
216 .get(session_id)
217 .cloned()
218 }
219
220 pub async fn workspace_session_handle(
221 &self,
222 session_id: &str,
223 ) -> Result<crate::session_manager::ManagedSessionHandle> {
224 let record = self.session_record(session_id).context("unknown session")?;
225 ensure!(
226 record.target.is_some()
227 && record.state == SessionState::Running
228 && !self.close_is_requested(session_id),
229 "session must have a live running target for file injection"
230 );
231 self.session_manager.session(session_id.to_owned()).await
232 }
233
234 pub async fn checkpoint_session_now(
244 &self,
245 session_id: &str,
246 ) -> Result<mj_core::state::CheckpointMetadata> {
247 let _upgrade_work = crate::upgrade::activity("requested checkpoint")?;
248 if let Some(busy) = self.session_lifecycle_busy(session_id) {
249 return Err(anyhow::Error::new(busy));
250 }
251 let session_id = session_id.to_owned();
252 let checkpoint = blocking(move || {
256 let mut controller = Controller::load()?;
257 mj_core::runtime::block_on(controller.checkpoint_session(&session_id))?
258 })
259 .await?;
260 refresh_runtime_controller(self).await;
261 Ok(checkpoint)
262 }
263
264 pub(super) fn session_lifecycle_busy(&self, session_id: &str) -> Option<SessionLifecycleBusy> {
268 let lifecycle_owner = self.owner();
269 let lifecycle = &lifecycle_owner.lifecycle;
270 let active = lifecycle.get(session_id)?;
271 active
272 .is_running()
273 .then(|| describe_lifecycle_busy(session_id, active))
274 }
275
276 pub(super) fn any_lifecycle_busy(&self) -> Option<SessionLifecycleBusy> {
280 self.owner()
281 .lifecycle
282 .iter()
283 .find(|(_, active)| active.is_running())
284 .map(|(session_id, active)| describe_lifecycle_busy(session_id, active))
285 }
286
287 pub fn session_projection(
291 &self,
292 ) -> (
293 mj_core::snapshot_map::SnapshotMap<String, SessionRecord>,
294 Vec<RuntimeLifecycleView>,
295 ) {
296 let controller_owner = self.owner();
297 let operations = Self::active_lifecycles_with(&controller_owner);
298 (controller_owner.projected_records(), operations)
299 }
300
301 pub(super) fn resume_candidates(&self) -> mj_client::daemon::ResumeCandidates {
305 let (records, subagents, moves, config, local_checkout_roots) = {
306 let owner = self.owner();
307 let controller = owner.controller();
308 let records = owner.projected_records();
309 let local_checkout_roots = records
310 .iter()
311 .filter_map(|(id, _)| match controller.state.checkout(id).ok()? {
312 mj_core::state::Checkout::ManagedWorktree { worktree, .. }
313 if worktree.target == mj_core::state::ManagedWorktreeTarget::Local =>
314 {
315 Some(worktree.worktree_root.clone())
316 }
317 _ => None,
318 })
319 .collect::<Vec<_>>();
320 (
321 records,
322 controller.state.subagents.clone(),
323 owner
324 .committed()
325 .map(|committed| committed.moves.clone())
326 .unwrap_or_default(),
327 controller.config.clone(),
328 local_checkout_roots,
329 )
330 };
331 let mut candidates = mj_client::daemon::ResumeCandidates {
332 local_checkout_roots,
333 ..mj_client::daemon::ResumeCandidates::default()
334 };
335 for (id, record) in &records {
336 if let Some(native_session_id) = &record.native_session_id {
337 candidates
338 .adopted_native_sessions
339 .push((record.harness_kind, native_session_id.clone()));
340 }
341 if record.state.is_active()
342 || subagents.contains_key(id)
343 || mj_core::native_agent::is_view_id(id)
344 {
345 continue;
346 }
347 if let Some(operation) = moves.get(id) {
348 candidates.moves.push(operation.clone());
349 }
350 candidates
351 .candidates
352 .push(mj_client::daemon::ResumeCandidate::of(record, &config));
353 }
354 candidates
355 }
356
357 pub(super) fn go_startup_session(
360 &self,
361 workspace_id: &str,
362 last_session_id: Option<&str>,
363 ) -> Option<SessionRecord> {
364 let (records, subagents) = {
365 let owner = self.owner();
366 (
367 owner.projected_records(),
368 owner.controller().state.subagents.clone(),
369 )
370 };
371 let eligible = |session: &&SessionRecord| {
372 session.workspace_id == workspace_id
373 && !session.archived
374 && !subagents.contains_key(&session.id)
375 && !mj_core::native_agent::is_view_id(&session.id)
376 && session.state != SessionState::DestroyedWithDataLoss
377 };
378 last_session_id
379 .and_then(|id| records.get(id))
380 .filter(eligible)
381 .or_else(|| {
382 records
383 .values()
384 .filter(eligible)
385 .max_by_key(|session| &session.updated_at)
386 })
387 .cloned()
388 }
389
390 pub(crate) fn worker_controller_projection(&self) -> Controller {
391 self.owner().pollable_worker_inputs().controller()
392 }
393
394 pub(crate) fn controller_projection(&self) -> Controller {
395 let owner = self.owner();
396 let mut state = owner.controller().state.clone();
397 state.sessions = owner.projected_records();
398 Controller {
399 config: owner.controller().config.clone(),
400 state,
401 }
402 }
403
404 pub(crate) fn active_controller_projection(&self) -> Controller {
405 let owner = self.owner();
406 Controller {
407 config: owner.controller().config.clone(),
408 state: mj_core::state::State {
409 sessions: owner
410 .indexes
411 .active
412 .keys()
413 .filter_map(|id| {
414 owner
415 .controller()
416 .state
417 .sessions
418 .get(id)
419 .map(|record| (id.clone(), record.clone()))
420 })
421 .collect(),
422 ..Default::default()
423 },
424 }
425 }
426
427 pub(super) fn set_lifecycle_resume_destination(
428 &self,
429 session_id: &str,
430 profile_id: String,
431 target_id: String,
432 ) {
433 if let Some(active) = self.owner().lifecycle.get_mut(session_id) {
434 active.resume_destination = Some((profile_id, target_id));
435 self.publish_revision();
436 }
437 }
438
439 pub(super) fn change_lifecycle_stage(
440 &self,
441 session_id: &str,
442 operation_id: &str,
443 stage: ProvisionStage,
444 active: bool,
445 ) {
446 let changed = {
447 let mut lifecycle_owner = self.owner();
448 let lifecycle = &mut lifecycle_owner.lifecycle;
449 let Some(operation) = lifecycle.get_mut(session_id) else {
450 return;
451 };
452 if operation.operation_id != operation_id || !operation.is_running() {
453 return;
454 }
455 if active {
456 let started_at = stage
457 .started_at_epoch_seconds()
458 .unwrap_or_else(epoch_seconds);
459 let entry = operation
460 .active_stages
461 .entry(stage)
462 .or_insert_with(|| (0, started_at));
463 entry.0 += 1;
464 entry.0 == 1
465 } else {
466 let Some((count, _)) = operation.active_stages.get_mut(&stage) else {
467 return;
468 };
469 *count -= 1;
470 if *count == 0 {
471 operation.active_stages.remove(&stage);
472 true
473 } else {
474 false
475 }
476 }
477 };
478 if changed {
479 self.publish_revision();
480 }
481 }
482
483 pub(crate) fn push_notice(&self, session_id: &str, text: impl Into<String>) {
486 const RETAINED_NOTICES: usize = 32;
487
488 let notice = RuntimeNotice {
489 id: self.next_notice_id.fetch_add(1, Ordering::AcqRel),
490 session_id: session_id.to_owned(),
491 text: text.into(),
492 };
493 {
494 let mut notices = self.notices.lock().unwrap_or_else(PoisonError::into_inner);
495 notices.push_back(notice);
496 while notices.len() > RETAINED_NOTICES {
497 notices.pop_front();
498 }
499 }
500 self.publish_revision();
501 }
502
503 pub(crate) fn attach_quota_refresh(&self, refresh: tokio::sync::mpsc::Sender<()>) {
507 self.quota
508 .lock()
509 .unwrap_or_else(PoisonError::into_inner)
510 .refresh = Some(refresh);
511 }
512
513 pub(crate) fn request_quota_refresh(&self) -> Result<()> {
516 let quota = self.quota.lock().unwrap_or_else(PoisonError::into_inner);
517 let refresh = quota
518 .refresh
519 .as_ref()
520 .context("the daemon's quota service is not running")?;
521 let _ = refresh.try_send(());
523 Ok(())
524 }
525
526 pub(crate) fn publish_quotas(&self, snapshot: mj_client::quota::QuotaSnapshot) {
529 {
530 let mut quota = self.quota.lock().unwrap_or_else(PoisonError::into_inner);
531 if quota.snapshot == snapshot {
532 return;
533 }
534 quota.snapshot = snapshot;
535 }
536 self.publish_revision();
537 }
538
539 pub(super) fn reserve_move_destination(&self, session_id: &str, operation_id: &str) {
540 let mut owner = self.owner();
541 if let Some(active) = owner.lifecycle.get_mut(session_id)
542 && active.operation_id == operation_id
543 && active.kind == LifecycleKind::Move
544 {
545 active.phase = match active.phase {
546 LifecyclePhase::Executing => LifecyclePhase::MovingDestination,
547 LifecyclePhase::Cancelling => LifecyclePhase::CancellingMoveDestination,
548 _ => return,
549 };
550 self.publish_revision();
551 }
552 }
553
554 pub(super) fn set_lifecycle_notice(&self, session_id: &str, operation_id: &str, notice: &str) {
555 if let Some(active) = self.owner().lifecycle.get_mut(session_id)
556 && active.operation_id == operation_id
557 && active.is_running()
558 {
559 active.notice = Some(notice.to_owned());
560 self.publish_revision();
561 }
562 }
563
564 fn cancel_operation(&self, session_id: &str, operation_id: &str) {
565 if let Some(active) = self.owner().lifecycle.get_mut(session_id)
566 && active.operation_id == operation_id
567 && active.request_cancel()
568 {
569 self.publish_revision();
570 }
571 }
572}