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::{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
27/// Prefix on every work id. Distinct from `tsk_`/`ctx_`/`ses_` so a reader of `trace.jsonl` can
28/// tell at a glance which id space a value belongs to.
29pub const WORK_ID_PREFIX: &str = "wrk_";
30
31/// A fresh work id, matching `^wrk_[0-9a-f]+$`.
32///
33/// Time-ordered like every other id the runtime mints, so two work ids sort in the order their
34/// commands were demoted.
35pub fn new_work_id() -> String {
36    format!("{WORK_ID_PREFIX}{}", uuid::Uuid::now_v7().simple())
37}
38
39/// Where a demoted command's full output lands, relative to the capsule workdir.
40///
41/// One rule, used by the writer and by every reader that has to find the file again.
42pub(crate) fn output_path_for(work_id: &str) -> String {
43    format!("logs/{work_id}.log")
44}
45
46/// A demoted command the session is no longer waiting for.
47#[derive(Debug, Clone)]
48pub(crate) struct DetachedWork {
49    pub work_id: String,
50    /// The program that ran, resolved the same way [`crate::shell::ShellResult::binary`] is.
51    pub binary: String,
52    /// The command text the model supplied, without the binary name.
53    pub command: String,
54    /// When the command was spawned, not when it was demoted — the foreground grace period counts
55    /// as running time, so this lines up with the `duration_ms` a completion carries.
56    pub started_at_ms: u64,
57}
58
59/// What a demoted command left behind when it finished.
60///
61/// Carries no output. The bytes are on disk at [`Self::output_path`] and the agent is told where,
62/// never what — a completion the model reads must not be able to grow with the command's output.
63#[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    /// Workdir-relative, always `logs/<work_id>.log`. Named even when [`Self::error`] is set, so
71    /// a reader knows where the output was meant to be.
72    pub output_path: String,
73    /// Bytes written to that file, header included.
74    pub output_bytes: u64,
75    pub resource_limit: Option<String>,
76    /// The conversation the command was started from. The completion task runs under this id, so
77    /// the result joins the thread that asked for it rather than opening a new one.
78    pub context_id: String,
79    /// Stamped [`TaskOrigin::Completion`] with the starting task's trust inherited.
80    pub provenance: TaskProvenance,
81    /// Some only when the wait itself failed — distinct from the command exiting non-zero.
82    pub error: Option<String>,
83}
84
85impl DetachedCompletion {
86    /// The `IncomingTask.message_text` the agent reads: the work id, the command, the exit code,
87    /// the duration and the output path. Never the output itself.
88    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    /// `"ok"` only for a command that ran to a clean exit with nothing attributed against it.
120    ///
121    /// A non-zero exit, a signal kill (which [`crate::shell`] reports as `128 + signal`), an
122    /// attributed resource limit and a wait that itself failed are all `"error"`: a failure is a
123    /// completion, and it is told apart from success by this field rather than by its prose.
124    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/// What a dispatch reports back when its command was demoted rather than finished.
134#[derive(Debug, Clone)]
135pub(crate) struct DetachedDispatchInfo {
136    pub work_id: String,
137    pub binary: String,
138    pub command: String,
139    /// The grace period this command outran, in milliseconds — the configured
140    /// `lifecycle.shell_grace_secs`, recorded so a trace reader can see which setting was in
141    /// force without consulting the manifest.
142    pub grace_ms: u64,
143}
144
145/// The tool result a demoted command hands back to the turn.
146///
147/// Constant size by construction: nothing here derives from the command, its arguments or its
148/// output, so no input can make the text the model reads grow. It names no path either — the
149/// output location arrives with the completion, which is the only thing that can say the file is
150/// finished being written.
151pub(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
168/// The demoted commands of one session, and the channel their completions arrive on.
169///
170/// Shared between the agent loop (which registers work) and each demoted command's own thread
171/// (which completes it), so the session-end sweep has one place to ask what is still running.
172pub(crate) struct DetachedRegistry {
173    outstanding: Mutex<BTreeMap<String, DetachedWork>>,
174    /// Every report the task loop turns into a `completion`-origin task, not only a demoted
175    /// command's. A delegation that outran its deadline arrives here too, so one channel feeds one
176    /// enqueue path and the admission rule cannot differ between them.
177    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    /// Hand the task loop one report. A send that fails means the loop is gone.
193    pub(crate) fn report(&self, report: DetachedReport) {
194        let _ = self.reports.send(report);
195    }
196
197    /// Record a demoted command. Called before the dispatch returns its handle, so the
198    /// session-end sweep cannot miss work registered after it looked.
199    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    /// Removes the work id from [`Self::outstanding`] and sends the completion.
207    ///
208    /// The channel is unbounded, so the blocking thread that calls this never waits and needs no
209    /// async context. A send that fails means the task loop is gone — the session is ending, and
210    /// the result has nowhere to be delivered.
211    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    /// Every command demoted and not yet completed, oldest work id first.
220    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
230/// Supplied by a dispatch that is allowed to demote.
231///
232/// `None` at a call site means the command runs to completion in the foreground. That is what
233/// keeps `plan.rs` and the script-capsule store state foreground-only: neither runs a task loop,
234/// so neither has anywhere to deliver a completion.
235pub(crate) struct DetachPolicy {
236    pub grace: Duration,
237    pub registry: Arc<DetachedRegistry>,
238    /// The command text as the model wrote it, without the binary name — the same string a
239    /// foreground `shell` trace record carries. `run_shell` only sees the argv it was handed, so
240    /// the caller that knows what was asked for supplies it here.
241    pub command: String,
242    pub context_id: String,
243    /// The provenance of the task that started the command. The completion is stamped
244    /// `TaskProvenance::derive(TaskOrigin::Completion, Some(that trust))`, so untrust survives
245    /// the round trip instead of being reset by the runtime enqueuing its own task.
246    pub provenance: Option<TaskProvenance>,
247}
248
249impl DetachPolicy {
250    /// The provenance the completion task carries.
251    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// -- Work a clean exit discarded ---------------------------------------------
260
261/// What the teardown sweep knows about one discarded command.
262///
263/// The two arms are the two places a command can be caught by the sweep, and they are told apart
264/// because what is known differs: a command still running has nothing on disk and never will,
265/// while one that finished between the task loop's last read and the sweep has an exit code and a
266/// written output log that no task will ever carry back.
267#[derive(Debug, Clone)]
268pub(crate) enum AbandonedDisposition {
269    /// Running when the session ended. No exit code exists, and no `logs/<work_id>.log` will be
270    /// written: [`crate::shell`] writes that file from the command's own runtime thread after
271    /// `child.wait()` returns, and that thread ends with the process.
272    StillRunning {
273        /// From spawn to the sweep, foreground portion included.
274        running_ms: u64,
275    },
276    /// Finished during teardown, after the task loop stopped reading completions. Everything a
277    /// `shell_completed` would have carried exists; only the task that would have carried it does
278    /// not.
279    FinishedTooLate {
280        exit_code: i32,
281        /// From spawn to exit, foreground portion included.
282        duration_ms: u64,
283        /// Workdir-relative, always `logs/<work_id>.log`, and on disk by the time this arm is
284        /// built.
285        output_path: String,
286        output_bytes: u64,
287        resource_limit: Option<String>,
288        /// Set only when the wait itself failed, as on [`DetachedCompletion::error`]. Without it a
289        /// report can read `status: error` with an exit code of `0` and no reason for either.
290        wait_error: Option<String>,
291        /// [`DetachedCompletion::status`] of the completion this was built from.
292        status: &'static str,
293    },
294}
295
296/// One discarded command, as the operator report and the `shell_abandoned` record both read it.
297#[derive(Debug, Clone)]
298pub(crate) struct AbandonedWork {
299    pub work_id: String,
300    /// As [`DetachedWork::binary`].
301    pub binary: String,
302    /// As [`DetachedWork::command`] — the text the model wrote, which the operator needs to know
303    /// what was actually left running.
304    pub command: String,
305    pub disposition: AbandonedDisposition,
306}
307
308impl AbandonedWork {
309    /// How long the command ran, whichever arm it was caught in — the `running_ms` of its
310    /// `shell_abandoned` line.
311    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    /// The exit code, output path and byte count, or `None` for a command still running.
319    ///
320    /// The three travel together because they exist together: a command with an exit code has a
321    /// written log, and one without has neither.
322    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
335/// The grouped operator report for every command one session discarded, as it lands on stderr and
336/// in `logs/bootstrap.log`.
337///
338/// One block for the whole session rather than a line per command: a session that discarded ten
339/// commands must state the condition once and then enumerate, or the condition is lost in the
340/// enumeration. Deliberately **not** [`LostReport::message_text`] — that text asserts nothing was
341/// recovered and nothing can be, which is false here: this session's registry was alive, so the
342/// running time is known, and a command that finished during teardown has its exit code and its
343/// output log on disk.
344pub(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// -- Work a dead session left unaccounted ------------------------------------
404
405/// A demoted command whose session died before anything could be recorded about it.
406///
407/// Reconstructed from the prior session's `shell_detached` line, which is the only trace of it
408/// that survives: [`DetachedRegistry`] lives in process memory, and [`crate::shell`] writes the
409/// output log from a runtime thread after `child.wait()` returns, so a runtime killed with
410/// `SIGKILL` leaves no `logs/<work id>.log` even for a command that ran to a clean exit.
411#[derive(Debug, Clone)]
412pub(crate) struct LostWork {
413    pub work_id: String,
414    /// As [`DetachedWork::binary`].
415    pub binary: String,
416    /// As [`DetachedWork::command`].
417    pub command: String,
418    /// The `shell_detached` line's own timestamp — the demotion instant, which is the start
419    /// instant plus the grace period the command outran.
420    pub detached_at_ms: u64,
421}
422
423/// Every unaccounted command of one prior session, reported as one task.
424///
425/// One report per resume rather than one per work id: enqueuing raises `pending_count` and so
426/// counts against `lifecycle.queue_depth`, and a session that lost ten commands must not cost ten
427/// pending slots to say so.
428#[derive(Debug, Clone)]
429pub(crate) struct LostReport {
430    /// The session that started the work, and whose `trace.jsonl` holds both the unmatched
431    /// `shell_detached` lines and the `shell_lost` lines that account for them.
432    pub started_in_session: String,
433    /// Non-empty by construction: a report with nothing in it is never built.
434    pub lost: Vec<LostWork>,
435    /// The conversation the resume runs under, so the report joins that thread.
436    pub context_id: String,
437    /// [`TaskOrigin::Completion`] carrying the lowest trust across [`Self::lost`] — one untrusted
438    /// command makes the whole report untrusted.
439    pub provenance: TaskProvenance,
440    /// The task this report is enqueued as. Minted before the `shell_lost` markers are appended,
441    /// because each marker names it as `reconciled_task_id`.
442    pub task_id: String,
443}
444
445impl LostReport {
446    /// The `IncomingTask.message_text` the agent reads: the work id, the binary, the command and
447    /// the demotion instant of each lost command, and the statement that nothing about them is
448    /// recoverable.
449    ///
450    /// Shares no opening line with [`DetachedCompletion::message_text`]. A model that cannot tell
451    /// "it failed" from "nobody knows" retries, and the retry is the cost demotion exists to
452    /// remove. Names no output path, quotes no byte count and asserts no exit code, because none
453    /// of the three exists.
454    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/// What the task loop turns into a `completion`-origin task.
479///
480/// One enum rather than two enqueue paths: an authority reporting on work it started is the same
481/// shape whether the work finished under this runtime or was left unaccounted by one that died.
482#[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    /// `run_shell` against `bash -c <script>` with a grace period, returning the outcome and the
512    /// registry the completion (if any) will arrive on.
513    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(&registry),
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    /// Block until the completion lands, so the test fails with a timeout rather than hanging.
544    ///
545    /// The channel carries every report the task loop enqueues, so a shell command's completion is
546    /// taken off it by variant: anything else on this receiver means a delegation report reached a
547    /// test that started no delegation.
548    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    /// Every completion an agent can be handed opens with a line no other one opens with, so a
571    /// model can tell a delegation's outcome from a demoted command's without reading further.
572    #[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    /// The whole argument of demotion: the call returns long before the command does, and the
619    /// result turns up afterwards with the output on disk rather than in hand.
620    #[test]
621    fn a_command_outrunning_the_grace_period_is_demoted() {
622        let workdir = tempfile::tempdir().unwrap();
623        let started = Instant::now();
624        // The output word is spelled nowhere in the command, so finding it in the completion
625        // would mean the output leaked into the turn rather than merely being echoed back.
626        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    /// A command that beats the grace period returns its result to the turn and registers nothing.
694    #[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    /// A failure is a completion, told apart from success by a field.
721    #[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    /// No input can make the demotion text grow: two work ids of equal length cost the same, and
757    /// the cost is small.
758    #[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        // Not token equality: `cl100k_base` splits one random hex id into a different number of
776        // pieces than another of the same length, which says nothing about this text. The claim
777        // that matters is that the cost is bounded and no input moves it.
778        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    /// The no-polling rule, checked against the source rather than trusted.
792    ///
793    /// A demoted command is learned about through its completion and no other way: no tool name,
794    /// host import or manifest-visible entry point takes a work id as an argument, and
795    /// `build_tool_inventory` gains no entry for one.
796    #[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        // Split so the needles are not spelled literally on the lines that use them — this test
803        // sweeps its own file too, and a self-match would be a permanent false positive.
804        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                // A public signature naming one would be a lookup surface for anything outside
817                // this crate — a CLI subcommand, a host embedder.
818                if (line.starts_with("pub fn ") || line.starts_with("pub async fn "))
819                    && line.contains(&parameter)
820                {
821                    offences.push(format!("{at}: public function takes a work id — {line}"));
822                }
823                // A tool's input parser reading the key would let the model name one.
824                if line.contains(&json_key) {
825                    offences.push(format!("{at}: tool input is parsed for a work id — {line}"));
826                }
827                // A tool manifest declaring it would put one in front of the model.
828                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    /// Every `.rs` file under this crate's `src`, as `(path relative to src, contents)`.
847    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    // ── The operator's abandonment report ────────────────────────────────────
879
880    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    /// A command still running is reported with the four things that are known about it, and is
907    /// told plainly that its output file will never exist — the runtime thread that writes it dies
908    /// with the process, so a report promising the file would send an operator looking for
909    /// something that never appears.
910    #[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    /// The `FinishedTooLate` arm names the exit code, the status, the duration and the real
934    /// output file, rather than being flattened into "still running, result lost". This is the
935    /// fidelity a clean exit has and a killed runtime does not.
936    #[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    /// One block for the session, then one entry per command: a session that discarded several
954    /// states the condition once and enumerates below it.
955    #[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    /// An attributed resource limit rides along, on the same terms
973    /// [`DetachedCompletion::message_text`] carries it.
974    #[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    /// `running_ms` and `recovered` are what the `shell_abandoned` line is written from, so the
996    /// two arms must disagree on `recovered` and agree on carrying a duration.
997    #[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    /// A wait that itself failed is named. It is what turns `status` to `"error"` on a command
1012    /// that exited zero, so a report carrying the status without the reason states a failure and
1013    /// withholds the only thing that explains it.
1014    #[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    /// The clean-exit report must not read like [`LostReport::message_text`]: that text asserts
1031    /// nothing was recovered and nothing can be, which is false here.
1032    #[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}