1use serde::Serialize;
34
35use crate::backend::blockcontroller;
36use crate::server::agent_resources::{self, AgentIdentity, AgentResources};
37use crate::server::AppState;
38
39use super::close_pane::{exit_line, process_extra, process_line, publish_shutdown, save_final_state, short_command, ShutdownExtra};
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum CliStop {
44 Graceful,
48 StopOnly { graceful: bool },
52 Replace,
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub struct Policy {
63 pub cli: CliStop,
64 pub release_claims: bool,
67 pub stop_shell_sessions: bool,
69 pub stop_background: bool,
73 pub stop_container: bool,
77}
78
79impl Policy {
80 pub const fn close() -> Self {
84 Self { cli: CliStop::Graceful, release_claims: false, stop_shell_sessions: true, stop_background: false, stop_container: true }
85 }
86 pub const fn quit() -> Self {
88 Self { cli: CliStop::Graceful, release_claims: true, stop_shell_sessions: true, stop_background: false, stop_container: true }
89 }
90 pub const fn stop(graceful: bool, stop_background: bool) -> Self {
94 Self { cli: CliStop::StopOnly { graceful }, release_claims: true, stop_shell_sessions: false, stop_background, stop_container: false }
95 }
96 pub const fn stop_cli_only(graceful: bool) -> Self {
100 Self { cli: CliStop::StopOnly { graceful }, release_claims: false, stop_shell_sessions: false, stop_background: false, stop_container: false }
101 }
102 pub const fn replace() -> Self {
104 Self { cli: CliStop::Replace, release_claims: false, stop_shell_sessions: false, stop_background: false, stop_container: false }
105 }
106 pub const fn app_exit() -> Self {
110 Self { cli: CliStop::Graceful, release_claims: false, stop_shell_sessions: true, stop_background: false, stop_container: true }
111 }
112}
113
114pub const SETTING_STOP_KEEPS_BACKGROUND: &str = "agent:stopkeepsbackground";
118
119pub const APP_EXIT_CAP: std::time::Duration = agentmux_common::process::SRV_APP_EXIT_CAP;
124
125const SHELL_SWEEP_GRACE: std::time::Duration = std::time::Duration::from_millis(800);
127
128#[derive(Clone, Debug, Default, Serialize)]
130pub struct TeardownReport {
131 pub block_id: String,
132 pub agent: String,
134 pub released_claims: usize,
135 pub stopped_shells: usize,
136 pub crons_targeting: Vec<String>,
138 pub stopped_background: usize,
140 #[serde(skip_serializing_if = "Option::is_none")]
143 pub stop_error: Option<String>,
144 pub stopped_container: Option<String>,
146 #[serde(skip_serializing_if = "Vec::is_empty")]
149 pub containers_left: Vec<String>,
150 pub survivors: Vec<agent_resources::ProcessEntry>,
153 pub before: AgentResources,
155}
156
157static CLOSES: std::sync::LazyLock<tokio_util::task::TaskTracker> =
162 std::sync::LazyLock::new(tokio_util::task::TaskTracker::new);
163
164pub(crate) async fn detached<T: Send + 'static>(fut: impl std::future::Future<Output = T> + Send + 'static) -> Option<T> {
167 CLOSES.spawn(fut).await.ok()
168}
169
170pub async fn run_many(state: &AppState, block_ids: &[String], policy: Policy) -> Vec<TeardownReport> {
173 let deadline = std::time::Instant::now() + blockcontroller::SHUTDOWN_GRACE;
174 let teardowns = block_ids.iter().map(|id| {
175 let (st, id) = (state.clone(), id.clone());
176 async move {
177 let report = detached({
178 let id = id.clone();
179 async move { run_one(&st, &id, policy, deadline).await }
180 })
181 .await;
182 report.unwrap_or_else(|| {
183 tracing::error!(block_id = %id, "agent_teardown: teardown panicked");
184 TeardownReport { agent: id.clone(), block_id: id, ..Default::default() }
185 })
186 }
187 });
188 futures_util::future::join_all(teardowns).await
189}
190
191pub async fn run(state: &AppState, block_id: &str, policy: Policy) -> TeardownReport {
193 run_many(state, std::slice::from_ref(&block_id.to_string()), policy)
194 .await
195 .pop()
196 .unwrap_or_default()
197}
198
199pub fn stop_now(block_id: &str, graceful: bool) -> Result<(), String> {
203 debug_assert!(matches!(Policy::stop_cli_only(graceful).cli, CliStop::StopOnly { .. }));
204 let ctrl = blockcontroller::get_controller(block_id)
205 .ok_or_else(|| format!("NOT_RUNNING: no controller for block {block_id}"))?;
206 ctrl.stop(graceful, blockcontroller::STATUS_DONE)
207}
208
209pub async fn stop(state: &AppState, block_id: &str, graceful: bool, stop_background: Option<bool>) -> Result<TeardownReport, String> {
215 if blockcontroller::get_controller(block_id).is_none() {
216 return Err(format!("NOT_RUNNING: no controller for block {block_id}"));
217 }
218 let stop_background = stop_background.unwrap_or_else(|| !stop_keeps_background(state));
219 let report = run(state, block_id, Policy::stop(graceful, stop_background)).await;
220 match report.stop_error {
221 Some(e) => Err(e),
222 None => Ok(report),
223 }
224}
225
226fn stop_keeps_background(state: &AppState) -> bool {
227 state
228 .config_watcher
229 .get_settings()
230 .extra
231 .get(SETTING_STOP_KEEPS_BACKGROUND)
232 .and_then(|v| v.as_bool())
233 .unwrap_or(true)
234}
235
236pub fn discard(block_id: &str) {
242 blockcontroller::delete_controller(block_id);
243}
244
245pub fn replace_now(ctrl: &dyn blockcontroller::Controller) -> Result<(), String> {
249 debug_assert!(matches!(Policy::replace().cli, CliStop::Replace));
250 ctrl.stop_for_replace(blockcontroller::STATUS_DONE)
251}
252
253pub async fn app_exit(state: &AppState) -> usize {
259 crate::backend::blockcontroller::durable_ssh::note_app_exiting();
262 let all: Vec<String> = blockcontroller::get_all_controllers().into_keys().collect();
263 let ids = {
266 let (st, ids) = (state.clone(), all.clone());
267 tokio::task::spawn_blocking(move || without_sub_blocks(&st, ids)).await.unwrap_or(all)
268 };
269 let cap = APP_EXIT_CAP.saturating_sub(SHELL_SWEEP_GRACE);
270 let all_closed = async {
271 run_many(state, &ids, Policy::app_exit()).await;
272 CLOSES.close();
276 CLOSES.wait().await;
277 };
278 if tokio::time::timeout(cap, all_closed).await.is_err() {
279 tracing::warn!(agents = ids.len(), "app exit: teardown hit the cap; the launcher's backstop takes the rest");
280 }
281 let live = state.shell_sessions.stop_all();
284 if live > 0 {
285 tracing::info!(count = live, "app exit: stopping leftover persistent shells");
286 tokio::time::sleep(SHELL_SWEEP_GRACE).await;
287 }
288 ids.len()
289}
290
291pub(crate) fn without_sub_blocks(state: &AppState, ids: Vec<String>) -> Vec<String> {
294 let subs: std::collections::HashSet<String> = ids
295 .iter()
296 .filter_map(|id| state.mstore.get::<crate::backend::obj::Block>(id).ok().flatten())
297 .flat_map(|b| b.subblockids.unwrap_or_default())
298 .collect();
299 ids.into_iter().filter(|id| !subs.contains(id)).collect()
300}
301
302async fn run_one(state: &AppState, block_id: &str, policy: Policy, deadline: std::time::Instant) -> TeardownReport {
303 let started = std::time::Instant::now();
304 let who = AgentIdentity::of(state, block_id);
306 let before = {
309 let (st, id) = (state.clone(), block_id.to_string());
310 tokio::task::spawn_blocking(move || agent_resources::snapshot(&st, &id))
311 .await
312 .unwrap_or_default()
313 };
314 let mut report = TeardownReport {
315 block_id: block_id.to_string(),
316 agent: who.label(block_id),
317 before: before.clone(),
318 ..Default::default()
319 };
320
321 if let CliStop::StopOnly { graceful } = policy.cli {
324 if let Err(e) = stop_now(block_id, graceful) {
325 tracing::warn!(block_id = %block_id, error = %e, "agent_teardown: stop failed; nothing else touched");
326 report.stop_error = Some(e);
327 return report;
328 }
329 }
330
331 if policy.release_claims {
333 let reason = if matches!(policy.cli, CliStop::StopOnly { .. }) { "agent stopped" } else { "agent quit" };
334 let (released, crons) = release_claims_and_list_crons(state, &who, reason).await;
335 report.released_claims = released;
336 report.crons_targeting = crons;
337 }
338 if policy.stop_shell_sessions {
339 report.stopped_shells = before
340 .shell_sessions
341 .iter()
342 .filter(|s| state.shell_sessions.stop(&s.shell_id))
343 .count();
344 }
345
346 if policy.stop_background && !before.background_tasks.is_empty() {
347 report.stopped_background = stop_background_tasks(&before).await;
348 }
349
350 match policy.cli {
351 CliStop::StopOnly { .. } => {} CliStop::Replace => {
353 if let Some(ctrl) = blockcontroller::get_controller(block_id) {
354 if let Err(e) = replace_now(ctrl.as_ref()) {
355 tracing::debug!(block_id = %block_id, error = %e, "agent_teardown: replace");
356 }
357 }
358 }
359 CliStop::Graceful => {
360 close_sub_blocks(state, &before.sub_blocks, deadline).await;
363 report.survivors = close_cli(state, block_id, &who, &before, deadline, started).await;
364 if policy.stop_container {
365 report.stopped_container = stop_container(state, block_id, &before, deadline).await;
366 }
367 report.containers_left = compose_containers_left(state, block_id, &before, deadline).await;
368 }
369 }
370 report
371}
372
373async fn stop_container(
377 state: &AppState,
378 block_id: &str,
379 before: &AgentResources,
380 deadline: std::time::Instant,
381) -> Option<String> {
382 let name = before.container.clone()?;
383 let shared = {
384 let (st, id, n) = (state.clone(), block_id.to_string(), name.clone());
385 tokio::task::spawn_blocking(move || agent_resources::container_shared(&st, &id, &n)).await.unwrap_or(true)
386 };
387 if shared {
388 tracing::info!(block_id = %block_id, container = %name, "agent_teardown: container still in use; kept");
389 return None;
390 }
391 let cm = state.container_manager.get().await?;
392 let grace = deadline.saturating_duration_since(std::time::Instant::now()).as_secs().max(1) as i64;
393 match cm.stop(&name, grace).await {
394 Ok(()) => {
395 publish_shutdown(state, block_id, "container", format!("stopped container {name}"), ShutdownExtra::default());
396 Some(name)
397 }
398 Err(e) => {
399 tracing::warn!(block_id = %block_id, container = %name, error = %e, "agent_teardown: container stop failed");
400 None
401 }
402 }
403}
404
405async fn compose_containers_left(
411 state: &AppState,
412 block_id: &str,
413 before: &AgentResources,
414 deadline: std::time::Instant,
415) -> Vec<String> {
416 let dir = std::path::PathBuf::from(&before.cwd);
417 let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(std::path::PathBuf::from);
418 if before.cwd.is_empty() || dir.parent().is_none() || home.as_deref() == Some(dir.as_path()) {
419 return Vec::new();
420 }
421 let Some(cm) = state.container_manager.get().await else { return Vec::new() };
422 let wait = deadline.saturating_duration_since(std::time::Instant::now()).max(std::time::Duration::from_secs(1));
424 let names = match tokio::time::timeout(wait, cm.running_compose_containers_under(&dir)).await {
425 Ok(Ok(names)) => names,
426 Ok(Err(e)) => {
427 tracing::warn!(block_id = %block_id, error = %e, "agent_teardown: listing Compose containers failed");
428 return Vec::new();
429 }
430 Err(_) => {
431 tracing::warn!(block_id = %block_id, "agent_teardown: Docker didn't list Compose containers in time");
432 return Vec::new();
433 }
434 };
435 for name in &names {
436 publish_shutdown(
437 state,
438 block_id,
439 "container",
440 format!("container {name} (Docker Compose, in this agent's folder) is still running"),
441 ShutdownExtra { container: Some(name.clone()), outcome: Some("left".into()), ..Default::default() },
442 );
443 }
444 names
445}
446
447async fn stop_background_tasks(before: &AgentResources) -> usize {
450 let tasks = before.background_tasks.clone();
451 tokio::task::spawn_blocking(move || {
452 let pids = agent_resources::live_background_pids(&tasks);
453 for pid in &pids {
454 tracing::info!(pid, "agent_teardown: stopping a background task");
455 agentmux_common::process::kill_process_group(*pid);
456 }
457 pids.len()
458 })
459 .await
460 .unwrap_or(0)
461}
462
463async fn close_sub_blocks(state: &AppState, sub_blocks: &[String], deadline: std::time::Instant) {
466 if sub_blocks.is_empty() {
467 return;
468 }
469 let subs = sub_blocks.iter().map(|id| Box::pin(run_one(state, id, Policy::close(), deadline)));
470 futures_util::future::join_all(subs).await;
471 for id in sub_blocks {
475 blockcontroller::unmark_closing(id);
476 }
477}
478
479async fn close_cli(
483 state: &AppState,
484 block_id: &str,
485 who: &AgentIdentity,
486 before: &AgentResources,
487 deadline: std::time::Instant,
488 started: std::time::Instant,
489) -> Vec<agent_resources::ProcessEntry> {
490 let label = if who.agent_id.is_empty() { "agent".to_string() } else { who.agent_id.clone() };
491 blockcontroller::mark_closing(block_id);
495 state.reactive_handler.unregister_block(block_id);
496 let ctrl = blockcontroller::take_controller(block_id);
497 let (controller_type, outcome) = match ctrl {
500 Some(ctrl) => {
501 if ctrl.get_runtime_status().turn_active {
502 publish_shutdown(state, block_id, "interrupt", "turn interrupted".into(), ShutdownExtra::default());
503 }
504 let outcome = ctrl.shutdown(deadline).await;
505 (ctrl.controller_type().to_string(), outcome)
506 }
507 None => (String::new(), blockcontroller::StopOutcome::NotRunning),
508 };
509 let (step, text, extra) = exit_line(&label, &outcome, started.elapsed());
510 publish_shutdown(state, block_id, step, text, extra);
511 terminate_members(state, block_id, deadline).await;
514 let alive: std::collections::HashSet<u32> =
518 state.process_tracker.list_block(block_id).into_iter().map(|p| p.pid).collect();
519 blockcontroller::release_block_processes(block_id);
520 for p in &before.processes {
521 let killed = alive.contains(&p.pid);
522 publish_shutdown(
523 state,
524 block_id,
525 "process",
526 process_line(&p.command, p.pid, killed),
527 process_extra(p.pid, &p.command, if killed { "killed" } else { "stopped" }),
528 );
529 }
530 let mut survivors = verify_gone(&before.processes).await;
533 for p in &survivors {
534 publish_shutdown(
535 state,
536 block_id,
537 "survivor",
538 format!("still running: {} (pid {})", short_command(&p.command), p.pid),
539 process_extra(p.pid, &p.command, "survived"),
540 );
541 }
542 for p in escaped(block_id, &survivors).await {
546 publish_shutdown(
547 state,
548 block_id,
549 "survivor",
550 format!("still running, outside the agent's tracking: {} (pid {})", short_command(&p.command), p.pid),
551 process_extra(p.pid, &p.command, "escaped"),
552 );
553 survivors.push(p);
554 }
555 blockcontroller::mark_closing_stopped(block_id);
556 crate::backend::container_credential::revoke_block(block_id);
557 save_final_state(state, block_id);
559 publish_shutdown(state, block_id, "saved", "conversation saved".into(), ShutdownExtra::default());
560 publish_shutdown(state, block_id, "done", "closing".into(), ShutdownExtra::default());
561 tracing::info!(
562 block_id = %block_id,
563 controller_type = %controller_type,
564 outcome = outcome.as_str(),
565 elapsed_ms = started.elapsed().as_millis() as u64,
566 survivors = survivors.len(),
567 "agent_shutdown"
568 );
569 survivors
570}
571
572const MEMBERS_GRACE_MAX: std::time::Duration = std::time::Duration::from_secs(2);
575
576async fn terminate_members(state: &AppState, block_id: &str, deadline: std::time::Instant) {
581 let grace = deadline.saturating_duration_since(std::time::Instant::now()).min(MEMBERS_GRACE_MAX);
582 if grace.is_zero() {
583 return;
584 }
585 let registry = state.process_tracker.clone();
586 let id = block_id.to_string();
587 let count = move || {
588 let (r, id) = (registry.clone(), id.clone());
589 tokio::task::spawn_blocking(move || r.member_count(&id))
590 };
591 if count().await.unwrap_or(0) == 0 {
592 return;
593 }
594 let (r, id) = (state.process_tracker.clone(), block_id.to_string());
595 if !tokio::task::spawn_blocking(move || r.terminate(&id)).await.unwrap_or(false) {
596 return;
597 }
598 let until = std::time::Instant::now() + grace;
599 while std::time::Instant::now() < until {
600 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
601 if count().await.unwrap_or(0) == 0 {
602 return;
603 }
604 }
605}
606
607async fn escaped(block_id: &str, known: &[agent_resources::ProcessEntry]) -> Vec<agent_resources::ProcessEntry> {
611 let scan = |id: String| async move {
612 tokio::task::spawn_blocking(move || crate::backend::process_tracker::scan::tagged(&id))
613 .await
614 .unwrap_or_default()
615 };
616 let first = scan(block_id.to_string()).await;
617 if first.is_empty() {
618 return Vec::new();
619 }
620 tokio::time::sleep(ESCAPE_SETTLE).await;
621 scan(block_id.to_string())
622 .await
623 .into_iter()
624 .filter(|p| first.iter().any(|f| f.pid == p.pid && f.started_at_ms == p.started_at_ms))
625 .filter(|p| !known.iter().any(|k| k.pid == p.pid))
626 .map(|p| agent_resources::ProcessEntry { pid: p.pid, command: p.command, started_at_ms: p.started_at_ms })
627 .collect()
628}
629
630const ESCAPE_SETTLE: std::time::Duration = std::time::Duration::from_millis(300);
631
632const VERIFY_WAIT: std::time::Duration = std::time::Duration::from_millis(1500);
635
636async fn verify_gone(before: &[agent_resources::ProcessEntry]) -> Vec<agent_resources::ProcessEntry> {
641 if before.is_empty() {
642 return Vec::new();
643 }
644 let mut left = wait_gone(before.to_vec()).await;
645 if left.is_empty() {
646 return left;
647 }
648 for p in &left {
649 tracing::warn!(pid = p.pid, command = %p.command, "agent_teardown: forcing a survivor");
650 let (pid, started) = (p.pid, p.started_at_ms);
651 let _ = tokio::task::spawn_blocking(move || agent_resources::force_kill_tree(pid, started)).await;
652 }
653 left = wait_gone(left).await;
654 left
655}
656
657async fn wait_gone(mut procs: Vec<agent_resources::ProcessEntry>) -> Vec<agent_resources::ProcessEntry> {
660 let until = std::time::Instant::now() + VERIFY_WAIT;
661 loop {
662 procs = tokio::task::spawn_blocking(move || agent_resources::survivors(&procs)).await.unwrap_or_default();
663 if procs.is_empty() || std::time::Instant::now() >= until {
664 return procs;
665 }
666 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
667 }
668}
669
670async fn release_claims_and_list_crons(state: &AppState, who: &AgentIdentity, reason: &'static str) -> (usize, Vec<String>) {
675 let (identity_store, shared_store) = (state.identity_store.clone(), state.shared_store.clone());
676 let (a, u) = (who.agent_id.clone(), who.uid.clone());
677 let result = tokio::task::spawn_blocking(move || {
678 let now = agentmux_common::time::now_ms();
679 let items = identity_store.work_queue_claimed_by(&a, u.as_deref().unwrap_or("")).unwrap_or_default();
680 let released = agent_resources::claims_held_by(&items, &a, u.as_deref())
681 .into_iter()
682 .filter(|w| {
683 matches!(
684 identity_store.work_queue_release(&w.id, &w.claimed_by, &w.claimed_by_uid, w.attempts, reason, now),
685 Ok(Some(_))
686 )
687 })
688 .count();
689 let crons = shared_store
690 .and_then(|s| s.cron_list().ok())
691 .map(|jobs| agent_resources::crons_targeting(&jobs, &a, u.as_deref()))
692 .unwrap_or_default();
693 (released, crons)
694 })
695 .await
696 .unwrap_or_default();
697 if result.0 > 0 {
698 crate::server::work_queue::publish_changed(state);
699 }
700 result
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 #[test]
708 fn policies_encode_todays_scopes() {
709 assert_eq!(Policy::close(), Policy { cli: CliStop::Graceful, release_claims: false, stop_shell_sessions: true, stop_background: false, stop_container: true });
710 assert_eq!(Policy::quit(), Policy { cli: CliStop::Graceful, release_claims: true, stop_shell_sessions: true, stop_background: false, stop_container: true });
711 assert_eq!(Policy::stop(false, false).cli, CliStop::StopOnly { graceful: false });
712 assert!(Policy::stop(true, false).release_claims && !Policy::stop(true, false).stop_shell_sessions);
714 assert!(Policy::stop(true, true).stop_background && !Policy::stop(true, false).stop_background);
715 assert!(Policy::close().stop_container && Policy::quit().stop_container && Policy::app_exit().stop_container);
717 assert!(!Policy::stop(true, true).stop_container && !Policy::replace().stop_container);
718 let w = Policy::stop_cli_only(true);
720 assert!(!w.release_claims && !w.stop_shell_sessions && !w.stop_background);
721 assert_eq!(Policy::replace(), Policy { cli: CliStop::Replace, release_claims: false, stop_shell_sessions: false, stop_background: false, stop_container: false });
722 assert_eq!(Policy::app_exit(), Policy { cli: CliStop::Graceful, release_claims: false, stop_shell_sessions: true, stop_background: false, stop_container: true });
723 assert!(APP_EXIT_CAP.saturating_sub(SHELL_SWEEP_GRACE) > blockcontroller::SHUTDOWN_GRACE);
725 }
726
727 #[test]
730 fn nothing_else_stops_an_agent() {
731 const ALLOWED: &[(&str, &str)] = &[
734 ("sagas/agent_teardown.rs", "the one path"),
735 ("backend/blockcontroller/mod.rs", "defines release_block_processes and delete_controller"),
736 ("backend/blockcontroller/persistent/", "the controller's own shutdown internals"),
737 ("backend/blockcontroller/shell/", "the controller's own stop internals"),
738 ("backend/process_tracker/", "the tracker itself"),
739 ("backend/shell_node.rs", "ShellSessionRegistry's own stop/stop_all"),
740 ("server/http_shell.rs", "the ShellStop tool: the agent stopping one shell on purpose"),
741 ];
742 const PATTERNS: &[&str] = &[
743 "release_block_processes(",
744 "shell_sessions.stop(",
745 ".stop_all()",
746 ".shutdown(deadline",
747 "ctrl.stop(",
748 ".stop_for_replace(",
749 "delete_controller(",
750 ];
751 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
752 let mut offenders = Vec::new();
753 let mut stack = vec![root.clone()];
754 while let Some(dir) = stack.pop() {
755 for entry in std::fs::read_dir(&dir).unwrap() {
756 let path = entry.unwrap().path();
757 if path.is_dir() {
758 stack.push(path);
759 continue;
760 }
761 if path.extension().and_then(|e| e.to_str()) != Some("rs") {
762 continue;
763 }
764 let rel = path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/");
765 let is_test_file = rel.ends_with("tests.rs") || rel.contains("/tests/");
767 if is_test_file || ALLOWED.iter().any(|(a, _)| rel.starts_with(a) || rel == *a) {
768 continue;
769 }
770 let text = std::fs::read_to_string(&path).unwrap();
771 let code = text.split("#[cfg(test)]").next().unwrap_or("");
773 for (i, line) in code.lines().enumerate() {
774 let t = line.trim_start();
775 if t.starts_with("//") {
776 continue;
777 }
778 if let Some(p) = PATTERNS.iter().find(|p| line.contains(*p)) {
779 offenders.push(format!("{rel}:{}: `{p}`", i + 1));
780 }
781 }
782 }
783 }
784 assert!(
785 offenders.is_empty(),
786 "an agent's processes are stopped only by sagas::agent_teardown (spec SPEC_AGENT_TEARDOWN_SINGLE_PATH §3 G1); \
787 route these through it:\n{}",
788 offenders.join("\n")
789 );
790 }
791}