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. No exit code exists, and no `logs/<work_id>.log` will be
302    /// written: [`crate::shell`] writes that file from the command's own runtime thread after
303    /// `child.wait()` returns, and that thread ends with the process.
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         Nothing was waited for and nothing was killed; what is known about each is below.\n",
383        if count == 1 { "was" } else { "were" }
384    );
385    for work in abandoned {
386        text.push_str(&format!(
387            "\nwork_id: {}\nbinary: {}\ncommand: {}\n",
388            work.work_id, work.binary, work.command
389        ));
390        match &work.disposition {
391            AbandonedDisposition::StillRunning { running_ms } => {
392                text.push_str(&format!(
393                    "state: still running after {running_ms} ms\n\
394                     result: none — no exit code, and no {} will be written, because the thread \
395                     that writes it ended with this session.\n",
396                    output_path_for(&work.work_id)
397                ));
398            }
399            AbandonedDisposition::FinishedTooLate {
400                exit_code,
401                duration_ms,
402                output_path,
403                output_bytes,
404                resource_limit,
405                wait_error,
406                status,
407            } => {
408                text.push_str(&format!(
409                    "state: finished during teardown after {duration_ms} ms\n\
410                     status: {status}\n\
411                     exit_code: {exit_code}\n"
412                ));
413                if let Some(limit) = resource_limit {
414                    text.push_str(&format!("resource_limit: {limit}\n"));
415                }
416                if let Some(error) = wait_error {
417                    text.push_str(&format!("wait_error: {error}\n"));
418                }
419                text.push_str(&format!(
420                    "output: {output_path} ({output_bytes} bytes, in the capsule workdir)\n\
421                     result: on disk — it finished too late for any task to carry it back to the \
422                     agent.\n"
423                ));
424            }
425        }
426    }
427    text.push_str(
428        "\nA command still running keeps running, detached from this session, with nothing \
429         reading its output. Declare lifecycle.task_acceptance: queue with lifecycle.after_task: \
430         sleep for a session that outlives its task and can be told the result instead.",
431    );
432    text
433}
434
435// -- Work a dead session left unaccounted ------------------------------------
436
437/// A demoted command whose session died before anything could be recorded about it.
438///
439/// Reconstructed from the prior session's `shell_detached` line, which is the only trace of it
440/// that survives: [`DetachedRegistry`] lives in process memory, and [`crate::shell`] writes the
441/// output log from a runtime thread after `child.wait()` returns, so a runtime killed with
442/// `SIGKILL` leaves no `logs/<work id>.log` even for a command that ran to a clean exit.
443#[derive(Debug, Clone)]
444pub(crate) struct LostWork {
445    pub work_id: String,
446    /// As [`DetachedWork::binary`].
447    pub binary: String,
448    /// As [`DetachedWork::command`].
449    pub command: String,
450    /// The `shell_detached` line's own timestamp — the demotion instant, which is the start
451    /// instant plus the grace period the command outran.
452    pub detached_at_ms: u64,
453}
454
455/// Every unaccounted command of one prior session, reported as one task.
456///
457/// One report per resume rather than one per work id: enqueuing raises `pending_count` and so
458/// counts against `lifecycle.queue_depth`, and a session that lost ten commands must not cost ten
459/// pending slots to say so.
460#[derive(Debug, Clone)]
461pub(crate) struct LostReport {
462    /// The session that started the work, and whose `trace.jsonl` holds both the unmatched
463    /// `shell_detached` lines and the `shell_lost` lines that account for them.
464    pub started_in_session: String,
465    /// Non-empty by construction: a report with nothing in it is never built.
466    pub lost: Vec<LostWork>,
467    /// The conversation the resume runs under, so the report joins that thread.
468    pub context_id: String,
469    /// [`TaskOrigin::Completion`] carrying the lowest trust across [`Self::lost`] — one untrusted
470    /// command makes the whole report untrusted.
471    pub provenance: TaskProvenance,
472    /// The task this report is enqueued as. Minted before the `shell_lost` markers are appended,
473    /// because each marker names it as `reconciled_task_id`.
474    pub task_id: String,
475}
476
477impl LostReport {
478    /// The `IncomingTask.message_text` the agent reads: the work id, the binary, the command and
479    /// the demotion instant of each lost command, and the statement that nothing about them is
480    /// recoverable.
481    ///
482    /// Shares no opening line with [`DetachedCompletion::message_text`]. A model that cannot tell
483    /// "it failed" from "nobody knows" retries, and the retry is the cost demotion exists to
484    /// remove. Names no output path, quotes no byte count and asserts no exit code, because none
485    /// of the three exists.
486    pub(crate) fn message_text(&self) -> String {
487        let mut text = format!(
488            "Background shell commands from an earlier session were never accounted for.\n\
489             \n\
490             Session {} ended without recording what became of the commands below; they were \
491             running in the background at the time. Nothing about them was recovered and nothing \
492             can be: no exit code, no output and no log file exists for any of them, and whether \
493             each command finished at all is unknown.\n",
494            self.started_in_session
495        );
496        for work in &self.lost {
497            text.push_str(&format!(
498                "\nwork_id: {}\nbinary: {}\ncommand: {}\ndetached_at_ms: {}\n",
499                work.work_id, work.binary, work.command, work.detached_at_ms
500            ));
501        }
502        text.push_str(
503            "\nNone of these is known to have succeeded and none is known to have failed. Treat \
504             each as unknown, and start the work again only if it still matters.",
505        );
506        text
507    }
508}
509
510/// What the task loop turns into a `completion`-origin task.
511///
512/// One enum rather than two enqueue paths: an authority reporting on work it started is the same
513/// shape whether the work finished under this runtime or was left unaccounted by one that died.
514#[derive(Debug, Clone)]
515pub(crate) enum DetachedReport {
516    Completed(DetachedCompletion),
517    Lost(LostReport),
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    use std::{
525        path::Path,
526        time::{Duration, Instant},
527    };
528
529    use crate::{
530        origin::TrustClass,
531        sandbox::ShellEnforcement,
532        shell::{run_shell, ShellOutcome},
533        types::CapabilityPolicy,
534    };
535
536    fn bash_policy() -> CapabilityPolicy {
537        CapabilityPolicy {
538            shell_allow: vec!["bash".to_string()],
539            ..Default::default()
540        }
541    }
542
543    /// `run_shell` against `bash -c <script>` with a grace period, returning the outcome and the
544    /// registry the completion (if any) will arrive on.
545    fn detached_run(
546        workdir: &Path,
547        script: &str,
548        grace: Duration,
549    ) -> (
550        ShellOutcome,
551        Arc<DetachedRegistry>,
552        UnboundedReceiver<DetachedReport>,
553    ) {
554        let (registry, receiver) = DetachedRegistry::new();
555        let outcome = run_shell(
556            "bash",
557            &["-c", script],
558            &[],
559            workdir,
560            workdir,
561            &bash_policy(),
562            &ShellEnforcement::environment_only(),
563            Some(DetachPolicy {
564                grace,
565                registry: Arc::clone(&registry),
566                command: script.to_string(),
567                context_id: "ctx_detached_tests".to_string(),
568                provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
569                abandoned: None,
570            }),
571        )
572        .expect("a declared binary runs");
573        (outcome, registry, receiver)
574    }
575
576    /// Block until the completion lands, so the test fails with a timeout rather than hanging.
577    ///
578    /// The channel carries every report the task loop enqueues, so a shell command's completion is
579    /// taken off it by variant: anything else on this receiver means a delegation report reached a
580    /// test that started no delegation.
581    fn await_completion(receiver: &mut UnboundedReceiver<DetachedReport>) -> DetachedCompletion {
582        let deadline = Instant::now() + Duration::from_secs(30);
583        loop {
584            match receiver.try_recv() {
585                Ok(DetachedReport::Completed(completion)) => return completion,
586                Ok(other) => panic!("a shell command's completion was expected; got {other:?}"),
587                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
588                    assert!(
589                        Instant::now() < deadline,
590                        "timed out waiting for the detached completion"
591                    );
592                    std::thread::sleep(Duration::from_millis(25));
593                }
594                Err(error) => panic!("the completion channel closed early: {error}"),
595            }
596        }
597    }
598
599    fn completion_provenance() -> TaskProvenance {
600        TaskProvenance::derive(TaskOrigin::Completion, Some(TrustClass::Untrusted))
601    }
602
603    /// Every completion an agent can be handed opens with a line no other one opens with, so a
604    /// model can tell a delegation's outcome from a demoted command's without reading further.
605    #[test]
606    fn no_two_completions_share_an_opening_line() {
607        let outcome = crate::delegation::DelegationOutcome {
608            delegation_id: "dlg_0001".to_string(),
609            capsule_name: "worker".to_string(),
610            capsule_version: "0.1.0".to_string(),
611            session_id: "ses_child".to_string(),
612            status: crate::delegation::DelegationStatus::Ok,
613            result_path: None,
614            workdir: "/tmp/child".to_string(),
615            duration_ms: 1,
616            detail: None,
617            reported_by: crate::delegation::Reporter::Launcher,
618            delivered: true,
619            delivery_error: None,
620        };
621        let completion = DetachedCompletion {
622            work_id: "wrk_0001".to_string(),
623            binary: "bash".to_string(),
624            command: "sleep 3".to_string(),
625            exit_code: 0,
626            duration_ms: 3_000,
627            output_path: "logs/wrk_0001.log".to_string(),
628            output_bytes: 0,
629            resource_limit: None,
630            context_id: "ctx_1".to_string(),
631            provenance: completion_provenance(),
632            error: None,
633        };
634        let texts = [
635            ("delegation outcome", outcome.message_text()),
636            ("detached shell completion", completion.message_text()),
637        ];
638        for (index, (name, text)) in texts.iter().enumerate() {
639            let opening = text.lines().next().unwrap_or_default();
640            assert!(!opening.is_empty(), "{name} opens with nothing");
641            for (other_name, other) in texts.iter().skip(index + 1) {
642                assert_ne!(
643                    opening,
644                    other.lines().next().unwrap_or_default(),
645                    "{name} and {other_name} open with the same line"
646                );
647            }
648        }
649    }
650
651    /// A demoted command's limit reaches the model in the same words a foreground call's does,
652    /// without displacing the opening line or the closing pointer at the output file.
653    #[test]
654    fn a_completion_names_its_limit_in_the_shared_wording() {
655        let completion = DetachedCompletion {
656            work_id: "wrk_0001".to_string(),
657            binary: "bash".to_string(),
658            command: "make -j64".to_string(),
659            exit_code: 1,
660            duration_ms: 3_000,
661            output_path: "logs/wrk_0001.log".to_string(),
662            output_bytes: 0,
663            resource_limit: Some("cgroup_pids_max".into()),
664            context_id: "ctx_1".to_string(),
665            provenance: completion_provenance(),
666            error: None,
667        };
668        let text = completion.message_text();
669        let expected = crate::resources::resource_limit_line("cgroup_pids_max");
670
671        assert!(
672            text.lines().any(|line| line == expected),
673            "no limit line in:\n{text}"
674        );
675        assert_eq!(
676            text.lines().next(),
677            Some("Background shell command finished.")
678        );
679        assert_eq!(completion.status(), "error");
680        assert!(text.ends_with("are not reproduced here."), "{text}");
681    }
682
683    /// The whole argument of demotion: the call returns long before the command does, and the
684    /// result turns up afterwards with the output on disk rather than in hand.
685    #[test]
686    fn a_command_outrunning_the_grace_period_is_demoted() {
687        let workdir = tempfile::tempdir().unwrap();
688        let started = Instant::now();
689        // The output word is spelled nowhere in the command, so finding it in the completion
690        // would mean the output leaked into the turn rather than merely being echoed back.
691        let (outcome, registry, mut receiver) = detached_run(
692            workdir.path(),
693            "sleep 3; printf 'fin%s' 'ished-marker'",
694            Duration::from_secs(1),
695        );
696
697        let info = match outcome {
698            ShellOutcome::Detached(info) => info,
699            ShellOutcome::Finished(result) => {
700                panic!(
701                    "a three-second command must not finish inside a one-second grace: {result:?}"
702                )
703            }
704        };
705        assert!(
706            started.elapsed() < Duration::from_secs(3),
707            "the call returned only after the command did"
708        );
709        assert!(
710            info.work_id.starts_with(WORK_ID_PREFIX)
711                && info.work_id[WORK_ID_PREFIX.len()..]
712                    .chars()
713                    .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
714            "work id {} must match ^wrk_[0-9a-f]+$",
715            info.work_id
716        );
717        assert_eq!(info.grace_ms, 1_000);
718        assert_eq!(
719            registry
720                .outstanding()
721                .iter()
722                .map(|work| work.work_id.clone())
723                .collect::<Vec<_>>(),
724            vec![info.work_id.clone()],
725            "the work is registered before the handle is returned"
726        );
727
728        let completion = await_completion(&mut receiver);
729        assert_eq!(completion.work_id, info.work_id);
730        assert_eq!(completion.exit_code, 0);
731        assert_eq!(completion.status(), "ok");
732        assert_eq!(
733            completion.output_path,
734            format!("logs/{}.log", info.work_id),
735            "the completion names logs/<work_id>.log"
736        );
737        assert_eq!(completion.provenance.origin(), TaskOrigin::Completion);
738        assert_eq!(completion.provenance.trust(), TrustClass::Trusted);
739        assert_eq!(completion.context_id, "ctx_detached_tests");
740        assert!(
741            registry.outstanding().is_empty(),
742            "a completed command is no longer outstanding"
743        );
744
745        let log = std::fs::read_to_string(workdir.path().join(&completion.output_path))
746            .expect("the completion's output file exists");
747        assert!(
748            log.contains("finished-marker"),
749            "the output is in the log: {log}"
750        );
751        assert!(
752            !completion.message_text().contains("finished-marker"),
753            "the output never reaches the agent: {}",
754            completion.message_text()
755        );
756    }
757
758    /// A command that beats the grace period returns its result to the turn and registers nothing.
759    #[test]
760    fn a_fast_command_finishes_in_the_foreground() {
761        let workdir = tempfile::tempdir().unwrap();
762        let (outcome, registry, mut receiver) =
763            detached_run(workdir.path(), "echo hi", Duration::from_secs(10));
764
765        let result = match outcome {
766            ShellOutcome::Finished(result) => result,
767            ShellOutcome::Detached(info) => {
768                panic!("`echo hi` must not be demoted: {}", info.work_id)
769            }
770        };
771        assert_eq!(result.stdout.trim(), "hi");
772        assert_eq!(result.exit_code, 0);
773        assert!(!result.truncated);
774        assert!(result.full_output_path.is_none());
775        assert!(registry.outstanding().is_empty());
776        assert!(
777            matches!(
778                receiver.try_recv(),
779                Err(tokio::sync::mpsc::error::TryRecvError::Empty)
780            ),
781            "a foreground command produces no completion"
782        );
783    }
784
785    /// A failure is a completion, told apart from success by a field.
786    #[test]
787    fn a_failing_detached_command_completes_with_its_exit_code() {
788        let workdir = tempfile::tempdir().unwrap();
789        let (outcome, _registry, mut receiver) = detached_run(
790            workdir.path(),
791            "sleep 3; echo to-stderr >&2; exit 7",
792            Duration::from_secs(1),
793        );
794        assert!(matches!(outcome, ShellOutcome::Detached(_)));
795
796        let completion = await_completion(&mut receiver);
797        assert_eq!(completion.exit_code, 7);
798        assert_eq!(completion.status(), "error");
799        assert!(
800            completion.message_text().contains("exit_code: 7"),
801            "the message names the exit code: {}",
802            completion.message_text()
803        );
804
805        let log = std::fs::read_to_string(workdir.path().join(&completion.output_path)).unwrap();
806        let stderr_section = log
807            .split("Stderr:")
808            .nth(1)
809            .expect("the log has a stderr section");
810        assert!(
811            stderr_section.contains("to-stderr"),
812            "stderr is in the log's Stderr section: {log}"
813        );
814
815        let (outcome, _registry, mut receiver) =
816            detached_run(workdir.path(), "sleep 3; exit 0", Duration::from_secs(1));
817        assert!(matches!(outcome, ShellOutcome::Detached(_)));
818        assert_eq!(await_completion(&mut receiver).status(), "ok");
819    }
820
821    /// No input can make the demotion text grow: two work ids of equal length cost the same, and
822    /// the cost is small.
823    #[test]
824    fn the_demotion_result_is_a_constant_size() {
825        let reduce = |work_id: &str| {
826            let result = demotion_tool_result(work_id);
827            result
828                .data
829                .or(result.summary)
830                .unwrap_or_else(|| "tool returned no data".to_string())
831        };
832        let first = reduce(&new_work_id());
833        let second = reduce(&new_work_id());
834
835        assert_eq!(
836            first.len(),
837            second.len(),
838            "two work ids of equal length must produce texts of equal size"
839        );
840        // Not token equality: `cl100k_base` splits one random hex id into a different number of
841        // pieces than another of the same length, which says nothing about this text. The claim
842        // that matters is that the cost is bounded and no input moves it.
843        for text in [&first, &second] {
844            let tokens = crate::agent::count_tokens(text);
845            assert!(
846                tokens < 120,
847                "the demotion result costs {tokens} tokens, over the 120-token ceiling"
848            );
849        }
850        assert!(
851            !first.contains("logs/"),
852            "the demotion result names no path: {first}"
853        );
854    }
855
856    /// The no-polling rule, checked against the source rather than trusted.
857    ///
858    /// A demoted command is learned about through its completion and no other way: no tool name,
859    /// host import or manifest-visible entry point takes a work id as an argument, and
860    /// `build_tool_inventory` gains no entry for one.
861    #[test]
862    fn no_surface_takes_a_work_id() {
863        const RULE: &str = "a demoted command is learned about through its completion and no \
864                            other way: nothing the model or a host embedder can reach may take a \
865                            work id as an argument";
866
867        // Split so the needles are not spelled literally on the lines that use them — this test
868        // sweeps its own file too, and a self-match would be a permanent false positive.
869        let work_id = concat!("work", "_id");
870        let parameter = format!("{work_id}:");
871        let json_key = format!(".get(\"{work_id}\")");
872        let schema = concat!("input", "_schema");
873
874        let sources = crate::source_scan::crate_sources();
875        let mut offences = Vec::new();
876        for (path, source) in &sources {
877            for (number, line) in source.lines().enumerate() {
878                let line = line.trim();
879                let at = format!("{path}:{}", number + 1);
880
881                // A public signature naming one would be a lookup surface for anything outside
882                // this crate — a CLI subcommand, a host embedder.
883                if (line.starts_with("pub fn ") || line.starts_with("pub async fn "))
884                    && line.contains(&parameter)
885                {
886                    offences.push(format!("{at}: public function takes a work id — {line}"));
887                }
888                // A tool's input parser reading the key would let the model name one.
889                if line.contains(&json_key) {
890                    offences.push(format!("{at}: tool input is parsed for a work id — {line}"));
891                }
892                // A tool manifest declaring it would put one in front of the model.
893                if line.contains(schema) && line.contains(work_id) {
894                    offences.push(format!("{at}: a tool schema declares a work id — {line}"));
895                }
896            }
897        }
898
899        let inventory = sources
900            .iter()
901            .find(|(path, _)| path.ends_with("inventory.rs"))
902            .map(|(_, source)| source)
903            .expect("agent/inventory.rs is part of this crate");
904        if inventory.contains(work_id) || inventory.contains(WORK_ID_PREFIX) {
905            offences.push("agent/inventory.rs: the tool inventory mentions a work id".to_string());
906        }
907
908        assert!(offences.is_empty(), "{RULE}\n{}", offences.join("\n"));
909    }
910
911    // ── The operator's abandonment report ────────────────────────────────────
912
913    fn still_running(work_id: &str, running_ms: u64) -> AbandonedWork {
914        AbandonedWork {
915            work_id: work_id.to_string(),
916            binary: "/usr/bin/bash".to_string(),
917            command: "sleep 45; echo done".to_string(),
918            disposition: AbandonedDisposition::StillRunning { running_ms },
919        }
920    }
921
922    fn finished_too_late(work_id: &str) -> AbandonedWork {
923        AbandonedWork {
924            work_id: work_id.to_string(),
925            binary: "/usr/bin/bash".to_string(),
926            command: "sleep 2; echo done".to_string(),
927            disposition: AbandonedDisposition::FinishedTooLate {
928                exit_code: 0,
929                duration_ms: 2_014,
930                output_path: output_path_for(work_id),
931                output_bytes: 91,
932                resource_limit: None,
933                wait_error: None,
934                status: "ok",
935            },
936        }
937    }
938
939    /// A command still running is reported with the four things that are known about it, and is
940    /// told plainly that its output file will never exist — the runtime thread that writes it dies
941    /// with the process, so a report promising the file would send an operator looking for
942    /// something that never appears.
943    #[test]
944    fn a_still_running_command_is_reported_with_what_is_known_and_promises_no_file() {
945        let text = abandonment_report_text("ses_abc", &[still_running("wrk_0a1b", 45_012)]);
946
947        assert!(
948            text.contains("1 background shell command discarded"),
949            "{text}"
950        );
951        assert!(text.contains("Session ses_abc"), "{text}");
952        assert!(text.contains("work_id: wrk_0a1b"), "{text}");
953        assert!(text.contains("binary: /usr/bin/bash"), "{text}");
954        assert!(text.contains("command: sleep 45; echo done"), "{text}");
955        assert!(
956            text.contains("state: still running after 45012 ms"),
957            "{text}"
958        );
959        assert!(
960            text.contains("no logs/wrk_0a1b.log will be written"),
961            "{text}"
962        );
963        assert!(!text.contains("exit_code:"), "no exit code exists: {text}");
964    }
965
966    /// The `FinishedTooLate` arm names the exit code, the status, the duration and the real
967    /// output file, rather than being flattened into "still running, result lost". This is the
968    /// fidelity a clean exit has and a killed runtime does not.
969    #[test]
970    fn a_command_that_finished_during_teardown_is_reported_with_its_result() {
971        let text = abandonment_report_text("ses_abc", &[finished_too_late("wrk_0c2d")]);
972
973        assert!(
974            text.contains("state: finished during teardown after 2014 ms"),
975            "{text}"
976        );
977        assert!(text.contains("status: ok"), "{text}");
978        assert!(text.contains("exit_code: 0"), "{text}");
979        assert!(
980            text.contains("output: logs/wrk_0c2d.log (91 bytes, in the capsule workdir)"),
981            "{text}"
982        );
983        assert!(!text.contains("still running after"), "{text}");
984    }
985
986    /// One block for the session, then one entry per command: a session that discarded several
987    /// states the condition once and enumerates below it.
988    #[test]
989    fn the_report_states_the_condition_once_and_enumerates_every_command() {
990        let text = abandonment_report_text(
991            "ses_abc",
992            &[still_running("wrk_0a1b", 12), finished_too_late("wrk_0c2d")],
993        );
994
995        assert_eq!(
996            text.matches("2 background shell commands discarded")
997                .count(),
998            1
999        );
1000        assert_eq!(text.matches("work_id: ").count(), 2);
1001        assert!(text.contains("wrk_0a1b"), "{text}");
1002        assert!(text.contains("wrk_0c2d"), "{text}");
1003    }
1004
1005    /// An attributed resource limit rides along, on the same terms
1006    /// [`DetachedCompletion::message_text`] carries it.
1007    #[test]
1008    fn an_attributed_resource_limit_is_named_in_the_report() {
1009        let mut work = finished_too_late("wrk_0c2d");
1010        if let AbandonedDisposition::FinishedTooLate {
1011            resource_limit,
1012            exit_code,
1013            status,
1014            ..
1015        } = &mut work.disposition
1016        {
1017            *resource_limit = Some("memory".to_string());
1018            *exit_code = 137;
1019            *status = "error";
1020        }
1021        let text = abandonment_report_text("ses_abc", &[work]);
1022
1023        assert!(text.contains("resource_limit: memory"), "{text}");
1024        assert!(text.contains("status: error"), "{text}");
1025        assert!(text.contains("exit_code: 137"), "{text}");
1026    }
1027
1028    /// `running_ms` and `recovered` are what the `shell_abandoned` line is written from, so the
1029    /// two arms must disagree on `recovered` and agree on carrying a duration.
1030    #[test]
1031    fn only_a_command_that_finished_carries_a_recovered_result() {
1032        assert_eq!(still_running("wrk_0a1b", 45_012).running_ms(), 45_012);
1033        assert!(still_running("wrk_0a1b", 45_012).recovered().is_none());
1034
1035        let finished = finished_too_late("wrk_0c2d");
1036        assert_eq!(finished.running_ms(), 2_014);
1037        assert_eq!(
1038            finished.recovered(),
1039            Some((0, "logs/wrk_0c2d.log", 91)),
1040            "the exit code, the output path and its byte count travel together"
1041        );
1042    }
1043
1044    /// A wait that itself failed is named. It is what turns `status` to `"error"` on a command
1045    /// that exited zero, so a report carrying the status without the reason states a failure and
1046    /// withholds the only thing that explains it.
1047    #[test]
1048    fn a_failed_wait_is_named_beside_the_status_it_explains() {
1049        let mut work = finished_too_late("wrk_0c2d");
1050        if let AbandonedDisposition::FinishedTooLate {
1051            wait_error, status, ..
1052        } = &mut work.disposition
1053        {
1054            *wait_error = Some("no child processes".to_string());
1055            *status = "error";
1056        }
1057        let text = abandonment_report_text("ses_abc", &[work]);
1058
1059        assert!(text.contains("status: error"), "{text}");
1060        assert!(text.contains("wait_error: no child processes"), "{text}");
1061    }
1062
1063    /// The clean-exit report must not read like [`LostReport::message_text`]: that text asserts
1064    /// nothing was recovered and nothing can be, which is false here.
1065    #[test]
1066    fn the_report_does_not_claim_nothing_is_recoverable() {
1067        let text = abandonment_report_text("ses_abc", &[still_running("wrk_0a1b", 12)]);
1068        assert!(
1069            !text.contains("Nothing about them was recovered"),
1070            "the clean-exit report is not the crash report: {text}"
1071        );
1072    }
1073}