Skip to main content

capsule_runtime/
detached.rs

1//! Shell commands that outran their grace period and finished in the background.
2//!
3//! Every shell command starts in the foreground. One that has not exited by the time
4//! `lifecycle.shell_grace_secs` elapses is demoted: the turn gets a handle and carries on, the
5//! command keeps running on an OS thread of its own, and its output goes to a file rather than
6//! into the conversation. When it finishes, the runtime enqueues a task on this capsule with
7//! [`crate::origin::TaskOrigin::Completion`] carrying the exit code and that file's path.
8//!
9//! The handle is not addressable. Nothing takes a work id as an argument — no tool, no host
10//! import, no CLI subcommand — because a pollable handle invites a loop that burns turns waiting,
11//! which is the cost demotion exists to remove. A demoted command is learned about through its
12//! completion and no other way.
13
14use 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
30/// Prefix on every work id. Distinct from `tsk_`/`ctx_`/`ses_` so a reader of `trace.jsonl` can
31/// tell at a glance which id space a value belongs to.
32pub const WORK_ID_PREFIX: &str = "wrk_";
33
34/// A fresh work id, matching `^wrk_[0-9a-f]+$`.
35///
36/// Time-ordered like every other id the runtime mints, so two work ids sort in the order their
37/// commands were demoted.
38pub fn new_work_id() -> String {
39    format!("{WORK_ID_PREFIX}{}", uuid::Uuid::now_v7().simple())
40}
41
42/// Where a demoted command's full output lands, relative to the capsule workdir.
43///
44/// One rule, used by the writer and by every reader that has to find the file again.
45pub(crate) fn output_path_for(work_id: &str) -> String {
46    format!("logs/{work_id}.log")
47}
48
49/// A demoted command the session is no longer waiting for.
50#[derive(Debug, Clone)]
51pub(crate) struct DetachedWork {
52    pub work_id: String,
53    /// The program that ran, resolved the same way [`crate::shell::ShellResult::binary`] is.
54    pub binary: String,
55    /// The command text the model supplied, without the binary name.
56    pub command: String,
57    /// When the command was spawned, not when it was demoted — the foreground grace period counts
58    /// as running time, so this lines up with the `duration_ms` a completion carries.
59    pub started_at_ms: u64,
60}
61
62/// What a demoted command left behind when it finished.
63///
64/// Carries no output. The bytes are on disk at [`Self::output_path`] and the agent is told where,
65/// never what — a completion the model reads must not be able to grow with the command's output.
66#[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    /// Workdir-relative, always `logs/<work_id>.log`. Named even when [`Self::error`] is set, so
74    /// a reader knows where the output was meant to be.
75    pub output_path: String,
76    /// Bytes written to that file, header included.
77    pub output_bytes: u64,
78    pub resource_limit: Option<String>,
79    /// The conversation the command was started from. The completion task runs under this id, so
80    /// the result joins the thread that asked for it rather than opening a new one.
81    pub context_id: String,
82    /// Stamped [`TaskOrigin::Completion`] with the starting task's trust inherited.
83    pub provenance: TaskProvenance,
84    /// Some only when the wait itself failed — distinct from the command exiting non-zero.
85    pub error: Option<String>,
86}
87
88impl DetachedCompletion {
89    /// The `IncomingTask.message_text` the agent reads: the work id, the command, the exit code,
90    /// the duration and the output path. Never the output itself.
91    ///
92    /// An attributed limit adds the [`crate::resources::resource_limit_line`] a foreground call
93    /// would get, naming the limit and what to do about it.
94    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    /// `"ok"` only for a command that ran to a clean exit with nothing attributed against it.
127    ///
128    /// A non-zero exit, a signal kill (which [`crate::shell`] reports as `128 + signal`), an
129    /// attributed resource limit and a wait that itself failed are all `"error"`: a failure is a
130    /// completion, and it is told apart from success by this field rather than by its prose.
131    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/// What a dispatch reports back when its command was demoted rather than finished.
141#[derive(Debug, Clone)]
142pub(crate) struct DetachedDispatchInfo {
143    pub work_id: String,
144    pub binary: String,
145    pub command: String,
146    /// The grace period this command outran, in milliseconds — the configured
147    /// `lifecycle.shell_grace_secs`, recorded so a trace reader can see which setting was in
148    /// force without consulting the manifest.
149    pub grace_ms: u64,
150}
151
152/// The tool result a demoted command hands back to the turn.
153///
154/// Constant size by construction: nothing here derives from the command, its arguments or its
155/// output, so no input can make the text the model reads grow. It names no path either — the
156/// output location arrives with the completion, which is the only thing that can say the file is
157/// finished being written.
158pub(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
175/// The demoted commands of one session, and the channel their completions arrive on.
176///
177/// Shared between the agent loop (which registers work) and each demoted command's own thread
178/// (which completes it), so the session-end sweep has one place to ask what is still running.
179pub(crate) struct DetachedRegistry {
180    outstanding: Mutex<BTreeMap<String, DetachedWork>>,
181    /// Every report the task loop turns into a `completion`-origin task, not only a demoted
182    /// command's. A delegation that outran its deadline arrives here too, so one channel feeds one
183    /// enqueue path and the admission rule cannot differ between them.
184    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    /// Hand the task loop one report. A send that fails means the loop is gone.
200    pub(crate) fn report(&self, report: DetachedReport) {
201        let _ = self.reports.send(report);
202    }
203
204    /// Record a demoted command. Called before the dispatch returns its handle, so the
205    /// session-end sweep cannot miss work registered after it looked.
206    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    /// Removes the work id from [`Self::outstanding`] and sends the completion.
214    ///
215    /// The channel is unbounded, so the blocking thread that calls this never waits and needs no
216    /// async context. A send that fails means the task loop is gone — the session is ending, and
217    /// the result has nowhere to be delivered.
218    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    /// Every command demoted and not yet completed, oldest work id first.
227    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
237/// Supplied by a dispatch that is allowed to demote.
238///
239/// `None` at a call site means the command runs to completion in the foreground. That is what
240/// keeps `plan.rs` and the script-capsule store state foreground-only: neither runs a task loop,
241/// so neither has anywhere to deliver a completion.
242pub(crate) struct DetachPolicy {
243    pub grace: Duration,
244    pub registry: Arc<DetachedRegistry>,
245    /// The command text as the model wrote it, without the binary name — the same string a
246    /// foreground `shell` trace record carries. `run_shell` only sees the argv it was handed, so
247    /// the caller that knows what was asked for supplies it here.
248    pub command: String,
249    pub context_id: String,
250    /// The provenance of the task that started the command. The completion is stamped
251    /// `TaskProvenance::derive(TaskOrigin::Completion, Some(that trust))`, so untrust survives
252    /// the round trip instead of being reset by the runtime enqueuing its own task.
253    pub provenance: Option<TaskProvenance>,
254    /// Raised when whoever was waiting for the foreground result has gone away. The command is
255    /// then demoted at once, exactly as if it had outrun `grace`. `None` for every caller that
256    /// cannot go away mid-call; the process-transport tool bridge is the one that can.
257    pub abandoned: Option<AbandonSignal>,
258}
259
260/// Set once by a caller that stopped waiting for a call's result.
261///
262/// Clones share one flag. Raising it never stops anything already running: a shell command still
263/// in the foreground reads it and demotes, which is the only effect it has.
264#[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    /// The provenance the completion task carries.
283    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// -- Work a clean exit discarded ---------------------------------------------
292
293/// What the teardown sweep knows about one discarded command.
294///
295/// The two arms are the two places a command can be caught by the sweep, and they are told apart
296/// because what is known differs: a command still running has nothing on disk and never will,
297/// while one that finished between the task loop's last read and the sweep has an exit code and a
298/// written output log that no task will ever carry back.
299#[derive(Debug, Clone)]
300pub(crate) enum AbandonedDisposition {
301    /// Running when the session ended, and killed with it. No exit code reaches any task.
302    /// `logs/<work_id>.log` is written only if the command's own runtime thread reaps it and
303    /// writes the file before the process exits, so the report neither promises nor denies it.
304    StillRunning {
305        /// From spawn to the sweep, foreground portion included.
306        running_ms: u64,
307    },
308    /// Finished during teardown, after the task loop stopped reading completions. Everything a
309    /// `shell_completed` would have carried exists; only the task that would have carried it does
310    /// not.
311    FinishedTooLate {
312        exit_code: i32,
313        /// From spawn to exit, foreground portion included.
314        duration_ms: u64,
315        /// Workdir-relative, always `logs/<work_id>.log`, and on disk by the time this arm is
316        /// built.
317        output_path: String,
318        output_bytes: u64,
319        resource_limit: Option<String>,
320        /// Set only when the wait itself failed, as on [`DetachedCompletion::error`]. Without it a
321        /// report can read `status: error` with an exit code of `0` and no reason for either.
322        wait_error: Option<String>,
323        /// [`DetachedCompletion::status`] of the completion this was built from.
324        status: &'static str,
325    },
326}
327
328/// One discarded command, as the operator report and the `shell_abandoned` record both read it.
329#[derive(Debug, Clone)]
330pub(crate) struct AbandonedWork {
331    pub work_id: String,
332    /// As [`DetachedWork::binary`].
333    pub binary: String,
334    /// As [`DetachedWork::command`] — the text the model wrote, which the operator needs to know
335    /// what was actually left running.
336    pub command: String,
337    pub disposition: AbandonedDisposition,
338}
339
340impl AbandonedWork {
341    /// How long the command ran, whichever arm it was caught in — the `running_ms` of its
342    /// `shell_abandoned` line.
343    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    /// The exit code, output path and byte count, or `None` for a command still running.
351    ///
352    /// The three travel together because they exist together: a command with an exit code has a
353    /// written log, and one without has neither.
354    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
367/// The grouped operator report for every command one session discarded, as it lands on stderr and
368/// in `logs/bootstrap.log`.
369///
370/// One block for the whole session rather than a line per command: a session that discarded ten
371/// commands must state the condition once and then enumerate, or the condition is lost in the
372/// enumeration. Deliberately **not** [`LostReport::message_text`] — that text asserts nothing was
373/// recovered and nothing can be, which is false here: this session's registry was alive, so the
374/// running time is known, and a command that finished during teardown has its exit code and its
375/// output log on disk.
376pub(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// -- Work a dead session left unaccounted ------------------------------------
439
440/// A demoted command whose session died before anything could be recorded about it.
441///
442/// Reconstructed from the prior session's `shell_detached` line, which is the only trace of it
443/// that survives: [`DetachedRegistry`] lives in process memory, and [`crate::shell`] writes the
444/// output log from a runtime thread after `child.wait()` returns, so a runtime killed with
445/// `SIGKILL` leaves no `logs/<work id>.log` even for a command that ran to a clean exit.
446#[derive(Debug, Clone)]
447pub(crate) struct LostWork {
448    pub work_id: String,
449    /// As [`DetachedWork::binary`].
450    pub binary: String,
451    /// As [`DetachedWork::command`].
452    pub command: String,
453    /// The `shell_detached` line's own timestamp — the demotion instant, which is the start
454    /// instant plus the grace period the command outran.
455    pub detached_at_ms: u64,
456}
457
458/// Every unaccounted command of one prior session, reported as one task.
459///
460/// One report per resume rather than one per work id: enqueuing raises `pending_count` and so
461/// counts against `lifecycle.queue_depth`, and a session that lost ten commands must not cost ten
462/// pending slots to say so.
463#[derive(Debug, Clone)]
464pub(crate) struct LostReport {
465    /// The session that started the work, and whose `trace.jsonl` holds both the unmatched
466    /// `shell_detached` lines and the `shell_lost` lines that account for them.
467    pub started_in_session: String,
468    /// Non-empty by construction: a report with nothing in it is never built.
469    pub lost: Vec<LostWork>,
470    /// The conversation the resume runs under, so the report joins that thread.
471    pub context_id: String,
472    /// [`TaskOrigin::Completion`] carrying the lowest trust across [`Self::lost`] — one untrusted
473    /// command makes the whole report untrusted.
474    pub provenance: TaskProvenance,
475    /// The task this report is enqueued as. Minted before the `shell_lost` markers are appended,
476    /// because each marker names it as `reconciled_task_id`.
477    pub task_id: String,
478}
479
480impl LostReport {
481    /// The `IncomingTask.message_text` the agent reads: the work id, the binary, the command and
482    /// the demotion instant of each lost command, and the statement that nothing about them is
483    /// recoverable.
484    ///
485    /// Shares no opening line with [`DetachedCompletion::message_text`]. A model that cannot tell
486    /// "it failed" from "nobody knows" retries, and the retry is the cost demotion exists to
487    /// remove. Names no output path, quotes no byte count and asserts no exit code, because none
488    /// of the three exists.
489    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/// What the task loop turns into a `completion`-origin task.
514///
515/// One enum rather than two enqueue paths: an authority reporting on work it started is the same
516/// shape whether the work finished under this runtime or was left unaccounted by one that died.
517#[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    /// `run_shell` against `bash -c <script>` with a grace period, returning the outcome and the
547    /// registry the completion (if any) will arrive on.
548    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(&registry),
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    /// Block until the completion lands, so the test fails with a timeout rather than hanging.
580    ///
581    /// The channel carries every report the task loop enqueues, so a shell command's completion is
582    /// taken off it by variant: anything else on this receiver means a delegation report reached a
583    /// test that started no delegation.
584    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    /// Every completion an agent can be handed opens with a line no other one opens with, so a
607    /// model can tell a delegation's outcome from a demoted command's without reading further.
608    #[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    /// A demoted command's limit reaches the model in the same words a foreground call's does,
655    /// without displacing the opening line or the closing pointer at the output file.
656    #[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    /// The whole argument of demotion: the call returns long before the command does, and the
687    /// result turns up afterwards with the output on disk rather than in hand.
688    #[test]
689    fn a_command_outrunning_the_grace_period_is_demoted() {
690        let workdir = tempfile::tempdir().unwrap();
691        let started = Instant::now();
692        // The output word is spelled nowhere in the command, so finding it in the completion
693        // would mean the output leaked into the turn rather than merely being echoed back.
694        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    /// A command that beats the grace period returns its result to the turn and registers nothing.
762    #[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    /// A failure is a completion, told apart from success by a field.
789    #[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    /// No input can make the demotion text grow: two work ids of equal length cost the same, and
825    /// the cost is small.
826    #[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        // Not token equality: `cl100k_base` splits one random hex id into a different number of
844        // pieces than another of the same length, which says nothing about this text. The claim
845        // that matters is that the cost is bounded and no input moves it.
846        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    /// The no-polling rule, checked against the source rather than trusted.
860    ///
861    /// A demoted command is learned about through its completion and no other way: no tool name,
862    /// host import or manifest-visible entry point takes a work id as an argument, and
863    /// `build_tool_inventory` gains no entry for one.
864    #[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        // Split so the needles are not spelled literally on the lines that use them — this test
871        // sweeps its own file too, and a self-match would be a permanent false positive.
872        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                // A public signature naming one would be a lookup surface for anything outside
885                // this crate — a CLI subcommand, a host embedder.
886                if (line.starts_with("pub fn ") || line.starts_with("pub async fn "))
887                    && line.contains(&parameter)
888                {
889                    offences.push(format!("{at}: public function takes a work id — {line}"));
890                }
891                // A tool's input parser reading the key would let the model name one.
892                if line.contains(&json_key) {
893                    offences.push(format!("{at}: tool input is parsed for a work id — {line}"));
894                }
895                // A tool manifest declaring it would put one in front of the model.
896                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    // ── The operator's abandonment report ────────────────────────────────────
915
916    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    /// A command still running is reported with the four things that are known about it and as
943    /// killed, which the session's end did to it. Whether its output file exists depends on
944    /// whether the runtime wrote it before exiting, so the report names the file without
945    /// promising it.
946    #[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    /// The `FinishedTooLate` arm names the exit code, the status, the duration and the real
982    /// output file, rather than being flattened into "still running, result lost". This is the
983    /// fidelity a clean exit has and a killed runtime does not.
984    #[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    /// One block for the session, then one entry per command: a session that discarded several
1002    /// states the condition once and enumerates below it.
1003    #[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    /// An attributed resource limit rides along, on the same terms
1021    /// [`DetachedCompletion::message_text`] carries it.
1022    #[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    /// `running_ms` and `recovered` are what the `shell_abandoned` line is written from, so the
1044    /// two arms must disagree on `recovered` and agree on carrying a duration.
1045    #[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    /// A wait that itself failed is named. It is what turns `status` to `"error"` on a command
1060    /// that exited zero, so a report carrying the status without the reason states a failure and
1061    /// withholds the only thing that explains it.
1062    #[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    /// The clean-exit report must not read like [`LostReport::message_text`]: that text asserts
1079    /// nothing was recovered and nothing can be, which is false here.
1080    #[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}