Skip to main content

capsule_runtime/
delegation.rs

1//! Which capsule delegated to this one, and how a delegated child says that it finished.
2//!
3//! A child launched by [`crate::child_launch`] knows its spawner from one injected environment
4//! variable, [`SPAWNER_ENV`], and from nothing it inherited: the parent's launcher composes a
5//! [`SpawnerHandle`] and applies it in the runtime-owned tail of the child's environment. A
6//! capsule launched any other way has no handle, records no lineage and contacts nothing.
7//!
8//! **Knowing your parent and reporting to it are separate.** Every handle names the session that
9//! spawned this one and the delegation that created it, which is what the child writes into its
10//! own `session_start`. Only a handle carrying a [`CompletionAddress`] also has somewhere to post
11//! an outcome: a parent that waits on the connection it already holds — every delegation made
12//! through [`crate::delegation_plane`] — supplies none, so its child is attributable without
13//! anything being sent anywhere.
14//!
15//! **The completion names the delegation; it never carries the child's output.** Every field of
16//! [`DelegationOutcome`] is composed by a runtime — ids, a status word, a path, a duration — and
17//! the one input that could grow, the crash detail, is capped twice: at the 20 stderr lines
18//! `child_launch` retains, and again at [`MAX_DETAIL_BYTES`]. The child's result stays in a file
19//! in the child's own directory, which sits inside the parent's single WASI preopen, so a parent
20//! that wants it reads it deliberately through an ordinary tool call and the untrusted-content
21//! fence applies at that read. This is the rule [`crate::detached`] already follows one layer
22//! down for a demoted shell command, which names `output_path` and never the output.
23//!
24//! Two reporters, one arrival path. The child reports for itself at the end of its own session;
25//! the parent's [`crate::child_launch::LaunchedChild`] reports for a child that could not. Both
26//! write [`COMPLETION_FILE`] into the child's directory and both post the same JSON-RPC
27//! `message/send` to the parent's A2A door, which is the only way a completion enters the
28//! parent's queue. A completion that cannot be delivered is recorded in that file with
29//! `delivered: false` and the refusal's reason, and a line goes to stderr; it is not retried
30//! beyond the launcher's single retry and it is not silently discarded.
31
32use std::path::{Path, PathBuf};
33
34use serde::{Deserialize, Serialize};
35use serde_json::Value;
36
37use crate::errors::RuntimeError;
38use crate::http_client::http_json;
39use crate::origin::{stamp_for_completion, TrustClass, PEER_ORIGIN_HEADER, PEER_TRUST_HEADER};
40
41/// The variable the parent's launcher injects into a delegated child, carrying its
42/// [`SpawnerHandle`] as compact JSON.
43///
44/// Applied in the runtime-owned tail of the child's environment alongside `MURMUR_ROOST_URL`, so
45/// a child cannot displace it by allowlisting the name in `capabilities.env.allow`.
46pub const SPAWNER_ENV: &str = "MURMUR_SPAWNER";
47
48/// Header naming the delegation a completion reports on. Read by the parent's door only for a
49/// request classified `completion`, and carried onto the enqueued task and its `task_start`.
50pub const DELEGATION_ID_HEADER: &str = "x-murmur-delegation-id";
51
52/// Header naming the session a completion is addressed to.
53///
54/// The parent's door refuses a completion whose addressed session is not its own — the shape a
55/// parent that restarted onto the same address leaves behind, where the port answers but the
56/// session that made the delegation is gone.
57pub const COMPLETION_SESSION_HEADER: &str = "x-murmur-completion-session";
58
59/// Prefix on every delegation id. Distinct from `tsk_`/`ctx_`/`ses_`/`wrk_`, so a reader of
60/// `trace.jsonl` can tell at a glance which id space a value belongs to.
61pub const DELEGATION_ID_PREFIX: &str = "dlg_";
62
63/// The child's own record of how it ended, in the child's directory.
64pub const COMPLETION_FILE: &str = "completion.json";
65
66/// Ceiling on [`DelegationOutcome::detail`], the one field built from something a child wrote.
67///
68/// `child_launch` already bounds the stderr tail to its last 20 lines; this bounds the bytes, so
69/// a child that logged one enormous line cannot grow the text the parent's agent reads.
70pub const MAX_DETAIL_BYTES: usize = 2_000;
71
72/// A fresh delegation id, matching `^dlg_[0-9a-f]+$`.
73///
74/// Time-ordered like every other id the runtime mints, so two delegation ids sort in the order
75/// the delegations were made.
76pub fn new_delegation_id() -> String {
77    format!("{DELEGATION_ID_PREFIX}{}", uuid::Uuid::now_v7().simple())
78}
79
80/// Where a completion is posted and the trust it inherits.
81///
82/// Absent for a delegation whose parent waits on the connection it already holds and therefore
83/// wants no completion. Reporting needs an address; knowing your parent does not, which is why
84/// this is the optional half of a [`Spawner`] and the lineage is the unconditional half.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct CompletionAddress {
87    /// The parent's own A2A endpoint, `http://host:port`.
88    pub url: String,
89    /// The trust class of the parent task that made the delegation. Inherited by the completion
90    /// through [`stamp_for_completion`] and decided nowhere else.
91    pub trust: TrustClass,
92}
93
94/// The parent a delegated child belongs to, supplied by the parent at launch.
95///
96/// Carries no delegation id: the id is minted by the launcher, one per launch, so a caller
97/// holding one `Spawner` cannot make two delegations report under one id.
98#[derive(Debug, Clone)]
99pub struct Spawner {
100    /// The parent's session id. A completion addressed to any other session is refused at the
101    /// door rather than delivered to whoever answers the address now, and it is the value the
102    /// child writes to its own `session_start.spawned_by`.
103    pub session_id: String,
104    /// The conversation the delegation was made from. The completion task runs under this id, so
105    /// the outcome joins the thread that asked for it.
106    pub context_id: String,
107    /// Where this delegation's completion goes, or `None` for a parent that wants none.
108    pub report_to: Option<CompletionAddress>,
109}
110
111/// The value injected into the child: a [`Spawner`] plus the delegation id the launcher minted.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct SpawnerHandle {
114    pub session_id: String,
115    pub context_id: String,
116    pub delegation_id: String,
117    pub report_to: Option<CompletionAddress>,
118}
119
120impl SpawnerHandle {
121    /// The handle for one launch of `spawner`'s child.
122    pub fn for_delegation(spawner: &Spawner, delegation_id: String) -> Self {
123        Self {
124            session_id: spawner.session_id.clone(),
125            context_id: spawner.context_id.clone(),
126            delegation_id,
127            report_to: spawner.report_to.clone(),
128        }
129    }
130
131    /// The compact JSON written into [`SPAWNER_ENV`].
132    ///
133    /// `url` and `trust` appear together or not at all: a lineage-only handle carries the three
134    /// keys that name the relationship and nothing that could be read as an address.
135    pub fn to_env_value(&self) -> String {
136        let mut value = serde_json::json!({
137            "session_id": self.session_id,
138            "context_id": self.context_id,
139            "delegation_id": self.delegation_id,
140        });
141        if let Some(address) = &self.report_to {
142            value["url"] = Value::String(address.url.clone());
143            value["trust"] = Value::String(address.trust.as_str().to_string());
144        }
145        value.to_string()
146    }
147
148    /// This process's own handle, read from [`SPAWNER_ENV`].
149    ///
150    /// `Ok(None)` for a capsule nobody delegated — the variable absent, or blank. A variable that
151    /// is set to something else is an error and not an absence: a child that cannot read its
152    /// spawner cannot record which session spawned it or report to it, and running it anyway
153    /// would produce work whose provenance and outcome reach nobody.
154    pub fn from_env() -> Result<Option<Self>, RuntimeError> {
155        let Some(raw) = std::env::var_os(SPAWNER_ENV) else {
156            return Ok(None);
157        };
158        let raw = raw.to_string_lossy().to_string();
159        if raw.trim().is_empty() {
160            return Ok(None);
161        }
162        Self::parse(raw.trim()).map(Some)
163    }
164
165    /// Parse one [`Self::to_env_value`] string.
166    pub fn parse(value: &str) -> Result<Self, RuntimeError> {
167        let unreadable = |reason: String| RuntimeError::SpawnerHandleUnreadable { reason };
168        let parsed: Value = serde_json::from_str(value)
169            .map_err(|error| unreadable(format!("it is not JSON: {error}")))?;
170        let field = |name: &str| -> Result<String, RuntimeError> {
171            parsed
172                .get(name)
173                .and_then(Value::as_str)
174                .filter(|value| !value.is_empty())
175                .map(str::to_string)
176                .ok_or_else(|| unreadable(format!("it carries no '{name}'")))
177        };
178        // Half an address is not a lineage-only handle with a stray key: it is a handle whose
179        // author meant a completion to arrive somewhere and did not say where, or under what
180        // trust, and delivering one on a guess is the thing this refuses to do.
181        let report_to =
182            match (parsed.get("url").is_some(), parsed.get("trust").is_some()) {
183                (false, false) => None,
184                (true, true) => {
185                    let trust_value = field("trust")?;
186                    // The one place in this module that names a trust class: every other use passes
187                    // the parsed value through `stamp_for_completion`, so the completion's class is
188                    // derived once, from the delegating task, and never decided here.
189                    let trust = TrustClass::parse(&trust_value).ok_or_else(|| {
190                        unreadable(format!("'{trust_value}' is not a trust class"))
191                    })?;
192                    Some(CompletionAddress {
193                        url: field("url")?,
194                        trust,
195                    })
196                }
197                (true, false) => return Err(unreadable(
198                    "it carries a 'url' with no 'trust'; a completion address is both or neither"
199                        .to_string(),
200                )),
201                (false, true) => return Err(unreadable(
202                    "it carries a 'trust' with no 'url'; a completion address is both or neither"
203                        .to_string(),
204                )),
205            };
206        Ok(Self {
207            session_id: field("session_id")?,
208            context_id: field("context_id")?,
209            delegation_id: field("delegation_id")?,
210            report_to,
211        })
212    }
213}
214
215/// How a delegated child ended.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "lowercase")]
218pub enum DelegationStatus {
219    /// The child's session finished. Reported by the child.
220    Ok,
221    /// The child's session ran and failed. Reported by the child.
222    Error,
223    /// The child's process ended without recording a completion. Reported by the launcher.
224    Crashed,
225    /// The parent ended the delegation itself. Recorded by the launcher and posted to nobody —
226    /// the only party that would be told is the party that did it.
227    Terminated,
228}
229
230impl DelegationStatus {
231    pub fn as_str(&self) -> &'static str {
232        match self {
233            Self::Ok => "ok",
234            Self::Error => "error",
235            Self::Crashed => "crashed",
236            Self::Terminated => "terminated",
237        }
238    }
239}
240
241/// Which of the two reporters built a completion.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "lowercase")]
244pub enum Reporter {
245    /// The child's own runtime, at the end of its session.
246    Child,
247    /// The parent's launcher, for a child that could not report for itself.
248    Launcher,
249}
250
251impl Reporter {
252    pub fn as_str(&self) -> &'static str {
253        match self {
254            Self::Child => "child",
255            Self::Launcher => "launcher",
256        }
257    }
258}
259
260/// What a delegation left behind: the notification the parent receives, and the shape of
261/// [`COMPLETION_FILE`].
262///
263/// Carries no output. The child's result is on disk at [`Self::result_path`], relative to
264/// [`Self::workdir`], and the parent is told where, never what.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct DelegationOutcome {
267    /// The id the parent's launcher minted, injected into the child and echoed back here.
268    pub delegation_id: String,
269    pub capsule_name: String,
270    pub capsule_version: String,
271    /// The child's own session, so its trace is findable.
272    pub session_id: String,
273    pub status: DelegationStatus,
274    /// Workdir-relative, `out/result.txt` for a capsule that wrote its result where the runtime
275    /// writes one. Absent when the child wrote none — a terminal path that failed without result
276    /// text legitimately leaves no file.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub result_path: Option<String>,
279    /// The child's directory, absolute — the root [`Self::result_path`] is relative to, and where
280    /// [`COMPLETION_FILE`] itself sits.
281    pub workdir: String,
282    pub duration_ms: u64,
283    /// Present only for a `crashed` or `terminated` outcome: the exit status, and for a crash the
284    /// child's bounded stderr tail. Capped at [`MAX_DETAIL_BYTES`].
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub detail: Option<String>,
287    pub reported_by: Reporter,
288    /// Whether the notification reached the parent's door. `false` on a `terminated` outcome,
289    /// which is never posted.
290    pub delivered: bool,
291    /// Why delivery failed, when one was attempted and refused.
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub delivery_error: Option<String>,
294}
295
296impl DelegationOutcome {
297    /// The `IncomingTask.message_text` the parent's agent reads: the delegation, the capsule, the
298    /// outcome, the duration and where the result is. Never the result itself.
299    pub fn message_text(&self) -> String {
300        let mut text = format!(
301            "Delegated capsule finished.\n\
302             delegation_id: {}\n\
303             capsule: {}@{}\n\
304             session_id: {}\n\
305             status: {}\n\
306             duration_ms: {}\n\
307             workdir: {}\n",
308            self.delegation_id,
309            self.capsule_name,
310            self.capsule_version,
311            self.session_id,
312            self.status.as_str(),
313            self.duration_ms,
314            self.workdir,
315        );
316        match &self.result_path {
317            Some(path) => text.push_str(&format!("result: {path} (in that workdir)")),
318            None => text.push_str("result: none (the child wrote no result file)"),
319        }
320        if let Some(detail) = &self.detail {
321            text.push_str(&format!("\ndetail: {detail}"));
322        }
323        text.push_str("\n\nThe child's own output is in that file and is not reproduced here.");
324        text
325    }
326
327    /// Bound `detail` at [`MAX_DETAIL_BYTES`], on a character boundary.
328    fn with_bounded_detail(mut self) -> Self {
329        if let Some(detail) = self.detail.take() {
330            self.detail = Some(bound_detail(detail));
331        }
332        self
333    }
334}
335
336/// `detail` cut to [`MAX_DETAIL_BYTES`] at a character boundary, with the cut marked.
337pub(crate) fn bound_detail(detail: String) -> String {
338    if detail.len() <= MAX_DETAIL_BYTES {
339        return detail;
340    }
341    let mut end = MAX_DETAIL_BYTES;
342    while end > 0 && !detail.is_char_boundary(end) {
343        end -= 1;
344    }
345    format!("{} […]", &detail[..end])
346}
347
348/// Where a finished child may have left its answer, most specific first.
349///
350/// A capsule that stays up for more than one task overwrites the unsuffixed file, so the per-task
351/// one is preferred; and the agent loop writes into the session directory beneath the child's
352/// workdir while a script capsule writes into that workdir directly. One rule, read by the
353/// delegating plane when it takes the answer and by the completion watcher when it names the file
354/// it did not read.
355pub fn child_result_candidates(
356    child_workdir: &Path,
357    session_id: &str,
358    task_id: &str,
359) -> [PathBuf; 4] {
360    let session_out = child_workdir.join(".murmur").join(session_id).join("out");
361    let workdir_out = child_workdir.join("out");
362    let per_task = format!("result_{task_id}.txt");
363    [
364        session_out.join(&per_task),
365        session_out.join("result.txt"),
366        workdir_out.join(&per_task),
367        workdir_out.join("result.txt"),
368    ]
369}
370
371/// The first of [`child_result_candidates`] that exists, or `None` when the child wrote no result.
372pub fn child_result_path(child_workdir: &Path, session_id: &str, task_id: &str) -> Option<PathBuf> {
373    child_result_candidates(child_workdir, session_id, task_id)
374        .into_iter()
375        .find(|path| path.is_file())
376}
377
378/// Where a child's own record of its ending goes: `<child workdir>/completion.json`.
379///
380/// One rule, used by the child that writes it and by the launcher's watcher that reads it.
381pub fn completion_path(workdir: &Path) -> PathBuf {
382    workdir.join(COMPLETION_FILE)
383}
384
385/// The completion the child recorded, or `None` when it recorded none.
386///
387/// This is what stops one delegation being reported twice: the launcher's watcher reads the file
388/// before reporting anything, and a child that already delivered is not reported again.
389pub fn read_completion(workdir: &Path) -> Option<DelegationOutcome> {
390    let raw = std::fs::read_to_string(completion_path(workdir)).ok()?;
391    serde_json::from_str(&raw).ok()
392}
393
394/// Write `outcome` to [`COMPLETION_FILE`], replacing whatever was there.
395///
396/// **Written to a sibling temporary file and renamed into place, never truncated in place.** The
397/// parent reads this file the moment the completion's task begins, and [`report_completion`]
398/// rewrites it immediately after posting that completion — so a plain truncating write leaves a
399/// window in which the parent reads an empty or half-written file and records the delegation's
400/// outcome as `unknown`. The rename makes every reader see either the whole previous content or
401/// the whole new content. The temporary name is unique because the child and the watcher behind
402/// it can both reach this function.
403pub fn write_completion(workdir: &Path, outcome: &DelegationOutcome) -> Result<(), String> {
404    let body = serde_json::to_string_pretty(outcome)
405        .map_err(|error| format!("failed to serialize the completion: {error}"))?;
406    let target = completion_path(workdir);
407    let temp = workdir.join(format!(
408        ".{COMPLETION_FILE}.{}.tmp",
409        uuid::Uuid::now_v7().simple()
410    ));
411    std::fs::write(&temp, format!("{body}\n"))
412        .map_err(|error| format!("failed to write {COMPLETION_FILE}: {error}"))?;
413    std::fs::rename(&temp, &target).map_err(|error| {
414        let _ = std::fs::remove_file(&temp);
415        format!("failed to write {COMPLETION_FILE}: {error}")
416    })
417}
418
419/// Post one completion to the parent's A2A door.
420///
421/// A JSON-RPC `message/send` carrying [`DelegationOutcome::message_text`], stamped with the four
422/// headers the door reads: the origin and trust of [`stamp_for_completion`], the delegation id,
423/// and the session the completion is addressed to. Blocking, because both reporters run outside
424/// any async context — the child's is a `Drop` guard at the end of its session, and the
425/// launcher's is a watcher thread.
426pub fn deliver_completion(
427    handle: &SpawnerHandle,
428    address: &CompletionAddress,
429    outcome: &DelegationOutcome,
430) -> Result<(), String> {
431    let stamped = stamp_for_completion(Some(address.trust));
432    let body = serde_json::json!({
433        "jsonrpc": "2.0",
434        "id": format!("req_{}", uuid::Uuid::now_v7().simple()),
435        "method": "message/send",
436        "params": {
437            "message": {
438                "messageId": format!("msg_{}", uuid::Uuid::now_v7().simple()),
439                "contextId": handle.context_id,
440                "role": "user",
441                "parts": [{"text": outcome.message_text()}]
442            }
443        }
444    })
445    .to_string();
446
447    let response = http_json(
448        "POST",
449        &address.url,
450        Some(&body),
451        &[
452            (PEER_ORIGIN_HEADER, stamped.origin().as_str()),
453            (PEER_TRUST_HEADER, stamped.trust().as_str()),
454            (DELEGATION_ID_HEADER, handle.delegation_id.as_str()),
455            (COMPLETION_SESSION_HEADER, handle.session_id.as_str()),
456        ],
457    )?;
458
459    // A door that refuses answers `200` with a JSON-RPC error, and one whose queue is full
460    // answers a `rejected` task. Neither delivered the completion, so neither is success.
461    if let Some(error) = response.get("error") {
462        let message = error
463            .get("message")
464            .and_then(Value::as_str)
465            .unwrap_or("the parent refused the completion");
466        return Err(message.to_string());
467    }
468    if response
469        .pointer("/result/status/state")
470        .and_then(Value::as_str)
471        == Some("rejected")
472    {
473        return Err("the parent rejected the completion: its queue is full".to_string());
474    }
475    Ok(())
476}
477
478/// Record `outcome` in the child's directory, post it, and record what the posting did.
479///
480/// The file is written before the post and rewritten after it, so a reporter that dies mid-post
481/// still leaves the outcome on disk. The returned outcome is the one that was written last.
482///
483/// `address` is separate from `handle` because a handle need not carry one: a caller reaches this
484/// only by having matched on [`SpawnerHandle::report_to`], so a lineage-only delegation cannot
485/// take this path by accident.
486pub fn report_completion(
487    handle: &SpawnerHandle,
488    address: &CompletionAddress,
489    outcome: DelegationOutcome,
490    workdir: &Path,
491) -> DelegationOutcome {
492    let mut outcome = outcome.with_bounded_detail();
493    outcome.delivered = false;
494    outcome.delivery_error = None;
495    if let Err(reason) = write_completion(workdir, &outcome) {
496        eprintln!(
497            "[capsule-runtime] delegation {}: {reason}",
498            outcome.delegation_id
499        );
500    }
501
502    match deliver_completion(handle, address, &outcome) {
503        Ok(()) => outcome.delivered = true,
504        Err(reason) => {
505            // The record, and the operator's only other sign that a result went nowhere. Not a
506            // failure of the child's own session: the work was done, and where it went is what
507            // could not be said.
508            eprintln!(
509                "[capsule-runtime] delegation {}: the completion could not be delivered to {}: {reason}; recorded in {}",
510                outcome.delegation_id,
511                address.url,
512                completion_path(workdir).display(),
513            );
514            outcome.delivery_error = Some(reason);
515        }
516    }
517    if let Err(reason) = write_completion(workdir, &outcome) {
518        eprintln!(
519            "[capsule-runtime] delegation {}: {reason}",
520            outcome.delegation_id
521        );
522    }
523    outcome
524}
525
526/// Record a delegation the parent ended itself, without posting it.
527///
528/// The only party that would be told is the party that did it, so this writes the file and
529/// contacts nobody.
530pub fn record_terminated(workdir: &Path, outcome: DelegationOutcome) -> DelegationOutcome {
531    let mut outcome = outcome.with_bounded_detail();
532    outcome.delivered = false;
533    outcome.delivery_error = None;
534    if let Err(reason) = write_completion(workdir, &outcome) {
535        eprintln!(
536            "[capsule-runtime] delegation {}: {reason}",
537            outcome.delegation_id
538        );
539    }
540    outcome
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    fn handle() -> SpawnerHandle {
548        SpawnerHandle {
549            session_id: "ses_parent".to_string(),
550            context_id: "ctx_parent".to_string(),
551            delegation_id: "dlg_0001".to_string(),
552            report_to: Some(address()),
553        }
554    }
555
556    fn address() -> CompletionAddress {
557        CompletionAddress {
558            url: "http://127.0.0.1:7777".to_string(),
559            trust: TrustClass::Untrusted,
560        }
561    }
562
563    fn outcome() -> DelegationOutcome {
564        DelegationOutcome {
565            delegation_id: "dlg_0001".to_string(),
566            capsule_name: "worker".to_string(),
567            capsule_version: "0.1.0".to_string(),
568            session_id: "ses_child".to_string(),
569            status: DelegationStatus::Ok,
570            result_path: Some("out/result.txt".to_string()),
571            workdir: "/tmp/parent/.murmur/children/worker-abc".to_string(),
572            duration_ms: 42,
573            detail: None,
574            reported_by: Reporter::Child,
575            delivered: false,
576            delivery_error: None,
577        }
578    }
579
580    #[test]
581    fn a_handle_round_trips_through_its_env_value() {
582        for trust in [TrustClass::Trusted, TrustClass::Untrusted] {
583            let mut original = handle();
584            original.report_to = Some(CompletionAddress {
585                url: address().url,
586                trust,
587            });
588            let parsed = SpawnerHandle::parse(&original.to_env_value()).expect("a written handle");
589            assert_eq!(parsed, original);
590        }
591    }
592
593    /// A child that is told who spawned it and nothing about where to report carries the lineage
594    /// and no address at all — not an empty one.
595    #[test]
596    fn a_lineage_only_handle_carries_no_address() {
597        let mut original = handle();
598        original.report_to = None;
599        let written = original.to_env_value();
600        assert!(!written.contains("url"), "{written}");
601        assert!(!written.contains("trust"), "{written}");
602
603        let parsed = SpawnerHandle::parse(&written).expect("a written handle");
604        assert_eq!(parsed, original);
605        assert_eq!(parsed.session_id, "ses_parent");
606        assert_eq!(parsed.delegation_id, "dlg_0001");
607    }
608
609    #[test]
610    fn an_unreadable_handle_is_an_error_and_not_an_absence() {
611        let refused = [
612            "".to_string(),
613            "not json".to_string(),
614            "{}".to_string(),
615            serde_json::json!({"url": "http://x", "session_id": "s", "context_id": "c",
616                               "delegation_id": "dlg_1"})
617            .to_string(),
618            serde_json::json!({"url": "http://x", "session_id": "s", "context_id": "c",
619                               "trust": "maybe", "delegation_id": "dlg_1"})
620            .to_string(),
621            // Half a completion address: the author meant an outcome to arrive somewhere and did
622            // not say where, or under what trust.
623            serde_json::json!({"session_id": "s", "context_id": "c", "delegation_id": "dlg_1",
624                               "trust": "trusted"})
625            .to_string(),
626        ];
627        for value in refused {
628            let error = SpawnerHandle::parse(&value).unwrap_err();
629            assert!(
630                matches!(error, RuntimeError::SpawnerHandleUnreadable { .. }),
631                "{value:?} produced {error}"
632            );
633            assert!(
634                error
635                    .to_string()
636                    .contains("tell its spawner that it finished"),
637                "the refusal must say why it is fatal: {error}"
638            );
639        }
640    }
641
642    /// A delegation id belongs to its own id space and two are never the same.
643    #[test]
644    fn delegation_ids_are_prefixed_and_unique() {
645        let first = new_delegation_id();
646        let second = new_delegation_id();
647        assert_ne!(first, second);
648        for id in [&first, &second] {
649            assert!(id.starts_with(DELEGATION_ID_PREFIX), "{id}");
650            assert!(
651                id[DELEGATION_ID_PREFIX.len()..]
652                    .chars()
653                    .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
654                "{id}"
655            );
656        }
657    }
658
659    /// The notification names the delegation and where the result is; it never carries it.
660    #[test]
661    fn the_message_names_the_result_and_never_carries_it() {
662        let text = outcome().message_text();
663        assert!(text.contains("delegation_id: dlg_0001"), "{text}");
664        assert!(text.contains("capsule: worker@0.1.0"), "{text}");
665        assert!(text.contains("session_id: ses_child"), "{text}");
666        assert!(text.contains("status: ok"), "{text}");
667        assert!(text.contains("result: out/result.txt"), "{text}");
668        assert!(text.contains("not reproduced here"), "{text}");
669    }
670
671    /// The only unbounded input near a completion is the crash detail, and it is capped.
672    #[test]
673    fn a_crash_detail_is_capped() {
674        let mut crashed = outcome();
675        crashed.status = DelegationStatus::Crashed;
676        crashed.detail = Some("x".repeat(MAX_DETAIL_BYTES * 4));
677        let bounded = crashed.with_bounded_detail();
678        let detail = bounded.detail.expect("the detail survives, bounded");
679        assert!(
680            detail.len() <= MAX_DETAIL_BYTES + "  […]".len(),
681            "detail is {} bytes",
682            detail.len()
683        );
684        assert!(detail.ends_with("[…]"), "the cut is marked: {detail}");
685    }
686
687    #[test]
688    fn a_completion_round_trips_through_the_file() {
689        let dir = tempfile::tempdir().unwrap();
690        assert!(read_completion(dir.path()).is_none());
691
692        let mut written = outcome();
693        written.delivered = true;
694        write_completion(dir.path(), &written).unwrap();
695
696        let read = read_completion(dir.path()).expect("the written completion reads back");
697        assert_eq!(read.delegation_id, written.delegation_id);
698        assert_eq!(read.status, DelegationStatus::Ok);
699        assert!(read.delivered);
700        assert_eq!(read.result_path.as_deref(), Some("out/result.txt"));
701
702        // An absent field is omitted from the file rather than written as null.
703        let raw = std::fs::read_to_string(completion_path(dir.path())).unwrap();
704        assert!(!raw.contains("detail"), "{raw}");
705        assert!(!raw.contains("delivery_error"), "{raw}");
706    }
707
708    /// A delegation the parent ended is recorded and posted to nobody. The url here answers
709    /// nothing, so a post would fail loudly.
710    #[test]
711    fn a_terminated_delegation_is_recorded_and_not_posted() {
712        let dir = tempfile::tempdir().unwrap();
713        let mut ended = outcome();
714        ended.status = DelegationStatus::Terminated;
715        ended.reported_by = Reporter::Launcher;
716        ended.detail = Some("the parent ended this delegation".to_string());
717
718        let recorded = record_terminated(dir.path(), ended);
719        assert!(!recorded.delivered);
720        assert!(recorded.delivery_error.is_none());
721        let read = read_completion(dir.path()).expect("the record exists");
722        assert_eq!(read.status, DelegationStatus::Terminated);
723        assert_eq!(read.reported_by, Reporter::Launcher);
724    }
725
726    /// A completion with nowhere to go is recorded rather than dropped.
727    #[test]
728    fn an_undeliverable_completion_is_recorded_with_its_reason() {
729        let dir = tempfile::tempdir().unwrap();
730        // Port 1 is reserved and nothing listens there.
731        let nowhere = CompletionAddress {
732            url: "http://127.0.0.1:1".to_string(),
733            trust: TrustClass::Untrusted,
734        };
735
736        let reported = report_completion(&handle(), &nowhere, outcome(), dir.path());
737        assert!(!reported.delivered);
738        let reason = reported.delivery_error.expect("the refusal is recorded");
739        assert!(reason.contains("failed to connect"), "{reason}");
740
741        let read = read_completion(dir.path()).expect("the record exists");
742        assert!(!read.delivered);
743        assert!(read.delivery_error.is_some());
744    }
745
746    /// A reader concurrent with a rewrite never sees a half-written file.
747    ///
748    /// `report_completion` rewrites the record immediately after posting it, and the parent reads
749    /// it the moment that completion's task begins — so the two overlap in ordinary use, and a
750    /// truncating write makes the parent record a successful delegation's outcome as `unknown`.
751    #[test]
752    fn a_rewrite_is_never_visible_half_written() {
753        let dir = tempfile::tempdir().unwrap();
754        write_completion(dir.path(), &outcome()).unwrap();
755
756        let path = dir.path().to_path_buf();
757        let writer = std::thread::spawn(move || {
758            for turn in 0..400 {
759                let mut record = outcome();
760                record.delivered = turn % 2 == 0;
761                // A detail long enough that one write is several pages, so a truncating write
762                // leaves a wide window rather than a theoretical one.
763                record.detail = Some("x".repeat(64 * 1024));
764                write_completion(&path, &record).unwrap();
765            }
766        });
767
768        let mut reads = 0;
769        while !writer.is_finished() {
770            assert!(
771                read_completion(dir.path()).is_some(),
772                "a concurrent reader saw no readable completion after {reads} good reads"
773            );
774            reads += 1;
775        }
776        writer.join().unwrap();
777        assert!(reads > 0, "the reader never got to run");
778
779        // Nothing is left behind for the parent's file tools to trip over.
780        let strays: Vec<_> = std::fs::read_dir(dir.path())
781            .unwrap()
782            .filter_map(|entry| entry.ok().map(|entry| entry.file_name()))
783            .filter(|name| name != COMPLETION_FILE)
784            .collect();
785        assert!(strays.is_empty(), "temporary files left behind: {strays:?}");
786    }
787}