Skip to main content

supercode_runtime/
background.rs

1//! P5-6 (COMPOSABLE-HARNESS-DESIGN.md §2 module 4 `tools.background`: "D1
2//! background exec + monitor/event feed; D10 bg-manager; D3 self-paced/
3//! scheduled loops"; §2.1 "tools.background → permissions.approvals
4//! (auto-policy) [C6 as dep]"; §2.2 C6): the pure, agent-independent data
5//! shapes and bounded-buffer arithmetic a runtime agent's
6//! `background_exec`/`background_status`/`background_list`/`background_kill`
7//! intrinsics build on — kept separate from `agent.rs` so the bounded-
8//! capture truncation logic and job-id shape are unit-testable without a
9//! full `Agent`/mock-`Provider`/real-subprocess harness, the same
10//! "pure config → set, testable without the loop" precedent
11//! subagent runtime documents for itself (P5-3).
12//!
13//! **Activation.** Everything here is inert until `Agent` actually consults
14//! it, which only happens when `Config::tools_background_enabled` is `true`
15//! (`capabilities.tools_background.enabled`, default `false`) — importing
16//! this module changes nothing for an agent that never turns the module on.
17//!
18//! **Process-kill reuse.** The actual OS-process spawn/kill machinery lives
19//! in the agent loop (it needs `tokio::process::Command`/`Child`, which this
20//! module deliberately does not depend on, keeping it synchronous and
21//! trivially unit-testable). The concurrency bound reuses
22//! the subagent concurrency guard verbatim — the same
23//! generic `Arc<AtomicUsize>` gauge machinery, just a second, independent
24//! gauge instance scoped to background JOBS rather than subagent SPAWNS
25//! (`Agent::background_concurrency_gauge`, distinct from
26//! `Agent::subagent_concurrency_gauge`).
27
28use std::sync::atomic::{AtomicU64, Ordering};
29use std::sync::Mutex;
30
31/// P5-6 (build brief "cap the buffer like P5-2's 16MiB caps"): the default
32/// per-job bounded-capture ceiling, matching `crate::mcp::MCP_MAX_RESPONSE_BYTES`'s
33/// hardening precedent — generous for real command output while bounding
34/// how much memory one background job (let alone `max_concurrent` of them
35/// at once) can force this process to hold.
36pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024;
37
38/// P5-6 (resource bound, mirroring `crate::subagents`'s "max concurrent...
39/// cap, fail-closed... configurable" precedent): the default maximum number
40/// of background jobs this agent may have in flight at once.
41pub const DEFAULT_MAX_CONCURRENT: usize = 4;
42
43/// A background job's run state, as observed by `background_status`/
44/// `background_list`.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum JobStatus {
47    /// Still running (no exit observed yet).
48    Running,
49    /// Exited on its own; `Some(code)` when the platform reported one
50    /// (`None` covers a signal-terminated exit with no portable code, same
51    /// convention `BashTool::execute` already uses via
52    /// `status.code().unwrap_or(-1)` — this type keeps the `Option`
53    /// instead of collapsing it, so callers can tell "exit code 0" from
54    /// "no code available" if they care to).
55    Exited(Option<i32>),
56    /// Killed via `background_kill` (or reclaimed on agent drop) before it
57    /// exited on its own.
58    Killed,
59}
60
61impl JobStatus {
62    /// The `"status"` string a tool result JSON reports.
63    pub fn as_str(self) -> &'static str {
64        match self {
65            JobStatus::Running => "running",
66            JobStatus::Exited(_) => "exited",
67            JobStatus::Killed => "killed",
68        }
69    }
70}
71
72/// Bounded, incrementally-appended output capture shared (via `Arc`)
73/// between a job's stdout/stderr reader tasks and whatever later polls it
74/// (`background_status`/`background_list`). Thread-safe; every method is
75/// fail-soft on a poisoned lock (treats it as "temporarily unavailable",
76/// the same posture `crate::permissions::approval::ApprovalCache` already
77/// documents for itself) rather than panicking a reader task or a tool
78/// call.
79#[derive(Debug, Default)]
80pub struct CapturedOutput {
81    inner: Mutex<CaptureState>,
82}
83
84#[derive(Debug, Default)]
85struct CaptureState {
86    buf: String,
87    truncated: bool,
88    /// Byte offset into `buf` already handed back by a previous
89    /// `drain_new` call — the event-feed cursor.
90    drained: usize,
91}
92
93impl CapturedOutput {
94    /// A fresh, empty capture.
95    pub fn new() -> Self {
96        CapturedOutput::default()
97    }
98
99    /// Append `chunk`, never growing the retained buffer past `cap` bytes —
100    /// bytes beyond the cap are DROPPED (fail-closed, never buffered) and
101    /// `truncated` latches `true` the first time that happens and stays
102    /// true thereafter. A caller must keep reading the underlying pipe past
103    /// this point regardless (to avoid blocking the child on a full,
104    /// undrained pipe) — this method only bounds what's RETAINED in
105    /// memory, not what's read off the pipe.
106    pub fn append(&self, chunk: &str, cap: usize) {
107        if chunk.is_empty() {
108            return;
109        }
110        let Ok(mut st) = self.inner.lock() else {
111            return;
112        };
113        if st.buf.len() >= cap {
114            st.truncated = true;
115            return;
116        }
117        let remaining = cap - st.buf.len();
118        if chunk.len() <= remaining {
119            st.buf.push_str(chunk);
120        } else {
121            // Largest char boundary <= remaining, same approach
122            // `Agent::cap_tool_output` already uses.
123            let mut end = remaining;
124            while end > 0 && !chunk.is_char_boundary(end) {
125                end -= 1;
126            }
127            st.buf.push_str(&chunk[..end]);
128            st.truncated = true;
129        }
130    }
131
132    /// The full captured text so far, and whether it was ever truncated.
133    pub fn snapshot(&self) -> (String, bool) {
134        self.inner
135            .lock()
136            .map(|st| (st.buf.clone(), st.truncated))
137            .unwrap_or_default()
138    }
139
140    /// Text appended since the last `drain_new` call (or since creation, on
141    /// the first call) — the event-feed's per-poll delta. Advances the
142    /// drain cursor even on an empty result, so polling twice in a row with
143    /// no new output between them returns `""` the second time, never a
144    /// repeat of the first drain.
145    pub fn drain_new(&self) -> String {
146        let Ok(mut st) = self.inner.lock() else {
147            return String::new();
148        };
149        let new = st.buf[st.drained..].to_string();
150        st.drained = st.buf.len();
151        new
152    }
153}
154
155/// P5-6: process-wide sequence number backing [`next_job_id`] —
156/// disambiguates two jobs spawned in the same millisecond, mirroring
157/// `crate::agent`'s own `SUBAGENT_ID_SEQ` precedent (kept as a second,
158/// independent counter rather than sharing that one, since a job id and a
159/// subagent id are never compared against each other).
160static JOB_ID_SEQ: AtomicU64 = AtomicU64::new(0);
161
162/// A fresh, process-unique background job id (`"bg-<hex-ts>-<hex-seq>"`),
163/// given the caller's own millisecond timestamp (kept as a parameter rather
164/// than reading the clock in here, so this stays a pure function for the
165/// unit tests below).
166pub fn next_job_id(now_ms: i64) -> String {
167    let seq = JOB_ID_SEQ.fetch_add(1, Ordering::Relaxed);
168    format!("bg-{now_ms:x}-{seq:x}")
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn job_status_as_str_matches_the_documented_schema_values() {
177        assert_eq!(JobStatus::Running.as_str(), "running");
178        assert_eq!(JobStatus::Exited(Some(0)).as_str(), "exited");
179        assert_eq!(JobStatus::Exited(None).as_str(), "exited");
180        assert_eq!(JobStatus::Killed.as_str(), "killed");
181    }
182
183    #[test]
184    fn next_job_id_is_unique_across_calls_even_at_the_same_timestamp() {
185        let a = next_job_id(1000);
186        let b = next_job_id(1000);
187        assert_ne!(a, b);
188        assert!(a.starts_with("bg-"));
189    }
190
191    #[test]
192    fn captured_output_appends_under_the_cap_without_truncation() {
193        let out = CapturedOutput::new();
194        out.append("hello ", 100);
195        out.append("world", 100);
196        let (buf, truncated) = out.snapshot();
197        assert_eq!(buf, "hello world");
198        assert!(!truncated);
199    }
200
201    #[test]
202    fn captured_output_never_grows_past_the_cap_and_latches_truncated() {
203        let out = CapturedOutput::new();
204        out.append("0123456789", 5); // only "01234" fits
205        let (buf, truncated) = out.snapshot();
206        assert_eq!(buf, "01234");
207        assert!(truncated);
208        assert_eq!(buf.len(), 5);
209
210        // Further appends past an already-full cap change nothing except
211        // (already-true) truncated — never grow past the cap.
212        out.append("more data that must be dropped entirely", 5);
213        let (buf2, truncated2) = out.snapshot();
214        assert_eq!(buf2, "01234");
215        assert!(truncated2);
216    }
217
218    #[test]
219    fn captured_output_respects_char_boundaries_when_truncating() {
220        let out = CapturedOutput::new();
221        // "héllo" — 'é' is 2 bytes; cap=2 lands mid-character at byte 2.
222        out.append("héllo", 2);
223        let (buf, truncated) = out.snapshot();
224        assert!(truncated);
225        assert!(buf.is_char_boundary(buf.len()));
226        assert!(std::str::from_utf8(buf.as_bytes()).is_ok());
227    }
228
229    #[test]
230    fn drain_new_returns_only_the_delta_since_the_last_call() {
231        let out = CapturedOutput::new();
232        out.append("first ", 1000);
233        assert_eq!(out.drain_new(), "first ");
234        assert_eq!(out.drain_new(), "", "no new output since the last drain");
235        out.append("second", 1000);
236        assert_eq!(out.drain_new(), "second");
237    }
238
239    #[test]
240    fn ten_mb_of_appends_never_retains_past_the_configured_cap() {
241        // Regression proof for the "must not OOM" requirement: a job
242        // producing far more output than the cap must leave the retained
243        // buffer bounded throughout, not merely "eventually" bounded.
244        let out = CapturedOutput::new();
245        let cap = 1024;
246        let chunk = "x".repeat(4096);
247        for _ in 0..2560 {
248            // 2560 * 4096 ~= 10 MiB fed in, cap = 1 KiB.
249            out.append(&chunk, cap);
250            let (buf, _) = out.snapshot();
251            assert!(buf.len() <= cap, "buffer must never exceed the cap");
252        }
253        let (buf, truncated) = out.snapshot();
254        assert_eq!(buf.len(), cap);
255        assert!(truncated);
256    }
257}