Skip to main content

capsule_runtime/
background_command.rs

1//! The runtime-provided `background-command` tool: the state of a shell command that was moved
2//! to the background, a wait for it, or a stop of it, named by the work id its shell call
3//! returned.
4//!
5//! The commands themselves are kept by [`crate::detached::DetachedRegistry`]; this module holds
6//! what the model reads: the description, the input schema, the input parser and the result.
7
8use std::time::Duration;
9
10use crate::{
11    bindings::host::murmur::tool::run::{Status, ToolResult},
12    detached::{CommandSnapshot, CommandState, WORK_ID_PREFIX},
13    shell::MAX_SHELL_OUTPUT_BYTES,
14};
15
16/// The runtime-provided tool an agent capsule has when it can run a shell command.
17pub const BACKGROUND_COMMAND_TOOL: &str = "background-command";
18
19/// The longest one `wait` blocks, in seconds, and what it blocks for when `wait_secs` is absent.
20pub const MAX_WAIT_SECS: u64 = 600;
21
22/// How long `stop` waits after `SIGTERM` before it sends `SIGKILL`.
23pub(crate) const STOP_TERM_GRACE: Duration = Duration::from_secs(2);
24
25/// How long `stop` waits after `SIGKILL` for the command to finish.
26pub(crate) const STOP_KILL_WAIT: Duration = Duration::from_secs(3);
27
28/// `background-command`'s input schema.
29pub const INPUT_SCHEMA: &str = r#"{"type":"object","properties":{"operation":{"type":"string","enum":["status","wait","stop"]},"work_id":{"type":"string"},"wait_secs":{"type":"integer","minimum":0}},"required":["operation","work_id"]}"#;
30
31/// `background-command`'s description, the same on both transports.
32pub fn description() -> String {
33    format!(
34        "Reports on, waits for, or stops a shell command that was moved to the background, named \
35         by the work id ({WORK_ID_PREFIX}…) its shell call returned. `operation` is `status`, \
36         `wait` or `stop`. `status` returns at once. `wait` returns once the command exits or \
37         after `wait_secs` seconds, whichever is first; `wait_secs` defaults to {MAX_WAIT_SECS}, \
38         which is also the most it can be. `stop` sends SIGTERM to the command and every process \
39         in its process group, SIGKILL {term} seconds later to any still running, and returns \
40         once the command has exited; a process that left the group is not reached and ends \
41         with the session. Each operation returns the command's state (running, exited or \
42         stopped), its exit code once it has one, the seconds since it started, and the last \
43         {MAX_SHELL_OUTPUT_BYTES} bytes of its combined stdout and stderr, with the path of the \
44         file in the session's records that holds all of it. A work id is valid for the rest of \
45         the session that issued it; a command still running at the session's end is killed.",
46        term = STOP_TERM_GRACE.as_secs(),
47    )
48}
49
50/// What a call asks for.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub(crate) enum Operation {
53    Status,
54    Wait,
55    Stop,
56}
57
58impl Operation {
59    const ALL: [Self; 3] = [Self::Status, Self::Wait, Self::Stop];
60
61    pub(crate) fn as_str(self) -> &'static str {
62        match self {
63            Self::Status => "status",
64            Self::Wait => "wait",
65            Self::Stop => "stop",
66        }
67    }
68}
69
70/// One parsed call.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub(crate) struct Request {
73    pub operation: Operation,
74    pub work_id: String,
75    /// Read only by [`Operation::Wait`].
76    pub wait_secs: Option<u64>,
77}
78
79/// The accepted operations, as a refusal names them.
80fn accepted_operations() -> String {
81    Operation::ALL
82        .map(|operation| format!("`{}`", operation.as_str()))
83        .join(", ")
84}
85
86/// Parses a call's input. The refusal names the offending field and value.
87pub(crate) fn parse_request(
88    args: &serde_json::Map<String, serde_json::Value>,
89) -> Result<Request, String> {
90    let operation = match args.get("operation") {
91        None | Some(serde_json::Value::Null) => {
92            return Err(format!(
93                "'{BACKGROUND_COMMAND_TOOL}' requires `operation`, one of {}",
94                accepted_operations()
95            ))
96        }
97        Some(value) => Operation::ALL
98            .into_iter()
99            .find(|operation| value.as_str() == Some(operation.as_str()))
100            .ok_or_else(|| {
101                format!(
102                    "'{BACKGROUND_COMMAND_TOOL}' has no operation {value}; `operation` is one of {}",
103                    accepted_operations()
104                )
105            })?,
106    };
107    let work_id = match args.get("work_id") {
108        None | Some(serde_json::Value::Null) => {
109            return Err(format!(
110                "'{BACKGROUND_COMMAND_TOOL}' requires `work_id`, the {WORK_ID_PREFIX}<hex> id a \
111                 shell call returned"
112            ))
113        }
114        Some(serde_json::Value::String(work_id)) => work_id.clone(),
115        Some(value) => {
116            return Err(format!(
117                "'{BACKGROUND_COMMAND_TOOL}' got `work_id` {value}; a work id is a string of the \
118                 form {WORK_ID_PREFIX}<hex>"
119            ))
120        }
121    };
122    if !is_work_id(&work_id) {
123        return Err(format!(
124            "`{work_id}` is not a work id: a work id has the form {WORK_ID_PREFIX}<hex>"
125        ));
126    }
127    let wait_secs = match args.get("wait_secs") {
128        None | Some(serde_json::Value::Null) => None,
129        Some(value) => Some(value.as_u64().ok_or_else(|| {
130            format!(
131                "'{BACKGROUND_COMMAND_TOOL}' got `wait_secs` {value}; `wait_secs` is a whole \
132                 number of seconds, 0 or more"
133            )
134        })?),
135    };
136    Ok(Request {
137        operation,
138        work_id,
139        wait_secs,
140    })
141}
142
143/// Whether `text` matches `^wrk_[0-9a-f]+$`.
144fn is_work_id(text: &str) -> bool {
145    text.strip_prefix(WORK_ID_PREFIX).is_some_and(|hex| {
146        !hex.is_empty()
147            && hex
148                .bytes()
149                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
150    })
151}
152
153/// The refusal for a well-formed work id this session never issued.
154pub(crate) fn unknown_work_id(work_id: &str) -> String {
155    format!("no background command with work id `{work_id}` in this session")
156}
157
158/// The refusal for a session that keeps no background commands.
159pub(crate) fn not_available() -> String {
160    format!("'{BACKGROUND_COMMAND_TOOL}': background commands are not available in this session")
161}
162
163/// How long a `wait` blocks: `wait_secs`, at most [`MAX_WAIT_SECS`], which is also what an
164/// absent `wait_secs` gives.
165pub fn effective_wait(wait_secs: Option<u64>) -> Duration {
166    Duration::from_secs(wait_secs.unwrap_or(MAX_WAIT_SECS).min(MAX_WAIT_SECS))
167}
168
169/// The tool result for `snapshot`, and the runtime note that goes after the fence.
170///
171/// `waited` is the time a `wait` blocked, and `None` for the other operations. A found command is
172/// `Passed` whatever its exit code. The note carries an attributed resource limit and the output
173/// holders a foreground call would report, and is `None` when there is neither.
174pub(crate) fn render(
175    snapshot: &CommandSnapshot,
176    waited: Option<Duration>,
177) -> (ToolResult, Option<String>) {
178    let mut lines = vec![
179        format!("work_id: {}", snapshot.work_id),
180        if snapshot.command.is_empty() {
181            format!("command: {}", snapshot.binary)
182        } else {
183            format!("command: {} {}", snapshot.binary, snapshot.command)
184        },
185        format!("state: {}", snapshot.state.as_str()),
186        format!("elapsed_secs: {}", snapshot.elapsed_ms / 1000),
187    ];
188    if let Some(waited) = waited {
189        lines.push(format!("waited_secs: {}", waited.as_secs()));
190    }
191    let mut metadata = vec![
192        ("work_id".to_string(), snapshot.work_id.clone()),
193        ("state".to_string(), snapshot.state.as_str().to_string()),
194    ];
195    let mut notes = Vec::new();
196    if let Some(completion) = &snapshot.finished {
197        lines.push(format!("exit_code: {}", completion.exit_code));
198        lines.push(format!("status: {}", completion.status()));
199        if let Some(error) = &completion.error {
200            lines.push(format!("wait_error: {error}"));
201        }
202        metadata.push(("exit_code".to_string(), completion.exit_code.to_string()));
203        notes.extend(
204            completion
205                .resource_limit
206                .as_deref()
207                .map(crate::resources::resource_limit_line),
208        );
209        notes.extend(completion.output_holders.note());
210    }
211    let truncated = snapshot.output_bytes > snapshot.tail.len() as u64;
212    lines.push(format!("output: {}", snapshot.output_path));
213    lines.push(format!("output_bytes: {}", snapshot.output_bytes));
214    lines.push(if truncated {
215        format!("output_shown: last {MAX_SHELL_OUTPUT_BYTES} bytes")
216    } else {
217        "output_shown: all".to_string()
218    });
219
220    let shown = if truncated {
221        from_char_boundary(&snapshot.tail)
222    } else {
223        &snapshot.tail
224    };
225    let mut data = lines.join("\n");
226    if !shown.is_empty() {
227        data.push_str("\n\n");
228        data.push_str(&String::from_utf8_lossy(shown));
229    }
230    let summary = match &snapshot.finished {
231        Some(completion) if snapshot.state != CommandState::Running => format!(
232            "{} {}, exit code {}",
233            snapshot.work_id,
234            snapshot.state.as_str(),
235            completion.exit_code
236        ),
237        _ => format!("{} {}", snapshot.work_id, snapshot.state.as_str()),
238    };
239    let result = ToolResult {
240        status: Status::Passed,
241        summary: Some(summary),
242        data: Some(data),
243        data_path: None,
244        truncated,
245        metadata,
246    };
247    (result, (!notes.is_empty()).then(|| notes.join("\n")))
248}
249
250/// `tail` from its first byte that starts a UTF-8 character: a tail cut from a longer output can
251/// begin inside one.
252fn from_char_boundary(tail: &[u8]) -> &[u8] {
253    let skip = tail
254        .iter()
255        .take(3)
256        .take_while(|byte| (**byte & 0b1100_0000) == 0b1000_0000)
257        .count();
258    &tail[skip..]
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    use serde_json::json;
266
267    /// The description states what each operation does and its limits, and nothing about when or
268    /// why to use it.
269    #[test]
270    fn the_description_states_operations_and_limits_only() {
271        let description = description();
272        for fact in [
273            "`status`",
274            "`wait`",
275            "`stop`",
276            "wrk_",
277            "600",
278            "16384",
279            "SIGTERM",
280            "SIGKILL",
281            "2 seconds",
282            "process group",
283            "running, exited or stopped",
284            "killed",
285        ] {
286            assert!(description.contains(fact), "{fact} missing: {description}");
287        }
288        let lower = description.to_lowercase();
289        for advice in ["when", "should", "call this", "use this"] {
290            assert!(!lower.contains(advice), "{advice}: {description}");
291        }
292    }
293
294    /// The schema parses, offers the three operations and requires `operation` and `work_id`.
295    #[test]
296    fn the_input_schema_names_the_three_operations() {
297        let schema: serde_json::Value = serde_json::from_str(INPUT_SCHEMA).unwrap();
298        assert_eq!(
299            schema["properties"]["operation"]["enum"],
300            json!(["status", "wait", "stop"])
301        );
302        assert_eq!(schema["required"], json!(["operation", "work_id"]));
303        assert_eq!(schema["properties"]["wait_secs"]["minimum"], json!(0));
304    }
305
306    #[test]
307    fn wait_secs_is_capped_at_the_runtime_limit() {
308        assert_eq!(effective_wait(None), Duration::from_secs(600));
309        assert_eq!(effective_wait(Some(0)), Duration::ZERO);
310        assert_eq!(effective_wait(Some(30)), Duration::from_secs(30));
311        assert_eq!(effective_wait(Some(100_000)), Duration::from_secs(600));
312    }
313
314    fn parse(args: serde_json::Value) -> Result<Request, String> {
315        parse_request(args.as_object().expect("a JSON object"))
316    }
317
318    #[test]
319    fn a_request_is_parsed_and_each_refusal_names_its_value() {
320        assert_eq!(
321            parse(json!({"operation": "wait", "work_id": "wrk_0a1b", "wait_secs": 5})),
322            Ok(Request {
323                operation: Operation::Wait,
324                work_id: "wrk_0a1b".to_string(),
325                wait_secs: Some(5),
326            })
327        );
328        let refused = |args: serde_json::Value| parse(args).unwrap_err();
329
330        let malformed = refused(json!({"operation": "status", "work_id": "abc"}));
331        assert!(
332            malformed.contains("`abc`") && malformed.contains("wrk_<hex>"),
333            "{malformed}"
334        );
335        for not_hex in ["wrk_", "wrk_0A1B", "wrk_xyz", "WRK_0a1b"] {
336            let refusal = refused(json!({"operation": "status", "work_id": not_hex}));
337            assert!(refusal.contains(not_hex), "{refusal}");
338        }
339        let missing = refused(json!({"operation": "status"}));
340        assert!(missing.contains("`work_id`"), "{missing}");
341        let restart = refused(json!({"operation": "restart", "work_id": "wrk_0a1b"}));
342        assert!(
343            restart.contains("\"restart\"") && restart.contains("`status`, `wait`, `stop`"),
344            "{restart}"
345        );
346        let negative =
347            refused(json!({"operation": "wait", "work_id": "wrk_0a1b", "wait_secs": -1}));
348        assert!(negative.contains("-1"), "{negative}");
349    }
350
351    fn snapshot(tail: &[u8], output_bytes: u64) -> CommandSnapshot {
352        CommandSnapshot {
353            work_id: "wrk_0a1b".to_string(),
354            binary: "/usr/bin/bash".to_string(),
355            command: "make test".to_string(),
356            state: CommandState::Running,
357            elapsed_ms: 12_345,
358            finished: None,
359            output_path: "/records/logs/wrk_0a1b.log".to_string(),
360            output_bytes,
361            tail: tail.to_vec(),
362        }
363    }
364
365    /// A running command's result has the lines a running state has, in order, and no exit
366    /// lines.
367    #[test]
368    fn a_running_command_renders_without_exit_lines() {
369        let (result, note) = render(&snapshot(b"tick 1\n", 7), None);
370        assert_eq!(
371            result.data.as_deref(),
372            Some(
373                "work_id: wrk_0a1b\ncommand: /usr/bin/bash make test\nstate: running\n\
374                 elapsed_secs: 12\noutput: /records/logs/wrk_0a1b.log\noutput_bytes: 7\n\
375                 output_shown: all\n\ntick 1\n"
376            )
377        );
378        assert!(note.is_none());
379        assert!(!result.truncated);
380        assert!(matches!(result.status, Status::Passed));
381    }
382
383    /// A tail cut inside a multi-byte character starts at the next character.
384    #[test]
385    fn a_cut_tail_starts_on_a_character() {
386        let mut tail = "é".as_bytes()[1..].to_vec();
387        tail.extend_from_slice(b"rest");
388        let (result, _) = render(
389            &snapshot(&tail, 100_000),
390            Some(Duration::from_millis(1_900)),
391        );
392        let data = result.data.unwrap();
393        assert!(data.contains("waited_secs: 1\n"), "{data}");
394        assert!(data.contains("output_shown: last 16384 bytes"), "{data}");
395        assert!(data.ends_with("\n\nrest"), "{data}");
396        assert!(result.truncated);
397    }
398}