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 Nothing was waited for and nothing was killed; what is known about each is below.\n",
383 if count == 1 { "was" } else { "were" }
384 );
385 for work in abandoned {
386 text.push_str(&format!(
387 "\nwork_id: {}\nbinary: {}\ncommand: {}\n",
388 work.work_id, work.binary, work.command
389 ));
390 match &work.disposition {
391 AbandonedDisposition::StillRunning { running_ms } => {
392 text.push_str(&format!(
393 "state: still running after {running_ms} ms\n\
394 result: none — no exit code, and no {} will be written, because the thread \
395 that writes it ended with this session.\n",
396 output_path_for(&work.work_id)
397 ));
398 }
399 AbandonedDisposition::FinishedTooLate {
400 exit_code,
401 duration_ms,
402 output_path,
403 output_bytes,
404 resource_limit,
405 wait_error,
406 status,
407 } => {
408 text.push_str(&format!(
409 "state: finished during teardown after {duration_ms} ms\n\
410 status: {status}\n\
411 exit_code: {exit_code}\n"
412 ));
413 if let Some(limit) = resource_limit {
414 text.push_str(&format!("resource_limit: {limit}\n"));
415 }
416 if let Some(error) = wait_error {
417 text.push_str(&format!("wait_error: {error}\n"));
418 }
419 text.push_str(&format!(
420 "output: {output_path} ({output_bytes} bytes, in the capsule workdir)\n\
421 result: on disk — it finished too late for any task to carry it back to the \
422 agent.\n"
423 ));
424 }
425 }
426 }
427 text.push_str(
428 "\nA command still running keeps running, detached from this session, with nothing \
429 reading its output. Declare lifecycle.task_acceptance: queue with lifecycle.after_task: \
430 sleep for a session that outlives its task and can be told the result instead.",
431 );
432 text
433}
434
435#[derive(Debug, Clone)]
444pub(crate) struct LostWork {
445 pub work_id: String,
446 pub binary: String,
448 pub command: String,
450 pub detached_at_ms: u64,
453}
454
455#[derive(Debug, Clone)]
461pub(crate) struct LostReport {
462 pub started_in_session: String,
465 pub lost: Vec<LostWork>,
467 pub context_id: String,
469 pub provenance: TaskProvenance,
472 pub task_id: String,
475}
476
477impl LostReport {
478 pub(crate) fn message_text(&self) -> String {
487 let mut text = format!(
488 "Background shell commands from an earlier session were never accounted for.\n\
489 \n\
490 Session {} ended without recording what became of the commands below; they were \
491 running in the background at the time. Nothing about them was recovered and nothing \
492 can be: no exit code, no output and no log file exists for any of them, and whether \
493 each command finished at all is unknown.\n",
494 self.started_in_session
495 );
496 for work in &self.lost {
497 text.push_str(&format!(
498 "\nwork_id: {}\nbinary: {}\ncommand: {}\ndetached_at_ms: {}\n",
499 work.work_id, work.binary, work.command, work.detached_at_ms
500 ));
501 }
502 text.push_str(
503 "\nNone of these is known to have succeeded and none is known to have failed. Treat \
504 each as unknown, and start the work again only if it still matters.",
505 );
506 text
507 }
508}
509
510#[derive(Debug, Clone)]
515pub(crate) enum DetachedReport {
516 Completed(DetachedCompletion),
517 Lost(LostReport),
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 use std::{
525 path::Path,
526 time::{Duration, Instant},
527 };
528
529 use crate::{
530 origin::TrustClass,
531 sandbox::ShellEnforcement,
532 shell::{run_shell, ShellOutcome},
533 types::CapabilityPolicy,
534 };
535
536 fn bash_policy() -> CapabilityPolicy {
537 CapabilityPolicy {
538 shell_allow: vec!["bash".to_string()],
539 ..Default::default()
540 }
541 }
542
543 fn detached_run(
546 workdir: &Path,
547 script: &str,
548 grace: Duration,
549 ) -> (
550 ShellOutcome,
551 Arc<DetachedRegistry>,
552 UnboundedReceiver<DetachedReport>,
553 ) {
554 let (registry, receiver) = DetachedRegistry::new();
555 let outcome = run_shell(
556 "bash",
557 &["-c", script],
558 &[],
559 workdir,
560 workdir,
561 &bash_policy(),
562 &ShellEnforcement::environment_only(),
563 Some(DetachPolicy {
564 grace,
565 registry: Arc::clone(®istry),
566 command: script.to_string(),
567 context_id: "ctx_detached_tests".to_string(),
568 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
569 abandoned: None,
570 }),
571 )
572 .expect("a declared binary runs");
573 (outcome, registry, receiver)
574 }
575
576 fn await_completion(receiver: &mut UnboundedReceiver<DetachedReport>) -> DetachedCompletion {
582 let deadline = Instant::now() + Duration::from_secs(30);
583 loop {
584 match receiver.try_recv() {
585 Ok(DetachedReport::Completed(completion)) => return completion,
586 Ok(other) => panic!("a shell command's completion was expected; got {other:?}"),
587 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
588 assert!(
589 Instant::now() < deadline,
590 "timed out waiting for the detached completion"
591 );
592 std::thread::sleep(Duration::from_millis(25));
593 }
594 Err(error) => panic!("the completion channel closed early: {error}"),
595 }
596 }
597 }
598
599 fn completion_provenance() -> TaskProvenance {
600 TaskProvenance::derive(TaskOrigin::Completion, Some(TrustClass::Untrusted))
601 }
602
603 #[test]
606 fn no_two_completions_share_an_opening_line() {
607 let outcome = crate::delegation::DelegationOutcome {
608 delegation_id: "dlg_0001".to_string(),
609 capsule_name: "worker".to_string(),
610 capsule_version: "0.1.0".to_string(),
611 session_id: "ses_child".to_string(),
612 status: crate::delegation::DelegationStatus::Ok,
613 result_path: None,
614 workdir: "/tmp/child".to_string(),
615 duration_ms: 1,
616 detail: None,
617 reported_by: crate::delegation::Reporter::Launcher,
618 delivered: true,
619 delivery_error: None,
620 };
621 let completion = DetachedCompletion {
622 work_id: "wrk_0001".to_string(),
623 binary: "bash".to_string(),
624 command: "sleep 3".to_string(),
625 exit_code: 0,
626 duration_ms: 3_000,
627 output_path: "logs/wrk_0001.log".to_string(),
628 output_bytes: 0,
629 resource_limit: None,
630 context_id: "ctx_1".to_string(),
631 provenance: completion_provenance(),
632 error: None,
633 };
634 let texts = [
635 ("delegation outcome", outcome.message_text()),
636 ("detached shell completion", completion.message_text()),
637 ];
638 for (index, (name, text)) in texts.iter().enumerate() {
639 let opening = text.lines().next().unwrap_or_default();
640 assert!(!opening.is_empty(), "{name} opens with nothing");
641 for (other_name, other) in texts.iter().skip(index + 1) {
642 assert_ne!(
643 opening,
644 other.lines().next().unwrap_or_default(),
645 "{name} and {other_name} open with the same line"
646 );
647 }
648 }
649 }
650
651 #[test]
654 fn a_completion_names_its_limit_in_the_shared_wording() {
655 let completion = DetachedCompletion {
656 work_id: "wrk_0001".to_string(),
657 binary: "bash".to_string(),
658 command: "make -j64".to_string(),
659 exit_code: 1,
660 duration_ms: 3_000,
661 output_path: "logs/wrk_0001.log".to_string(),
662 output_bytes: 0,
663 resource_limit: Some("cgroup_pids_max".into()),
664 context_id: "ctx_1".to_string(),
665 provenance: completion_provenance(),
666 error: None,
667 };
668 let text = completion.message_text();
669 let expected = crate::resources::resource_limit_line("cgroup_pids_max");
670
671 assert!(
672 text.lines().any(|line| line == expected),
673 "no limit line in:\n{text}"
674 );
675 assert_eq!(
676 text.lines().next(),
677 Some("Background shell command finished.")
678 );
679 assert_eq!(completion.status(), "error");
680 assert!(text.ends_with("are not reproduced here."), "{text}");
681 }
682
683 #[test]
686 fn a_command_outrunning_the_grace_period_is_demoted() {
687 let workdir = tempfile::tempdir().unwrap();
688 let started = Instant::now();
689 let (outcome, registry, mut receiver) = detached_run(
692 workdir.path(),
693 "sleep 3; printf 'fin%s' 'ished-marker'",
694 Duration::from_secs(1),
695 );
696
697 let info = match outcome {
698 ShellOutcome::Detached(info) => info,
699 ShellOutcome::Finished(result) => {
700 panic!(
701 "a three-second command must not finish inside a one-second grace: {result:?}"
702 )
703 }
704 };
705 assert!(
706 started.elapsed() < Duration::from_secs(3),
707 "the call returned only after the command did"
708 );
709 assert!(
710 info.work_id.starts_with(WORK_ID_PREFIX)
711 && info.work_id[WORK_ID_PREFIX.len()..]
712 .chars()
713 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
714 "work id {} must match ^wrk_[0-9a-f]+$",
715 info.work_id
716 );
717 assert_eq!(info.grace_ms, 1_000);
718 assert_eq!(
719 registry
720 .outstanding()
721 .iter()
722 .map(|work| work.work_id.clone())
723 .collect::<Vec<_>>(),
724 vec![info.work_id.clone()],
725 "the work is registered before the handle is returned"
726 );
727
728 let completion = await_completion(&mut receiver);
729 assert_eq!(completion.work_id, info.work_id);
730 assert_eq!(completion.exit_code, 0);
731 assert_eq!(completion.status(), "ok");
732 assert_eq!(
733 completion.output_path,
734 format!("logs/{}.log", info.work_id),
735 "the completion names logs/<work_id>.log"
736 );
737 assert_eq!(completion.provenance.origin(), TaskOrigin::Completion);
738 assert_eq!(completion.provenance.trust(), TrustClass::Trusted);
739 assert_eq!(completion.context_id, "ctx_detached_tests");
740 assert!(
741 registry.outstanding().is_empty(),
742 "a completed command is no longer outstanding"
743 );
744
745 let log = std::fs::read_to_string(workdir.path().join(&completion.output_path))
746 .expect("the completion's output file exists");
747 assert!(
748 log.contains("finished-marker"),
749 "the output is in the log: {log}"
750 );
751 assert!(
752 !completion.message_text().contains("finished-marker"),
753 "the output never reaches the agent: {}",
754 completion.message_text()
755 );
756 }
757
758 #[test]
760 fn a_fast_command_finishes_in_the_foreground() {
761 let workdir = tempfile::tempdir().unwrap();
762 let (outcome, registry, mut receiver) =
763 detached_run(workdir.path(), "echo hi", Duration::from_secs(10));
764
765 let result = match outcome {
766 ShellOutcome::Finished(result) => result,
767 ShellOutcome::Detached(info) => {
768 panic!("`echo hi` must not be demoted: {}", info.work_id)
769 }
770 };
771 assert_eq!(result.stdout.trim(), "hi");
772 assert_eq!(result.exit_code, 0);
773 assert!(!result.truncated);
774 assert!(result.full_output_path.is_none());
775 assert!(registry.outstanding().is_empty());
776 assert!(
777 matches!(
778 receiver.try_recv(),
779 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
780 ),
781 "a foreground command produces no completion"
782 );
783 }
784
785 #[test]
787 fn a_failing_detached_command_completes_with_its_exit_code() {
788 let workdir = tempfile::tempdir().unwrap();
789 let (outcome, _registry, mut receiver) = detached_run(
790 workdir.path(),
791 "sleep 3; echo to-stderr >&2; exit 7",
792 Duration::from_secs(1),
793 );
794 assert!(matches!(outcome, ShellOutcome::Detached(_)));
795
796 let completion = await_completion(&mut receiver);
797 assert_eq!(completion.exit_code, 7);
798 assert_eq!(completion.status(), "error");
799 assert!(
800 completion.message_text().contains("exit_code: 7"),
801 "the message names the exit code: {}",
802 completion.message_text()
803 );
804
805 let log = std::fs::read_to_string(workdir.path().join(&completion.output_path)).unwrap();
806 let stderr_section = log
807 .split("Stderr:")
808 .nth(1)
809 .expect("the log has a stderr section");
810 assert!(
811 stderr_section.contains("to-stderr"),
812 "stderr is in the log's Stderr section: {log}"
813 );
814
815 let (outcome, _registry, mut receiver) =
816 detached_run(workdir.path(), "sleep 3; exit 0", Duration::from_secs(1));
817 assert!(matches!(outcome, ShellOutcome::Detached(_)));
818 assert_eq!(await_completion(&mut receiver).status(), "ok");
819 }
820
821 #[test]
824 fn the_demotion_result_is_a_constant_size() {
825 let reduce = |work_id: &str| {
826 let result = demotion_tool_result(work_id);
827 result
828 .data
829 .or(result.summary)
830 .unwrap_or_else(|| "tool returned no data".to_string())
831 };
832 let first = reduce(&new_work_id());
833 let second = reduce(&new_work_id());
834
835 assert_eq!(
836 first.len(),
837 second.len(),
838 "two work ids of equal length must produce texts of equal size"
839 );
840 for text in [&first, &second] {
844 let tokens = crate::agent::count_tokens(text);
845 assert!(
846 tokens < 120,
847 "the demotion result costs {tokens} tokens, over the 120-token ceiling"
848 );
849 }
850 assert!(
851 !first.contains("logs/"),
852 "the demotion result names no path: {first}"
853 );
854 }
855
856 #[test]
862 fn no_surface_takes_a_work_id() {
863 const RULE: &str = "a demoted command is learned about through its completion and no \
864 other way: nothing the model or a host embedder can reach may take a \
865 work id as an argument";
866
867 let work_id = concat!("work", "_id");
870 let parameter = format!("{work_id}:");
871 let json_key = format!(".get(\"{work_id}\")");
872 let schema = concat!("input", "_schema");
873
874 let sources = crate::source_scan::crate_sources();
875 let mut offences = Vec::new();
876 for (path, source) in &sources {
877 for (number, line) in source.lines().enumerate() {
878 let line = line.trim();
879 let at = format!("{path}:{}", number + 1);
880
881 if (line.starts_with("pub fn ") || line.starts_with("pub async fn "))
884 && line.contains(¶meter)
885 {
886 offences.push(format!("{at}: public function takes a work id — {line}"));
887 }
888 if line.contains(&json_key) {
890 offences.push(format!("{at}: tool input is parsed for a work id — {line}"));
891 }
892 if line.contains(schema) && line.contains(work_id) {
894 offences.push(format!("{at}: a tool schema declares a work id — {line}"));
895 }
896 }
897 }
898
899 let inventory = sources
900 .iter()
901 .find(|(path, _)| path.ends_with("inventory.rs"))
902 .map(|(_, source)| source)
903 .expect("agent/inventory.rs is part of this crate");
904 if inventory.contains(work_id) || inventory.contains(WORK_ID_PREFIX) {
905 offences.push("agent/inventory.rs: the tool inventory mentions a work id".to_string());
906 }
907
908 assert!(offences.is_empty(), "{RULE}\n{}", offences.join("\n"));
909 }
910
911 fn still_running(work_id: &str, running_ms: u64) -> AbandonedWork {
914 AbandonedWork {
915 work_id: work_id.to_string(),
916 binary: "/usr/bin/bash".to_string(),
917 command: "sleep 45; echo done".to_string(),
918 disposition: AbandonedDisposition::StillRunning { running_ms },
919 }
920 }
921
922 fn finished_too_late(work_id: &str) -> AbandonedWork {
923 AbandonedWork {
924 work_id: work_id.to_string(),
925 binary: "/usr/bin/bash".to_string(),
926 command: "sleep 2; echo done".to_string(),
927 disposition: AbandonedDisposition::FinishedTooLate {
928 exit_code: 0,
929 duration_ms: 2_014,
930 output_path: output_path_for(work_id),
931 output_bytes: 91,
932 resource_limit: None,
933 wait_error: None,
934 status: "ok",
935 },
936 }
937 }
938
939 #[test]
944 fn a_still_running_command_is_reported_with_what_is_known_and_promises_no_file() {
945 let text = abandonment_report_text("ses_abc", &[still_running("wrk_0a1b", 45_012)]);
946
947 assert!(
948 text.contains("1 background shell command discarded"),
949 "{text}"
950 );
951 assert!(text.contains("Session ses_abc"), "{text}");
952 assert!(text.contains("work_id: wrk_0a1b"), "{text}");
953 assert!(text.contains("binary: /usr/bin/bash"), "{text}");
954 assert!(text.contains("command: sleep 45; echo done"), "{text}");
955 assert!(
956 text.contains("state: still running after 45012 ms"),
957 "{text}"
958 );
959 assert!(
960 text.contains("no logs/wrk_0a1b.log will be written"),
961 "{text}"
962 );
963 assert!(!text.contains("exit_code:"), "no exit code exists: {text}");
964 }
965
966 #[test]
970 fn a_command_that_finished_during_teardown_is_reported_with_its_result() {
971 let text = abandonment_report_text("ses_abc", &[finished_too_late("wrk_0c2d")]);
972
973 assert!(
974 text.contains("state: finished during teardown after 2014 ms"),
975 "{text}"
976 );
977 assert!(text.contains("status: ok"), "{text}");
978 assert!(text.contains("exit_code: 0"), "{text}");
979 assert!(
980 text.contains("output: logs/wrk_0c2d.log (91 bytes, in the capsule workdir)"),
981 "{text}"
982 );
983 assert!(!text.contains("still running after"), "{text}");
984 }
985
986 #[test]
989 fn the_report_states_the_condition_once_and_enumerates_every_command() {
990 let text = abandonment_report_text(
991 "ses_abc",
992 &[still_running("wrk_0a1b", 12), finished_too_late("wrk_0c2d")],
993 );
994
995 assert_eq!(
996 text.matches("2 background shell commands discarded")
997 .count(),
998 1
999 );
1000 assert_eq!(text.matches("work_id: ").count(), 2);
1001 assert!(text.contains("wrk_0a1b"), "{text}");
1002 assert!(text.contains("wrk_0c2d"), "{text}");
1003 }
1004
1005 #[test]
1008 fn an_attributed_resource_limit_is_named_in_the_report() {
1009 let mut work = finished_too_late("wrk_0c2d");
1010 if let AbandonedDisposition::FinishedTooLate {
1011 resource_limit,
1012 exit_code,
1013 status,
1014 ..
1015 } = &mut work.disposition
1016 {
1017 *resource_limit = Some("memory".to_string());
1018 *exit_code = 137;
1019 *status = "error";
1020 }
1021 let text = abandonment_report_text("ses_abc", &[work]);
1022
1023 assert!(text.contains("resource_limit: memory"), "{text}");
1024 assert!(text.contains("status: error"), "{text}");
1025 assert!(text.contains("exit_code: 137"), "{text}");
1026 }
1027
1028 #[test]
1031 fn only_a_command_that_finished_carries_a_recovered_result() {
1032 assert_eq!(still_running("wrk_0a1b", 45_012).running_ms(), 45_012);
1033 assert!(still_running("wrk_0a1b", 45_012).recovered().is_none());
1034
1035 let finished = finished_too_late("wrk_0c2d");
1036 assert_eq!(finished.running_ms(), 2_014);
1037 assert_eq!(
1038 finished.recovered(),
1039 Some((0, "logs/wrk_0c2d.log", 91)),
1040 "the exit code, the output path and its byte count travel together"
1041 );
1042 }
1043
1044 #[test]
1048 fn a_failed_wait_is_named_beside_the_status_it_explains() {
1049 let mut work = finished_too_late("wrk_0c2d");
1050 if let AbandonedDisposition::FinishedTooLate {
1051 wait_error, status, ..
1052 } = &mut work.disposition
1053 {
1054 *wait_error = Some("no child processes".to_string());
1055 *status = "error";
1056 }
1057 let text = abandonment_report_text("ses_abc", &[work]);
1058
1059 assert!(text.contains("status: error"), "{text}");
1060 assert!(text.contains("wait_error: no child processes"), "{text}");
1061 }
1062
1063 #[test]
1066 fn the_report_does_not_claim_nothing_is_recoverable() {
1067 let text = abandonment_report_text("ses_abc", &[still_running("wrk_0a1b", 12)]);
1068 assert!(
1069 !text.contains("Nothing about them was recovered"),
1070 "the clean-exit report is not the crash report: {text}"
1071 );
1072 }
1073}