1use std::{
15 collections::BTreeMap,
16 sync::{
17 atomic::{AtomicBool, Ordering},
18 Arc, Mutex,
19 },
20 time::Duration,
21};
22
23use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
24
25use crate::{
26 bindings::host::murmur::tool::run::{Status, ToolResult},
27 origin::{TaskOrigin, TaskProvenance},
28};
29
30pub const WORK_ID_PREFIX: &str = "wrk_";
33
34pub fn new_work_id() -> String {
39 format!("{WORK_ID_PREFIX}{}", uuid::Uuid::now_v7().simple())
40}
41
42pub(crate) fn output_path_for(work_id: &str) -> String {
46 format!("logs/{work_id}.log")
47}
48
49#[derive(Debug, Clone)]
51pub(crate) struct DetachedWork {
52 pub work_id: String,
53 pub binary: String,
55 pub command: String,
57 pub started_at_ms: u64,
60}
61
62#[derive(Debug, Clone)]
67pub(crate) struct DetachedCompletion {
68 pub work_id: String,
69 pub binary: String,
70 pub command: String,
71 pub exit_code: i32,
72 pub duration_ms: u64,
73 pub output_path: String,
76 pub output_bytes: u64,
78 pub resource_limit: Option<String>,
79 pub context_id: String,
82 pub provenance: TaskProvenance,
84 pub error: Option<String>,
86}
87
88impl DetachedCompletion {
89 pub(crate) fn message_text(&self) -> String {
95 let mut text = format!(
96 "Background shell command finished.\n\
97 work_id: {}\n\
98 binary: {}\n\
99 command: {}\n\
100 status: {}\n\
101 exit_code: {}\n\
102 duration_ms: {}\n\
103 output: {} ({} bytes, in the capsule workdir)",
104 self.work_id,
105 self.binary,
106 self.command,
107 self.status(),
108 self.exit_code,
109 self.duration_ms,
110 self.output_path,
111 self.output_bytes,
112 );
113 if let Some(limit) = &self.resource_limit {
114 text.push('\n');
115 text.push_str(&crate::resources::resource_limit_line(limit));
116 }
117 if let Some(error) = &self.error {
118 text.push_str(&format!("\nwait_error: {error}"));
119 }
120 text.push_str(
121 "\n\nThe full stdout and stderr are in that file and are not reproduced here.",
122 );
123 text
124 }
125
126 pub(crate) fn status(&self) -> &'static str {
132 if self.exit_code == 0 && self.error.is_none() && self.resource_limit.is_none() {
133 "ok"
134 } else {
135 "error"
136 }
137 }
138}
139
140#[derive(Debug, Clone)]
142pub(crate) struct DetachedDispatchInfo {
143 pub work_id: String,
144 pub binary: String,
145 pub command: String,
146 pub grace_ms: u64,
150}
151
152pub(crate) fn demotion_tool_result(work_id: &str) -> ToolResult {
159 ToolResult {
160 status: Status::Passed,
161 summary: Some(format!(
162 "Command still running in the background as {work_id}."
163 )),
164 data: Some(format!(
165 "The command did not finish in time and is now running in the background as {work_id}. \
166 Its result will arrive later as a separate task; there is nothing to poll and no way \
167 to ask about it. Continue with other work."
168 )),
169 data_path: None,
170 truncated: false,
171 metadata: vec![("work_id".to_string(), work_id.to_string())],
172 }
173}
174
175pub(crate) struct DetachedRegistry {
180 outstanding: Mutex<BTreeMap<String, DetachedWork>>,
181 reports: UnboundedSender<DetachedReport>,
185}
186
187impl DetachedRegistry {
188 pub(crate) fn new() -> (Arc<Self>, UnboundedReceiver<DetachedReport>) {
189 let (reports, receiver) = unbounded_channel();
190 (
191 Arc::new(Self {
192 outstanding: Mutex::new(BTreeMap::new()),
193 reports,
194 }),
195 receiver,
196 )
197 }
198
199 pub(crate) fn report(&self, report: DetachedReport) {
201 let _ = self.reports.send(report);
202 }
203
204 pub(crate) fn register(&self, work: DetachedWork) {
207 self.outstanding
208 .lock()
209 .expect("detached registry mutex")
210 .insert(work.work_id.clone(), work);
211 }
212
213 pub(crate) fn complete(&self, completion: DetachedCompletion) {
219 self.outstanding
220 .lock()
221 .expect("detached registry mutex")
222 .remove(&completion.work_id);
223 self.report(DetachedReport::Completed(completion));
224 }
225
226 pub(crate) fn outstanding(&self) -> Vec<DetachedWork> {
228 self.outstanding
229 .lock()
230 .expect("detached registry mutex")
231 .values()
232 .cloned()
233 .collect()
234 }
235}
236
237pub(crate) struct DetachPolicy {
243 pub grace: Duration,
244 pub registry: Arc<DetachedRegistry>,
245 pub command: String,
249 pub context_id: String,
250 pub provenance: Option<TaskProvenance>,
254 pub abandoned: Option<AbandonSignal>,
258}
259
260#[derive(Debug, Clone, Default)]
265pub(crate) struct AbandonSignal(Arc<AtomicBool>);
266
267impl AbandonSignal {
268 pub(crate) fn new() -> Self {
269 Self::default()
270 }
271
272 pub(crate) fn raise(&self) {
273 self.0.store(true, Ordering::SeqCst);
274 }
275
276 pub(crate) fn is_raised(&self) -> bool {
277 self.0.load(Ordering::SeqCst)
278 }
279}
280
281impl DetachPolicy {
282 pub(crate) fn completion_provenance(&self) -> TaskProvenance {
284 TaskProvenance::derive(
285 TaskOrigin::Completion,
286 self.provenance.map(|task| task.trust()),
287 )
288 }
289}
290
291#[derive(Debug, Clone)]
300pub(crate) enum AbandonedDisposition {
301 StillRunning {
305 running_ms: u64,
307 },
308 FinishedTooLate {
312 exit_code: i32,
313 duration_ms: u64,
315 output_path: String,
318 output_bytes: u64,
319 resource_limit: Option<String>,
320 wait_error: Option<String>,
323 status: &'static str,
325 },
326}
327
328#[derive(Debug, Clone)]
330pub(crate) struct AbandonedWork {
331 pub work_id: String,
332 pub binary: String,
334 pub command: String,
337 pub disposition: AbandonedDisposition,
338}
339
340impl AbandonedWork {
341 pub(crate) fn running_ms(&self) -> u64 {
344 match &self.disposition {
345 AbandonedDisposition::StillRunning { running_ms } => *running_ms,
346 AbandonedDisposition::FinishedTooLate { duration_ms, .. } => *duration_ms,
347 }
348 }
349
350 pub(crate) fn recovered(&self) -> Option<(i32, &str, u64)> {
355 match &self.disposition {
356 AbandonedDisposition::StillRunning { .. } => None,
357 AbandonedDisposition::FinishedTooLate {
358 exit_code,
359 output_path,
360 output_bytes,
361 ..
362 } => Some((*exit_code, output_path.as_str(), *output_bytes)),
363 }
364 }
365}
366
367pub(crate) fn abandonment_report_text(session_id: &str, abandoned: &[AbandonedWork]) -> String {
377 let count = abandoned.len();
378 let plural = if count == 1 { "command" } else { "commands" };
379 let mut text = format!(
380 "[capsule-runtime] {count} background shell {plural} discarded at session end.\n\n\
381 Session {session_id} ended while {count} demoted shell {plural} {} unaccounted for. \
382 Every process the session started was killed when it ended; what is known about each \
383 command is below.\n",
384 if count == 1 { "was" } else { "were" }
385 );
386 for work in abandoned {
387 text.push_str(&format!(
388 "\nwork_id: {}\nbinary: {}\ncommand: {}\n",
389 work.work_id, work.binary, work.command
390 ));
391 match &work.disposition {
392 AbandonedDisposition::StillRunning { running_ms } => {
393 text.push_str(&format!(
394 "state: killed at session end after {running_ms} ms\n\
395 result: none — it was killed before it finished, so no task carries an exit \
396 code. Whatever it wrote reaches {} only if the runtime wrote that file \
397 before it exited.\n",
398 output_path_for(&work.work_id)
399 ));
400 }
401 AbandonedDisposition::FinishedTooLate {
402 exit_code,
403 duration_ms,
404 output_path,
405 output_bytes,
406 resource_limit,
407 wait_error,
408 status,
409 } => {
410 text.push_str(&format!(
411 "state: finished during teardown after {duration_ms} ms\n\
412 status: {status}\n\
413 exit_code: {exit_code}\n"
414 ));
415 if let Some(limit) = resource_limit {
416 text.push_str(&format!("resource_limit: {limit}\n"));
417 }
418 if let Some(error) = wait_error {
419 text.push_str(&format!("wait_error: {error}\n"));
420 }
421 text.push_str(&format!(
422 "output: {output_path} ({output_bytes} bytes, in the capsule workdir)\n\
423 result: on disk — it finished too late for any task to carry it back to the \
424 agent.\n"
425 ));
426 }
427 }
428 }
429 text.push_str(
430 "\nA session's end kills every command it demoted and every process its shell calls \
431 started in the background. Declare lifecycle.task_acceptance: queue with \
432 lifecycle.after_task: sleep for a session that outlives its task and can be told the \
433 result instead.",
434 );
435 text
436}
437
438#[derive(Debug, Clone)]
447pub(crate) struct LostWork {
448 pub work_id: String,
449 pub binary: String,
451 pub command: String,
453 pub detached_at_ms: u64,
456}
457
458#[derive(Debug, Clone)]
464pub(crate) struct LostReport {
465 pub started_in_session: String,
468 pub lost: Vec<LostWork>,
470 pub context_id: String,
472 pub provenance: TaskProvenance,
475 pub task_id: String,
478}
479
480impl LostReport {
481 pub(crate) fn message_text(&self) -> String {
490 let mut text = format!(
491 "Background shell commands from an earlier session were never accounted for.\n\
492 \n\
493 Session {} ended without recording what became of the commands below; they were \
494 running in the background at the time. Nothing about them was recovered and nothing \
495 can be: no exit code, no output and no log file exists for any of them, and whether \
496 each command finished at all is unknown.\n",
497 self.started_in_session
498 );
499 for work in &self.lost {
500 text.push_str(&format!(
501 "\nwork_id: {}\nbinary: {}\ncommand: {}\ndetached_at_ms: {}\n",
502 work.work_id, work.binary, work.command, work.detached_at_ms
503 ));
504 }
505 text.push_str(
506 "\nNone of these is known to have succeeded and none is known to have failed. Treat \
507 each as unknown, and start the work again only if it still matters.",
508 );
509 text
510 }
511}
512
513#[derive(Debug, Clone)]
518pub(crate) enum DetachedReport {
519 Completed(DetachedCompletion),
520 Lost(LostReport),
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 use std::{
528 path::Path,
529 time::{Duration, Instant},
530 };
531
532 use crate::{
533 origin::TrustClass,
534 sandbox::ShellEnforcement,
535 shell::{run_shell, ShellOutcome},
536 types::CapabilityPolicy,
537 };
538
539 fn bash_policy() -> CapabilityPolicy {
540 CapabilityPolicy {
541 shell_allow: vec!["bash".to_string()],
542 ..Default::default()
543 }
544 }
545
546 fn detached_run(
549 workdir: &Path,
550 script: &str,
551 grace: Duration,
552 ) -> (
553 ShellOutcome,
554 Arc<DetachedRegistry>,
555 UnboundedReceiver<DetachedReport>,
556 ) {
557 let (registry, receiver) = DetachedRegistry::new();
558 let outcome = run_shell(
559 "bash",
560 &["-c", script],
561 &[],
562 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
563 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
564 &bash_policy(),
565 &ShellEnforcement::environment_only(),
566 Some(DetachPolicy {
567 grace,
568 registry: Arc::clone(®istry),
569 command: script.to_string(),
570 context_id: "ctx_detached_tests".to_string(),
571 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
572 abandoned: None,
573 }),
574 )
575 .expect("a declared binary runs");
576 (outcome, registry, receiver)
577 }
578
579 fn await_completion(receiver: &mut UnboundedReceiver<DetachedReport>) -> DetachedCompletion {
585 let deadline = Instant::now() + Duration::from_secs(30);
586 loop {
587 match receiver.try_recv() {
588 Ok(DetachedReport::Completed(completion)) => return completion,
589 Ok(other) => panic!("a shell command's completion was expected; got {other:?}"),
590 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
591 assert!(
592 Instant::now() < deadline,
593 "timed out waiting for the detached completion"
594 );
595 std::thread::sleep(Duration::from_millis(25));
596 }
597 Err(error) => panic!("the completion channel closed early: {error}"),
598 }
599 }
600 }
601
602 fn completion_provenance() -> TaskProvenance {
603 TaskProvenance::derive(TaskOrigin::Completion, Some(TrustClass::Untrusted))
604 }
605
606 #[test]
609 fn no_two_completions_share_an_opening_line() {
610 let outcome = crate::delegation::DelegationOutcome {
611 delegation_id: "dlg_0001".to_string(),
612 capsule_name: "worker".to_string(),
613 capsule_version: "0.1.0".to_string(),
614 session_id: "ses_child".to_string(),
615 status: crate::delegation::DelegationStatus::Ok,
616 result_path: None,
617 workdir: "/tmp/child".to_string(),
618 duration_ms: 1,
619 detail: None,
620 reported_by: crate::delegation::Reporter::Launcher,
621 delivered: true,
622 delivery_error: None,
623 };
624 let completion = DetachedCompletion {
625 work_id: "wrk_0001".to_string(),
626 binary: "bash".to_string(),
627 command: "sleep 3".to_string(),
628 exit_code: 0,
629 duration_ms: 3_000,
630 output_path: "logs/wrk_0001.log".to_string(),
631 output_bytes: 0,
632 resource_limit: None,
633 context_id: "ctx_1".to_string(),
634 provenance: completion_provenance(),
635 error: None,
636 };
637 let texts = [
638 ("delegation outcome", outcome.message_text()),
639 ("detached shell completion", completion.message_text()),
640 ];
641 for (index, (name, text)) in texts.iter().enumerate() {
642 let opening = text.lines().next().unwrap_or_default();
643 assert!(!opening.is_empty(), "{name} opens with nothing");
644 for (other_name, other) in texts.iter().skip(index + 1) {
645 assert_ne!(
646 opening,
647 other.lines().next().unwrap_or_default(),
648 "{name} and {other_name} open with the same line"
649 );
650 }
651 }
652 }
653
654 #[test]
657 fn a_completion_names_its_limit_in_the_shared_wording() {
658 let completion = DetachedCompletion {
659 work_id: "wrk_0001".to_string(),
660 binary: "bash".to_string(),
661 command: "make -j64".to_string(),
662 exit_code: 1,
663 duration_ms: 3_000,
664 output_path: "logs/wrk_0001.log".to_string(),
665 output_bytes: 0,
666 resource_limit: Some("cgroup_pids_max".into()),
667 context_id: "ctx_1".to_string(),
668 provenance: completion_provenance(),
669 error: None,
670 };
671 let text = completion.message_text();
672 let expected = crate::resources::resource_limit_line("cgroup_pids_max");
673
674 assert!(
675 text.lines().any(|line| line == expected),
676 "no limit line in:\n{text}"
677 );
678 assert_eq!(
679 text.lines().next(),
680 Some("Background shell command finished.")
681 );
682 assert_eq!(completion.status(), "error");
683 assert!(text.ends_with("are not reproduced here."), "{text}");
684 }
685
686 #[test]
689 fn a_command_outrunning_the_grace_period_is_demoted() {
690 let workdir = tempfile::tempdir().unwrap();
691 let started = Instant::now();
692 let (outcome, registry, mut receiver) = detached_run(
695 workdir.path(),
696 "sleep 3; printf 'fin%s' 'ished-marker'",
697 Duration::from_secs(1),
698 );
699
700 let info = match outcome {
701 ShellOutcome::Detached(info) => info,
702 ShellOutcome::Finished(result) => {
703 panic!(
704 "a three-second command must not finish inside a one-second grace: {result:?}"
705 )
706 }
707 };
708 assert!(
709 started.elapsed() < Duration::from_secs(3),
710 "the call returned only after the command did"
711 );
712 assert!(
713 info.work_id.starts_with(WORK_ID_PREFIX)
714 && info.work_id[WORK_ID_PREFIX.len()..]
715 .chars()
716 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
717 "work id {} must match ^wrk_[0-9a-f]+$",
718 info.work_id
719 );
720 assert_eq!(info.grace_ms, 1_000);
721 assert_eq!(
722 registry
723 .outstanding()
724 .iter()
725 .map(|work| work.work_id.clone())
726 .collect::<Vec<_>>(),
727 vec![info.work_id.clone()],
728 "the work is registered before the handle is returned"
729 );
730
731 let completion = await_completion(&mut receiver);
732 assert_eq!(completion.work_id, info.work_id);
733 assert_eq!(completion.exit_code, 0);
734 assert_eq!(completion.status(), "ok");
735 assert_eq!(
736 completion.output_path,
737 format!("logs/{}.log", info.work_id),
738 "the completion names logs/<work_id>.log"
739 );
740 assert_eq!(completion.provenance.origin(), TaskOrigin::Completion);
741 assert_eq!(completion.provenance.trust(), TrustClass::Trusted);
742 assert_eq!(completion.context_id, "ctx_detached_tests");
743 assert!(
744 registry.outstanding().is_empty(),
745 "a completed command is no longer outstanding"
746 );
747
748 let log = std::fs::read_to_string(workdir.path().join(&completion.output_path))
749 .expect("the completion's output file exists");
750 assert!(
751 log.contains("finished-marker"),
752 "the output is in the log: {log}"
753 );
754 assert!(
755 !completion.message_text().contains("finished-marker"),
756 "the output never reaches the agent: {}",
757 completion.message_text()
758 );
759 }
760
761 #[test]
763 fn a_fast_command_finishes_in_the_foreground() {
764 let workdir = tempfile::tempdir().unwrap();
765 let (outcome, registry, mut receiver) =
766 detached_run(workdir.path(), "echo hi", Duration::from_secs(10));
767
768 let result = match outcome {
769 ShellOutcome::Finished(result) => result,
770 ShellOutcome::Detached(info) => {
771 panic!("`echo hi` must not be demoted: {}", info.work_id)
772 }
773 };
774 assert_eq!(result.stdout.trim(), "hi");
775 assert_eq!(result.exit_code, 0);
776 assert!(!result.truncated);
777 assert!(result.full_output_path.is_none());
778 assert!(registry.outstanding().is_empty());
779 assert!(
780 matches!(
781 receiver.try_recv(),
782 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
783 ),
784 "a foreground command produces no completion"
785 );
786 }
787
788 #[test]
790 fn a_failing_detached_command_completes_with_its_exit_code() {
791 let workdir = tempfile::tempdir().unwrap();
792 let (outcome, _registry, mut receiver) = detached_run(
793 workdir.path(),
794 "sleep 3; echo to-stderr >&2; exit 7",
795 Duration::from_secs(1),
796 );
797 assert!(matches!(outcome, ShellOutcome::Detached(_)));
798
799 let completion = await_completion(&mut receiver);
800 assert_eq!(completion.exit_code, 7);
801 assert_eq!(completion.status(), "error");
802 assert!(
803 completion.message_text().contains("exit_code: 7"),
804 "the message names the exit code: {}",
805 completion.message_text()
806 );
807
808 let log = std::fs::read_to_string(workdir.path().join(&completion.output_path)).unwrap();
809 let stderr_section = log
810 .split("Stderr:")
811 .nth(1)
812 .expect("the log has a stderr section");
813 assert!(
814 stderr_section.contains("to-stderr"),
815 "stderr is in the log's Stderr section: {log}"
816 );
817
818 let (outcome, _registry, mut receiver) =
819 detached_run(workdir.path(), "sleep 3; exit 0", Duration::from_secs(1));
820 assert!(matches!(outcome, ShellOutcome::Detached(_)));
821 assert_eq!(await_completion(&mut receiver).status(), "ok");
822 }
823
824 #[test]
827 fn the_demotion_result_is_a_constant_size() {
828 let reduce = |work_id: &str| {
829 let result = demotion_tool_result(work_id);
830 result
831 .data
832 .or(result.summary)
833 .unwrap_or_else(|| "tool returned no data".to_string())
834 };
835 let first = reduce(&new_work_id());
836 let second = reduce(&new_work_id());
837
838 assert_eq!(
839 first.len(),
840 second.len(),
841 "two work ids of equal length must produce texts of equal size"
842 );
843 for text in [&first, &second] {
847 let tokens = crate::agent::count_tokens(text);
848 assert!(
849 tokens < 120,
850 "the demotion result costs {tokens} tokens, over the 120-token ceiling"
851 );
852 }
853 assert!(
854 !first.contains("logs/"),
855 "the demotion result names no path: {first}"
856 );
857 }
858
859 #[test]
865 fn no_surface_takes_a_work_id() {
866 const RULE: &str = "a demoted command is learned about through its completion and no \
867 other way: nothing the model or a host embedder can reach may take a \
868 work id as an argument";
869
870 let work_id = concat!("work", "_id");
873 let parameter = format!("{work_id}:");
874 let json_key = format!(".get(\"{work_id}\")");
875 let schema = concat!("input", "_schema");
876
877 let sources = crate::source_scan::crate_sources();
878 let mut offences = Vec::new();
879 for (path, source) in &sources {
880 for (number, line) in source.lines().enumerate() {
881 let line = line.trim();
882 let at = format!("{path}:{}", number + 1);
883
884 if (line.starts_with("pub fn ") || line.starts_with("pub async fn "))
887 && line.contains(¶meter)
888 {
889 offences.push(format!("{at}: public function takes a work id — {line}"));
890 }
891 if line.contains(&json_key) {
893 offences.push(format!("{at}: tool input is parsed for a work id — {line}"));
894 }
895 if line.contains(schema) && line.contains(work_id) {
897 offences.push(format!("{at}: a tool schema declares a work id — {line}"));
898 }
899 }
900 }
901
902 let inventory = sources
903 .iter()
904 .find(|(path, _)| path.ends_with("inventory.rs"))
905 .map(|(_, source)| source)
906 .expect("agent/inventory.rs is part of this crate");
907 if inventory.contains(work_id) || inventory.contains(WORK_ID_PREFIX) {
908 offences.push("agent/inventory.rs: the tool inventory mentions a work id".to_string());
909 }
910
911 assert!(offences.is_empty(), "{RULE}\n{}", offences.join("\n"));
912 }
913
914 fn still_running(work_id: &str, running_ms: u64) -> AbandonedWork {
917 AbandonedWork {
918 work_id: work_id.to_string(),
919 binary: "/usr/bin/bash".to_string(),
920 command: "sleep 45; echo done".to_string(),
921 disposition: AbandonedDisposition::StillRunning { running_ms },
922 }
923 }
924
925 fn finished_too_late(work_id: &str) -> AbandonedWork {
926 AbandonedWork {
927 work_id: work_id.to_string(),
928 binary: "/usr/bin/bash".to_string(),
929 command: "sleep 2; echo done".to_string(),
930 disposition: AbandonedDisposition::FinishedTooLate {
931 exit_code: 0,
932 duration_ms: 2_014,
933 output_path: output_path_for(work_id),
934 output_bytes: 91,
935 resource_limit: None,
936 wait_error: None,
937 status: "ok",
938 },
939 }
940 }
941
942 #[test]
947 fn a_still_running_command_is_reported_as_killed_with_what_is_known() {
948 let text = abandonment_report_text("ses_abc", &[still_running("wrk_0a1b", 45_012)]);
949
950 assert!(
951 text.contains("1 background shell command discarded"),
952 "{text}"
953 );
954 assert!(text.contains("Session ses_abc"), "{text}");
955 assert!(text.contains("work_id: wrk_0a1b"), "{text}");
956 assert!(text.contains("binary: /usr/bin/bash"), "{text}");
957 assert!(text.contains("command: sleep 45; echo done"), "{text}");
958 assert!(
959 text.contains("state: killed at session end after 45012 ms"),
960 "{text}"
961 );
962 assert!(text.contains("reaches logs/wrk_0a1b.log only if"), "{text}");
963 assert!(
964 !text.contains("will be written"),
965 "the file is neither promised nor denied: {text}"
966 );
967 assert!(!text.contains("exit_code:"), "no exit code exists: {text}");
968 for retired in [
969 "nothing was killed",
970 "Nothing was waited for",
971 "keeps running",
972 ] {
973 assert!(
974 !text.contains(retired),
975 "the session's end kills what it started, so the report must not say {retired:?}: \
976 {text}"
977 );
978 }
979 }
980
981 #[test]
985 fn a_command_that_finished_during_teardown_is_reported_with_its_result() {
986 let text = abandonment_report_text("ses_abc", &[finished_too_late("wrk_0c2d")]);
987
988 assert!(
989 text.contains("state: finished during teardown after 2014 ms"),
990 "{text}"
991 );
992 assert!(text.contains("status: ok"), "{text}");
993 assert!(text.contains("exit_code: 0"), "{text}");
994 assert!(
995 text.contains("output: logs/wrk_0c2d.log (91 bytes, in the capsule workdir)"),
996 "{text}"
997 );
998 assert!(!text.contains("killed at session end"), "{text}");
999 }
1000
1001 #[test]
1004 fn the_report_states_the_condition_once_and_enumerates_every_command() {
1005 let text = abandonment_report_text(
1006 "ses_abc",
1007 &[still_running("wrk_0a1b", 12), finished_too_late("wrk_0c2d")],
1008 );
1009
1010 assert_eq!(
1011 text.matches("2 background shell commands discarded")
1012 .count(),
1013 1
1014 );
1015 assert_eq!(text.matches("work_id: ").count(), 2);
1016 assert!(text.contains("wrk_0a1b"), "{text}");
1017 assert!(text.contains("wrk_0c2d"), "{text}");
1018 }
1019
1020 #[test]
1023 fn an_attributed_resource_limit_is_named_in_the_report() {
1024 let mut work = finished_too_late("wrk_0c2d");
1025 if let AbandonedDisposition::FinishedTooLate {
1026 resource_limit,
1027 exit_code,
1028 status,
1029 ..
1030 } = &mut work.disposition
1031 {
1032 *resource_limit = Some("memory".to_string());
1033 *exit_code = 137;
1034 *status = "error";
1035 }
1036 let text = abandonment_report_text("ses_abc", &[work]);
1037
1038 assert!(text.contains("resource_limit: memory"), "{text}");
1039 assert!(text.contains("status: error"), "{text}");
1040 assert!(text.contains("exit_code: 137"), "{text}");
1041 }
1042
1043 #[test]
1046 fn only_a_command_that_finished_carries_a_recovered_result() {
1047 assert_eq!(still_running("wrk_0a1b", 45_012).running_ms(), 45_012);
1048 assert!(still_running("wrk_0a1b", 45_012).recovered().is_none());
1049
1050 let finished = finished_too_late("wrk_0c2d");
1051 assert_eq!(finished.running_ms(), 2_014);
1052 assert_eq!(
1053 finished.recovered(),
1054 Some((0, "logs/wrk_0c2d.log", 91)),
1055 "the exit code, the output path and its byte count travel together"
1056 );
1057 }
1058
1059 #[test]
1063 fn a_failed_wait_is_named_beside_the_status_it_explains() {
1064 let mut work = finished_too_late("wrk_0c2d");
1065 if let AbandonedDisposition::FinishedTooLate {
1066 wait_error, status, ..
1067 } = &mut work.disposition
1068 {
1069 *wait_error = Some("no child processes".to_string());
1070 *status = "error";
1071 }
1072 let text = abandonment_report_text("ses_abc", &[work]);
1073
1074 assert!(text.contains("status: error"), "{text}");
1075 assert!(text.contains("wait_error: no child processes"), "{text}");
1076 }
1077
1078 #[test]
1081 fn the_report_does_not_claim_nothing_is_recoverable() {
1082 let text = abandonment_report_text("ses_abc", &[still_running("wrk_0a1b", 12)]);
1083 assert!(
1084 !text.contains("Nothing about them was recovered"),
1085 "the clean-exit report is not the crash report: {text}"
1086 );
1087 }
1088}