1use agentmux_common::ipc::Command;
34use serde_json::{json, Value};
35
36use super::{
37 alloc_saga_id, classify_run_saga_result, emit_saga_started, emit_terminal, run_saga, SagaCtx,
38};
39use crate::backend::blockcontroller;
40use crate::server::AppState;
41
42pub async fn run(state: &AppState, block_ids: Vec<String>) -> Result<Value, String> {
46 run_with(state, block_ids, CloseOpts::default()).await
47}
48
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
51pub struct CloseOpts {
52 pub frontend_waits: bool,
59}
60
61pub async fn run_with(state: &AppState, block_ids: Vec<String>, opts: CloseOpts) -> Result<Value, String> {
62 let mut ids: Vec<String> = Vec::new();
63 for id in block_ids {
64 if !id.is_empty() && !ids.contains(&id) {
65 ids.push(id);
66 }
67 }
68 if ids.is_empty() {
69 return Err("ClosePane: no block ids".to_string());
70 }
71
72 let (tab_id, present, leaf_to_delete) = {
73 let s = state.srv_state.lock().await;
74 let mut tab_id: Option<String> = None;
75 let mut present = Vec::new();
76 for id in &ids {
77 let Some(block) = s.blocks.get(id) else {
78 continue; };
80 match &tab_id {
81 None => tab_id = Some(block.tab_id.clone()),
82 Some(t) if *t != block.tab_id => {
83 return Err(format!(
84 "ClosePane: blocks span tabs ({} is in {}, not {})",
85 id, block.tab_id, t
86 ));
87 }
88 Some(_) => {}
89 }
90 present.push(id.clone());
91 }
92 let Some(tab_id) = tab_id else {
93 return Err(format!("ClosePane: block not found: {}", ids.join(", ")));
94 };
95 if !s.tabs.contains_key(&tab_id) {
96 return Err(format!("ClosePane: tab not found: {}", tab_id));
97 }
98 let leaf_to_delete = s
101 .tabs
102 .get(&tab_id)
103 .and_then(|t| t.rootnode.as_ref())
104 .and_then(|root| {
105 present
106 .iter()
107 .find_map(|id| crate::backend::layout::find_leaf_containing_block(root, id))
108 })
109 .and_then(|leaf| {
110 let data = leaf.data.as_ref()?;
111 let all_closing = crate::backend::layout::leaf_members(data)
112 .iter()
113 .all(|m| ids.contains(m) || !s.blocks.contains_key(m));
114 all_closing.then(|| (leaf.id.clone(), data.block_id.clone()))
115 });
116 (tab_id, present, leaf_to_delete)
117 };
118
119 let saga_id = alloc_saga_id(state);
120 emit_saga_started(
121 state,
122 saga_id,
123 "close_pane",
124 json!({
125 "tab_id": &tab_id,
126 "block_ids": &present,
127 }),
128 )
129 .await?;
130 let ctx = SagaCtx::new(state, saga_id);
131 let result = run_saga(
132 "close_pane",
133 run_inner(ctx, tab_id, present.clone(), leaf_to_delete, opts, gone_before(&ids, &present)),
134 )
135 .await;
136 for id in &present {
137 finish_close(state, id).await;
138 }
139 emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
140 result
141}
142
143async fn run_inner(
144 ctx: SagaCtx<'_>,
145 tab_id: String,
146 block_ids: Vec<String>,
147 leaf_to_delete: Option<(String, String)>,
148 opts: CloseOpts,
149 gone: Vec<String>,
152) -> Result<Value, String> {
153 let mut failures = Vec::new();
154 let mut failed_ids: Vec<&String> = Vec::new();
155 super::agent_teardown::run_many(ctx.state, &block_ids, super::agent_teardown::Policy::close()).await;
156 for block_id in &block_ids {
157 if let Err(reason) = ctx
158 .dispatch(Command::DeleteBlock {
159 tab_id: tab_id.clone(),
160 block_id: block_id.clone(),
161 })
162 .await
163 {
164 tracing::warn!(
165 tab_id = %tab_id,
166 block_id = %block_id,
167 "[saga] ClosePane: DeleteBlock dispatch failed (process already stopped): {}",
168 reason
169 );
170 failures.push(format!("{block_id}: {reason}"));
171 failed_ids.push(block_id);
172 }
173 }
174
175 if !failures.is_empty() {
180 if opts.frontend_waits {
185 let mut closed: Vec<String> = block_ids.iter().filter(|id| !failed_ids.contains(id)).cloned().collect();
186 closed.extend(gone.iter().cloned());
187 queue_member_deletes(ctx.state, &tab_id, &closed).await;
188 }
189 let reason = format!("ClosePane: {}", failures.join("; "));
192 for id in &failed_ids {
193 publish_shutdown_error(ctx.state, id, &reason);
194 }
195 return Err(reason);
196 }
197
198 let leaf_removed = leaf_to_delete.is_some();
199 if opts.frontend_waits {
200 let mut closed = block_ids.clone();
205 closed.extend(gone.iter().cloned());
206 queue_member_deletes(ctx.state, &tab_id, &closed).await;
207 }
208 if let Some((node_id, visible_block_id)) = leaf_to_delete {
209 if let Err(reason) = ctx
211 .dispatch(Command::LayoutDeleteNode {
212 tab_id: tab_id.clone(),
213 node_id: node_id.clone(),
214 correlation_id: String::new(),
215 })
216 .await
217 {
218 tracing::warn!(
219 tab_id = %tab_id,
220 node_id = %node_id,
221 "[saga] ClosePane: LayoutDeleteNode failed (best-effort): {}",
222 reason
223 );
224 }
225 if opts.frontend_waits {
230 } else if let Err(reason) = crate::server::service::layout_helpers::queue_source_layout_delete(
232 ctx.state,
233 &tab_id,
234 &visible_block_id,
235 )
236 .await
237 {
238 tracing::warn!(
239 tab_id = %tab_id,
240 block_id = %visible_block_id,
241 "[saga] ClosePane: queue_source_layout_delete failed (best-effort): {}",
242 reason
243 );
244 }
245 }
246
247 Ok(json!({
248 "tab_id": tab_id,
249 "block_ids": block_ids,
250 "leaf_removed": leaf_removed,
251 }))
252}
253
254pub(crate) async fn workspace_close_lock(workspace_id: &str) -> tokio::sync::OwnedMutexGuard<()> {
258 static LOCKS: std::sync::LazyLock<
259 std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>>,
260 > = std::sync::LazyLock::new(Default::default);
261 let lock = LOCKS
262 .lock()
263 .unwrap()
264 .entry(workspace_id.to_string())
265 .or_default()
266 .clone();
267 lock.lock_owned().await
268}
269
270fn gone_before(requested: &[String], present: &[String]) -> Vec<String> {
272 requested.iter().filter(|id| !present.contains(id)).cloned().collect()
273}
274
275async fn queue_member_deletes(state: &AppState, tab_id: &str, block_ids: &[String]) {
278 if block_ids.is_empty() {
279 return;
280 }
281 let actions = block_ids
282 .iter()
283 .map(|id| crate::server::service::layout_helpers::source_delete_action(id))
284 .collect();
285 if let Err(reason) = crate::server::service::reducer_helpers::queue_layout_actions_via_reducer(state, tab_id, actions).await {
286 tracing::warn!(tab_id = %tab_id, "[saga] ClosePane: queueing frontend deletes failed (best-effort): {}", reason);
287 }
288}
289
290pub(crate) const EVENT_AGENT_SHUTDOWN: &str = "agent:shutdown";
293
294#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
297#[ts(export, export_to = "../../../frontend/types/rpc/")]
298pub struct ShutdownLineEvent {
299 pub block_id: String,
300 #[ts(type = "number")]
301 pub seq: u64,
302 pub step: String,
303 pub text: String,
304 #[serde(flatten)]
305 pub extra: ShutdownExtra,
306}
307
308#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, ts_rs::TS)]
310#[ts(export, export_to = "../../../frontend/types/rpc/")]
311pub struct ShutdownExtra {
312 #[serde(skip_serializing_if = "Option::is_none")]
314 #[ts(optional, type = "number")]
315 pub forced_after_ms: Option<u64>,
316 #[serde(skip_serializing_if = "Option::is_none")]
318 #[ts(optional)]
319 pub error: Option<String>,
320 #[serde(skip_serializing_if = "Option::is_none")]
322 #[ts(optional)]
323 pub container: Option<String>,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 #[ts(optional)]
326 pub outcome: Option<String>,
327 #[serde(skip_serializing_if = "Option::is_none")]
329 #[ts(optional)]
330 pub process: Option<ShutdownProcess>,
331}
332
333#[derive(Debug, Clone, PartialEq, serde::Serialize, ts_rs::TS)]
334#[ts(export, export_to = "../../../frontend/types/rpc/")]
335pub struct ShutdownProcess {
336 pub pid: u32,
337 pub name: String,
338 pub outcome: String,
340}
341
342pub(crate) fn process_extra(pid: u32, command: &str, outcome: &str) -> ShutdownExtra {
344 ShutdownExtra { process: Some(ShutdownProcess { pid, name: short_command(command), outcome: outcome.to_string() }), ..Default::default() }
345}
346
347static SHUTDOWN_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
349
350const SHUTDOWN_LINE_MAX: usize = 80;
352
353fn clip(s: &str, max: usize) -> String {
354 if s.chars().count() <= max {
355 return s.to_string();
356 }
357 let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
358 out.push('…');
359 out
360}
361
362pub(crate) fn short_command(command: &str) -> String {
364 let command = command.trim();
365 if command.is_empty() {
366 return "process".to_string();
367 }
368 clip(command, 48)
369}
370
371pub(crate) fn process_line(command: &str, pid: u32, killed: bool) -> String {
372 clip(&format!("{} (pid {pid}) — {}", short_command(command), if killed { "killed" } else { "stopped" }), SHUTDOWN_LINE_MAX)
373}
374
375pub(crate) fn exit_line(
377 label: &str,
378 outcome: &blockcontroller::StopOutcome,
379 elapsed: std::time::Duration,
380) -> (&'static str, String, ShutdownExtra) {
381 use blockcontroller::StopOutcome::*;
382 match outcome {
383 Killed => (
384 "kill",
385 clip(&format!("{label} — killed after {} s", elapsed.as_secs().max(1)), SHUTDOWN_LINE_MAX),
386 ShutdownExtra { forced_after_ms: Some(elapsed.as_millis() as u64), ..Default::default() },
387 ),
388 Exited => ("exit", clip(&format!("{label} — exited"), SHUTDOWN_LINE_MAX), ShutdownExtra::default()),
389 Stopped => ("exit", clip(&format!("{label} — stopped"), SHUTDOWN_LINE_MAX), ShutdownExtra::default()),
390 NotRunning => ("exit", clip(&format!("{label} — was not running"), SHUTDOWN_LINE_MAX), ShutdownExtra::default()),
391 }
392}
393
394pub(crate) fn publish_shutdown(state: &AppState, block_id: &str, step: &str, text: String, extra: ShutdownExtra) {
397 let line = ShutdownLineEvent {
398 block_id: block_id.to_string(),
399 seq: SHUTDOWN_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
400 step: step.to_string(),
401 text,
402 extra,
403 };
404 state.broker.publish_event::<crate::backend::mps_events::AgentShutdown>(&line, vec![format!("block:{block_id}")], 0);
405}
406
407pub(crate) fn publish_shutdown_error(state: &AppState, block_id: &str, reason: &str) {
409 publish_shutdown(
410 state,
411 block_id,
412 "error",
413 clip(&format!("couldn't close: {reason}"), SHUTDOWN_LINE_MAX),
414 ShutdownExtra { error: Some(reason.to_string()), ..Default::default() },
415 );
416}
417
418pub(crate) fn save_final_state(state: &AppState, block_id: &str) {
423 use crate::backend::blockcontroller::session_recovery;
424
425 let has_active_pid = state
426 .mstore
427 .get::<crate::backend::obj::Block>(block_id)
428 .ok()
429 .flatten()
430 .is_some_and(|b| {
431 b.meta
432 .get(session_recovery::META_SESSION_ACTIVE_PID)
433 .is_some_and(|v| !v.is_null())
434 });
435 if has_active_pid {
436 session_recovery::clear_active_pid(&state.mstore, block_id);
437 }
438
439 match state.mstore.instance_get_by_block_id(block_id) {
440 Ok(Some(instance)) => {
441 let now_ms = agentmux_common::time::now_ms();
442 let upd = crate::backend::storage::agents::InstanceUpdate {
443 status: Some(
444 crate::backend::storage::agents::InstanceStatus::Stopped
445 .as_str()
446 .to_string(),
447 ),
448 ended_at: Some(now_ms),
449 ..Default::default()
450 };
451 if let Err(e) = state.mstore.instance_update_partial(&instance.id, &upd) {
452 tracing::warn!(
453 block_id = %block_id,
454 instance_id = %instance.id,
455 error = %e,
456 "ClosePane: failed to mark instance stopped"
457 );
458 }
459 }
460 Ok(None) => {}
461 Err(e) => {
462 tracing::debug!(block_id = %block_id, error = %e, "ClosePane: instance lookup failed");
463 }
464 }
465}
466
467pub(crate) async fn finish_close(state: &AppState, block_id: &str) {
471 let gone = !state.srv_state.lock().await.blocks.contains_key(block_id);
472 if gone {
473 state.broker.purge_scope(&format!("block:{}", block_id));
474 }
475 blockcontroller::unmark_closing(block_id);
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use crate::backend::obj::Block;
482 use crate::server::tests::test_state;
483 use agentmux_common::ipc::Event;
484
485 async fn dispatch_apply(state: &AppState, cmd: Command) -> Vec<Event> {
486 let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
487 for ev in &events {
488 crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
489 }
490 events
491 }
492
493 async fn seed_tab(state: &AppState) -> (String, String) {
494 let ws_id = dispatch_apply(state, Command::CreateWorkspace { name: "w".into() })
495 .await
496 .iter()
497 .find_map(|e| match e {
498 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
499 _ => None,
500 })
501 .unwrap();
502 let tab_id = dispatch_apply(
503 state,
504 Command::CreateTab {
505 workspace_id: ws_id.clone(),
506 name: "t".into(),
507 },
508 )
509 .await
510 .iter()
511 .find_map(|e| match e {
512 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
513 _ => None,
514 })
515 .unwrap();
516 (ws_id, tab_id)
517 }
518
519 async fn seed_block(state: &AppState, tab_id: &str) -> String {
520 dispatch_apply(
521 state,
522 Command::CreateBlock {
523 tab_id: tab_id.to_string(),
524 meta: serde_json::Value::Null,
525 },
526 )
527 .await
528 .iter()
529 .find_map(|e| match e {
530 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
531 _ => None,
532 })
533 .unwrap()
534 }
535
536 fn stack_leaf(id: &str, stack: &[&str], visible: &str) -> agentmux_common::LayoutNode {
537 agentmux_common::LayoutNode {
538 id: id.into(),
539 data: Some(agentmux_common::LayoutNodeData {
540 block_id: visible.to_string(),
541 block_stack: stack.iter().map(|s| s.to_string()).collect(),
542 active_block_id: visible.to_string(),
543 ..Default::default()
544 }),
545 ..Default::default()
546 }
547 }
548
549 async fn set_tree(state: &AppState, tab_id: &str, tree: agentmux_common::LayoutNode) {
550 dispatch_apply(
551 state,
552 Command::LayoutSetTree {
553 tab_id: tab_id.to_string(),
554 new_tree: Some(tree),
555 correlation_id: String::new(),
556 slices: None,
557 },
558 )
559 .await;
560 }
561
562 fn queued_deletes(state: &AppState, tab_id: &str) -> Vec<String> {
563 let tab = state.mstore.must_get::<crate::backend::obj::Tab>(tab_id).unwrap();
564 let layout = state
565 .mstore
566 .must_get::<crate::backend::obj::LayoutState>(&tab.layoutstate)
567 .unwrap();
568 layout
569 .pendingbackendactions
570 .unwrap_or_default()
571 .into_iter()
572 .filter(|a| a.actiontype == "delete")
573 .map(|a| a.blockid)
574 .collect()
575 }
576
577 #[tokio::test]
580 async fn a_waiting_frontend_gets_one_delete_per_member_for_a_whole_pane() {
581 let state = test_state();
582 let (_ws, tab_id) = seed_tab(&state).await;
583 let a = seed_block(&state, &tab_id).await;
584 let b = seed_block(&state, &tab_id).await;
585 set_tree(&state, &tab_id, stack_leaf("leaf", &[&a, &b], &a)).await;
586
587 let out = run_with(&state, vec![a.clone(), b.clone()], CloseOpts { frontend_waits: true }).await.unwrap();
588 assert_eq!(out["leaf_removed"], true);
589 assert_eq!(queued_deletes(&state, &tab_id), vec![a.clone(), b.clone()]);
592 }
593
594 #[tokio::test]
595 async fn a_waiting_frontend_is_told_about_one_closed_tab_of_a_kept_pane() {
596 let state = test_state();
597 let (_ws, tab_id) = seed_tab(&state).await;
598 let a = seed_block(&state, &tab_id).await;
599 let b = seed_block(&state, &tab_id).await;
600 set_tree(&state, &tab_id, stack_leaf("leaf", &[&a, &b], &a)).await;
601
602 let out = run_with(&state, vec![a.clone()], CloseOpts { frontend_waits: true }).await.unwrap();
603 assert_eq!(out["leaf_removed"], false);
604 assert!(state.srv_state.lock().await.blocks.contains_key(&b), "sibling survives");
605 assert_eq!(queued_deletes(&state, &tab_id), vec![a.clone()], "the kept pane drops just that tab");
606 }
607
608 #[tokio::test]
611 async fn shutdown_publishes_an_ordered_log_scoped_to_the_block() {
612 let state = test_state();
613 let seen: std::sync::Arc<std::sync::Mutex<Vec<(Vec<String>, serde_json::Value)>>> = Default::default();
614 let sink = seen.clone();
615 state.broker.add_observer(std::sync::Arc::new(move |ev: &crate::backend::mps::MuxEvent| {
616 if ev.event == EVENT_AGENT_SHUTDOWN {
617 sink.lock().unwrap().push((ev.scopes.clone(), ev.data.clone().unwrap_or_default()));
618 }
619 }));
620 let (_ws, tab_id) = seed_tab(&state).await;
621 let a = seed_block(&state, &tab_id).await;
622 set_tree(&state, &tab_id, stack_leaf("leaf", &[&a], &a)).await;
623
624 run(&state, vec![a.clone()]).await.unwrap();
625
626 let seen = seen.lock().unwrap();
627 let steps: Vec<&str> = seen.iter().map(|(_, d)| d["step"].as_str().unwrap()).collect();
628 assert_eq!(steps, vec!["exit", "saved", "done"]);
629 assert_eq!(seen[0].1["text"], "agent — was not running");
630 assert!(seen.iter().all(|(scopes, d)| scopes == &vec![format!("block:{a}")] && d["block_id"] == a.as_str()));
631 let seqs: Vec<u64> = seen.iter().map(|(_, d)| d["seq"].as_u64().unwrap()).collect();
632 assert!(seqs.windows(2).all(|w| w[0] < w[1]), "strictly increasing: {seqs:?}");
633 }
634
635 #[test]
636 fn shutdown_lines_stay_short_and_say_what_happened() {
637 let (step, text, extra) =
638 exit_line("Camper", &blockcontroller::StopOutcome::Killed, std::time::Duration::from_millis(5_200));
639 assert_eq!((step, text.as_str()), ("kill", "Camper — killed after 5 s"));
640 assert_eq!(extra.forced_after_ms, Some(5_200));
641 assert_eq!(exit_line("Camper", &blockcontroller::StopOutcome::Exited, Default::default()).1, "Camper — exited");
642 assert_eq!(process_line("node dev-server.js", 4312, false), "node dev-server.js (pid 4312) — stopped");
643 let long = process_line(&"x".repeat(200), 1, true);
644 assert!(long.chars().count() <= SHUTDOWN_LINE_MAX && long.ends_with("— killed"), "{long}");
645 assert_eq!(short_command(" "), "process");
646 }
647
648 #[tokio::test]
651 async fn a_waiting_frontend_also_drops_members_that_were_already_gone() {
652 let state = test_state();
653 let (_ws, tab_id) = seed_tab(&state).await;
654 let a = seed_block(&state, &tab_id).await;
655 let b = seed_block(&state, &tab_id).await;
656 set_tree(&state, &tab_id, stack_leaf("leaf", &[&a, &b], &a)).await;
657 run(&state, vec![b.clone()]).await.unwrap(); let before = queued_deletes(&state, &tab_id).len();
659
660 run_with(&state, vec![a.clone(), b.clone()], CloseOpts { frontend_waits: true }).await.unwrap();
661 let queued = queued_deletes(&state, &tab_id);
662 assert_eq!(&queued[before..], &[a.clone(), b.clone()], "both leave the waiting frontend");
663 }
664
665 #[test]
666 fn gone_before_lists_requested_ids_missing_at_the_start() {
667 let ids = vec!["a".to_string(), "b".to_string(), "c".to_string()];
668 assert_eq!(gone_before(&ids, &["a".to_string(), "c".to_string()]), vec!["b".to_string()]);
669 assert!(gone_before(&ids, &ids).is_empty());
670 }
671
672 #[tokio::test]
673 async fn closing_a_stacked_pane_removes_every_member() {
674 let state = test_state();
675 let (_ws, tab_id) = seed_tab(&state).await;
676 let a = seed_block(&state, &tab_id).await;
677 let b = seed_block(&state, &tab_id).await;
678 let c = seed_block(&state, &tab_id).await;
679 set_tree(&state, &tab_id, stack_leaf("leaf", &[&a, &b, &c], &a)).await;
680
681 let out = run(&state, vec![a.clone(), b.clone(), c.clone()]).await.unwrap();
682 assert_eq!(out["leaf_removed"], true);
683
684 {
685 let s = state.srv_state.lock().await;
686 for id in [&a, &b, &c] {
687 assert!(!s.blocks.contains_key(id), "block {id} must be deleted");
688 }
689 assert!(s.tabs[&tab_id].block_ids.is_empty(), "no member left in tab.blockids");
690 assert!(s.tabs[&tab_id].rootnode.is_none(), "leaf pruned");
691 }
692 for id in [&a, &b, &c] {
693 assert!(state.mstore.get::<Block>(id).unwrap().is_none());
694 assert!(!blockcontroller::is_closing(id), "respawn guard lifted after close");
695 }
696 assert_eq!(queued_deletes(&state, &tab_id), vec![a.clone()], "one frontend prune, for the visible block");
697 }
698
699 #[tokio::test]
703 async fn closing_one_member_keeps_the_pane_and_its_siblings() {
704 let state = test_state();
705 let (_ws, tab_id) = seed_tab(&state).await;
706 let a = seed_block(&state, &tab_id).await;
707 let b = seed_block(&state, &tab_id).await;
708 set_tree(&state, &tab_id, stack_leaf("leaf", &[&a, &b], &a)).await;
709
710 let out = run(&state, vec![a.clone()]).await.unwrap();
711 assert_eq!(out["leaf_removed"], false);
712
713 let s = state.srv_state.lock().await;
714 assert!(!s.blocks.contains_key(&a));
715 assert!(s.blocks.contains_key(&b), "sibling survives");
716 assert_eq!(s.tabs[&tab_id].block_ids, vec![b.clone()]);
717 assert!(s.tabs[&tab_id].rootnode.is_some(), "pane survives");
718 drop(s);
719 assert!(queued_deletes(&state, &tab_id).is_empty());
720 }
721
722 fn sample_agent(id: &str) -> crate::backend::storage::agents::AgentDefinition {
723 crate::backend::storage::agents::AgentDefinition {
724 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
725 id: id.to_string(),
726 slug: id.to_string(),
727 name: id.to_string(),
728 icon: "✦".to_string(),
729 provider: "claude".to_string(),
730 description: String::new(),
731 working_directory: String::new(),
732 shell: String::new(),
733 provider_flags: String::new(),
734 auto_start: 0,
735 restart_on_crash: 0,
736 idle_timeout_minutes: 0,
737 created_at: 0,
738 agent_type: "host".to_string(),
739 environment: String::new(),
740 agent_bus_id: String::new(),
741 is_seeded: 0,
742 accounts: String::new(),
743 parent_id: String::new(),
744 branch_label: String::new(),
745 updated_at: 0,
746 user_hidden: 0,
747 container_image: String::new(),
748 container_volumes: "[]".to_string(),
749 container_name: String::new(),
750 use_ambient_login: 0,
751 auto_continue_enabled: 0,
752 model_vendor_base_url: String::new(),
753 memory_id: String::new(),
754 }
755 }
756
757 #[tokio::test]
761 async fn final_state_is_saved_before_the_block_is_deleted() {
762 use crate::backend::storage::agents::{AgentInstance, InstanceStatus};
763 let state = test_state();
764 let (_ws, tab_id) = seed_tab(&state).await;
765 let a = seed_block(&state, &tab_id).await;
766
767 let mut meta = crate::backend::obj::MetaMapType::new();
768 meta.insert(
769 crate::backend::blockcontroller::session_recovery::META_SESSION_ACTIVE_PID.to_string(),
770 serde_json::json!(4242),
771 );
772 crate::server::service::update_object_meta(&state.mstore, &format!("block:{a}"), &meta).unwrap();
773
774 let mut def = sample_agent("def-close");
775 state.mstore.agent_def_insert(&mut def).unwrap();
776 state
777 .mstore
778 .instance_create(&AgentInstance {
779 id: "def-close".to_string(),
780 definition_id: "def-close".to_string(),
781 parent_instance_id: String::new(),
782 block_id: a.clone(),
783 session_id: "sess-keep".to_string(),
784 status: InstanceStatus::Running.as_str().to_string(),
785 github_context: String::new(),
786 started_at: 1000,
787 ended_at: 0,
788 created_at: 1000,
789 identity_id: String::new(),
790 memory_id: String::new(),
791 instance_name: String::new(),
792 working_directory: String::new(),
793 display_hidden: false,
794 })
795 .unwrap();
796
797 crate::sagas::agent_teardown::run_many(&state, std::slice::from_ref(&a), crate::sagas::agent_teardown::Policy::close()).await;
799 let block = state.mstore.get::<Block>(&a).unwrap().expect("block still exists");
800 assert!(
801 block
802 .meta
803 .get(crate::backend::blockcontroller::session_recovery::META_SESSION_ACTIVE_PID)
804 .is_none_or(|v| v.is_null()),
805 "active pid cleared before delete"
806 );
807 let inst = state.mstore.instance_get_by_block_id(&a).unwrap().unwrap();
808 assert_eq!(inst.status, "stopped");
809 assert!(inst.ended_at > 0);
810 assert_eq!(inst.session_id, "sess-keep", "session id kept for resume");
811 assert!(blockcontroller::is_closing(&a), "respawn refused mid-close");
812 finish_close(&state, &a).await;
813 }
814
815 #[tokio::test]
816 async fn rejects_blocks_from_different_tabs() {
817 let state = test_state();
818 let (ws, tab1) = seed_tab(&state).await;
819 let tab2 = dispatch_apply(
820 &state,
821 Command::CreateTab {
822 workspace_id: ws,
823 name: "t2".into(),
824 },
825 )
826 .await
827 .iter()
828 .find_map(|e| match e {
829 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
830 _ => None,
831 })
832 .unwrap();
833 let a = seed_block(&state, &tab1).await;
834 let b = seed_block(&state, &tab2).await;
835 let err = run(&state, vec![a.clone(), b.clone()]).await.unwrap_err();
836 assert!(err.contains("span tabs"), "got: {err}");
837 let s = state.srv_state.lock().await;
838 assert!(s.blocks.contains_key(&a) && s.blocks.contains_key(&b), "nothing closed");
839 }
840
841 #[tokio::test]
842 async fn skips_ids_that_are_already_gone() {
843 let state = test_state();
844 let (_ws, tab_id) = seed_tab(&state).await;
845 let a = seed_block(&state, &tab_id).await;
846 let out = run(&state, vec!["ghost".into(), a.clone()]).await.unwrap();
847 assert_eq!(out["block_ids"], json!([a]));
848 let err = run(&state, vec!["ghost".into()]).await.unwrap_err();
849 assert!(err.contains("block not found"), "got: {err}");
850 }
851
852 struct SlowStop {
854 block_id: String,
855 delay: std::time::Duration,
856 }
857
858 impl blockcontroller::Controller for SlowStop {
859 fn start(&self, _: crate::backend::obj::MetaMapType, _: Option<Value>, _: bool) -> Result<(), String> {
860 Ok(())
861 }
862 fn stop(&self, _: bool, _: &str) -> Result<(), String> {
863 Ok(())
864 }
865 fn shutdown(&self, _deadline: std::time::Instant) -> blockcontroller::ShutdownFuture {
866 let delay = self.delay;
867 Box::pin(async move {
868 tokio::time::sleep(delay).await;
869 blockcontroller::StopOutcome::Exited
870 })
871 }
872 fn get_runtime_status(&self) -> blockcontroller::BlockControllerRuntimeStatus {
873 blockcontroller::BlockControllerRuntimeStatus {
874 blockid: self.block_id.clone(),
875 ..Default::default()
876 }
877 }
878 fn send_input(&self, _: blockcontroller::BlockInputUnion, _: Option<u64>) -> Result<(), String> {
879 Ok(())
880 }
881 fn controller_type(&self) -> &str {
882 "slow-stop-test"
883 }
884 fn block_id(&self) -> &str {
885 &self.block_id
886 }
887 fn as_any(&self) -> &dyn std::any::Any {
888 self
889 }
890 }
891
892 fn register_slow(block_id: &str, delay_ms: u64) {
893 blockcontroller::register_controller(
894 block_id,
895 std::sync::Arc::new(SlowStop {
896 block_id: block_id.to_string(),
897 delay: std::time::Duration::from_millis(delay_ms),
898 }),
899 );
900 }
901
902 #[tokio::test]
905 async fn agents_in_one_close_stop_concurrently() {
906 let state = test_state();
907 let (_ws, tab_id) = seed_tab(&state).await;
908 let mut ids = Vec::new();
909 for _ in 0..3 {
910 let id = seed_block(&state, &tab_id).await;
911 register_slow(&id, 1000);
912 ids.push(id);
913 }
914 let started = std::time::Instant::now();
915 crate::sagas::agent_teardown::run_many(&state, &ids, crate::sagas::agent_teardown::Policy::close()).await;
916 let elapsed = started.elapsed();
917 assert!(elapsed < std::time::Duration::from_millis(2500), "sequential? took {elapsed:?}");
918 for id in &ids {
919 assert!(blockcontroller::get_controller(id).is_none(), "controller removed");
920 finish_close(&state, id).await;
921 }
922 }
923
924 #[tokio::test]
928 async fn a_teardown_outlives_a_dropped_caller() {
929 let state = test_state();
930 let (_ws, tab_id) = seed_tab(&state).await;
931 let a = seed_block(&state, &tab_id).await;
932 register_slow(&a, 500);
933 let caller = {
934 let (st, a) = (state.clone(), a.clone());
935 tokio::spawn(async move {
936 crate::sagas::agent_teardown::run_many(&st, std::slice::from_ref(&a), crate::sagas::agent_teardown::Policy::close())
937 .await;
938 })
939 };
940 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
941 caller.abort();
942 let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
943 while !(blockcontroller::is_closing(&a) && !blockcontroller::closing_blocks_still_running().contains(&a)) {
944 assert!(std::time::Instant::now() < until, "the teardown was cut short with its caller");
945 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
946 }
947 assert!(blockcontroller::get_controller(&a).is_none());
948 finish_close(&state, &a).await;
949 }
950
951 #[tokio::test]
955 async fn a_pane_close_takes_its_drawers() {
956 let state = test_state();
957 let (_ws, tab_id) = seed_tab(&state).await;
958 let a = seed_block(&state, &tab_id).await;
959 let drawer = format!("drawer-of-{a}");
960 let mut block = state.mstore.must_get::<crate::backend::obj::Block>(&a).unwrap();
961 block.subblockids = Some(vec![drawer.clone()]);
962 state.mstore.update(&mut block).unwrap();
963 register_slow(&a, 0);
964 register_slow(&drawer, 0);
965
966 crate::sagas::agent_teardown::run_many(&state, std::slice::from_ref(&a), crate::sagas::agent_teardown::Policy::close()).await;
967 assert!(blockcontroller::get_controller(&drawer).is_none(), "the drawer's controller is gone");
968 assert!(blockcontroller::get_controller(&a).is_none());
969 assert!(!blockcontroller::is_closing(&drawer), "a drawer isn't left unable to respawn");
970 finish_close(&state, &a).await;
971 }
972
973 #[tokio::test]
977 async fn stop_keeps_background_tasks_unless_asked() {
978 let state = test_state();
979 let (_ws, tab_id) = seed_tab(&state).await;
980 let a = seed_block(&state, &tab_id).await;
981 #[cfg(windows)]
983 let mut dev = std::process::Command::new("cmd").args(["/C", "ping -n 30 127.0.0.1 > nul"]).spawn().unwrap();
984 #[cfg(unix)]
985 let mut dev = {
986 use std::os::unix::process::CommandExt as _;
987 std::process::Command::new("sh").args(["-c", "sleep 30"]).process_group(0).spawn().unwrap()
988 };
989 let now = agentmux_common::time::now_ms() as i64;
990 state.mstore.background_task_observe("bg-dev", &a, "task dev", now, now).unwrap();
991 state.mstore.background_task_set_pid("bg-dev", dev.id() as i64).unwrap();
992
993 register_slow(&a, 0);
994 let kept = crate::sagas::agent_teardown::stop(&state, &a, true, Some(false)).await.unwrap();
995 assert_eq!(kept.stopped_background, 0);
996 assert!(dev.try_wait().unwrap().is_none(), "kept running");
997
998 register_slow(&a, 0);
999 let stopped = crate::sagas::agent_teardown::stop(&state, &a, true, Some(true)).await.unwrap();
1000 assert_eq!(stopped.stopped_background, 1);
1001 let until = std::time::Instant::now() + std::time::Duration::from_secs(10);
1002 while dev.try_wait().unwrap().is_none() && std::time::Instant::now() < until {
1003 std::thread::sleep(std::time::Duration::from_millis(50));
1004 }
1005 assert!(dev.try_wait().unwrap().is_some(), "the background task was stopped");
1006 assert!(state.srv_state.lock().await.blocks.contains_key(&a), "Stop keeps the block");
1007 assert!(
1008 crate::sagas::agent_teardown::stop(&state, "no-such-block", true, None).await.unwrap_err().starts_with("NOT_RUNNING"),
1009 );
1010 blockcontroller::delete_controller(&a);
1011 }
1012
1013 struct FailingStop(String);
1015
1016 impl blockcontroller::Controller for FailingStop {
1017 fn start(&self, _: crate::backend::obj::MetaMapType, _: Option<Value>, _: bool) -> Result<(), String> {
1018 Ok(())
1019 }
1020 fn stop(&self, _: bool, _: &str) -> Result<(), String> {
1021 Err("stop_subprocess: no such process".into())
1022 }
1023 fn get_runtime_status(&self) -> blockcontroller::BlockControllerRuntimeStatus {
1024 blockcontroller::BlockControllerRuntimeStatus { blockid: self.0.clone(), ..Default::default() }
1025 }
1026 fn send_input(&self, _: blockcontroller::BlockInputUnion, _: Option<u64>) -> Result<(), String> {
1027 Ok(())
1028 }
1029 fn controller_type(&self) -> &str {
1030 "failing-stop-test"
1031 }
1032 fn block_id(&self) -> &str {
1033 &self.0
1034 }
1035 fn as_any(&self) -> &dyn std::any::Any {
1036 self
1037 }
1038 }
1039
1040 #[tokio::test]
1044 async fn a_failed_stop_is_an_error_and_keeps_everything() {
1045 let state = test_state();
1046 let (_ws, tab_id) = seed_tab(&state).await;
1047 let a = seed_block(&state, &tab_id).await;
1048 #[cfg(windows)]
1049 let mut dev = std::process::Command::new("cmd").args(["/C", "ping -n 30 127.0.0.1 > nul"]).spawn().unwrap();
1050 #[cfg(unix)]
1051 let mut dev = {
1052 use std::os::unix::process::CommandExt as _;
1053 std::process::Command::new("sh").args(["-c", "sleep 30"]).process_group(0).spawn().unwrap()
1054 };
1055 let now = agentmux_common::time::now_ms() as i64;
1056 state.mstore.background_task_observe("bg-keep", &a, "task dev", now, now).unwrap();
1057 state.mstore.background_task_set_pid("bg-keep", dev.id() as i64).unwrap();
1058 blockcontroller::register_controller(&a, std::sync::Arc::new(FailingStop(a.clone())));
1059
1060 let err = crate::sagas::agent_teardown::stop(&state, &a, true, Some(true)).await.unwrap_err();
1061 assert!(err.contains("no such process"), "the stop's own error: {err}");
1062 std::thread::sleep(std::time::Duration::from_millis(300));
1063 assert!(dev.try_wait().unwrap().is_none(), "the background task was not stopped");
1064
1065 blockcontroller::take_controller(&a);
1066 let _ = dev.kill();
1067 let _ = dev.wait();
1068 }
1069
1070 #[tokio::test]
1073 async fn app_exit_leaves_drawers_to_their_parent() {
1074 let state = test_state();
1075 let (_ws, tab_id) = seed_tab(&state).await;
1076 let a = seed_block(&state, &tab_id).await;
1077 let b = seed_block(&state, &tab_id).await;
1078 let drawer = format!("drawer-of-{a}");
1079 let mut block = state.mstore.must_get::<crate::backend::obj::Block>(&a).unwrap();
1080 block.subblockids = Some(vec![drawer.clone()]);
1081 state.mstore.update(&mut block).unwrap();
1082 let ids = crate::sagas::agent_teardown::without_sub_blocks(&state, vec![a.clone(), drawer, b.clone()]);
1083 assert_eq!(ids, vec![a, b]);
1084 }
1085
1086 #[tokio::test]
1090 async fn last_tab_close_is_refused_without_stopping_its_agents() {
1091 let state = test_state();
1092 let (ws, tab_id) = seed_tab(&state).await;
1093 let a = seed_block(&state, &tab_id).await;
1094 register_slow(&a, 0);
1095
1096 let err = crate::sagas::delete_tab::run(&state, ws, tab_id).await.unwrap_err();
1097 assert!(err.contains("last tab"), "got: {err}");
1098 assert!(blockcontroller::get_controller(&a).is_some(), "agent must not be stopped");
1099 assert!(!blockcontroller::is_closing(&a));
1100 blockcontroller::delete_controller(&a);
1101 }
1102
1103 #[tokio::test]
1106 async fn window_tab_close_stops_its_agents() {
1107 let state = test_state();
1108 let (ws, tab1) = seed_tab(&state).await;
1109 let tab2 = dispatch_apply(
1110 &state,
1111 Command::CreateTab {
1112 workspace_id: ws.clone(),
1113 name: "t2".into(),
1114 },
1115 )
1116 .await
1117 .iter()
1118 .find_map(|e| match e {
1119 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
1120 _ => None,
1121 })
1122 .unwrap();
1123 let a = seed_block(&state, &tab2).await;
1124 let b = seed_block(&state, &tab2).await;
1125 register_slow(&a, 300);
1126 register_slow(&b, 300);
1127
1128 crate::sagas::delete_tab::run(&state, ws, tab2.clone()).await.unwrap();
1129
1130 assert!(blockcontroller::get_controller(&a).is_none());
1131 assert!(blockcontroller::get_controller(&b).is_none());
1132 assert!(!blockcontroller::is_closing(&a) && !blockcontroller::is_closing(&b));
1133 let s = state.srv_state.lock().await;
1134 assert!(!s.tabs.contains_key(&tab2));
1135 assert!(s.tabs.contains_key(&tab1));
1136 }
1137
1138 #[tokio::test]
1141 async fn waiting_on_a_closing_block_returns_once_it_has_stopped() {
1142 let id = format!("closing-wait-{}", uuid::Uuid::new_v4());
1143 blockcontroller::mark_closing(&id);
1144 let waiter_id = id.clone();
1145 let waiter = tokio::spawn(async move {
1146 let started = std::time::Instant::now();
1147 blockcontroller::wait_closing_stopped(&waiter_id, std::time::Duration::from_secs(5)).await;
1148 started.elapsed()
1149 });
1150 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1151 blockcontroller::mark_closing_stopped(&id);
1152 let waited = waiter.await.unwrap();
1153 assert!(waited >= std::time::Duration::from_millis(250), "returned early: {waited:?}");
1154 assert!(waited < std::time::Duration::from_secs(2), "missed the signal: {waited:?}");
1155 blockcontroller::unmark_closing(&id);
1156 }
1157
1158 #[test]
1159 fn closing_guard_blocks_resync() {
1160 let block = Block {
1161 oid: "closing-guard-blk".to_string(),
1162 parentoref: String::new(),
1163 version: 1,
1164 runtimeopts: None,
1165 stickers: None,
1166 meta: {
1167 let mut m = crate::backend::obj::MetaMapType::new();
1168 m.insert("controller".to_string(), json!("shell"));
1169 m
1170 },
1171 subblockids: None,
1172 };
1173 blockcontroller::mark_closing(&block.oid);
1174 let err = blockcontroller::resync_controller(
1175 &block, "tab-1", None, false, true, None, None, None, None, None, None, None,
1176 std::sync::Arc::from("test-boot"), "test-key", None,
1177 )
1178 .unwrap_err();
1179 blockcontroller::unmark_closing(&block.oid);
1180 assert!(err.contains("is closing"), "got: {err}");
1181 assert!(blockcontroller::get_controller(&block.oid).is_none());
1182 }
1183}