1use std::{
15 collections::BTreeMap,
16 sync::{Arc, Mutex},
17 time::Duration,
18};
19
20use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
21
22use crate::{
23 bindings::host::murmur::tool::run::{Status, ToolResult},
24 origin::{TaskOrigin, TaskProvenance},
25};
26
27pub const WORK_ID_PREFIX: &str = "wrk_";
30
31pub fn new_work_id() -> String {
36 format!("{WORK_ID_PREFIX}{}", uuid::Uuid::now_v7().simple())
37}
38
39pub(crate) fn output_path_for(work_id: &str) -> String {
43 format!("logs/{work_id}.log")
44}
45
46#[derive(Debug, Clone)]
48pub(crate) struct DetachedWork {
49 pub work_id: String,
50 pub binary: String,
52 pub command: String,
54 pub started_at_ms: u64,
57}
58
59#[derive(Debug, Clone)]
64pub(crate) struct DetachedCompletion {
65 pub work_id: String,
66 pub binary: String,
67 pub command: String,
68 pub exit_code: i32,
69 pub duration_ms: u64,
70 pub output_path: String,
73 pub output_bytes: u64,
75 pub resource_limit: Option<String>,
76 pub context_id: String,
79 pub provenance: TaskProvenance,
81 pub error: Option<String>,
83}
84
85impl DetachedCompletion {
86 pub(crate) fn message_text(&self) -> String {
89 let mut text = format!(
90 "Background shell command finished.\n\
91 work_id: {}\n\
92 binary: {}\n\
93 command: {}\n\
94 status: {}\n\
95 exit_code: {}\n\
96 duration_ms: {}\n\
97 output: {} ({} bytes, in the capsule workdir)",
98 self.work_id,
99 self.binary,
100 self.command,
101 self.status(),
102 self.exit_code,
103 self.duration_ms,
104 self.output_path,
105 self.output_bytes,
106 );
107 if let Some(limit) = &self.resource_limit {
108 text.push_str(&format!("\nresource_limit: {limit}"));
109 }
110 if let Some(error) = &self.error {
111 text.push_str(&format!("\nwait_error: {error}"));
112 }
113 text.push_str(
114 "\n\nThe full stdout and stderr are in that file and are not reproduced here.",
115 );
116 text
117 }
118
119 pub(crate) fn status(&self) -> &'static str {
125 if self.exit_code == 0 && self.error.is_none() && self.resource_limit.is_none() {
126 "ok"
127 } else {
128 "error"
129 }
130 }
131}
132
133#[derive(Debug, Clone)]
135pub(crate) struct DetachedDispatchInfo {
136 pub work_id: String,
137 pub binary: String,
138 pub command: String,
139 pub grace_ms: u64,
143}
144
145pub(crate) fn demotion_tool_result(work_id: &str) -> ToolResult {
152 ToolResult {
153 status: Status::Passed,
154 summary: Some(format!(
155 "Command still running in the background as {work_id}."
156 )),
157 data: Some(format!(
158 "The command did not finish in time and is now running in the background as {work_id}. \
159 Its result will arrive later as a separate task; there is nothing to poll and no way \
160 to ask about it. Continue with other work."
161 )),
162 data_path: None,
163 truncated: false,
164 metadata: vec![("work_id".to_string(), work_id.to_string())],
165 }
166}
167
168pub(crate) struct DetachedRegistry {
173 outstanding: Mutex<BTreeMap<String, DetachedWork>>,
174 reports: UnboundedSender<DetachedReport>,
178}
179
180impl DetachedRegistry {
181 pub(crate) fn new() -> (Arc<Self>, UnboundedReceiver<DetachedReport>) {
182 let (reports, receiver) = unbounded_channel();
183 (
184 Arc::new(Self {
185 outstanding: Mutex::new(BTreeMap::new()),
186 reports,
187 }),
188 receiver,
189 )
190 }
191
192 pub(crate) fn report(&self, report: DetachedReport) {
194 let _ = self.reports.send(report);
195 }
196
197 pub(crate) fn register(&self, work: DetachedWork) {
200 self.outstanding
201 .lock()
202 .expect("detached registry mutex")
203 .insert(work.work_id.clone(), work);
204 }
205
206 pub(crate) fn complete(&self, completion: DetachedCompletion) {
212 self.outstanding
213 .lock()
214 .expect("detached registry mutex")
215 .remove(&completion.work_id);
216 self.report(DetachedReport::Completed(completion));
217 }
218
219 pub(crate) fn outstanding(&self) -> Vec<DetachedWork> {
221 self.outstanding
222 .lock()
223 .expect("detached registry mutex")
224 .values()
225 .cloned()
226 .collect()
227 }
228}
229
230pub(crate) struct DetachPolicy {
236 pub grace: Duration,
237 pub registry: Arc<DetachedRegistry>,
238 pub command: String,
242 pub context_id: String,
243 pub provenance: Option<TaskProvenance>,
247}
248
249impl DetachPolicy {
250 pub(crate) fn completion_provenance(&self) -> TaskProvenance {
252 TaskProvenance::derive(
253 TaskOrigin::Completion,
254 self.provenance.map(|task| task.trust()),
255 )
256 }
257}
258
259#[derive(Debug, Clone)]
268pub(crate) enum AbandonedDisposition {
269 StillRunning {
273 running_ms: u64,
275 },
276 FinishedTooLate {
280 exit_code: i32,
281 duration_ms: u64,
283 output_path: String,
286 output_bytes: u64,
287 resource_limit: Option<String>,
288 wait_error: Option<String>,
291 status: &'static str,
293 },
294}
295
296#[derive(Debug, Clone)]
298pub(crate) struct AbandonedWork {
299 pub work_id: String,
300 pub binary: String,
302 pub command: String,
305 pub disposition: AbandonedDisposition,
306}
307
308impl AbandonedWork {
309 pub(crate) fn running_ms(&self) -> u64 {
312 match &self.disposition {
313 AbandonedDisposition::StillRunning { running_ms } => *running_ms,
314 AbandonedDisposition::FinishedTooLate { duration_ms, .. } => *duration_ms,
315 }
316 }
317
318 pub(crate) fn recovered(&self) -> Option<(i32, &str, u64)> {
323 match &self.disposition {
324 AbandonedDisposition::StillRunning { .. } => None,
325 AbandonedDisposition::FinishedTooLate {
326 exit_code,
327 output_path,
328 output_bytes,
329 ..
330 } => Some((*exit_code, output_path.as_str(), *output_bytes)),
331 }
332 }
333}
334
335pub(crate) fn abandonment_report_text(session_id: &str, abandoned: &[AbandonedWork]) -> String {
345 let count = abandoned.len();
346 let plural = if count == 1 { "command" } else { "commands" };
347 let mut text = format!(
348 "[capsule-runtime] {count} background shell {plural} discarded at session end.\n\n\
349 Session {session_id} ended while {count} demoted shell {plural} {} unaccounted for. \
350 Nothing was waited for and nothing was killed; what is known about each is below.\n",
351 if count == 1 { "was" } else { "were" }
352 );
353 for work in abandoned {
354 text.push_str(&format!(
355 "\nwork_id: {}\nbinary: {}\ncommand: {}\n",
356 work.work_id, work.binary, work.command
357 ));
358 match &work.disposition {
359 AbandonedDisposition::StillRunning { running_ms } => {
360 text.push_str(&format!(
361 "state: still running after {running_ms} ms\n\
362 result: none — no exit code, and no {} will be written, because the thread \
363 that writes it ended with this session.\n",
364 output_path_for(&work.work_id)
365 ));
366 }
367 AbandonedDisposition::FinishedTooLate {
368 exit_code,
369 duration_ms,
370 output_path,
371 output_bytes,
372 resource_limit,
373 wait_error,
374 status,
375 } => {
376 text.push_str(&format!(
377 "state: finished during teardown after {duration_ms} ms\n\
378 status: {status}\n\
379 exit_code: {exit_code}\n"
380 ));
381 if let Some(limit) = resource_limit {
382 text.push_str(&format!("resource_limit: {limit}\n"));
383 }
384 if let Some(error) = wait_error {
385 text.push_str(&format!("wait_error: {error}\n"));
386 }
387 text.push_str(&format!(
388 "output: {output_path} ({output_bytes} bytes, in the capsule workdir)\n\
389 result: on disk — it finished too late for any task to carry it back to the \
390 agent.\n"
391 ));
392 }
393 }
394 }
395 text.push_str(
396 "\nA command still running keeps running, detached from this session, with nothing \
397 reading its output. Declare lifecycle.task_acceptance: queue with lifecycle.after_task: \
398 sleep for a session that outlives its task and can be told the result instead.",
399 );
400 text
401}
402
403#[derive(Debug, Clone)]
412pub(crate) struct LostWork {
413 pub work_id: String,
414 pub binary: String,
416 pub command: String,
418 pub detached_at_ms: u64,
421}
422
423#[derive(Debug, Clone)]
429pub(crate) struct LostReport {
430 pub started_in_session: String,
433 pub lost: Vec<LostWork>,
435 pub context_id: String,
437 pub provenance: TaskProvenance,
440 pub task_id: String,
443}
444
445impl LostReport {
446 pub(crate) fn message_text(&self) -> String {
455 let mut text = format!(
456 "Background shell commands from an earlier session were never accounted for.\n\
457 \n\
458 Session {} ended without recording what became of the commands below; they were \
459 running in the background at the time. Nothing about them was recovered and nothing \
460 can be: no exit code, no output and no log file exists for any of them, and whether \
461 each command finished at all is unknown.\n",
462 self.started_in_session
463 );
464 for work in &self.lost {
465 text.push_str(&format!(
466 "\nwork_id: {}\nbinary: {}\ncommand: {}\ndetached_at_ms: {}\n",
467 work.work_id, work.binary, work.command, work.detached_at_ms
468 ));
469 }
470 text.push_str(
471 "\nNone of these is known to have succeeded and none is known to have failed. Treat \
472 each as unknown, and start the work again only if it still matters.",
473 );
474 text
475 }
476}
477
478#[derive(Debug, Clone)]
483pub(crate) enum DetachedReport {
484 Completed(DetachedCompletion),
485 Lost(LostReport),
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 use std::{
493 path::Path,
494 time::{Duration, Instant},
495 };
496
497 use crate::{
498 origin::TrustClass,
499 sandbox::ShellEnforcement,
500 shell::{run_shell, ShellOutcome},
501 types::CapabilityPolicy,
502 };
503
504 fn bash_policy() -> CapabilityPolicy {
505 CapabilityPolicy {
506 shell_allow: vec!["bash".to_string()],
507 ..Default::default()
508 }
509 }
510
511 fn detached_run(
514 workdir: &Path,
515 script: &str,
516 grace: Duration,
517 ) -> (
518 ShellOutcome,
519 Arc<DetachedRegistry>,
520 UnboundedReceiver<DetachedReport>,
521 ) {
522 let (registry, receiver) = DetachedRegistry::new();
523 let outcome = run_shell(
524 "bash",
525 &["-c", script],
526 &[],
527 workdir,
528 workdir,
529 &bash_policy(),
530 &ShellEnforcement::environment_only(),
531 Some(DetachPolicy {
532 grace,
533 registry: Arc::clone(®istry),
534 command: script.to_string(),
535 context_id: "ctx_detached_tests".to_string(),
536 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
537 }),
538 )
539 .expect("a declared binary runs");
540 (outcome, registry, receiver)
541 }
542
543 fn await_completion(receiver: &mut UnboundedReceiver<DetachedReport>) -> DetachedCompletion {
549 let deadline = Instant::now() + Duration::from_secs(30);
550 loop {
551 match receiver.try_recv() {
552 Ok(DetachedReport::Completed(completion)) => return completion,
553 Ok(other) => panic!("a shell command's completion was expected; got {other:?}"),
554 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
555 assert!(
556 Instant::now() < deadline,
557 "timed out waiting for the detached completion"
558 );
559 std::thread::sleep(Duration::from_millis(25));
560 }
561 Err(error) => panic!("the completion channel closed early: {error}"),
562 }
563 }
564 }
565
566 fn completion_provenance() -> TaskProvenance {
567 TaskProvenance::derive(TaskOrigin::Completion, Some(TrustClass::Untrusted))
568 }
569
570 #[test]
573 fn no_two_completions_share_an_opening_line() {
574 let outcome = crate::delegation::DelegationOutcome {
575 delegation_id: "dlg_0001".to_string(),
576 capsule_name: "worker".to_string(),
577 capsule_version: "0.1.0".to_string(),
578 session_id: "ses_child".to_string(),
579 status: crate::delegation::DelegationStatus::Ok,
580 result_path: None,
581 workdir: "/tmp/child".to_string(),
582 duration_ms: 1,
583 detail: None,
584 reported_by: crate::delegation::Reporter::Launcher,
585 delivered: true,
586 delivery_error: None,
587 };
588 let completion = DetachedCompletion {
589 work_id: "wrk_0001".to_string(),
590 binary: "bash".to_string(),
591 command: "sleep 3".to_string(),
592 exit_code: 0,
593 duration_ms: 3_000,
594 output_path: "logs/wrk_0001.log".to_string(),
595 output_bytes: 0,
596 resource_limit: None,
597 context_id: "ctx_1".to_string(),
598 provenance: completion_provenance(),
599 error: None,
600 };
601 let texts = [
602 ("delegation outcome", outcome.message_text()),
603 ("detached shell completion", completion.message_text()),
604 ];
605 for (index, (name, text)) in texts.iter().enumerate() {
606 let opening = text.lines().next().unwrap_or_default();
607 assert!(!opening.is_empty(), "{name} opens with nothing");
608 for (other_name, other) in texts.iter().skip(index + 1) {
609 assert_ne!(
610 opening,
611 other.lines().next().unwrap_or_default(),
612 "{name} and {other_name} open with the same line"
613 );
614 }
615 }
616 }
617
618 #[test]
621 fn a_command_outrunning_the_grace_period_is_demoted() {
622 let workdir = tempfile::tempdir().unwrap();
623 let started = Instant::now();
624 let (outcome, registry, mut receiver) = detached_run(
627 workdir.path(),
628 "sleep 3; printf 'fin%s' 'ished-marker'",
629 Duration::from_secs(1),
630 );
631
632 let info = match outcome {
633 ShellOutcome::Detached(info) => info,
634 ShellOutcome::Finished(result) => {
635 panic!(
636 "a three-second command must not finish inside a one-second grace: {result:?}"
637 )
638 }
639 };
640 assert!(
641 started.elapsed() < Duration::from_secs(3),
642 "the call returned only after the command did"
643 );
644 assert!(
645 info.work_id.starts_with(WORK_ID_PREFIX)
646 && info.work_id[WORK_ID_PREFIX.len()..]
647 .chars()
648 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
649 "work id {} must match ^wrk_[0-9a-f]+$",
650 info.work_id
651 );
652 assert_eq!(info.grace_ms, 1_000);
653 assert_eq!(
654 registry
655 .outstanding()
656 .iter()
657 .map(|work| work.work_id.clone())
658 .collect::<Vec<_>>(),
659 vec![info.work_id.clone()],
660 "the work is registered before the handle is returned"
661 );
662
663 let completion = await_completion(&mut receiver);
664 assert_eq!(completion.work_id, info.work_id);
665 assert_eq!(completion.exit_code, 0);
666 assert_eq!(completion.status(), "ok");
667 assert_eq!(
668 completion.output_path,
669 format!("logs/{}.log", info.work_id),
670 "the completion names logs/<work_id>.log"
671 );
672 assert_eq!(completion.provenance.origin(), TaskOrigin::Completion);
673 assert_eq!(completion.provenance.trust(), TrustClass::Trusted);
674 assert_eq!(completion.context_id, "ctx_detached_tests");
675 assert!(
676 registry.outstanding().is_empty(),
677 "a completed command is no longer outstanding"
678 );
679
680 let log = std::fs::read_to_string(workdir.path().join(&completion.output_path))
681 .expect("the completion's output file exists");
682 assert!(
683 log.contains("finished-marker"),
684 "the output is in the log: {log}"
685 );
686 assert!(
687 !completion.message_text().contains("finished-marker"),
688 "the output never reaches the agent: {}",
689 completion.message_text()
690 );
691 }
692
693 #[test]
695 fn a_fast_command_finishes_in_the_foreground() {
696 let workdir = tempfile::tempdir().unwrap();
697 let (outcome, registry, mut receiver) =
698 detached_run(workdir.path(), "echo hi", Duration::from_secs(10));
699
700 let result = match outcome {
701 ShellOutcome::Finished(result) => result,
702 ShellOutcome::Detached(info) => {
703 panic!("`echo hi` must not be demoted: {}", info.work_id)
704 }
705 };
706 assert_eq!(result.stdout.trim(), "hi");
707 assert_eq!(result.exit_code, 0);
708 assert!(!result.truncated);
709 assert!(result.full_output_path.is_none());
710 assert!(registry.outstanding().is_empty());
711 assert!(
712 matches!(
713 receiver.try_recv(),
714 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
715 ),
716 "a foreground command produces no completion"
717 );
718 }
719
720 #[test]
722 fn a_failing_detached_command_completes_with_its_exit_code() {
723 let workdir = tempfile::tempdir().unwrap();
724 let (outcome, _registry, mut receiver) = detached_run(
725 workdir.path(),
726 "sleep 3; echo to-stderr >&2; exit 7",
727 Duration::from_secs(1),
728 );
729 assert!(matches!(outcome, ShellOutcome::Detached(_)));
730
731 let completion = await_completion(&mut receiver);
732 assert_eq!(completion.exit_code, 7);
733 assert_eq!(completion.status(), "error");
734 assert!(
735 completion.message_text().contains("exit_code: 7"),
736 "the message names the exit code: {}",
737 completion.message_text()
738 );
739
740 let log = std::fs::read_to_string(workdir.path().join(&completion.output_path)).unwrap();
741 let stderr_section = log
742 .split("Stderr:")
743 .nth(1)
744 .expect("the log has a stderr section");
745 assert!(
746 stderr_section.contains("to-stderr"),
747 "stderr is in the log's Stderr section: {log}"
748 );
749
750 let (outcome, _registry, mut receiver) =
751 detached_run(workdir.path(), "sleep 3; exit 0", Duration::from_secs(1));
752 assert!(matches!(outcome, ShellOutcome::Detached(_)));
753 assert_eq!(await_completion(&mut receiver).status(), "ok");
754 }
755
756 #[test]
759 fn the_demotion_result_is_a_constant_size() {
760 let reduce = |work_id: &str| {
761 let result = demotion_tool_result(work_id);
762 result
763 .data
764 .or(result.summary)
765 .unwrap_or_else(|| "tool returned no data".to_string())
766 };
767 let first = reduce(&new_work_id());
768 let second = reduce(&new_work_id());
769
770 assert_eq!(
771 first.len(),
772 second.len(),
773 "two work ids of equal length must produce texts of equal size"
774 );
775 for text in [&first, &second] {
779 let tokens = crate::agent::count_tokens(text);
780 assert!(
781 tokens < 120,
782 "the demotion result costs {tokens} tokens, over the 120-token ceiling"
783 );
784 }
785 assert!(
786 !first.contains("logs/"),
787 "the demotion result names no path: {first}"
788 );
789 }
790
791 #[test]
797 fn no_surface_takes_a_work_id() {
798 const RULE: &str = "a demoted command is learned about through its completion and no \
799 other way: nothing the model or a host embedder can reach may take a \
800 work id as an argument";
801
802 let work_id = concat!("work", "_id");
805 let parameter = format!("{work_id}:");
806 let json_key = format!(".get(\"{work_id}\")");
807 let schema = concat!("input", "_schema");
808
809 let sources = crate_sources();
810 let mut offences = Vec::new();
811 for (path, source) in &sources {
812 for (number, line) in source.lines().enumerate() {
813 let line = line.trim();
814 let at = format!("{path}:{}", number + 1);
815
816 if (line.starts_with("pub fn ") || line.starts_with("pub async fn "))
819 && line.contains(¶meter)
820 {
821 offences.push(format!("{at}: public function takes a work id — {line}"));
822 }
823 if line.contains(&json_key) {
825 offences.push(format!("{at}: tool input is parsed for a work id — {line}"));
826 }
827 if line.contains(schema) && line.contains(work_id) {
829 offences.push(format!("{at}: a tool schema declares a work id — {line}"));
830 }
831 }
832 }
833
834 let inventory = sources
835 .iter()
836 .find(|(path, _)| path.ends_with("inventory.rs"))
837 .map(|(_, source)| source)
838 .expect("agent/inventory.rs is part of this crate");
839 if inventory.contains(work_id) || inventory.contains(WORK_ID_PREFIX) {
840 offences.push("agent/inventory.rs: the tool inventory mentions a work id".to_string());
841 }
842
843 assert!(offences.is_empty(), "{RULE}\n{}", offences.join("\n"));
844 }
845
846 fn crate_sources() -> Vec<(String, String)> {
848 let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src"));
849 let mut sources = Vec::new();
850 let mut pending = vec![root.to_path_buf()];
851 while let Some(dir) = pending.pop() {
852 for entry in std::fs::read_dir(&dir).expect("the crate's src directory is readable") {
853 let entry = entry.expect("a readable directory entry");
854 let path = entry.path();
855 if path.is_dir() {
856 pending.push(path);
857 } else if path.extension().is_some_and(|ext| ext == "rs") {
858 let relative = path
859 .strip_prefix(root)
860 .unwrap_or(&path)
861 .to_string_lossy()
862 .into_owned();
863 sources.push((
864 relative,
865 std::fs::read_to_string(&path).expect("a readable source file"),
866 ));
867 }
868 }
869 }
870 assert!(
871 sources.len() > 10,
872 "the source sweep found only {} files, so it is not sweeping the crate",
873 sources.len()
874 );
875 sources
876 }
877
878 fn still_running(work_id: &str, running_ms: u64) -> AbandonedWork {
881 AbandonedWork {
882 work_id: work_id.to_string(),
883 binary: "/usr/bin/bash".to_string(),
884 command: "sleep 45; echo done".to_string(),
885 disposition: AbandonedDisposition::StillRunning { running_ms },
886 }
887 }
888
889 fn finished_too_late(work_id: &str) -> AbandonedWork {
890 AbandonedWork {
891 work_id: work_id.to_string(),
892 binary: "/usr/bin/bash".to_string(),
893 command: "sleep 2; echo done".to_string(),
894 disposition: AbandonedDisposition::FinishedTooLate {
895 exit_code: 0,
896 duration_ms: 2_014,
897 output_path: output_path_for(work_id),
898 output_bytes: 91,
899 resource_limit: None,
900 wait_error: None,
901 status: "ok",
902 },
903 }
904 }
905
906 #[test]
911 fn a_still_running_command_is_reported_with_what_is_known_and_promises_no_file() {
912 let text = abandonment_report_text("ses_abc", &[still_running("wrk_0a1b", 45_012)]);
913
914 assert!(
915 text.contains("1 background shell command discarded"),
916 "{text}"
917 );
918 assert!(text.contains("Session ses_abc"), "{text}");
919 assert!(text.contains("work_id: wrk_0a1b"), "{text}");
920 assert!(text.contains("binary: /usr/bin/bash"), "{text}");
921 assert!(text.contains("command: sleep 45; echo done"), "{text}");
922 assert!(
923 text.contains("state: still running after 45012 ms"),
924 "{text}"
925 );
926 assert!(
927 text.contains("no logs/wrk_0a1b.log will be written"),
928 "{text}"
929 );
930 assert!(!text.contains("exit_code:"), "no exit code exists: {text}");
931 }
932
933 #[test]
937 fn a_command_that_finished_during_teardown_is_reported_with_its_result() {
938 let text = abandonment_report_text("ses_abc", &[finished_too_late("wrk_0c2d")]);
939
940 assert!(
941 text.contains("state: finished during teardown after 2014 ms"),
942 "{text}"
943 );
944 assert!(text.contains("status: ok"), "{text}");
945 assert!(text.contains("exit_code: 0"), "{text}");
946 assert!(
947 text.contains("output: logs/wrk_0c2d.log (91 bytes, in the capsule workdir)"),
948 "{text}"
949 );
950 assert!(!text.contains("still running after"), "{text}");
951 }
952
953 #[test]
956 fn the_report_states_the_condition_once_and_enumerates_every_command() {
957 let text = abandonment_report_text(
958 "ses_abc",
959 &[still_running("wrk_0a1b", 12), finished_too_late("wrk_0c2d")],
960 );
961
962 assert_eq!(
963 text.matches("2 background shell commands discarded")
964 .count(),
965 1
966 );
967 assert_eq!(text.matches("work_id: ").count(), 2);
968 assert!(text.contains("wrk_0a1b"), "{text}");
969 assert!(text.contains("wrk_0c2d"), "{text}");
970 }
971
972 #[test]
975 fn an_attributed_resource_limit_is_named_in_the_report() {
976 let mut work = finished_too_late("wrk_0c2d");
977 if let AbandonedDisposition::FinishedTooLate {
978 resource_limit,
979 exit_code,
980 status,
981 ..
982 } = &mut work.disposition
983 {
984 *resource_limit = Some("memory".to_string());
985 *exit_code = 137;
986 *status = "error";
987 }
988 let text = abandonment_report_text("ses_abc", &[work]);
989
990 assert!(text.contains("resource_limit: memory"), "{text}");
991 assert!(text.contains("status: error"), "{text}");
992 assert!(text.contains("exit_code: 137"), "{text}");
993 }
994
995 #[test]
998 fn only_a_command_that_finished_carries_a_recovered_result() {
999 assert_eq!(still_running("wrk_0a1b", 45_012).running_ms(), 45_012);
1000 assert!(still_running("wrk_0a1b", 45_012).recovered().is_none());
1001
1002 let finished = finished_too_late("wrk_0c2d");
1003 assert_eq!(finished.running_ms(), 2_014);
1004 assert_eq!(
1005 finished.recovered(),
1006 Some((0, "logs/wrk_0c2d.log", 91)),
1007 "the exit code, the output path and its byte count travel together"
1008 );
1009 }
1010
1011 #[test]
1015 fn a_failed_wait_is_named_beside_the_status_it_explains() {
1016 let mut work = finished_too_late("wrk_0c2d");
1017 if let AbandonedDisposition::FinishedTooLate {
1018 wait_error, status, ..
1019 } = &mut work.disposition
1020 {
1021 *wait_error = Some("no child processes".to_string());
1022 *status = "error";
1023 }
1024 let text = abandonment_report_text("ses_abc", &[work]);
1025
1026 assert!(text.contains("status: error"), "{text}");
1027 assert!(text.contains("wait_error: no child processes"), "{text}");
1028 }
1029
1030 #[test]
1033 fn the_report_does_not_claim_nothing_is_recoverable() {
1034 let text = abandonment_report_text("ses_abc", &[still_running("wrk_0a1b", 12)]);
1035 assert!(
1036 !text.contains("Nothing about them was recovered"),
1037 "the clean-exit report is not the crash report: {text}"
1038 );
1039 }
1040}