Skip to main content

capsule_runtime/
trace.rs

1use std::time::{Instant, SystemTime, UNIX_EPOCH};
2
3use murmur_artifact::{ContainmentClass, LockOrigin, TraceCapture};
4use serde::Serialize;
5use serde_json::Value;
6use tokio::{
7    fs::File,
8    io::{AsyncWriteExt, BufWriter},
9};
10
11use crate::{
12    agent::{DriverUsage, OccupancyBasis},
13    containment::ScopeReport,
14    credential_gateway::GatewayOffsets,
15    formation::FormationId,
16    gateway_credential::CredentialChange,
17    inference_retry::{InferenceRetryRecord, RetryNote, RetryStop},
18    lanes::TaskLane,
19    origin::{TaskProvenance, TrustClass},
20    trace_blobs::BlobStore,
21    types::InstalledArtifactSummary,
22    workdir_fs::{WorkdirRoot, DEFAULT_FILE_MODE},
23};
24
25/// A session's trace file, directly under its session root.
26pub(crate) const TRACE_FILE_NAME: &str = "trace.jsonl";
27
28pub(crate) struct TraceWriter {
29    writer: BufWriter<File>,
30    session_id: String,
31    capsule_name: String,
32    capsule_version: String,
33    model: String,
34    capabilities: Vec<String>,
35    /// The session's complete effective grant set, computed once at stage time. Written whole to
36    /// `session_start` as `effective_grants`, and also the source of that event's
37    /// `containment_declared`/`containment_achieved`/`workdir_exec` summary fields — they are read
38    /// off this report rather than passed in beside it, so a trace cannot claim one containment
39    /// class at the top level and another inside the report.
40    effective_grants: ScopeReport,
41    /// How much of each turn's driver request this trace keeps — see [`TraceCapture`]. Decides
42    /// whether an `inference` event carries content hashes at all, whether the bodies behind
43    /// them reach [`Self::blobs`], and whether `tool_call.output` is written.
44    capture: TraceCapture,
45    /// The content-addressed bodies this session stored, at `<workdir>/blobs/<sha256>`. Only
46    /// written to under [`TraceCapture::Content`]; the directory is created on the first blob.
47    blobs: BlobStore,
48    /// Where the effective system prompt came from: `"manifest"`, `"cli"` or `"none"`. Derived
49    /// once in [`TraceWriter::open`] from the resolved prompt and the override flag, then repeated
50    /// on every `session_start` the same way `model` is — the prompt cannot change between the
51    /// tasks of one session.
52    system_prompt_source: &'static str,
53    /// SHA-256 (lowercase hex) of the resolved system prompt, or `None` when no prompt is in
54    /// effect. Always written to `session_start` (as `null` when absent) so a trace records *that*
55    /// a prompt was in effect and which one, even when the text itself is withheld.
56    system_prompt_sha256: Option<String>,
57    /// Where the inference credential came from: `"config"`, `"environment"`, `"manifest"` or
58    /// `"none"`. Set by [`Self::set_credential_source`] before `session_start` is written.
59    credential_source: &'static str,
60    /// Every credential gateway the session holds. Set by [`Self::set_gateways`] before
61    /// `session_start` is written; empty until then.
62    gateways: Vec<SessionGateway>,
63    /// What a controller may change on this session. Set by [`Self::set_control`] before
64    /// `session_start` is written; `None` for a capsule with no `control:` block.
65    control: Option<SessionControl>,
66    /// The driver choices. Set by [`Self::set_inference_choices`] before `session_start` is
67    /// written; empty for a capsule with no alternates.
68    inference_choices: Vec<SessionInferenceChoice>,
69    /// `inference.tool_refresh` as `ToolRefresh::wire_name` spells it, or `None` under
70    /// `transport: process`. Set by [`Self::set_tool_refresh`] before `session_start` is written.
71    tool_refresh: Option<&'static str>,
72    /// The staged artifacts whose `murmur.lock` pin a running capsule fetched. Set by
73    /// [`Self::set_runtime_artifacts`] before `session_start` is written; empty until then.
74    runtime_artifacts: Vec<RuntimeArtifact>,
75    /// Where staging took the facts about each allowlisted binary it examined. Set by
76    /// [`Self::set_host_facts`] before `session_start` is written; empty until then.
77    host_facts: Vec<HostFactsRecord>,
78    /// What staging read for `inference.instruction_files`, one record per entry. Set by
79    /// [`Self::set_instruction_files`] before `session_start` is written; `None` until then and
80    /// for a capsule without the key.
81    instruction_files: Option<Vec<crate::instruction_files::InstructionFileRecord>>,
82    /// The session `mur run --resume` continued, verbatim as the operator's address resolved it.
83    /// `None` on every ordinary launch. Written to `session_start` on both, so its absence
84    /// identifies a trace from a runtime that predates the key.
85    resumed_from: Option<String>,
86    /// The launch-scoped context id: the `--context` value, or the id `--resume` resolved to.
87    /// `None` when each task of this launch mints its own.
88    context_id: Option<String>,
89    /// The session that spawned this one, and the delegation that created it, read out of the
90    /// handle the spawning capsule's launcher injected. Both `None` for a capsule nobody
91    /// delegated, and both omitted from `session_start` when absent rather than written as null.
92    spawned_by: Option<String>,
93    spawn_delegation_id: Option<String>,
94    /// The formation this session is a member of. Set by [`Self::set_formation_id`] before
95    /// `session_start` is written; `None` for a session in no formation.
96    formation_id: Option<FormationId>,
97    /// This session's roster name and callees, set by [`Self::set_formation_member`] for a
98    /// formation member.
99    formation_member: Option<(String, Vec<String>)>,
100    session_start_time: Instant,
101    /// The session node of the event tree: the `event_id` `session_start` carries, and the
102    /// `parent_id` every launch-scoped event names. Minted in [`TraceWriter::open`] rather than
103    /// at write time so [`ResourceTraceAppender`], which is opened before the frame is written,
104    /// can be handed the same value.
105    session_event_id: String,
106    session_started: bool,
107    session_ended: bool,
108    /// The task node: the `event_id` of the active task's `task_start`. Set by
109    /// [`Self::write_task_start`], cleared by [`Self::write_task_end`], `None` between tasks.
110    task_event_id: Option<String>,
111    /// The turn node: the `event_id` of the active turn's own agent-loop `inference`. Every
112    /// event a turn produces hangs off it. Cleared at both task boundaries so a new task never
113    /// parents its first events to the previous task's last turn.
114    turn_event_id: Option<String>,
115    // Running totals — updated as events are written, used for fallback session_end on error exit
116    total_turns: u32,
117    total_input_tokens: u64,
118    total_output_tokens: u64,
119    total_tool_calls: u32,
120    total_shell_calls: u32,
121    // Per-task counters — reset on each write_task_start; consumed by write_task_end
122    task_turns: u32,
123    task_input_tokens: u64,
124    task_output_tokens: u64,
125    task_tool_calls: u32,
126    task_shell_calls: u32,
127    /// Agent-loop `inference` lines (`origin` absent) written since `task_start`, counted or
128    /// not; a hook's `run-inference` record is not one. Where an attempt's turn numbering starts,
129    /// so a task's turns number straight through its reopens and continuations. Reset at both
130    /// task boundaries, so a run outside any task frame starts at 0.
131    task_agent_turns: u32,
132    /// Counted hook `run-inference` lines (`origin` present) written since `task_start`: the part
133    /// of [`Self::task_turns`] no agent-loop turn spent. Reset with the other per-task counters.
134    task_hook_inferences: u32,
135    /// Agent-loop `inference` lines whose `stop_reason` is `"max_tokens"`, written since
136    /// `task_start`: `task_end.capped_turns`. Reset with the other per-task counters.
137    task_capped_turns: u32,
138    task_start_instant: Option<Instant>,
139    pub(crate) active_task_id: Option<String>,
140    /// How many `task_failed` lines this writer has written, so a caller can tell whether one run
141    /// wrote its own without comparing reasons.
142    task_failures_written: u64,
143    /// The `reason` of the last `task_failed` line, as written (already capped).
144    last_task_failure: Option<String>,
145    /// The spend ceilings `session_start` records. Set by [`Self::set_spend_ceilings`].
146    max_session_tokens: Option<u64>,
147    machine_tokens_per_day: Option<u64>,
148    /// What `session-status` reads, fed by this writer's `inference`, `task_start` and `task_end`
149    /// writes and named by `session_start.deadline`. Set by [`Self::set_session_status`]; `None`
150    /// feeds nothing.
151    session_status: Option<std::sync::Arc<crate::session_status::SessionStatus>>,
152    /// The session's record of component calls the wall-clock bound stopped, drained into
153    /// `call_deadline` lines ahead of every line this writer writes. Set by
154    /// [`Self::set_call_deadlines`]; `None` drains nothing.
155    call_deadlines: Option<crate::limits::CallDeadlines>,
156}
157
158/// Provenance of an inference record that did **not** come from the agent
159/// loop's own turn — today, only a hook's `run-inference` call. `None` at a
160/// `write_inference`/`emit_inference` call site means "ordinary agent-loop
161/// turn", which serializes exactly as it did before this existed.
162#[derive(Debug, Clone)]
163pub(crate) struct InferenceOrigin {
164    /// `hook:<manifest name>` of the hook that made the call.
165    pub(crate) source: String,
166    /// Model string actually sent (the attempted model, on failure).
167    pub(crate) model: String,
168}
169
170/// How one `transport: http` call was sent, as its `inference` line records it.
171#[derive(Debug, Clone, PartialEq)]
172pub(crate) struct InferenceCallSettings {
173    /// The output cap the request carried as `max_tokens`, and the amount the spend meter
174    /// reserved for it.
175    pub(crate) max_output_tokens: u32,
176    /// The input budget the call was measured against, `min(input ceiling, context window −
177    /// max_output_tokens)`; `None` when its model has no window.
178    pub(crate) input_budget: Option<u32>,
179    /// The `MURMUR_INFERENCE_DRIVER_CONFIG` the call's driver ran with, from
180    /// [`recorded_driver_config`]. `None` when the driver saw none.
181    pub(crate) driver_config: Option<Value>,
182}
183
184/// The `MURMUR_INFERENCE_DRIVER_CONFIG` in `env`, as an `inference` line records it: `None` when
185/// `env` has none, otherwise its JSON with every object key and string leaf passed through
186/// `redact`, at any depth. A number leaf whose decimal text `redact` changes becomes the string
187/// [`crate::gateway_credential::REDACTED`]; booleans and nulls are kept as they are.
188///
189/// A value that is not JSON is recorded as `redact(value)`, a JSON string: it is what the driver
190/// saw. The manifest parser only produces JSON objects here.
191pub(crate) fn recorded_driver_config(
192    env: &[(String, String)],
193    redact: impl Fn(&str) -> String,
194) -> Option<Value> {
195    fn redact_value(value: Value, redact: &impl Fn(&str) -> String) -> Value {
196        match value {
197            Value::String(text) => Value::String(redact(&text)),
198            Value::Number(number) => {
199                let text = number.to_string();
200                if redact(&text) == text {
201                    Value::Number(number)
202                } else {
203                    Value::String(crate::gateway_credential::REDACTED.to_string())
204                }
205            }
206            Value::Array(items) => Value::Array(
207                items
208                    .into_iter()
209                    .map(|item| redact_value(item, redact))
210                    .collect(),
211            ),
212            Value::Object(entries) => Value::Object(
213                entries
214                    .into_iter()
215                    .map(|(key, item)| (redact(&key), redact_value(item, redact)))
216                    .collect(),
217            ),
218            other @ (Value::Bool(_) | Value::Null) => other,
219        }
220    }
221    let (_, raw) = env
222        .iter()
223        .find(|(key, _)| key == "MURMUR_INFERENCE_DRIVER_CONFIG")?;
224    Some(match serde_json::from_str::<Value>(raw) {
225        Ok(value) => redact_value(value, &redact),
226        Err(_) => Value::String(redact(raw)),
227    })
228}
229
230/// `compaction_declined.reason` when the threshold was crossed but no bound hook returned
231/// `replace-context`.
232pub(crate) const COMPACTION_DECLINED_NO_HOOK_REPLACEMENT: &str = "no_hook_replacement";
233
234/// `compaction_declined.reason` when a hook's replacement was rejected because its tool calls
235/// and tool results did not pair up.
236pub(crate) const COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL: &str = "unresolved_tool_call";
237
238/// `context_seed.outcome` when the whole proposal fit the budget and was committed as-is.
239pub(crate) const SEED_OUTCOME_SEEDED: &str = "seeded";
240
241/// `context_seed.outcome` when the proposal was over budget and the oldest messages were
242/// dropped from its front until the rest fit.
243pub(crate) const SEED_OUTCOME_TRIMMED: &str = "trimmed";
244
245/// `context_seed.outcome` when the overflowing front was handed to the compaction hook and
246/// its summary became the seed's first message.
247pub(crate) const SEED_OUTCOME_COMPACTED: &str = "compacted";
248
249/// `context_seed.outcome` when nothing was committed. Always paired with a `reason`.
250pub(crate) const SEED_OUTCOME_REJECTED: &str = "rejected";
251
252/// `context_seed.reason` when one message alone is wider than the whole budget. Trimming
253/// cannot help — dropping everything else still leaves it over — so nothing is committed.
254pub(crate) const SEED_REJECTED_MESSAGE_OVER_BUDGET: &str = "message_over_budget";
255
256/// `context_seed.reason` when the proposal overflows the budget by more multiples of it than
257/// the runtime will summarize. A seed that far over is a broken hook, not a full memory.
258pub(crate) const SEED_REJECTED_OVERFLOW_OVER_LIMIT: &str = "overflow_over_limit";
259
260/// `context_seed.reason` when the capsule declares no `context.max_tokens`, so
261/// `context.seed_budget` is a fraction of nothing and there is no ceiling to enforce.
262pub(crate) const SEED_REJECTED_NO_BUDGET: &str = "no_budget";
263
264/// `context_seed.reason` when the session runs `inference.transport: process`, which owns its
265/// own context and has no host-side message list to seed.
266pub(crate) const SEED_REJECTED_UNSUPPORTED_TRANSPORT: &str = "unsupported_transport";
267
268/// `retention.store` for the session directories under a workdir.
269pub(crate) const RETENTION_STORE_SESSIONS: &str = "sessions";
270
271/// `retention.store` for the conversation records under `~/.murmur/conversations/`.
272pub(crate) const RETENTION_STORE_RECORDS: &str = "records";
273
274/// `retention.reason` when `trace.retain.max_sessions` put a session outside the newest N.
275pub(crate) const RETENTION_REASON_MAX_SESSIONS: &str = "max_sessions";
276
277/// `retention.reason` when `trace.retain.max_age` or `context.retain.max_age` put a session or a
278/// record outside the window.
279pub(crate) const RETENTION_REASON_MAX_AGE: &str = "max_age";
280
281/// `retention.reason` when `context.retain.max_messages` truncated the front of a record.
282pub(crate) const RETENTION_REASON_MAX_MESSAGES: &str = "max_messages";
283
284/// Mint one event identity: `evt_` + a UUID v7 in simple form, the same scheme `ses_`, `tsk_`,
285/// `ctx_`, `dep_` and `req_` use. Ids therefore sort by mint time and carry their own
286/// millisecond timestamp, so a trace can be ordered and time-bounded without reading any
287/// payload field.
288///
289/// Called once per line at the moment of the write. An id is never reused, never derived from
290/// the event's content, and never reconstructed from anything else in the file.
291pub(crate) fn new_event_id() -> String {
292    format!("evt_{}", uuid::Uuid::now_v7().simple())
293}
294
295// ── Event structs (Serialize → JSONL lines) ──────────────────────────────────
296//
297// Every struct opens with `event_type`, `event_id`, `parent_id`, `session_id` and `timestamp`,
298// in that order, so a human scanning the file reads a line's identity before its payload.
299// `parent_id` is the `event_id` of the event this one hangs off — see [`TraceWriter`]'s
300// `session_parent`/`task_parent`/`turn_parent` for which node each event names, and
301// [`new_event_id`] for the id format.
302//
303// The turn-level events (`inference`, `tool_call`, `skill_call`, `shell`, `compaction`,
304// `compaction_declined`) additionally carry `task_id`, taken from the writer's active task and
305// always written — `null` when no task is in scope. `task_start`/`task_end` carry their own and
306// are not duplicated.
307
308#[derive(Serialize)]
309struct SessionStartEvent {
310    event_type: &'static str,
311    event_id: String,
312    parent_id: Option<String>,
313    session_id: String,
314    timestamp: u64,
315    capsule_name: String,
316    capsule_version: String,
317    model: String,
318    /// `inference.max_turns`. Always written, `null` when no cap applies.
319    max_turns: Option<u32>,
320    /// The primary driver choice's output cap and context window in force, and where each came
321    /// from. Always written, `null` under `transport: process`, which resolves none.
322    model_limits: Option<crate::model_limits::ResolvedModelLimits>,
323    /// `inference.max_session_tokens` as this session enforces it. Always written, `null` when
324    /// no session ceiling applies.
325    max_session_tokens: Option<u64>,
326    /// `spend.machine_tokens_per_day` when this session keeps the machine ledger. Always written,
327    /// `null` when the machine ceiling does not cover this session.
328    machine_tokens_per_day: Option<u64>,
329    /// The deadline this session runs under: the earliest of its own `mur run --deadline` and the
330    /// one it inherited. Always written, `null` when it has neither.
331    deadline: Option<SessionStartDeadline>,
332    capabilities: Vec<String>,
333    tools_declared: Vec<String>,
334    /// Strongest containment class any source asked for. A *requirement*, not an observation.
335    containment_declared: ContainmentClass,
336    /// What this session's commands actually ran under: the class of the tier it installed (the
337    /// probed host tier capped by the declared floor), capped at `advisory` by `workdir_exec` below
338    /// or a runtime directory inside the workdir. Reading these two together is how a session's
339    /// trace shows whether the capsule ran under the containment its operator asked for.
340    containment_achieved: ContainmentClass,
341    /// `capabilities.filesystem.workdir_exec`. Always written, including `false`, so its absence
342    /// identifies a trace from a runtime that predates the key rather than a capsule that declined
343    /// it.
344    ///
345    /// `true` is the reason a trace can show `containment_achieved: advisory` on a host whose
346    /// `session_start` would otherwise say `scoped` — and it is the record that this session's
347    /// `capabilities.shell.allow` was advisory too, since anything in the workdir could run.
348    workdir_exec: bool,
349    /// Where this host's permission to create an unprivileged user namespace came from, as
350    /// `UsernsGrant::wire_name` — `null` only off Linux, where AppArmor does not exist.
351    ///
352    /// Mirrored to the top level from `effective_grants` for the same reason `workdir_exec` is:
353    /// an auditor should not have to descend into a nested object to answer it. Recorded because
354    /// `containment_achieved: sealed` reached through the shipped AppArmor profile and the same
355    /// class reached on a host whose unprivileged-userns hardening is switched off for every
356    /// binary are two very different records, and this key is the only thing separating them.
357    ///
358    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
359    /// runtime that predates the key.
360    userns_grant: Option<crate::sealed::UsernsGrant>,
361    /// The complete grant set this session ran under — every destination, binary, path and
362    /// environment variable the policy actually opened, plus the enforcement tier installed — in the
363    /// exact shape `mur run --explain-scope --json` prints for the same policy on the same host.
364    ///
365    /// `capabilities` above stays what it always was: a list of category *names*. It answers "did
366    /// this session have network access?" but not "to where?", which is the question an auditor
367    /// reading a finished trace actually has. This field answers it without re-parsing the
368    /// manifest, which by then may have changed or moved.
369    effective_grants: ScopeReport,
370    /// Where the system prompt this session ran with came from: `"manifest"` when the manifest's
371    /// own `inference.system_prompt`/`system_prompt_file`/`system_prompt_artifact` supplied it,
372    /// `"cli"` when `mur run --system-prompt` overrode it (including when the override was empty
373    /// and therefore cleared it), `"none"` when no prompt was in effect at all.
374    ///
375    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
376    /// runtime that predates the key, not a session that had no prompt.
377    system_prompt_source: &'static str,
378    /// SHA-256 (lowercase hex) of the resolved prompt — the text as `resolve_system_prompt`
379    /// returned it, before the `[Capsule]` identity block is prepended — or `null` when no prompt
380    /// was in effect. Always written, so two sessions can be compared for prompt equality without
381    /// either trace having to carry the prompt itself.
382    ///
383    /// Deliberately not the same value as `inference.system_sha`, which covers the augmented
384    /// prompt that actually went on the wire: this one names the prompt the operator wrote.
385    /// Under `trace.capture: content` those resolved bytes are also the blob this hash names.
386    system_prompt_sha256: Option<String>,
387    /// Where the inference credential this session attaches came from: `"config"`,
388    /// `"environment"`, `"manifest"` or `"none"`. Never the value.
389    credential_source: &'static str,
390    /// Every credential gateway the session holds, the inference gateway first. Always written,
391    /// and empty for a session with none, so an auditor can tell an unmetered third-party
392    /// upstream from spend the ceilings cover.
393    gateways: Vec<SessionGateway>,
394    /// What a controller holding this session's control token may change: the setting names and
395    /// secret names `control:` declares. Omitted entirely for a capsule with no `control:` block.
396    #[serde(skip_serializing_if = "Option::is_none")]
397    control: Option<SessionControl>,
398    /// Every driver choice the agent loop may be switched to, the primary first, each with its
399    /// credential's source and whether it resolved at launch. Omitted entirely for a capsule that
400    /// declares no `inference.alternates`.
401    #[serde(skip_serializing_if = "Vec::is_empty")]
402    inference_choices: Vec<SessionInferenceChoice>,
403    /// `inference.tool_refresh`: `"compaction"` or `"immediate"` under `transport: http`, `null`
404    /// under `transport: process`, where the harness owns its tool list. Always written.
405    tool_refresh: Option<&'static str>,
406    /// Every staged artifact whose `murmur.lock` origin is `runtime` — pinned by a running
407    /// capsule's `manage.pull()`, never vetted by the operator — in staging order. Each reaches
408    /// the model marked untrusted. Always written, `[]` when there are none.
409    runtime_artifacts: Vec<RuntimeArtifact>,
410    /// One object per allowlisted binary staging examined, saying whether its facts came from a
411    /// kept `~/.murmur/host_facts` entry or were worked out, and why. Always written, `[]` when
412    /// no binary was examined.
413    host_facts: Vec<HostFactsRecord>,
414    /// One object per `inference.instruction_files` entry, in manifest order: its `path` as
415    /// written and its `status`, with `resolved`, `bytes`, `included_bytes`, `truncated` and
416    /// `sha256` (of the included text) on a `read` entry, `same_as` on a `same_file` one and
417    /// `error` on an `unreadable` one. `null` for a capsule without the key, `[]` for an empty
418    /// list. Always written.
419    instruction_files: Option<Vec<crate::instruction_files::InstructionFileRecord>>,
420    /// The session `mur run --resume` continued, or `null` on an ordinary launch. Together with
421    /// `context_id` below it is what makes a resumed conversation followable back through the
422    /// sessions that built it.
423    ///
424    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
425    /// runtime that predates the key.
426    resumed_from: Option<String>,
427    /// The context id every task of this launch runs under — the `mur run --context` value, or
428    /// the id `--resume` resolved to — and `null` when each task mints its own. `task_start`
429    /// carries the id a task actually ran under either way.
430    ///
431    /// Always written, on the same terms as `resumed_from`.
432    context_id: Option<String>,
433    /// The `ses_` id of the session that spawned this one, for a capsule launched by another
434    /// capsule's `delegate-task`. Omitted from the record entirely for every other launch: the
435    /// field is absent, not null.
436    #[serde(skip_serializing_if = "Option::is_none")]
437    spawned_by: Option<String>,
438    /// The `dlg_` id of the delegation that created this session, character-identical to the id
439    /// in the spawning session's own `delegation_start`. Present exactly when `spawned_by` is.
440    #[serde(skip_serializing_if = "Option::is_none")]
441    delegation_id: Option<String>,
442    /// The formation this session is a member of. Omitted entirely for a session in no formation,
443    /// absent rather than null, on the same terms as `spawned_by`. Being on `session_start`, it is
444    /// on the trace's first line, which is the only line a reader grouping sessions reads.
445    #[serde(skip_serializing_if = "Option::is_none")]
446    formation_id: Option<FormationId>,
447    /// This session's roster name, for a session its formation launcher handed credentials to.
448    /// Omitted for every other session, a delegated child of a member included.
449    #[serde(skip_serializing_if = "Option::is_none")]
450    formation_member: Option<String>,
451    /// The members this one may call, in roster order: `[]` for a member that may call nobody.
452    /// Present exactly when `formation_member` is.
453    #[serde(skip_serializing_if = "Option::is_none")]
454    formation_callees: Option<Vec<String>>,
455}
456
457/// `session_start.deadline`.
458#[derive(Serialize)]
459struct SessionStartDeadline {
460    /// When the runtime ends the session, RFC 3339 UTC.
461    at: String,
462    /// Seconds from `mur run`'s start to `at`: the `--deadline` value, or for an inherited
463    /// deadline the whole seconds left at the start, rounded up.
464    secs: u64,
465    /// The notice margin in force, `lifecycle.deadline_notice_secs` or its default; `0` sends
466    /// none.
467    notice_secs: u64,
468    /// `SessionDeadline::source`: `"own"` or `"inherited"`.
469    source: &'static str,
470}
471
472#[derive(Serialize)]
473struct InferenceEvent {
474    event_type: &'static str,
475    event_id: String,
476    parent_id: Option<String>,
477    session_id: String,
478    timestamp: u64,
479    turn: u32,
480    task_id: Option<String>,
481    /// The input this turn cost, and the number the task and session totals accumulate.
482    ///
483    /// Under `transport: http` it is the runtime's own tiktoken estimate of the request, counted
484    /// before the request was sent, and the provider's own count sits beside it in
485    /// `input_tokens_actual`. Under `transport: process` the runtime estimates nothing — the
486    /// harness holds the conversation — so this *is* the harness's own reported count and
487    /// `input_tokens_actual` is absent: there is one measurement on that transport and it is
488    /// recorded once.
489    ///
490    /// Absent means no count was made at all: a `process` driver reporting no usage, or a failed
491    /// call that is not counted (see [`TraceWriter::write_failed_inference`]). Never conflate it
492    /// with `0`, which is a count of none.
493    #[serde(skip_serializing_if = "Option::is_none")]
494    input_tokens: Option<u64>,
495    /// The output this turn cost, on the same terms as [`Self::input_tokens`]: the runtime's own
496    /// tiktoken estimate of the raw driver response under `http`, the harness's own reported
497    /// count under `process`.
498    #[serde(skip_serializing_if = "Option::is_none")]
499    output_tokens: Option<u64>,
500    decision: String,
501    /// The provider's own stop reason for this turn, verbatim as the agent loop dispatched on
502    /// it. Present on every agent-loop turn, including one whose driver reported nothing — an
503    /// empty string then, because that is what the loop dispatched on. `"error"` on every failed
504    /// call, on either transport and for a hook's `run-inference`. Otherwise absent means no
505    /// driver response was parsed at all: a hook's `run-inference` and the `process` transport
506    /// both name their `decision` locally and never saw a provider stop reason.
507    #[serde(skip_serializing_if = "Option::is_none")]
508    stop_reason: Option<String>,
509    tool_name: Option<String>,
510    /// The provider's own counts for this call, each written only when the driver reported
511    /// it. Absent means the driver reported nothing, never zero.
512    #[serde(skip_serializing_if = "Option::is_none")]
513    input_tokens_actual: Option<u64>,
514    #[serde(skip_serializing_if = "Option::is_none")]
515    output_tokens_actual: Option<u64>,
516    #[serde(skip_serializing_if = "Option::is_none")]
517    cached_tokens: Option<u64>,
518    #[serde(skip_serializing_if = "Option::is_none")]
519    cache_write_tokens: Option<u64>,
520    /// The part of the output the provider or harness reports as reasoning — a subset of
521    /// `output_tokens`, never an addition to it. Absent when nothing reported one.
522    #[serde(skip_serializing_if = "Option::is_none")]
523    thinking_tokens: Option<u64>,
524    /// Where this inference came from. Absent for an ordinary agent-loop turn,
525    /// so every pre-existing consumer sees a byte-identical record; `"hook:<name>"`
526    /// for a completion that hook ran through `murmur:runtime/inference`'s
527    /// `run-inference`.
528    #[serde(skip_serializing_if = "Option::is_none")]
529    origin: Option<String>,
530    /// The driver choice that served an agent-loop turn, by name. Written only for a capsule that
531    /// declares `inference.alternates`, beside `model`.
532    #[serde(skip_serializing_if = "Option::is_none")]
533    driver_choice: Option<String>,
534    /// Model string actually sent for this call (the attempted model, on failure). Written
535    /// alongside `origin` for a hook's completion, and alongside `driver_choice` for an agent-loop
536    /// turn of a capsule that declares alternates. Any other turn's model is the session-start
537    /// record's and is not repeated per turn.
538    #[serde(skip_serializing_if = "Option::is_none")]
539    model: Option<String>,
540    /// The output cap this request carried as `max_tokens`, which is also what the spend meter
541    /// reserved for it. Written on every `transport: http` line — agent-loop turn, hook
542    /// `run-inference` and failed call alike — and on no `transport: process` line, where the
543    /// harness runs the model and the runtime sets no cap.
544    #[serde(skip_serializing_if = "Option::is_none")]
545    max_output_tokens: Option<u32>,
546    /// The call's input budget, `min(input ceiling, context window − max_output_tokens)`.
547    /// Present exactly when `max_output_tokens` is; `null` there means the model has no window.
548    /// The outer `Option` is the key, the inner one its value.
549    #[serde(skip_serializing_if = "Option::is_none")]
550    input_budget: Option<Option<u32>>,
551    /// The `MURMUR_INFERENCE_DRIVER_CONFIG` this call's driver ran with, every credential value
552    /// the session holds replaced with `[redacted]`. Present exactly when `max_output_tokens` is;
553    /// `null` there means the driver saw no config. The outer `Option` is the key, the inner one
554    /// its value.
555    #[serde(skip_serializing_if = "Option::is_none")]
556    driver_config: Option<Option<Value>>,
557    /// Identities of the messages this request embedded, in the order they sat in it. Under an
558    /// active driver continuation only the tail past the acked length is sent, and this names
559    /// exactly that tail. Empty — and therefore absent — for a hook's own `run-inference` and for
560    /// the `process` transport, neither of which sends a message list the runtime minted.
561    #[serde(skip_serializing_if = "Vec::is_empty")]
562    message_ids: Vec<String>,
563    /// SHA-256 (lowercase hex) of the UTF-8 bytes of this request's `system` string — the
564    /// augmented prompt with the `[Capsule]` block already in it.
565    ///
566    /// These are the bytes Murmur **sent**, not what the model **saw**: provider-side injection,
567    /// tokenizer differences and safety layers all happen past the wire and are invisible to the
568    /// runtime. Absent under `trace.capture: none`, and for a record the runtime did not build
569    /// the payload for — a hook's `run-inference`, and the `process` transport.
570    #[serde(skip_serializing_if = "Option::is_none")]
571    system_sha: Option<String>,
572    /// SHA-256 (lowercase hex) of this request's serialized `tools` array. The bytes Murmur
573    /// sent, on the same terms as [`Self::system_sha`].
574    #[serde(skip_serializing_if = "Option::is_none")]
575    tools_sha: Option<String>,
576    /// SHA-256 (lowercase hex) of the raw driver response body, as the loop read it before
577    /// parsing. What the driver returned to Murmur, byte for byte.
578    #[serde(skip_serializing_if = "Option::is_none")]
579    response_sha: Option<String>,
580    /// SHA-256 (lowercase hex) of each message this request embedded, in send order — one entry
581    /// per `message_ids` entry, over the same messages after the runtime's identity keys are
582    /// stripped. The bytes Murmur sent, on the same terms as [`Self::system_sha`].
583    ///
584    /// Not redundant with `message_ids`: an id names an entity and is freshly minted every run,
585    /// so comparing two runs' id arrays only says every id differs. Comparing these says where
586    /// the two prompts stopped agreeing — the divergence index is the first unequal position.
587    #[serde(skip_serializing_if = "Vec::is_empty")]
588    message_shas: Vec<String>,
589    /// Why the call failed, from the `INFERENCE_ERROR_*` vocabulary. Present exactly on a record
590    /// whose `decision` and `stop_reason` are both `"error"`; absent on every call that returned
591    /// an answer, however unusable the loop then found it.
592    #[serde(skip_serializing_if = "Option::is_none")]
593    error_code: Option<String>,
594    /// The failure in words, present exactly when `error_code` is. Every credential value the
595    /// session holds is already replaced with `[redacted]`, and the text is capped at
596    /// [`MAX_TASK_FAILURE_REASON_BYTES`] on a character boundary.
597    #[serde(skip_serializing_if = "Option::is_none")]
598    error: Option<String>,
599    /// The HTTP status the runtime's credential gateway received for the failed call. Absent when
600    /// no request reached the provider, on the `process` transport, and for a driver whose
601    /// gateway the runtime does not own.
602    #[serde(skip_serializing_if = "Option::is_none")]
603    provider_status: Option<u16>,
604    /// Milliseconds from the start of the attempt this record describes until the driver's
605    /// request reached the runtime's credential gateway. Present exactly on a call made through
606    /// a runtime-owned gateway; `null` there means no request reached it. The outer `Option` is
607    /// the key, the inner one its value.
608    #[serde(skip_serializing_if = "Option::is_none")]
609    gateway_reached_ms: Option<Option<u64>>,
610    /// Milliseconds from the start of the attempt until the provider's response head arrived at
611    /// the gateway, the last one when the gateway resent. Present exactly when
612    /// `gateway_reached_ms` is; `null` there means no response head arrived.
613    #[serde(skip_serializing_if = "Option::is_none")]
614    upstream_responded_ms: Option<Option<u64>>,
615    /// How many attempts the call took, written only when it took more than one. Each retry
616    /// before the last attempt has its own `inference_retry` line.
617    #[serde(skip_serializing_if = "Option::is_none")]
618    attempts: Option<u32>,
619    /// Why a call whose last attempt failed transiently was not made again:
620    /// [`crate::inference_retry::RetryStop::wire_name`]. Absent on every other record.
621    #[serde(skip_serializing_if = "Option::is_none")]
622    retry_stopped: Option<&'static str>,
623}
624
625/// The bodies one driver request put on the wire, split into the pieces an `inference` event
626/// hashes.
627///
628/// Built from the payload [`crate::agent::build_driver_payload`] produced and the raw response
629/// string the driver returned, so what the trace names and what the driver received cannot
630/// drift: there is no second serialization for them to drift apart in.
631///
632/// Every piece is the bytes Murmur **sent** (or, for the response, was handed back), never what
633/// the model saw.
634pub(crate) struct WireCapture {
635    /// The payload's `system` string value as UTF-8 text — the augmented prompt, `[Capsule]`
636    /// block included. Stored as text so its blob `cat`s as a readable prompt.
637    system: Vec<u8>,
638    /// The payload's `tools` array, serialized.
639    tools: Vec<u8>,
640    /// Each element of the payload's `messages` array, serialized, in send order. That array is
641    /// the `wire_messages` slice after `strip_message_identity`, so no message identity key can
642    /// reach a hash or a blob.
643    messages: Vec<Vec<u8>>,
644    /// The raw driver response body, exactly as the loop read it.
645    response: Vec<u8>,
646}
647
648impl WireCapture {
649    /// Split a built driver payload and its raw response into the four bodies the trace hashes.
650    ///
651    /// `payload` must be the value that was serialized and handed to the driver; taking the
652    /// pieces out of it, rather than rebuilding them from the loop's own state, is what makes a
653    /// blob byte-identical to the substring of the request it came from.
654    pub(crate) fn from_driver_payload(payload: &Value, response: &str) -> Self {
655        Self {
656            system: payload
657                .get("system")
658                .and_then(Value::as_str)
659                .unwrap_or_default()
660                .as_bytes()
661                .to_vec(),
662            tools: payload
663                .get("tools")
664                .map(|tools| serde_json::to_vec(tools).unwrap_or_default())
665                .unwrap_or_default(),
666            messages: payload
667                .get("messages")
668                .and_then(Value::as_array)
669                .map(|messages| {
670                    messages
671                        .iter()
672                        .map(|message| serde_json::to_vec(message).unwrap_or_default())
673                        .collect()
674                })
675                .unwrap_or_default(),
676            response: response.as_bytes().to_vec(),
677        }
678    }
679}
680
681#[derive(Serialize)]
682struct ToolCallEvent {
683    event_type: &'static str,
684    event_id: String,
685    parent_id: Option<String>,
686    session_id: String,
687    timestamp: u64,
688    turn: u32,
689    task_id: Option<String>,
690    tool_name: String,
691    /// The provider's own id for this call, as it appeared on the driver response block that
692    /// asked for it. Recorded verbatim and never parsed — the same contract `resource_id` has.
693    /// `null` when the provider named none, which is how every host-synthesized call reads.
694    tool_call_id: Option<String>,
695    input: Value,
696    input_bytes: u64,
697    #[serde(skip_serializing_if = "Option::is_none")]
698    output: Option<String>,
699    output_bytes: u64,
700    duration_ms: u64,
701    status: String,
702    /// The runtime's own text when it could not run the call, such as a shell
703    /// command whose sandbox setup failed before exec. Written under every [`TraceCapture`],
704    /// unlike `output`; peer handle tokens are redacted and the text is capped at
705    /// [`MAX_TASK_FAILURE_REASON_BYTES`] on a character boundary. Absent when the tool ran,
706    /// including when it reported its own failure.
707    #[serde(skip_serializing_if = "Option::is_none")]
708    error: Option<String>,
709    /// The tool's self-declared `state_effect` for this call (`read`/`mutate`),
710    /// lifted verbatim from `tool-result.metadata`. Absent when the tool declared
711    /// nothing; consumers treat absence conservatively. See `wit/tool.wit`.
712    #[serde(skip_serializing_if = "Option::is_none")]
713    state_effect: Option<String>,
714    /// The resource this call addressed, as declared by the tool and lifted verbatim
715    /// from `tool-result.metadata`. An opaque, tool-defined string — never parsed here.
716    /// Absent when the tool declared nothing, in which case consumers fall back to
717    /// guessing the resource from the call's input. See `wit/tool.wit`.
718    #[serde(skip_serializing_if = "Option::is_none")]
719    resource_id: Option<String>,
720    /// The images the call returned, accepted or refused, in the order it named them. Absent
721    /// when it returned none. Never holds image bytes.
722    #[serde(skip_serializing_if = "Vec::is_empty")]
723    attachments: Vec<AttachmentRecord>,
724}
725
726/// One image a tool call returned or a task message carried, as the trace records it.
727///
728/// An accepted image carries `bytes` and `sha256`, the name of its blob beside the conversation;
729/// a refused one carries `refused`, the refusal code its note ends with. `path` is the work-folder
730/// path a tool named, or `message.parts[<i>]` for an image an A2A message carried.
731#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
732pub(crate) struct AttachmentRecord {
733    pub(crate) path: String,
734    pub(crate) media_type: String,
735    #[serde(skip_serializing_if = "Option::is_none")]
736    pub(crate) bytes: Option<u64>,
737    #[serde(skip_serializing_if = "Option::is_none")]
738    pub(crate) sha256: Option<String>,
739    #[serde(skip_serializing_if = "Option::is_none")]
740    pub(crate) refused: Option<&'static str>,
741}
742
743#[derive(Serialize)]
744struct SkillCallEvent {
745    event_type: &'static str,
746    event_id: String,
747    parent_id: Option<String>,
748    session_id: String,
749    timestamp: u64,
750    turn: u32,
751    task_id: Option<String>,
752    skill_name: String,
753    output_bytes: u64,
754    duration_ms: u64,
755    status: String,
756    /// The skill's `murmur.lock` origin: `"operator"` or `"runtime"`. Always written.
757    origin: &'static str,
758    /// `"trusted"` for an operator pin, `"untrusted"` for a runtime one, whose guidance reached
759    /// the model fenced. Always written, in the spelling `task_start.trust` uses.
760    trust: &'static str,
761}
762
763#[derive(Serialize)]
764struct ShellEvent {
765    event_type: &'static str,
766    event_id: String,
767    parent_id: Option<String>,
768    session_id: String,
769    timestamp: u64,
770    turn: u32,
771    task_id: Option<String>,
772    /// The program that ran — a canonical absolute path when the host `PATH` resolved
773    /// the invoked name, else the bare name. `command` carries the argument list alone,
774    /// so this is the only field that says *what* ran.
775    binary: String,
776    command: String,
777    exit_code: i32,
778    stdout_bytes: u64,
779    stderr_bytes: u64,
780    duration_ms: u64,
781    /// The `capabilities.resources` field this subprocess was killed for exceeding, when the
782    /// kernel's own evidence names exactly one (`SIGXCPU`/`SIGXFSZ`, or a cgroup
783    /// `memory.events`/`pids.events` counter that moved). Omitted from the JSONL entirely
784    /// otherwise — including for a subprocess that died for a reason no single limit can be
785    /// pinned to, which must not read as "no limit was involved".
786    #[serde(skip_serializing_if = "Option::is_none")]
787    resource_limit: Option<String>,
788}
789
790/// A shell command that outran its grace period and moved to the background. Hangs off the turn
791/// that started it, so `mur trace steps` shows it where the model saw it.
792#[derive(Serialize)]
793struct ShellDetachedEvent {
794    event_type: &'static str,
795    event_id: String,
796    parent_id: Option<String>,
797    session_id: String,
798    timestamp: u64,
799    turn: u32,
800    task_id: Option<String>,
801    work_id: String,
802    binary: String,
803    command: String,
804    /// The `lifecycle.shell_grace_secs` in force, in milliseconds — what this command outran.
805    /// Absent when the session declares no grace, so the command was demoted by an abandoned
806    /// call or a cancel.
807    #[serde(skip_serializing_if = "Option::is_none")]
808    grace_ms: Option<u64>,
809}
810
811/// A detached command's completion reaching the agent, written once per work id by whichever path
812/// delivered it: [`ResourceTraceAppender::write_shell_completed`] right after the
813/// `background_command` line of the call that returned the finished state, or
814/// [`TraceWriter::write_shell_completed`] where the task loop queues it as a task. Hangs off the
815/// session rather than a turn: the turn that started the command may be long over.
816#[derive(Serialize)]
817struct ShellCompletedEvent {
818    event_type: &'static str,
819    event_id: String,
820    parent_id: Option<String>,
821    session_id: String,
822    timestamp: u64,
823    work_id: String,
824    binary: String,
825    command: String,
826    exit_code: i32,
827    duration_ms: u64,
828    /// Epoch milliseconds at which the runtime recorded the command's exit, which is never after
829    /// [`Self::timestamp`].
830    exited_at_ms: u64,
831    /// Absolute path of the file holding the command's full stdout and stderr,
832    /// `logs/<work_id>.log` in the session's records directory.
833    output_path: String,
834    output_bytes: u64,
835    /// Attributed the same way [`ShellEvent::resource_limit`] is, and omitted on the same terms.
836    #[serde(skip_serializing_if = "Option::is_none")]
837    resource_limit: Option<String>,
838    /// `"ok"` or `"error"` — the field that tells a failed background command from a successful
839    /// one without reading its prose.
840    status: String,
841    /// [`crate::detached::Delivery::as_str`]: `"background_command"` or `"task"`.
842    delivered_via: &'static str,
843    /// The `completion`-origin task this completion was enqueued as, which is what makes "which
844    /// command produced this task" answerable from `trace.jsonl` alone. Present exactly when
845    /// [`Self::delivered_via`] is `"task"`.
846    #[serde(skip_serializing_if = "Option::is_none")]
847    completion_task_id: Option<String>,
848}
849
850impl ShellCompletedEvent {
851    fn new(
852        session_id: String,
853        parent_id: Option<String>,
854        completion: &crate::detached::DetachedCompletion,
855        delivery: crate::detached::Delivery,
856        completion_task_id: Option<&str>,
857    ) -> Self {
858        Self {
859            event_type: "shell_completed",
860            event_id: new_event_id(),
861            parent_id,
862            session_id,
863            timestamp: timestamp_ms(),
864            work_id: completion.work_id.clone(),
865            binary: completion.binary.clone(),
866            command: completion.command.clone(),
867            exit_code: completion.exit_code,
868            duration_ms: completion.duration_ms,
869            exited_at_ms: completion.exited_at_ms,
870            output_path: completion.output_path.clone(),
871            output_bytes: completion.output_bytes,
872            resource_limit: completion.resource_limit.clone(),
873            status: completion.status().to_string(),
874            delivered_via: delivery.as_str(),
875            completion_task_id: completion_task_id.map(str::to_string),
876        }
877    }
878}
879
880/// One answered `background-command` call. Hangs off the session, as `session_status` does.
881#[derive(Serialize)]
882struct BackgroundCommandEvent<'a> {
883    event_type: &'static str,
884    event_id: String,
885    parent_id: Option<String>,
886    session_id: String,
887    timestamp: u64,
888    /// The task that made the call. Absent outside a task.
889    #[serde(skip_serializing_if = "Option::is_none")]
890    task_id: Option<String>,
891    work_id: &'a str,
892    /// `status`, `wait` or `stop`.
893    operation: &'static str,
894    /// `running`, `exited` or `stopped`, as the call returned it.
895    state: &'static str,
896    /// Present once the command has exited.
897    #[serde(skip_serializing_if = "Option::is_none")]
898    exit_code: Option<i32>,
899    /// How long a `wait` blocked. Absent for the other operations.
900    #[serde(skip_serializing_if = "Option::is_none")]
901    waited_ms: Option<u64>,
902    /// Bytes in the log's output section, as the call returned it.
903    output_bytes: u64,
904}
905
906/// The fields of a `process_start` line: a program a WASM tool started through `murmur:process`.
907#[derive(Serialize)]
908pub(crate) struct ProcessStartLine<'a> {
909    /// The task whose tool call started it. Absent outside a task.
910    #[serde(skip_serializing_if = "Option::is_none")]
911    pub(crate) task_id: Option<String>,
912    /// The tool that started it.
913    pub(crate) tool: &'a str,
914    /// The session process's handle, or the `prc_` id minted for one `run`.
915    pub(crate) process_id: &'a str,
916    /// `run` or `session`.
917    pub(crate) kind: &'static str,
918    /// The tool's name for a session process; null for a `run`.
919    pub(crate) name: Option<&'a str>,
920    /// The program, resolved against the host `PATH`.
921    pub(crate) binary: &'a str,
922    /// The arguments, without the program.
923    pub(crate) argv: &'a [String],
924    /// The process's pid, which also names its process group.
925    pub(crate) pid: i32,
926}
927
928#[derive(Serialize)]
929struct ProcessStartEvent<'a> {
930    event_type: &'static str,
931    event_id: String,
932    parent_id: Option<String>,
933    session_id: String,
934    timestamp: u64,
935    #[serde(flatten)]
936    line: ProcessStartLine<'a>,
937}
938
939/// The fields of a `process_exit` line.
940#[derive(Serialize)]
941pub(crate) struct ProcessExitLine<'a> {
942    #[serde(skip_serializing_if = "Option::is_none")]
943    pub(crate) task_id: Option<String>,
944    pub(crate) tool: &'a str,
945    pub(crate) process_id: &'a str,
946    pub(crate) kind: &'static str,
947    pub(crate) name: Option<&'a str>,
948    pub(crate) binary: &'a str,
949    /// The exit code; a death by signal `n` is `128 + n`.
950    pub(crate) exit_code: i32,
951    /// `exited`, `stopped`, `timed_out` or `session_end`.
952    pub(crate) reason: &'static str,
953    pub(crate) duration_ms: u64,
954}
955
956#[derive(Serialize)]
957struct ProcessExitEvent<'a> {
958    event_type: &'static str,
959    event_id: String,
960    parent_id: Option<String>,
961    session_id: String,
962    timestamp: u64,
963    #[serde(flatten)]
964    line: ProcessExitLine<'a>,
965}
966
967/// The fields of a `process_refused` line.
968#[derive(Serialize)]
969pub(crate) struct ProcessRefusedLine<'a> {
970    #[serde(skip_serializing_if = "Option::is_none")]
971    pub(crate) task_id: Option<String>,
972    pub(crate) tool: &'a str,
973    /// `run`, `session` or `connect`.
974    pub(crate) kind: &'static str,
975    /// The session process named; null for a `run`.
976    pub(crate) name: Option<&'a str>,
977    /// The program, resolved; absent for `connect`.
978    #[serde(skip_serializing_if = "Option::is_none")]
979    pub(crate) binary: Option<&'a str>,
980    /// The port asked for; present for `connect` only.
981    #[serde(skip_serializing_if = "Option::is_none")]
982    pub(crate) port: Option<u16>,
983    pub(crate) rule: &'static str,
984    pub(crate) reason: &'a str,
985}
986
987#[derive(Serialize)]
988struct ProcessRefusedEvent<'a> {
989    event_type: &'static str,
990    event_id: String,
991    parent_id: Option<String>,
992    session_id: String,
993    timestamp: u64,
994    #[serde(flatten)]
995    line: ProcessRefusedLine<'a>,
996}
997
998/// A detached command the session ended without carrying its result back. No task will ever report
999/// it; this record and the operator report beside it are what keep the loss visible.
1000///
1001/// The command was either still running at the sweep or finished during teardown, and the second
1002/// case knows more: [`Self::exit_code`], [`Self::output_path`] and [`Self::output_bytes`] are
1003/// written together for it and omitted together for the first. A command still running therefore
1004/// produces a line byte-identical in shape to one written before those fields existed — omitted,
1005/// never `null`, because `null` would read as a known-absent exit code rather than an unknown one.
1006#[derive(Serialize)]
1007struct ShellAbandonedEvent {
1008    event_type: &'static str,
1009    event_id: String,
1010    parent_id: Option<String>,
1011    session_id: String,
1012    timestamp: u64,
1013    work_id: String,
1014    binary: String,
1015    command: String,
1016    /// How long the command had been running when the session gave up on it. For a command that
1017    /// finished during teardown, its full duration from spawn to exit.
1018    running_ms: u64,
1019    /// `128 + signal` for a signal kill, as on `shell_completed`. Absent for a command still
1020    /// running.
1021    #[serde(skip_serializing_if = "Option::is_none")]
1022    exit_code: Option<i32>,
1023    /// Absolute path of the file holding the command's full stdout and stderr,
1024    /// `logs/<work_id>.log` in the session's records directory. Absent for a command still
1025    /// running, for which that file was never written and never will be.
1026    #[serde(skip_serializing_if = "Option::is_none")]
1027    output_path: Option<String>,
1028    /// Size of that file. Present exactly when [`Self::output_path`] is.
1029    #[serde(skip_serializing_if = "Option::is_none")]
1030    output_bytes: Option<u64>,
1031}
1032
1033/// A demoted command a later launch found unaccounted for, appended to the `trace.jsonl` of the
1034/// session that started it.
1035///
1036/// Carries no `exit_code`, no `status`, no `duration_ms`, no `output_path` and no `output_bytes`:
1037/// none of them exists for a command whose runtime died, and a record that named any of them
1038/// would be readable as a result. The event type is the whole discriminator against
1039/// [`ShellCompletedEvent`].
1040///
1041/// Its presence is also what stops the same work id being reported by a second resume, so this
1042/// line and the `shell_detached` it answers live in the same file and are pruned together by
1043/// [`crate::retention::prune_sessions`].
1044#[derive(Serialize)]
1045struct ShellLostEvent {
1046    event_type: &'static str,
1047    event_id: String,
1048    /// The prior session's own `session_start` node. Omitted from the line when that record could
1049    /// not be read back — a line must never name a parent the file does not hold.
1050    #[serde(skip_serializing_if = "Option::is_none")]
1051    parent_id: Option<String>,
1052    /// The prior session's id, not the resuming one's, so the file stays internally consistent.
1053    session_id: String,
1054    timestamp: u64,
1055    work_id: String,
1056    binary: String,
1057    command: String,
1058    /// The `shell_detached` line's own timestamp.
1059    detached_at_ms: u64,
1060    /// The session that found the work unaccounted for and reported it.
1061    reconciled_by_session: String,
1062    /// The `completion`-origin task that session enqueued to report it, which is what makes "which
1063    /// task reported this loss" answerable from the two files alone.
1064    reconciled_task_id: String,
1065}
1066
1067/// A demotion whose own `shell_detached` line could not be written.
1068///
1069/// Best-effort and usually absent: it goes to the file that just failed. When it does land, it is
1070/// the record that the work id below has no `shell_detached` line and so will never be reported
1071/// as lost.
1072#[derive(Serialize)]
1073struct ShellDetachUnrecordedEvent {
1074    event_type: &'static str,
1075    event_id: String,
1076    parent_id: Option<String>,
1077    session_id: String,
1078    timestamp: u64,
1079    turn: u32,
1080    task_id: Option<String>,
1081    work_id: String,
1082    binary: String,
1083    /// The write error, as the operating system reported it.
1084    reason: String,
1085}
1086
1087/// A tool call the driver attached safety checks to, and how they ended. Written once per such
1088/// call, after the person answered (or could not be asked) and, for an acknowledged call, before
1089/// its `tool_call` record. The person's reply is never written.
1090#[derive(Serialize)]
1091struct SafetyCheckEvent {
1092    event_type: &'static str,
1093    event_id: String,
1094    parent_id: Option<String>,
1095    session_id: String,
1096    timestamp: u64,
1097    turn: u32,
1098    task_id: Option<String>,
1099    tool_call_id: String,
1100    tool: String,
1101    /// The block's `safety_checks` exactly as the driver sent it, a malformed one included.
1102    checks: serde_json::Value,
1103    /// `acknowledged`, `declined`, `timed_out`, `canceled`, `no_input_channel` or `malformed`.
1104    outcome: &'static str,
1105}
1106
1107#[derive(Serialize)]
1108struct CompactionEvent {
1109    event_type: &'static str,
1110    event_id: String,
1111    parent_id: Option<String>,
1112    session_id: String,
1113    timestamp: u64,
1114    turn: u32,
1115    task_id: Option<String>,
1116    /// The occupancy the trigger compared: the provider's count of the request and its
1117    /// completion, or the estimate when the call reported none.
1118    tokens_before: u64,
1119    /// Which kind of number `tokens_before` is.
1120    tokens_before_source: OccupancyBasis,
1121    /// The estimate of the committed context. No provider has counted it yet; the next
1122    /// `inference` line carries the first count.
1123    tokens_after: u64,
1124}
1125
1126/// The compaction threshold was crossed and the context was left as it was. The session
1127/// continues over budget, so the record of *why* is the only thing separating this from a
1128/// session that never needed compacting at all.
1129#[derive(Serialize)]
1130struct CompactionDeclinedEvent {
1131    event_type: &'static str,
1132    event_id: String,
1133    parent_id: Option<String>,
1134    session_id: String,
1135    timestamp: u64,
1136    turn: u32,
1137    task_id: Option<String>,
1138    /// Context occupancy at the moment of the decline — the same measurement `compaction`
1139    /// records as `tokens_before`, and the budget the session went on running over.
1140    tokens: u64,
1141    /// Which kind of number `tokens` is.
1142    tokens_source: OccupancyBasis,
1143    /// `"no_hook_replacement"` when no bound hook returned `replace-context`;
1144    /// `"unresolved_tool_call"` when the replacement's tool calls and tool results did not pair.
1145    reason: String,
1146}
1147
1148/// An `on-task-start` hook proposed context and the runtime decided what to do with it.
1149///
1150/// Written once per task that had a seeding hook return something, whatever the decision —
1151/// including a rejection, where `tokens` is `0`. A seed that was silently discarded is
1152/// indistinguishable from a capsule with no memory hook at all, which is the failure this
1153/// record exists to make impossible.
1154#[derive(Serialize)]
1155struct ContextSeedEvent {
1156    event_type: &'static str,
1157    event_id: String,
1158    parent_id: Option<String>,
1159    session_id: String,
1160    timestamp: u64,
1161    task_id: Option<String>,
1162    /// Manifest name of the hook that returned the `seed-context`.
1163    hook_name: String,
1164    /// Tokens actually committed to the head of the context: `0` for a rejection, the
1165    /// proposal's own count for `seeded`, and the surviving count for `trimmed`/`compacted`.
1166    tokens: u64,
1167    /// Tokens the hook proposed, before any trim or summarization. Together with
1168    /// `budget_tokens` this is what makes a rejection readable without the hook's own log.
1169    proposed_tokens: u64,
1170    /// The ceiling in force: `floor(context.max_tokens * context.seed_budget)`, or `0` when
1171    /// the capsule declares no `context.max_tokens`.
1172    budget_tokens: u64,
1173    /// `"seeded"`, `"trimmed"`, `"compacted"` or `"rejected"` — see the `SEED_OUTCOME_*`
1174    /// constants.
1175    outcome: String,
1176    /// Why nothing was committed, on `"rejected"` only — see the `SEED_REJECTED_*`
1177    /// constants. Absent from the JSONL for every other outcome.
1178    #[serde(skip_serializing_if = "Option::is_none")]
1179    reason: Option<String>,
1180    /// The `msg_`-prefixed id of every message committed, in the order they were placed.
1181    /// Empty on a rejection. These are the ids the runtime minted; they never reach the
1182    /// driver payload, so this record is the only place they are visible.
1183    message_ids: Vec<String>,
1184}
1185
1186/// Retention deleted something. One line per (store, reason) pair that removed anything, in the
1187/// trace of the session that performed the deletion — the only place the pruning of session N's
1188/// predecessors can go.
1189///
1190/// Session-scoped, and written immediately after `session_start`: a session directory that
1191/// vanishes with no explanation makes "where did my trace go" unanswerable, and the answer has to
1192/// be in the trace of the run that did it.
1193#[derive(Serialize)]
1194struct RetentionEvent {
1195    event_type: &'static str,
1196    event_id: String,
1197    parent_id: Option<String>,
1198    session_id: String,
1199    timestamp: u64,
1200    /// [`RETENTION_STORE_SESSIONS`] or [`RETENTION_STORE_RECORDS`].
1201    store: String,
1202    /// [`RETENTION_REASON_MAX_SESSIONS`], [`RETENTION_REASON_MAX_AGE`] or
1203    /// [`RETENTION_REASON_MAX_MESSAGES`].
1204    reason: String,
1205    /// How many units this reason removed from this store: session directories, context
1206    /// directories, or — for a truncation — the one record that was rewritten. Never zero;
1207    /// nothing removed means no event.
1208    removed: u32,
1209    /// What went: `ses_` directory names, or context ids.
1210    targets: Vec<String>,
1211    /// Messages dropped from the front of the record. Written for
1212    /// [`RETENTION_REASON_MAX_MESSAGES`] only, and absent from the JSONL otherwise.
1213    #[serde(skip_serializing_if = "Option::is_none")]
1214    messages_dropped: Option<u64>,
1215}
1216
1217#[derive(Serialize)]
1218struct A2aTaskReceivedEvent {
1219    event_type: &'static str,
1220    event_id: String,
1221    parent_id: Option<String>,
1222    session_id: String,
1223    timestamp: u64,
1224    task_id: String,
1225    context_id: String,
1226    message_id: String,
1227    traceparent_from_caller: Option<String>,
1228    /// Who the door let the task in as: [`RECEIVED_FROM_OPERATOR`], [`RECEIVED_FROM_MEMBER`],
1229    /// [`RECEIVED_FROM_CREDENTIAL`] or [`RECEIVED_FROM_PUBLIC`].
1230    caller: &'static str,
1231    /// The formation member that called, present exactly with `caller: "member"`.
1232    #[serde(skip_serializing_if = "Option::is_none")]
1233    caller_member: Option<String>,
1234    /// The declared credential whose token let the task in, present exactly with
1235    /// `caller: "credential"`.
1236    #[serde(skip_serializing_if = "Option::is_none")]
1237    caller_credential: Option<String>,
1238    /// The `sha256` of the task's message text, omitted under `capture: none`. Under `content`
1239    /// the text is stored as the blob of that name.
1240    #[serde(skip_serializing_if = "Option::is_none")]
1241    message_sha256: Option<String>,
1242}
1243
1244/// `a2a_task_received.caller` for a task the operator token let in.
1245pub(crate) const RECEIVED_FROM_OPERATOR: &str = "operator";
1246/// `a2a_task_received.caller` for a task a formation token let in.
1247pub(crate) const RECEIVED_FROM_MEMBER: &str = "member";
1248/// `a2a_task_received.caller` for a task a declared credential's token let in.
1249pub(crate) const RECEIVED_FROM_CREDENTIAL: &str = "credential";
1250/// `a2a_task_received.caller` for a task a door with no `network.authentication` let in.
1251pub(crate) const RECEIVED_FROM_PUBLIC: &str = "public";
1252
1253#[derive(Serialize)]
1254struct A2aSendEvent {
1255    event_type: &'static str,
1256    event_id: String,
1257    parent_id: Option<String>,
1258    session_id: String,
1259    timestamp: u64,
1260    peer_url: String,
1261    message_id: String,
1262    task_id: String,
1263    context_id: String,
1264    traceparent: Option<String>,
1265    /// The class this runtime stamped on the outgoing request, so a chain of capsules reads the
1266    /// same way from the sending end as the receiving end's `task_start` reads it.
1267    trust: String,
1268}
1269
1270/// A successful pull, by `manage.pull()` or `pull-artifact`: the artifact a running capsule
1271/// fetched and pinned in `murmur.lock`. A refused or failed pull writes nothing.
1272#[derive(Serialize)]
1273struct ArtifactPulledEvent {
1274    event_type: &'static str,
1275    event_id: String,
1276    parent_id: Option<String>,
1277    session_id: String,
1278    timestamp: u64,
1279    name: String,
1280    version: String,
1281    /// `"skill"` or `"tool"`, the only roles a pull installs.
1282    runtime: &'static str,
1283    /// The origin the lock entry holds after the pull: `"runtime"`, or `"operator"` when the
1284    /// operator had already pinned the same version.
1285    origin: &'static str,
1286    /// The session the lock entry names as the puller — this one, unless an earlier pull of the
1287    /// same pin recorded another. `null` for an `"operator"` entry.
1288    session: Option<String>,
1289    trust: &'static str,
1290    /// Where the payload came from: `"project store"`, `"global store"` or
1291    /// `"github:<owner>/<repo>"`.
1292    fetched_from: String,
1293    /// GitHub's numeric id of the scope's owner, written to the lock entry beside the pin.
1294    /// Absent when neither the source nor the stored payload records one.
1295    #[serde(skip_serializing_if = "Option::is_none")]
1296    owner_id: Option<u64>,
1297}
1298
1299#[derive(Serialize)]
1300struct SessionEndEvent {
1301    event_type: &'static str,
1302    event_id: String,
1303    parent_id: Option<String>,
1304    session_id: String,
1305    timestamp: u64,
1306    total_turns: u32,
1307    total_input_tokens: u64,
1308    total_output_tokens: u64,
1309    total_tool_calls: u32,
1310    total_shell_calls: u32,
1311    duration_ms: u64,
1312    exit_status: String,
1313}
1314
1315#[derive(Serialize)]
1316struct TaskStartEvent {
1317    event_type: &'static str,
1318    event_id: String,
1319    parent_id: Option<String>,
1320    session_id: String,
1321    timestamp: u64,
1322    task_id: String,
1323    context_id: String,
1324    /// Which door the task came through. `source` and `origin` answer different questions —
1325    /// `"a2a"` covers both a trusted peer and an untyped webhook.
1326    source: String,
1327    origin: String,
1328    trust: String,
1329    /// The queue lane this task waited in, derived from `origin`. Recorded so the order two
1330    /// tasks ran in is answerable from the trace alone.
1331    lane: String,
1332    message_parts_bytes: u64,
1333    /// The kind of each part of the A2A message the task arrived as, `text`, `data` or `raw`, in
1334    /// order. Absent for a task that did not arrive over the door.
1335    #[serde(skip_serializing_if = "Vec::is_empty")]
1336    part_kinds: Vec<&'static str>,
1337    /// The message's `referenceTaskIds`, each once. Absent when it referenced none.
1338    #[serde(skip_serializing_if = "Vec::is_empty")]
1339    reference_task_ids: Vec<String>,
1340    /// The images the message's `raw` parts carried, each with its `path` as
1341    /// `message.parts[<i>]`. Absent when it carried none.
1342    #[serde(skip_serializing_if = "Vec::is_empty")]
1343    attachments: Vec<AttachmentRecord>,
1344}
1345
1346#[derive(Serialize)]
1347struct TaskEndEvent {
1348    event_type: &'static str,
1349    event_id: String,
1350    parent_id: Option<String>,
1351    session_id: String,
1352    timestamp: u64,
1353    task_id: String,
1354    exit_status: String,
1355    duration_ms: u64,
1356    turns: u32,
1357    input_tokens: u64,
1358    output_tokens: u64,
1359    tool_calls: u32,
1360    shell_calls: u32,
1361    /// How many times this task's agent loop was reopened by an `on-task-end` hook
1362    /// before the terminal outcome. `0` for a task that ran once (the common case).
1363    reopen_count: u32,
1364    /// How many of this task's agent-loop turns stopped at the `inference.max_tokens` output
1365    /// cap. Always present; `0` when none did.
1366    capped_turns: u32,
1367    /// The work ids of the background shell commands still running when the task ended, oldest
1368    /// first. Absent when there were none.
1369    #[serde(skip_serializing_if = "Vec::is_empty")]
1370    running_commands: Vec<String>,
1371    /// How `lifecycle.keep_working` went for this task. Absent when the capsule does not set it.
1372    #[serde(skip_serializing_if = "Option::is_none")]
1373    keep_working: Option<KeepWorkingSummary>,
1374    /// How `lifecycle.done_check` went for this task. Absent when the capsule does not set it.
1375    #[serde(skip_serializing_if = "Option::is_none")]
1376    done_check: Option<DoneCheckSummary>,
1377}
1378
1379/// `task_end.done_check`: the runs of `lifecycle.done_check`'s command for this task, the reopens
1380/// they caused and whether the last run passed.
1381#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1382pub(crate) struct DoneCheckSummary {
1383    /// Runs, across reopens.
1384    pub(crate) runs: u32,
1385    /// Reopens the failing runs caused.
1386    pub(crate) reopens: u32,
1387    /// `lifecycle.done_check.limit`.
1388    pub(crate) limit: u32,
1389    /// Whether the last run passed: `null` when the check never ran, `false` after a run a cancel
1390    /// or the session deadline stopped.
1391    pub(crate) met: Option<bool>,
1392    /// What ended the task with the check unmet: `limit`, `max_turns`, `spend_ceiling` or
1393    /// `deadline`. `null` otherwise.
1394    pub(crate) stopped_by: Option<&'static str>,
1395}
1396
1397/// `task_end.keep_working`: the continuations `lifecycle.keep_working` sent this task and what
1398/// stopped them.
1399#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1400pub(crate) struct KeepWorkingSummary {
1401    /// Continuations sent, across reopens.
1402    pub(crate) used: u32,
1403    /// `lifecycle.keep_working.limit`.
1404    pub(crate) limit: u32,
1405    /// What stopped the task being kept working: `limit`, `max_turns`, `spend_ceiling`,
1406    /// `deadline` or `no_session`. `null` when it ended on anything else — a failure, a cancel, a
1407    /// turn end that handed off work.
1408    pub(crate) stopped_by: Option<&'static str>,
1409}
1410
1411/// A person stopped this task. Written where the loop actually stopped, so the record names the
1412/// wait that was interrupted and everything that was still running at that moment.
1413///
1414/// Distinct from the `task_end` that follows it: `task_end` says the task is over and with which
1415/// `exit_status`, this says a person made that happen and what it left behind. A task cancelled
1416/// while still queued has no `task_start` and gets no `task_end`, so this is its only record.
1417#[derive(Serialize)]
1418struct TaskCanceledEvent {
1419    event_type: &'static str,
1420    event_id: String,
1421    parent_id: Option<String>,
1422    session_id: String,
1423    timestamp: u64,
1424    task_id: String,
1425    /// The turn that was in flight, 0-based. Absent for a task cancelled before it ever ran.
1426    #[serde(skip_serializing_if = "Option::is_none")]
1427    turn: Option<u32>,
1428    /// Where cancellation landed: `queued`, `turn`, `inference`, `input`, `delegation` or
1429    /// `harness`.
1430    phase: String,
1431    /// Demoted shell commands still running when the loop stopped. They keep their own
1432    /// lifecycle — nothing here was killed.
1433    detached_work_ids: Vec<String>,
1434    /// Delegations in flight when the loop stopped, named before the cancelled task ends them.
1435    delegation_ids: Vec<String>,
1436}
1437
1438/// `task_rejected.cause` when the session ended on its own — close-out, `after_task: exit`, a
1439/// failed launch task or the idle timeout — with the task still queued.
1440pub(crate) const TASK_REJECTED_SESSION_ENDED: &str = "session_ended";
1441
1442/// `task_rejected.cause` when `mur stop` (`SIGTERM`) ended the session with the task still queued.
1443pub(crate) const TASK_REJECTED_SESSION_STOPPED: &str = "session_stopped";
1444
1445/// The session stopped taking work while this task was still queued, so it was refused rather
1446/// than run. Written once per refused task, after the task loop has ended and before
1447/// `session_end`.
1448///
1449/// A refused task never started, so it has no `task_start`, no `task_end` and never a
1450/// `task_failed`: this is its only record. It hangs off the session node.
1451#[derive(Serialize)]
1452struct TaskRejectedEvent {
1453    event_type: &'static str,
1454    event_id: String,
1455    parent_id: Option<String>,
1456    session_id: String,
1457    timestamp: u64,
1458    task_id: String,
1459    context_id: String,
1460    /// The `IncomingTask::source` the task was enqueued with: `a2a`, `detached_shell` or
1461    /// `detached_lost`.
1462    source: String,
1463    /// One of the `TASK_REJECTED_*` constants.
1464    cause: String,
1465    /// The `status.message` the task's final `rejected` frame carries.
1466    reason: String,
1467}
1468
1469/// `task_failed.cause` when the driver returned an error status or a response whose
1470/// `stop_reason` is `"error"`, and no credential rejection is pending.
1471pub(crate) const TASK_FAILED_DRIVER_ERROR: &str = "driver_error";
1472
1473/// `task_failed.cause` for a driver error while the gateway holds a credential rejection: the
1474/// provider keeps refusing the key, and the reason is the `E-RUN-027` message.
1475pub(crate) const TASK_FAILED_CREDENTIAL_REJECTED: &str = "credential_rejected";
1476
1477/// `task_failed.cause` for a response the loop cannot act on: `stop_reason: "tool_call"` with no
1478/// `tool_call` block, or a `stop_reason` that is missing or unsupported.
1479pub(crate) const TASK_FAILED_MALFORMED_RESPONSE: &str = "malformed_response";
1480
1481/// `task_failed.cause` when a bound compaction hook returned `Err`.
1482pub(crate) const TASK_FAILED_COMPACTION_HOOK: &str = "compaction_hook";
1483
1484/// `task_failed.cause` when a `request-input` wait passed `lifecycle.input_timeout_secs`.
1485pub(crate) const TASK_FAILED_INPUT_TIMEOUT: &str = "input_timeout";
1486
1487/// `task_failed.cause` when an `on-task-end` hook still asked to reopen and
1488/// `lifecycle.max_task_reopens` or `inference.max_turns` refused it.
1489pub(crate) const TASK_FAILED_REOPEN_BUDGET_EXHAUSTED: &str = "reopen_budget_exhausted";
1490
1491/// `task_failed.cause` for an attempt that ended in a runtime error no other cause names. The
1492/// reason is the error's own text.
1493pub(crate) const TASK_FAILED_RUNTIME_ERROR: &str = "runtime_error";
1494
1495/// `task_failed.cause` when two agent-loop turns in a row stopped at the `inference.max_tokens`
1496/// output cap with no text and no tool call. The reason names both turns and the cap.
1497pub(crate) const TASK_FAILED_OUTPUT_CAP: &str = "output_cap";
1498
1499/// `task_failed.cause` for a task ended because a runtime write, append, read, create or removal
1500/// inside the workdir was refused. Always preceded by one `workdir_path_refused` line per refusal.
1501pub(crate) const TASK_FAILED_WORKDIR_PATH_REFUSED: &str = "workdir_path_refused";
1502
1503/// `inference.error_code` when the provider refused the inference credential: it answered `401`
1504/// and the gateway's re-read and resend were refused too. The record's `error` is the runtime's
1505/// own `E-RUN-027` message, never the provider's body.
1506pub(crate) const INFERENCE_ERROR_CREDENTIAL_REJECTED: &str = TASK_FAILED_CREDENTIAL_REJECTED;
1507
1508/// `inference.error_code` when the driver reported the call failed and no credential rejection is
1509/// pending: a provider HTTP error, the driver's own error, a gateway refusal surfaced to the
1510/// driver, or a response whose `stop_reason` is `"error"`.
1511pub(crate) const INFERENCE_ERROR_DRIVER_ERROR: &str = TASK_FAILED_DRIVER_ERROR;
1512
1513/// `inference.error_code` when the driver component could not be invoked or trapped:
1514/// instantiation or import failure, the execution deadline, or a memory limit.
1515pub(crate) const INFERENCE_ERROR_DRIVER_FAILED: &str = "driver_failed";
1516
1517/// `inference.error_code` when the driver reported success but returned no body, or a body that
1518/// is not JSON.
1519pub(crate) const INFERENCE_ERROR_MALFORMED_RESPONSE: &str = TASK_FAILED_MALFORMED_RESPONSE;
1520
1521/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind `auth`.
1522pub(crate) const INFERENCE_ERROR_HARNESS_AUTH: &str = "harness_auth";
1523
1524/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind `quota`.
1525pub(crate) const INFERENCE_ERROR_HARNESS_QUOTA: &str = "harness_quota";
1526
1527/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind
1528/// `harness-error`.
1529pub(crate) const INFERENCE_ERROR_HARNESS_ERROR: &str = "harness_error";
1530
1531/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind `other`.
1532pub(crate) const INFERENCE_ERROR_HARNESS_OTHER: &str = "harness_other";
1533
1534/// `inference.error_code` when a `process` harness went silent past the inactivity timeout.
1535pub(crate) const INFERENCE_ERROR_HARNESS_INACTIVE: &str = "harness_inactive";
1536
1537/// One model call that failed, as [`TraceWriter::write_failed_inference`] records it.
1538pub(crate) struct FailedInference<'a> {
1539    pub(crate) turn: u32,
1540    /// One of the `INFERENCE_ERROR_*` constants.
1541    pub(crate) error_code: &'static str,
1542    /// The failure in words, already redacted. Capped by the writer.
1543    pub(crate) error: &'a str,
1544    /// The HTTP status the credential gateway received for this call, when it received one.
1545    pub(crate) provider_status: Option<u16>,
1546    /// What the provider itself reported, when the failure carried a `usage` block.
1547    pub(crate) usage: Option<&'a DriverUsage>,
1548    /// `Some` for a hook's `run-inference`; `None` for an agent-loop call.
1549    pub(crate) origin: Option<&'a InferenceOrigin>,
1550    /// The driver choice the call was dispatched to, on a capsule that declares alternates.
1551    pub(crate) choice: Option<&'a crate::driver_choice::DriverChoice>,
1552    /// The messages the failed request embedded.
1553    pub(crate) message_ids: Vec<String>,
1554    pub(crate) tool_name: Option<String>,
1555    /// `(input_tokens, output_tokens)` this call counts toward the turn and token totals. `None`
1556    /// is a call that is not counted at all — no turn, no tokens, and neither key on the record.
1557    pub(crate) spent: Option<(Option<u64>, Option<u64>)>,
1558    /// How the call was sent; `None` on the `process` transport.
1559    pub(crate) settings: Option<&'a InferenceCallSettings>,
1560    /// The call's attempts and why its retrying stopped, as its retry loop ended.
1561    pub(crate) retry: RetryNote,
1562    /// The last attempt's gateway timing; `None` for a call made through no runtime gateway.
1563    pub(crate) gateway: Option<GatewayOffsets>,
1564}
1565
1566/// One `inference` line, as the writers that share [`TraceWriter::write_inference_event`] hand
1567/// it over.
1568struct InferenceLine<'a> {
1569    turn: u32,
1570    input_tokens: Option<u64>,
1571    output_tokens: Option<u64>,
1572    decision: String,
1573    stop_reason: Option<&'a str>,
1574    tool_name: Option<String>,
1575    origin: Option<&'a InferenceOrigin>,
1576    usage: Option<&'a DriverUsage>,
1577    message_ids: Vec<String>,
1578    wire: Option<&'a WireCapture>,
1579    choice: Option<&'a crate::driver_choice::DriverChoice>,
1580    /// `(error_code, error, provider_status)` for a failed call.
1581    failure: Option<(&'static str, String, Option<u16>)>,
1582    /// Whether the line advances the turn and token counters.
1583    counted: bool,
1584    /// How the call was sent; `None` on the `process` transport, which writes neither key.
1585    settings: Option<&'a InferenceCallSettings>,
1586    retry: RetryNote,
1587    /// When the attempt's request reached the runtime's credential gateway and was answered;
1588    /// `None` for a call made through no runtime gateway, which writes neither key.
1589    gateway: Option<GatewayOffsets>,
1590}
1591
1592/// The most `task_failed.reason` carries, in bytes. A driver's error text is provider-supplied
1593/// and unbounded; the whole of it stays in `out/result.txt`.
1594pub(crate) const MAX_TASK_FAILURE_REASON_BYTES: usize = 2_000;
1595
1596/// One runtime write, append, read, create or removal inside the workdir that was refused and did
1597/// nothing. Written whatever `trace.capture` is; parented to the
1598/// task node in a task and to the session node outside one.
1599#[derive(Serialize)]
1600struct WorkdirPathRefusedEvent<'a> {
1601    event_type: &'static str,
1602    event_id: String,
1603    parent_id: Option<String>,
1604    session_id: String,
1605    timestamp: u64,
1606    #[serde(skip_serializing_if = "Option::is_none")]
1607    task_id: Option<String>,
1608    /// The path the runtime meant to touch, relative to the accessible workdir.
1609    path: &'a str,
1610    operation: crate::workdir_fs::WorkdirOperation,
1611    /// The component that was refused, relative to the accessible workdir; the whole path for an
1612    /// invalid component.
1613    refused_at: &'a str,
1614    reason: crate::workdir_fs::RefusalReason,
1615}
1616
1617/// A task attempt failed, and why. Written once per failing attempt, before its task's
1618/// `task_end`, by whichever part of the runtime saw the failure.
1619///
1620/// `task_end.exit_status` says `failed`; this names the cause from a closed vocabulary (the
1621/// `TASK_FAILED_*` constants) and carries the reason in words. Never written for a task that
1622/// ended `ok`, `max_turns_reached`, `spend_ceiling_reached` or `canceled`: those have their own
1623/// records.
1624#[derive(Serialize)]
1625struct TaskFailedEvent {
1626    event_type: &'static str,
1627    event_id: String,
1628    parent_id: Option<String>,
1629    session_id: String,
1630    timestamp: u64,
1631    /// The task in progress. Absent for a failure recorded before any `task_start`.
1632    #[serde(skip_serializing_if = "Option::is_none")]
1633    task_id: Option<String>,
1634    /// The turn that failed, 0-based. Absent when the failure is the task's rather than one
1635    /// turn's.
1636    #[serde(skip_serializing_if = "Option::is_none")]
1637    turn: Option<u32>,
1638    cause: String,
1639    /// Capped at [`MAX_TASK_FAILURE_REASON_BYTES`] on a character boundary.
1640    reason: String,
1641}
1642
1643/// `task_reopened.attempt_context` for a next attempt that continues the task's own
1644/// conversation, with the hook's feedback as the one message it adds.
1645pub(crate) const REOPEN_CONTEXT_CONTINUED: &str = "continued";
1646
1647/// `task_reopened.attempt_context` for a next attempt that starts from a fresh context built
1648/// from the rewritten `task.md`, because the previous attempt left no conversation to continue.
1649pub(crate) const REOPEN_CONTEXT_RESTARTED: &str = "restarted";
1650
1651/// One `on-task-end` hook reopened the task: its agent loop is about to run again with
1652/// the hook's feedback. Written between two agent-loop attempts for the same task, so
1653/// `mur trace show` can show which hook drove each reopen, why, and what the next attempt
1654/// starts from.
1655#[derive(Serialize)]
1656struct TaskReopenedEvent {
1657    event_type: &'static str,
1658    event_id: String,
1659    parent_id: Option<String>,
1660    session_id: String,
1661    timestamp: u64,
1662    task_id: String,
1663    /// Manifest name of the `on-task-end` hook that returned `reopen-task`.
1664    hook_name: String,
1665    /// Feedback text the hook asked to inject into the reopened task content.
1666    reason: String,
1667    /// 1-based ordinal of this reopen within the task (first reopen = 1).
1668    reopen_number: u32,
1669    /// [`REOPEN_CONTEXT_CONTINUED`] or [`REOPEN_CONTEXT_RESTARTED`].
1670    attempt_context: &'static str,
1671    /// The turns the next attempt is handed: `inference.max_turns` less every turn the task's
1672    /// attempts have spent so far. `null` when
1673    /// `inference.max_turns` sets no cap.
1674    turns_remaining: Option<u32>,
1675}
1676
1677/// A task continued once with everything it had handed off: its attempt ended with a turn left,
1678/// it waited until no `call-member` call and no `delegate-task` delegation was outstanding, and
1679/// the next attempt is handed every answer and outcome in one message. Written after that round's
1680/// `member_call` and `delegation` lines and before the continued attempt's first `inference`.
1681#[derive(Serialize)]
1682struct TaskContinuedEvent<'a> {
1683    event_type: &'static str,
1684    event_id: String,
1685    parent_id: Option<String>,
1686    session_id: String,
1687    timestamp: u64,
1688    task_id: String,
1689    /// 1-based ordinal of this continuation within the task, counted across reopens.
1690    continuation_number: u32,
1691    /// The `mcl_` id of every call whose outcome this continuation delivers, in arrival order.
1692    member_calls: &'a [String],
1693    /// The `dlg_` id of every delegation whose outcome this continuation delivers, arrived or
1694    /// ended by the backstop, in id order.
1695    delegations: &'a [String],
1696    /// How long the task waited, from its attempt's end until nothing was outstanding.
1697    waited_ms: u64,
1698    /// The turns the continued attempt is handed: `inference.max_turns` less every turn the
1699    /// task's attempts have spent so far. `null` when
1700    /// `inference.max_turns` sets no cap.
1701    turns_remaining: Option<u32>,
1702}
1703
1704/// `lifecycle.keep_working` sent its message after the task's attempt ended at a turn end. Written
1705/// after that turn's `inference` and before the continued attempt's first one.
1706#[derive(Serialize)]
1707struct KeepWorkingEvent {
1708    event_type: &'static str,
1709    event_id: String,
1710    parent_id: Option<String>,
1711    session_id: String,
1712    timestamp: u64,
1713    task_id: String,
1714    /// 1-based ordinal of this continuation within the task, counted across reopens.
1715    number: u32,
1716    /// `lifecycle.keep_working.limit`.
1717    limit: u32,
1718    /// The turns the continued attempt is handed: `inference.max_turns` less every turn the
1719    /// task's attempts have spent so far. `null` when `inference.max_turns` sets no cap.
1720    turns_remaining: Option<u32>,
1721    /// The byte length of the message sent.
1722    message_bytes: u64,
1723}
1724
1725/// One run of `lifecycle.done_check`'s command at a turn end. Written after that turn's
1726/// `inference` and any `keep_working` record, before the next attempt's first `inference` or the
1727/// task's `task_end`.
1728#[derive(Serialize)]
1729struct DoneCheckEvent<'a> {
1730    event_type: &'static str,
1731    event_id: String,
1732    parent_id: Option<String>,
1733    session_id: String,
1734    timestamp: u64,
1735    #[serde(flatten)]
1736    run: &'a DoneCheckRun<'a>,
1737}
1738
1739/// The fields of one `done_check` record that describe the run.
1740#[derive(Debug, Serialize)]
1741pub(crate) struct DoneCheckRun<'a> {
1742    pub(crate) task_id: &'a str,
1743    /// 1-based ordinal of this run within the task, counted across reopens.
1744    pub(crate) number: u32,
1745    /// `lifecycle.done_check.command` as declared.
1746    pub(crate) command: &'a str,
1747    /// `passed`, `failed`, `timed_out`, `stopped` or `not_started`.
1748    pub(crate) outcome: &'static str,
1749    /// `null` for `timed_out`, `stopped` and `not_started`.
1750    pub(crate) exit_code: Option<i32>,
1751    pub(crate) duration_ms: u64,
1752    /// Bytes returned after the 16 KiB-per-stream cap.
1753    pub(crate) stdout_bytes: u64,
1754    pub(crate) stderr_bytes: u64,
1755    pub(crate) truncated: bool,
1756    /// The `capabilities.resources` field whose limit killed the command.
1757    pub(crate) resource_limit: Option<&'a str>,
1758    /// Whether this run reopened the task.
1759    pub(crate) reopened: bool,
1760    /// The reopens the check may still cause in this task, after this run.
1761    pub(crate) reopens_left: u32,
1762    /// `inference.max_turns` less every turn the task has spent. `null` with no cap.
1763    pub(crate) turns_remaining: Option<u32>,
1764}
1765
1766/// A component call the wall-clock bound on `capabilities.limits.deadline_seconds` stopped while
1767/// it waited on the host. Written once per firing, before the record of the call it cut and
1768/// before `session_end`; never for an epoch trap.
1769#[derive(Serialize)]
1770struct CallDeadlineEvent {
1771    event_type: &'static str,
1772    event_id: String,
1773    parent_id: Option<String>,
1774    session_id: String,
1775    timestamp: u64,
1776    #[serde(skip_serializing_if = "Option::is_none")]
1777    task_id: Option<String>,
1778    /// [`crate::limits::CallDeadline::component`].
1779    component: String,
1780    /// [`crate::limits::ComponentRole::wire_name`].
1781    role: &'static str,
1782    /// [`crate::limits::CallDeadline::call`].
1783    call: String,
1784    deadline_seconds: u64,
1785    /// How long the stopped phase had run, from the start of that phase.
1786    elapsed_ms: u64,
1787}
1788
1789/// A driver call failed transiently and is about to be made again. The call's one `inference`
1790/// line follows its last attempt.
1791#[derive(Serialize)]
1792struct InferenceRetryEvent<'a> {
1793    event_type: &'static str,
1794    event_id: String,
1795    parent_id: Option<String>,
1796    session_id: String,
1797    timestamp: u64,
1798    turn: u32,
1799    task_id: Option<String>,
1800    attempt: u32,
1801    max_retries: u32,
1802    reason: &'static str,
1803    #[serde(skip_serializing_if = "Option::is_none")]
1804    provider_status: Option<u16>,
1805    error: &'a str,
1806    delay_ms: u64,
1807    #[serde(skip_serializing_if = "Option::is_none")]
1808    retry_after_ms: Option<u64>,
1809    charged_tokens: u64,
1810    #[serde(skip_serializing_if = "Option::is_none")]
1811    origin: Option<&'a str>,
1812}
1813
1814/// A spend ceiling refused a driver call before it was sent. No `inference` line accompanies it,
1815/// because no call was made.
1816#[derive(Serialize)]
1817struct SpendCeilingReachedEvent<'a> {
1818    event_type: &'static str,
1819    event_id: String,
1820    parent_id: Option<String>,
1821    session_id: String,
1822    timestamp: u64,
1823    turn: u32,
1824    task_id: Option<String>,
1825    #[serde(flatten)]
1826    refusal: &'a crate::spend::SpendRefusal,
1827    /// `hook:<name>` for a hook's `run-inference` call; absent for an agent-loop turn.
1828    #[serde(skip_serializing_if = "Option::is_none")]
1829    origin: Option<&'a str>,
1830}
1831
1832/// What a `transport: process` session spawned, and what the driver planned for it.
1833///
1834/// Separate from [`HarnessStartEvent`] so the runner builds one value rather than passing a dozen
1835/// positional arguments. Flattened into the event, so the record has no nested object.
1836#[derive(Serialize)]
1837pub(crate) struct HarnessStart {
1838    /// The process driver artifact and its version.
1839    pub driver: String,
1840    pub driver_version: String,
1841    /// What the driver said it drives, and the executable actually spawned.
1842    pub harness: String,
1843    /// The absolute path spawned — not the name the manifest or the driver asked for.
1844    pub binary: String,
1845    /// `inference.command`, or the process driver's `describe()`.
1846    pub binary_source: String,
1847    /// What the harness printed for its version arguments, or `null` when it could not be read.
1848    pub harness_version: Option<String>,
1849    /// Whether [`Self::harness_version`] matched one of the driver's `tested-versions`.
1850    pub version_tested: bool,
1851    /// The harness session id the runtime minted for this run, and whether it started or resumed.
1852    pub harness_session_id: String,
1853    pub session_mode: String,
1854    /// How many arguments the plan carried. Never the arguments themselves: a driver is free to
1855    /// put the bridge's bearer token in one.
1856    pub args_count: usize,
1857    /// The names of the variables actually delivered, sorted. Never their values.
1858    pub env_names: Vec<String>,
1859    /// Which of `USER` and `LOGNAME` the host did not set, sorted; empty when it set both. Never
1860    /// their values.
1861    pub account_env_unset: Vec<String>,
1862    /// The names of the files written into the run's private directory. Never their contents.
1863    pub files: Vec<String>,
1864    /// The capsule tool names the bridge offered, bare. Empty when no bridge was bound.
1865    pub bridge_tools: Vec<String>,
1866    /// How many bytes were written to the harness's stdin, and whether it stayed open after.
1867    pub stdin_bytes: usize,
1868    pub keep_stdin_open: bool,
1869    /// SHA-256 (lowercase hex) of the exact system prompt handed to the process driver: the
1870    /// fact lines, the resolved prompt and any instruction-file blocks. Unlike
1871    /// `session_start.system_prompt_sha256`, which names the prompt the operator wrote.
1872    pub system_prompt_sha256: String,
1873    /// The text [`Self::system_prompt_sha256`] hashes, stored as that blob under
1874    /// `trace.capture: content` and never written to the line.
1875    #[serde(skip)]
1876    pub system_prompt: String,
1877}
1878
1879#[derive(Serialize)]
1880struct HarnessStartEvent {
1881    event_type: &'static str,
1882    event_id: String,
1883    parent_id: Option<String>,
1884    session_id: String,
1885    timestamp: u64,
1886    task_id: Option<String>,
1887    #[serde(flatten)]
1888    start: HarnessStart,
1889}
1890
1891/// A `W-*` warning the run raised. The same text goes to stderr; this is the durable copy, so a
1892/// run read back later still says what it warned about.
1893#[derive(Serialize)]
1894struct HarnessWarningEvent {
1895    event_type: &'static str,
1896    event_id: String,
1897    parent_id: Option<String>,
1898    session_id: String,
1899    timestamp: u64,
1900    task_id: Option<String>,
1901    code: String,
1902    message: String,
1903}
1904
1905/// The session the harness itself reports, which is not this session: the runtime's `session_id`
1906/// names the capsule run, `harness_session_id` names the conversation inside the harness.
1907#[derive(Serialize)]
1908struct HarnessSessionEvent {
1909    event_type: &'static str,
1910    event_id: String,
1911    parent_id: Option<String>,
1912    session_id: String,
1913    timestamp: u64,
1914    task_id: Option<String>,
1915    harness_session_id: String,
1916    /// How the harness says it is billing. Anything but `subscription` is also warned about.
1917    auth: String,
1918    /// The model the harness says it is running, when it says.
1919    model: Option<String>,
1920}
1921
1922/// The harness session a person asked this capsule to drop, recorded because forgetting is not
1923/// the same as never having known: the turn that follows starts a new conversation under a context
1924/// that had one, and this is what says so.
1925#[derive(Serialize)]
1926struct HarnessSessionForgottenEvent {
1927    event_type: &'static str,
1928    event_id: String,
1929    parent_id: Option<String>,
1930    session_id: String,
1931    timestamp: u64,
1932    task_id: Option<String>,
1933    /// The context whose entry was dropped.
1934    context_id: String,
1935    /// The id that was dropped, which the harness is no longer asked to continue.
1936    harness_session_id: String,
1937    /// Which surface carried the request: `cli` or `a2a`.
1938    requested_by: String,
1939}
1940
1941#[derive(Serialize)]
1942struct HarnessRetryEvent {
1943    event_type: &'static str,
1944    event_id: String,
1945    parent_id: Option<String>,
1946    session_id: String,
1947    timestamp: u64,
1948    task_id: Option<String>,
1949    attempt: u32,
1950    reason: String,
1951}
1952
1953/// A task-node record whose payload is one line of text: `harness_note` and
1954/// `harness_suggestion`.
1955#[derive(Serialize)]
1956struct HarnessTextEvent {
1957    event_type: &'static str,
1958    event_id: String,
1959    parent_id: Option<String>,
1960    session_id: String,
1961    timestamp: u64,
1962    task_id: Option<String>,
1963    text: String,
1964}
1965
1966/// One subscription window the harness reported, as `harness_quota` records it. A member the
1967/// harness did not report is left out, never written as zero or `null`.
1968#[derive(Serialize)]
1969struct HarnessQuotaWindow<'a> {
1970    name: &'a str,
1971    #[serde(skip_serializing_if = "Option::is_none")]
1972    used: Option<f64>,
1973    #[serde(skip_serializing_if = "Option::is_none")]
1974    resets_at_ms: Option<u64>,
1975    #[serde(skip_serializing_if = "Option::is_none")]
1976    window_minutes: Option<u64>,
1977    #[serde(skip_serializing_if = "Option::is_none")]
1978    state: Option<&'static str>,
1979}
1980
1981#[derive(Serialize)]
1982struct HarnessQuotaEvent<'a> {
1983    event_type: &'static str,
1984    event_id: String,
1985    parent_id: Option<String>,
1986    session_id: String,
1987    timestamp: u64,
1988    task_id: Option<String>,
1989    windows: Vec<HarnessQuotaWindow<'a>>,
1990}
1991
1992#[derive(Serialize)]
1993struct HarnessFailedEvent {
1994    event_type: &'static str,
1995    event_id: String,
1996    parent_id: Option<String>,
1997    session_id: String,
1998    timestamp: u64,
1999    task_id: Option<String>,
2000    /// The driver's `failure-kind`, spelled as the WIT spells it, or `max-turns` from the runtime.
2001    kind: String,
2002    message: String,
2003    /// `harness` when the harness reported it, `runtime` when this runtime ended the turn.
2004    source: String,
2005}
2006
2007/// How the runtime tried to stop a harness a person cancelled the task of.
2008#[derive(Serialize)]
2009struct HarnessInterruptEvent {
2010    event_type: &'static str,
2011    event_id: String,
2012    parent_id: Option<String>,
2013    session_id: String,
2014    timestamp: u64,
2015    task_id: Option<String>,
2016    /// The driver's `interrupt-method`, spelled as the WIT spells it.
2017    method: String,
2018    /// Whether a graceful interrupt actually reached the harness.
2019    delivered: bool,
2020    /// How long the harness has to end on its own before it is killed. `0` when it is killed at
2021    /// once, which is every ending where `delivered` is false.
2022    grace_ms: u64,
2023}
2024
2025/// How one spawned harness ended. Written once per spawn, on every path out of the run.
2026#[derive(Serialize)]
2027pub(crate) struct HarnessExit {
2028    /// The process's exit code, or `null` when a signal ended it.
2029    pub code: Option<i32>,
2030    /// The signal that ended the process, or `null` when it exited on its own.
2031    pub signal: Option<i32>,
2032    /// What ended the run: `terminal`, `eof`, `inactivity`, `max_turns`, `driver_error` or
2033    /// `canceled`.
2034    pub cause: String,
2035    /// Whether the runtime had to kill the process rather than wait for it.
2036    pub killed: bool,
2037    /// Wall time from spawn to reaped.
2038    pub duration_ms: u64,
2039}
2040
2041#[derive(Serialize)]
2042struct HarnessExitEvent {
2043    event_type: &'static str,
2044    event_id: String,
2045    parent_id: Option<String>,
2046    session_id: String,
2047    timestamp: u64,
2048    task_id: Option<String>,
2049    #[serde(flatten)]
2050    exit: HarnessExit,
2051}
2052
2053/// A policy hook refused a call before it ran. The one record that says a call the model asked
2054/// for never happened — there is no `tool_call` or `shell` line for a denied call, because
2055/// nothing ran.
2056#[derive(Serialize)]
2057struct CallDeniedEvent {
2058    event_type: &'static str,
2059    event_id: String,
2060    parent_id: Option<String>,
2061    session_id: String,
2062    timestamp: u64,
2063    turn: u32,
2064    /// The gated lifecycle function whose decision point refused: `"on-shell"` or
2065    /// `"on-tool-call"`.
2066    event: String,
2067    /// Manifest name of the policy hook that refused.
2068    hook_name: String,
2069    /// What was refused: the resolved executable path for a shell call, the tool name
2070    /// otherwise.
2071    target: String,
2072    /// The hook's own reason, or the runtime's when the hook supplied none it could use.
2073    reason: String,
2074}
2075
2076/// A tool call was refused before it ran because its input lacked a name the tool's
2077/// `input_schema` lists in `required`. Written in every capture mode: under the default `meta`
2078/// the accompanying `tool_call` record carries no `output`, so this is where the reason lands.
2079#[derive(Serialize)]
2080struct ToolInputRefusedEvent {
2081    event_type: &'static str,
2082    event_id: String,
2083    parent_id: Option<String>,
2084    session_id: String,
2085    timestamp: u64,
2086    turn: u32,
2087    tool_name: String,
2088    /// The provider's id for the call, or `null` where the call arrived without one — every
2089    /// call bridged from a `transport: process` harness.
2090    tool_call_id: Option<String>,
2091    /// The required names the input lacked, in the schema's declared order.
2092    missing: Vec<String>,
2093    /// The refusal text the model was handed.
2094    reason: String,
2095}
2096
2097/// The manifest's own read-only rule refused a call before it ran. Distinct from
2098/// [`CallDeniedEvent`] because it names a different authority and a different subject: no hook was
2099/// asked, and what it carries is the path, the declared rule and the evidence that identified the
2100/// call as a write.
2101#[derive(Serialize)]
2102struct ProtectedPathDeniedEvent {
2103    event_type: &'static str,
2104    event_id: String,
2105    parent_id: Option<String>,
2106    session_id: String,
2107    timestamp: u64,
2108    turn: u32,
2109    /// Which dispatch path was refused: `"shell"` or `"tool"`.
2110    call: String,
2111    /// What was refused: the resolved executable path for a shell call, the tool name otherwise.
2112    target: String,
2113    /// The resolved workdir-relative path, never the string the model typed, so two spellings of
2114    /// one file produce one comparable record.
2115    path: String,
2116    /// The `capabilities.filesystem.read_only` entry that covers `path`, as declared.
2117    rule: String,
2118    /// What identified the call as a write — a redirection operator, a write-verb argument
2119    /// position, or the tool-input key pairing.
2120    signal: String,
2121    /// The same text the model was given, so the trace and the agent agree on why.
2122    reason: String,
2123}
2124
2125#[derive(Serialize)]
2126struct HookDispatchErrorEvent {
2127    event_type: &'static str,
2128    event_id: String,
2129    parent_id: Option<String>,
2130    session_id: String,
2131    timestamp: u64,
2132    hook_name: String,
2133    event: String,
2134    arm: String,
2135}
2136
2137/// A formation member's lifeline read EOF: the formation it belongs to has ended, and this
2138/// session is winding down because of it. Written only when the lifeline is what began the
2139/// session's termination.
2140#[derive(Serialize)]
2141struct FormationEndedEvent {
2142    event_type: &'static str,
2143    event_id: String,
2144    parent_id: Option<String>,
2145    session_id: String,
2146    timestamp: u64,
2147    formation_id: String,
2148}
2149
2150/// One `session-status` call: the object the model was handed, measured at the call.
2151#[derive(Serialize)]
2152struct SessionStatusEvent<'a> {
2153    event_type: &'static str,
2154    event_id: String,
2155    parent_id: Option<String>,
2156    session_id: String,
2157    timestamp: u64,
2158    /// The task that made the call. Absent outside a task.
2159    #[serde(skip_serializing_if = "Option::is_none")]
2160    task_id: Option<String>,
2161    status: &'a crate::session_status::StatusReport,
2162}
2163
2164/// The one deadline notice, appended to the tool result named here.
2165#[derive(Serialize)]
2166struct DeadlineNoticeEvent<'a> {
2167    event_type: &'static str,
2168    event_id: String,
2169    parent_id: Option<String>,
2170    session_id: String,
2171    timestamp: u64,
2172    /// The task whose tool result carried it. Absent outside a task.
2173    #[serde(skip_serializing_if = "Option::is_none")]
2174    task_id: Option<String>,
2175    /// The tool whose result carried it.
2176    tool_name: &'a str,
2177    /// When the runtime ends the session, RFC 3339 UTC.
2178    deadline_at: &'a str,
2179    /// Whole seconds left when the result was handed back.
2180    left_secs: u64,
2181    /// The margin it was due under.
2182    notice_secs: u64,
2183}
2184
2185/// The session reached its deadline, own or inherited, and is winding down because of it. Written
2186/// by the termination routine before any task is cancelled.
2187#[derive(Serialize)]
2188struct DeadlineReachedEvent {
2189    event_type: &'static str,
2190    event_id: String,
2191    parent_id: Option<String>,
2192    session_id: String,
2193    timestamp: u64,
2194    /// When the deadline fell, RFC 3339 UTC.
2195    deadline_at: String,
2196    /// `session_start.deadline.secs`.
2197    deadline_secs: u64,
2198    /// `SessionDeadline::source`: `"own"` or `"inherited"`.
2199    source: &'static str,
2200}
2201
2202/// A delegated child's spawner lifeline read EOF: the process that spawned this session has
2203/// ended, and this session is winding down because of it. Written only when that lifeline is what
2204/// began the session's termination.
2205///
2206/// The lineage keys are the ones `session_start` carries, and like there they are absent rather
2207/// than null for a session launched with a lifeline but no `MURMUR_SPAWNER`.
2208#[derive(Serialize)]
2209struct SpawnerEndedEvent {
2210    event_type: &'static str,
2211    event_id: String,
2212    parent_id: Option<String>,
2213    session_id: String,
2214    timestamp: u64,
2215    #[serde(skip_serializing_if = "Option::is_none")]
2216    spawned_by: Option<String>,
2217    #[serde(skip_serializing_if = "Option::is_none")]
2218    delegation_id: Option<String>,
2219}
2220
2221#[derive(Serialize)]
2222struct ResourceListEvent {
2223    event_type: &'static str,
2224    event_id: String,
2225    parent_id: Option<String>,
2226    session_id: String,
2227    timestamp: u64,
2228    /// `exports.files.root` verbatim, or `""` when the capsule declares no export — a refusal on
2229    /// an undeclared plane is still a complete record of what was asked for.
2230    root: String,
2231    entry_count: usize,
2232    total_bytes: u64,
2233    /// Which completed turn the listing is as of.
2234    generation: u64,
2235    containment_achieved: ContainmentClass,
2236    /// `"ok"`, or the wire error code the request was refused with.
2237    outcome: String,
2238    /// `null` on `ok`, one short sentence otherwise.
2239    reason: Option<String>,
2240}
2241
2242#[derive(Serialize)]
2243struct ResourceReadEvent {
2244    event_type: &'static str,
2245    event_id: String,
2246    parent_id: Option<String>,
2247    session_id: String,
2248    timestamp: u64,
2249    /// The requested path *after* percent-decoding, so `%2e%2e%2f` and `../` read as the same
2250    /// attempt rather than as two unrelated findings.
2251    path: String,
2252    outcome: String,
2253    /// `null` on every non-`ok` outcome.
2254    bytes: Option<u64>,
2255    /// `null` on every non-`ok` outcome.
2256    sha256: Option<String>,
2257    generation: u64,
2258    containment_achieved: ContainmentClass,
2259    reason: Option<String>,
2260}
2261
2262/// One `share-file` call, served or refused, written at the moment of the mint.
2263#[derive(Serialize)]
2264struct PeerHandleMintEvent {
2265    event_type: &'static str,
2266    event_id: String,
2267    parent_id: Option<String>,
2268    session_id: String,
2269    timestamp: u64,
2270    /// `null` on every non-`ok` outcome: a mint that was refused produced no token, so there is
2271    /// no handle to identify.
2272    handle_id: Option<String>,
2273    /// The path relative to `exports.peer_files.root`, canonicalised, or the path the agent asked
2274    /// for when the mint was refused. Never a host path and never the workdir-relative form.
2275    path: String,
2276    audience: String,
2277    /// `null` on every non-`ok` outcome.
2278    expires_at_ms: Option<u64>,
2279    outcome: String,
2280    reason: Option<String>,
2281}
2282
2283/// One redeem against `/resources/peer/<handle>`, written by the listener concurrently with a
2284/// possibly-running task.
2285#[derive(Serialize)]
2286struct PeerHandleRedeemEvent {
2287    event_type: &'static str,
2288    event_id: String,
2289    parent_id: Option<String>,
2290    session_id: String,
2291    timestamp: u64,
2292    handle_id: String,
2293    /// `null` until the MAC has verified. A payload that failed the MAC is caller-controlled and
2294    /// must not be copied into this capsule's own audit record as if it were fact.
2295    path: Option<String>,
2296    /// The runtime's own current counter, never a value taken from the token — so it is always
2297    /// present and always true.
2298    generation: u64,
2299    /// The `x-murmur-audience` header exactly as asserted, or `null` when none was sent.
2300    audience_asserted: Option<String>,
2301    /// `null` on every non-`ok` outcome.
2302    bytes: Option<u64>,
2303    /// `null` on every non-`ok` outcome.
2304    sha256: Option<String>,
2305    outcome: String,
2306    reason: Option<String>,
2307}
2308
2309/// One `fetch-peer-file` call on the ingesting side, served or refused.
2310#[derive(Serialize)]
2311struct PeerFileFetchEvent {
2312    event_type: &'static str,
2313    event_id: String,
2314    parent_id: Option<String>,
2315    session_id: String,
2316    timestamp: u64,
2317    peer: String,
2318    handle_id: String,
2319    /// Where the bytes landed, relative to the accessible workdir. `null` on every non-`ok`
2320    /// outcome.
2321    stored_path: Option<String>,
2322    bytes: Option<u64>,
2323    sha256: Option<String>,
2324    outcome: String,
2325    reason: Option<String>,
2326}
2327
2328/// One launched child, written the moment it is up.
2329///
2330/// Two surfaces launch children — the agent's `delegate-task` tool and a plan's `capsule` step —
2331/// and both write this line, off the same struct and off the same session node. Which one made a
2332/// given delegation is not recorded, because it is not a fact about the delegation.
2333///
2334/// Separate from the `delegation` line below because the two answer different questions and land
2335/// at different times: this one reaches disk while the delegation is still in flight, so a child
2336/// that hangs, crashes or is ended at its deadline is attributable from the parent's side whatever
2337/// happens next. A delegation the daemon refused writes none of these — nothing was launched.
2338#[derive(Serialize)]
2339struct DelegationStartEvent {
2340    event_type: &'static str,
2341    event_id: String,
2342    parent_id: Option<String>,
2343    session_id: String,
2344    timestamp: u64,
2345    /// The `dlg_` id the launcher minted, character-identical to the one on this delegation's
2346    /// terminal `delegation` line and in the child's own `session_start`.
2347    delegation_id: String,
2348    capsule: String,
2349    version: String,
2350    /// The session id the child's runtime minted for itself.
2351    child_session_id: String,
2352    /// The child's directory, relative to this capsule's accessible workdir.
2353    child_workdir: String,
2354    /// The child's records directory, absolute, as the child reported it: its own `trace.jsonl`
2355    /// and `completion.json` are there.
2356    child_records_dir: String,
2357    /// What was copied from this capsule's work folder into the child's before it started, or
2358    /// `null` for a child not listed under `capabilities.spawn.workdir_snapshot`. Always
2359    /// serialized.
2360    workdir_snapshot: Option<crate::workdir_snapshot::WorkdirSnapshot>,
2361}
2362
2363impl DelegationStartEvent {
2364    /// The one place this record is built, so the two appenders that write it cannot drift into
2365    /// two shapes. Which surface launched the child is not an argument, because it is not a field.
2366    #[allow(clippy::too_many_arguments)]
2367    fn new(
2368        session_id: &str,
2369        session_event_id: &str,
2370        delegation_id: &str,
2371        capsule: &str,
2372        version: &str,
2373        child_session_id: &str,
2374        child_workdir: &str,
2375        child_records_dir: &str,
2376        workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
2377    ) -> Self {
2378        Self {
2379            event_type: "delegation_start",
2380            event_id: new_event_id(),
2381            parent_id: Some(session_event_id.to_string()),
2382            session_id: session_id.to_string(),
2383            timestamp: timestamp_ms(),
2384            delegation_id: delegation_id.to_string(),
2385            capsule: capsule.to_string(),
2386            version: version.to_string(),
2387            child_session_id: child_session_id.to_string(),
2388            child_workdir: child_workdir.to_string(),
2389            child_records_dir: child_records_dir.to_string(),
2390            workdir_snapshot: workdir_snapshot.cloned(),
2391        }
2392    }
2393}
2394
2395/// A `call-member` call whose callee holds the task, written as the tool call returns — or later,
2396/// by the call's watcher, when a callee that was busy takes the task.
2397///
2398/// Carries the callee's roster name and the id of the task its door holds, never its door's URL
2399/// or the token the call presented.
2400#[derive(Serialize)]
2401struct MemberCallStartEvent {
2402    event_type: &'static str,
2403    event_id: String,
2404    parent_id: Option<String>,
2405    session_id: String,
2406    timestamp: u64,
2407    /// The calling task.
2408    task_id: String,
2409    /// The `mcl_` id the call returned to the model.
2410    call_id: String,
2411    /// The callee's roster name.
2412    member: String,
2413    /// The task the callee's door holds, as its `GetTask` names it.
2414    member_task_id: String,
2415}
2416
2417/// One offer of a `call-member` call that the callee's door turned away busy: it answered
2418/// `SendMessage` with `rejected` and [`crate::a2a::REJECTED_BUSY_MESSAGE`]. The first is written
2419/// as the tool call returns, each later one by the call's watcher as it offers the task again.
2420///
2421/// Carries the callee's roster name, never its door's URL or the token the call presented.
2422#[derive(Serialize)]
2423struct MemberCallBusyEvent {
2424    event_type: &'static str,
2425    event_id: String,
2426    parent_id: Option<String>,
2427    session_id: String,
2428    timestamp: u64,
2429    /// The calling task.
2430    task_id: String,
2431    /// The `mcl_` id the call returned to the model.
2432    call_id: String,
2433    /// The callee's roster name.
2434    member: String,
2435    /// Which offer of the call this was, from 1.
2436    offer: u32,
2437    /// From the tool call that made the call to this refusal.
2438    waited_ms: u64,
2439    /// The door's status message.
2440    message: String,
2441}
2442
2443/// One `call-member` call, written once it is accounted for: delivered to the calling task, or
2444/// left behind when that task ended.
2445///
2446/// A call that never started writes only this, with no `member_task_id`. Carries no URL and no
2447/// token.
2448#[derive(Serialize)]
2449struct MemberCallEvent {
2450    event_type: &'static str,
2451    event_id: String,
2452    parent_id: Option<String>,
2453    session_id: String,
2454    timestamp: u64,
2455    task_id: String,
2456    call_id: String,
2457    member: String,
2458    /// Absent, not null, for a call the callee never held.
2459    #[serde(skip_serializing_if = "Option::is_none")]
2460    member_task_id: Option<String>,
2461    /// `completed`, `failed`, `canceled`, `rejected`, `timed_out`, `unreachable`, `abandoned` or
2462    /// `no_answer`.
2463    status: String,
2464    duration_ms: u64,
2465    /// The callee's answer, its status message, or why the call ended.
2466    output: String,
2467    truncated: bool,
2468    /// Whether the calling task received `output`: as the tool result for a call that never
2469    /// started, as a continuation for one that did.
2470    delivered: bool,
2471    /// The members further down that gave the callee no answer, as its `GetTask` reported them.
2472    /// Absent when it reported none.
2473    #[serde(skip_serializing_if = "Vec::is_empty")]
2474    no_answer_below: Vec<NoAnswerBelow>,
2475}
2476
2477/// One member a callee reported as giving it no answer.
2478#[derive(Serialize)]
2479struct NoAnswerBelow {
2480    member: String,
2481    /// A `member_call` status other than `completed`.
2482    status: String,
2483}
2484
2485/// One delegation, written when it ends.
2486///
2487/// One line per call whatever happened, including a call the daemon refused: a delegation that was
2488/// asked for and not made is as much a fact of the run as one that produced an answer. When it is
2489/// written depends on how far the delegation got — a `delegate-task` call that started a child
2490/// writes it as that child's completion arrives, out of the child's own `completion.json`; one that
2491/// never started a child writes it within the call. A plan `capsule` step waits for its child's
2492/// answer, so it always writes this line within the step. Carries neither the task text nor the
2493/// child's answer — both are the agent's own conversation, which the `tool_call` line for the
2494/// same call already records under the session's `trace.capture` setting.
2495#[derive(Serialize)]
2496struct DelegationEvent {
2497    event_type: &'static str,
2498    event_id: String,
2499    parent_id: Option<String>,
2500    session_id: String,
2501    timestamp: u64,
2502    /// The sub-capsule the agent named, and the exact version it named.
2503    capsule: String,
2504    version: String,
2505    /// The `dlg_` id naming this delegation. `null` on a `refused` outcome, which made none.
2506    delegation_id: Option<String>,
2507    /// The child's own session id, so its trace is findable. `null` when no child ran.
2508    child_session_id: Option<String>,
2509    /// How long the child ran, for a delegation that started; how long the call took, for one that
2510    /// never started.
2511    duration_ms: u64,
2512    /// How the delegation ended. Which vocabulary it is drawn from depends on whether the
2513    /// delegating call waited for the answer itself:
2514    ///
2515    /// - A delegation whose outcome arrived later as a completion reads `ok`, `error`, `crashed`
2516    ///   or `terminated` from [`crate::delegation::DelegationStatus`], or `unknown` when the
2517    ///   completion left no readable file.
2518    /// - One the caller waited out reads `completed` or `timed_out` from
2519    ///   [`crate::delegation_plane::DelegationStatus`].
2520    /// - One that never started reads `failed` or `refused`, from that same enum, either way.
2521    outcome: String,
2522    /// `null` where nothing needs saying. Otherwise the completion's `detail`, or the same
2523    /// sentence the model was given.
2524    reason: Option<String>,
2525}
2526
2527impl DelegationEvent {
2528    /// The one place this record is built, so the two appenders that write it cannot drift into
2529    /// two shapes. Which surface launched the child is not an argument, because it is not a field.
2530    #[allow(clippy::too_many_arguments)]
2531    fn new(
2532        session_id: &str,
2533        session_event_id: &str,
2534        capsule: &str,
2535        version: &str,
2536        delegation_id: Option<String>,
2537        child_session_id: Option<String>,
2538        duration_ms: u64,
2539        outcome: &str,
2540        reason: Option<String>,
2541    ) -> Self {
2542        Self {
2543            event_type: "delegation",
2544            event_id: new_event_id(),
2545            parent_id: Some(session_event_id.to_string()),
2546            session_id: session_id.to_string(),
2547            timestamp: timestamp_ms(),
2548            capsule: capsule.to_string(),
2549            version: version.to_string(),
2550            delegation_id,
2551            child_session_id,
2552            duration_ms,
2553            outcome: outcome.to_string(),
2554            reason,
2555        }
2556    }
2557}
2558
2559// ── TraceWriter impl ─────────────────────────────────────────────────────────
2560
2561impl TraceWriter {
2562    #[allow(clippy::too_many_arguments)]
2563    pub(crate) async fn open(
2564        records_root: &WorkdirRoot,
2565        session_id: String,
2566        capsule_name: String,
2567        capsule_version: String,
2568        model: String,
2569        capabilities: Vec<String>,
2570        effective_grants: ScopeReport,
2571        capture: TraceCapture,
2572        system_prompt: Option<String>,
2573        system_prompt_overridden: bool,
2574        resumed_from: Option<String>,
2575        context_id: Option<String>,
2576        spawned_by: Option<String>,
2577        spawn_delegation_id: Option<String>,
2578    ) -> std::io::Result<Self> {
2579        let file = File::from_std(records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?);
2580        // `"cli"` wins over `"none"` when the override cleared the prompt: the operator passed
2581        // `--system-prompt ""`, which is a decision worth recording, not an absence.
2582        let system_prompt_source = match (system_prompt_overridden, system_prompt.is_some()) {
2583            (true, _) => "cli",
2584            (false, true) => "manifest",
2585            (false, false) => "none",
2586        };
2587        let system_prompt_sha256 = system_prompt
2588            .as_ref()
2589            .map(|prompt| murmur_artifact::sha256_hex(prompt.as_bytes()));
2590        let blobs = BlobStore::new(records_root);
2591        // The prompt the operator wrote, kept under the hash `session_start` already carried.
2592        // Written here rather than at `session_start` because a session writes that frame once
2593        // per task and the blob is the same one every time.
2594        if let (true, Some(prompt), Some(sha)) = (
2595            capture.captures_content(),
2596            system_prompt.as_ref(),
2597            system_prompt_sha256.as_deref(),
2598        ) {
2599            blobs.put(sha, prompt.as_bytes()).await?;
2600        }
2601        Ok(Self {
2602            writer: BufWriter::new(file),
2603            session_id,
2604            capsule_name,
2605            capsule_version,
2606            model,
2607            capabilities,
2608            effective_grants,
2609            capture,
2610            blobs,
2611            system_prompt_source,
2612            system_prompt_sha256,
2613            credential_source: "none",
2614            gateways: Vec::new(),
2615            control: None,
2616            inference_choices: Vec::new(),
2617            tool_refresh: None,
2618            runtime_artifacts: Vec::new(),
2619            host_facts: Vec::new(),
2620            instruction_files: None,
2621            resumed_from,
2622            context_id,
2623            spawned_by,
2624            spawn_delegation_id,
2625            formation_id: None,
2626            formation_member: None,
2627            session_start_time: Instant::now(),
2628            session_event_id: new_event_id(),
2629            session_started: false,
2630            session_ended: false,
2631            task_event_id: None,
2632            turn_event_id: None,
2633            total_turns: 0,
2634            total_input_tokens: 0,
2635            total_output_tokens: 0,
2636            total_tool_calls: 0,
2637            total_shell_calls: 0,
2638            task_turns: 0,
2639            task_input_tokens: 0,
2640            task_output_tokens: 0,
2641            task_tool_calls: 0,
2642            task_shell_calls: 0,
2643            task_agent_turns: 0,
2644            task_hook_inferences: 0,
2645            task_capped_turns: 0,
2646            task_start_instant: None,
2647            active_task_id: None,
2648            task_failures_written: 0,
2649            last_task_failure: None,
2650            max_session_tokens: None,
2651            machine_tokens_per_day: None,
2652            session_status: None,
2653            call_deadlines: None,
2654        })
2655    }
2656
2657    /// The session's [`crate::limits::CallDeadlines`], which this writer drains into one
2658    /// `call_deadline` line per firing ahead of each line it writes, `session_end` included.
2659    pub(crate) fn set_call_deadlines(&mut self, deadlines: crate::limits::CallDeadlines) {
2660        self.call_deadlines = Some(deadlines);
2661    }
2662
2663    /// The session's standing, which every later `inference`, `task_start` and `task_end` write
2664    /// feeds and whose deadline every later `session_start` records.
2665    pub(crate) fn set_session_status(
2666        &mut self,
2667        status: std::sync::Arc<crate::session_status::SessionStatus>,
2668    ) {
2669        self.session_status = Some(status);
2670    }
2671
2672    /// The spend ceilings this session runs under, written into every later `session_start`.
2673    pub(crate) fn set_spend_ceilings(
2674        &mut self,
2675        max_session_tokens: Option<u64>,
2676        machine_tokens_per_day: Option<u64>,
2677    ) {
2678        self.max_session_tokens = max_session_tokens;
2679        self.machine_tokens_per_day = machine_tokens_per_day;
2680    }
2681
2682    /// The session node of this writer's event tree — the `event_id` its `session_start` will
2683    /// carry, available before that line is written so [`ResourceTraceAppender`] can be opened
2684    /// against the same node.
2685    pub(crate) fn session_event_id(&self) -> &str {
2686        &self.session_event_id
2687    }
2688
2689    /// Whether this session's `inference` records carry content hashes — everything but
2690    /// `trace.capture: none`. The agent loop asks before splitting a payload into a
2691    /// [`WireCapture`], so `none` costs no serialization at all.
2692    pub(crate) fn captures_wire(&self) -> bool {
2693        self.capture.captures_hashes()
2694    }
2695
2696    /// Parent for a launch-scoped event: the session node, or `None` on a writer that has not
2697    /// written `session_start`. A trace line must never name a parent that has no line behind
2698    /// it.
2699    fn session_parent(&self) -> Option<String> {
2700        self.session_started.then(|| self.session_event_id.clone())
2701    }
2702
2703    /// Parent for a task-scoped event: the task node, falling back to the session node when no
2704    /// task is active.
2705    fn task_parent(&self) -> Option<String> {
2706        self.task_event_id.clone().or_else(|| self.session_parent())
2707    }
2708
2709    /// Parent for a turn-scoped event: the turn node, falling back to the task node and then to
2710    /// the session node. The fallbacks are what keeps a tool call written outside any turn — a
2711    /// hook's, or the process transport's, before its first `inference` — attached to the tree.
2712    fn turn_parent(&self) -> Option<String> {
2713        self.turn_event_id.clone().or_else(|| self.task_parent())
2714    }
2715
2716    /// Records where the inference credential came from, as
2717    /// [`crate::gateway_credential::CredentialSource::trace_name`] names it. `"none"` until set.
2718    pub(crate) fn set_credential_source(&mut self, source: &'static str) {
2719        self.credential_source = source;
2720    }
2721
2722    /// Records the session's credential gateways for `session_start.gateways`.
2723    pub(crate) fn set_gateways(&mut self, gateways: Vec<SessionGateway>) {
2724        self.gateways = gateways;
2725    }
2726
2727    /// Records what `control:` declares for `session_start.control`.
2728    pub(crate) fn set_control(&mut self, control: Option<SessionControl>) {
2729        self.control = control;
2730    }
2731
2732    /// Records the driver choices for `session_start.inference_choices`. Left empty, the key is
2733    /// not written.
2734    pub(crate) fn set_inference_choices(&mut self, choices: Vec<SessionInferenceChoice>) {
2735        self.inference_choices = choices;
2736    }
2737
2738    /// Records the runtime-origin entries of `installed` for `session_start.runtime_artifacts`,
2739    /// in `installed`'s order. Operator-declared entries are left out.
2740    pub(crate) fn set_runtime_artifacts(&mut self, installed: &[InstalledArtifactSummary]) {
2741        self.runtime_artifacts = installed
2742            .iter()
2743            .filter_map(|artifact| match &artifact.origin {
2744                LockOrigin::Runtime { session } => Some(RuntimeArtifact {
2745                    name: artifact.name.clone(),
2746                    version: artifact.version.clone(),
2747                    origin: artifact.origin.as_str(),
2748                    session: session.clone(),
2749                    trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
2750                }),
2751                LockOrigin::Operator => None,
2752            })
2753            .collect();
2754    }
2755
2756    /// Records `uses`, in order, for `session_start.host_facts`.
2757    pub(crate) fn set_host_facts(&mut self, uses: &[crate::host_facts::HostFactsUse]) {
2758        self.host_facts = uses.iter().map(HostFactsRecord::of).collect();
2759    }
2760
2761    /// Records that this inference call is the first to use a setting value a controller set:
2762    /// `turn` is the call's zero-based turn, the same number its `inference` line carries, and
2763    /// `change_id` the `event_id` of the `control_change` that set `value`.
2764    ///
2765    /// Written before the call is sent, so it precedes the turn's `inference` line and hangs off
2766    /// the task.
2767    pub(crate) async fn write_control_applied(
2768        &mut self,
2769        turn: u32,
2770        name: &str,
2771        value: Value,
2772        change_id: &str,
2773    ) -> std::io::Result<()> {
2774        let event = ControlAppliedEvent {
2775            event_type: "control_applied",
2776            event_id: new_event_id(),
2777            parent_id: self.task_parent(),
2778            session_id: self.session_id.clone(),
2779            timestamp: timestamp_ms(),
2780            turn,
2781            task_id: self.active_task_id.clone(),
2782            name,
2783            value,
2784            change_id,
2785        };
2786        self.write_event(&event).await
2787    }
2788
2789    /// Records the formation this session is a member of for `session_start.formation_id`.
2790    pub(crate) fn set_formation_id(&mut self, formation_id: Option<&FormationId>) {
2791        self.formation_id = formation_id.cloned();
2792    }
2793
2794    /// Records `session_start.formation_member` and `session_start.formation_callees` for a
2795    /// session its formation launcher handed credentials to.
2796    pub(crate) fn set_formation_member(
2797        &mut self,
2798        member: Option<&crate::formation_credentials::FormationMember>,
2799    ) {
2800        self.formation_member = member.map(|member| {
2801            (
2802                member.name().to_string(),
2803                member.callees().map(str::to_string).collect(),
2804            )
2805        });
2806    }
2807
2808    /// Records what staging read for `inference.instruction_files` for
2809    /// `session_start.instruction_files`; `None` writes `null`.
2810    pub(crate) fn set_instruction_files(
2811        &mut self,
2812        files: Option<&crate::instruction_files::InstructionFiles>,
2813    ) {
2814        self.instruction_files =
2815            files.map(crate::instruction_files::InstructionFiles::trace_records);
2816    }
2817
2818    /// Records `inference.tool_refresh` for `session_start.tool_refresh`: the wire name under
2819    /// `transport: http`, `None` under `transport: process`.
2820    pub(crate) fn set_tool_refresh(&mut self, tool_refresh: Option<&'static str>) {
2821        self.tool_refresh = tool_refresh;
2822    }
2823
2824    /// Records that the tool array the http agent loop sends changed before this call:
2825    /// `trigger` is why (`"compaction"`, `"agent"` or `"immediate"`), `added` and `removed` the
2826    /// sorted names that entered and left it, and `tools` every name it now offers, in array
2827    /// order.
2828    ///
2829    /// Written before the call is sent, so it precedes the turn's `inference` line and hangs off
2830    /// the task, as `control_applied` does.
2831    pub(crate) async fn write_tools_refreshed(
2832        &mut self,
2833        turn: u32,
2834        trigger: &str,
2835        added: &[String],
2836        removed: &[String],
2837        tools: &[String],
2838    ) -> std::io::Result<()> {
2839        let event = ToolsRefreshedEvent {
2840            event_type: "tools_refreshed",
2841            event_id: new_event_id(),
2842            parent_id: self.task_parent(),
2843            session_id: self.session_id.clone(),
2844            timestamp: timestamp_ms(),
2845            turn,
2846            task_id: self.active_task_id.clone(),
2847            trigger,
2848            added,
2849            removed,
2850            tools,
2851        };
2852        self.write_event(&event).await
2853    }
2854
2855    pub(crate) async fn write_session_start(
2856        &mut self,
2857        max_turns: Option<u32>,
2858        model_limits: Option<crate::model_limits::ResolvedModelLimits>,
2859        tools_declared: Vec<String>,
2860    ) -> std::io::Result<()> {
2861        let event = SessionStartEvent {
2862            event_type: "session_start",
2863            event_id: self.session_event_id.clone(),
2864            parent_id: None,
2865            session_id: self.session_id.clone(),
2866            timestamp: timestamp_ms(),
2867            capsule_name: self.capsule_name.clone(),
2868            capsule_version: self.capsule_version.clone(),
2869            model: self.model.clone(),
2870            max_turns,
2871            model_limits,
2872            max_session_tokens: self.max_session_tokens,
2873            machine_tokens_per_day: self.machine_tokens_per_day,
2874            deadline: self.session_status.as_ref().and_then(|status| {
2875                status.deadline().map(|deadline| SessionStartDeadline {
2876                    at: deadline.at_rfc3339(),
2877                    secs: deadline.secs(),
2878                    notice_secs: status.notice_secs(),
2879                    source: deadline.source().as_str(),
2880                })
2881            }),
2882            capabilities: self.capabilities.clone(),
2883            tools_declared,
2884            containment_declared: self.effective_grants.declared_containment,
2885            containment_achieved: self.effective_grants.achieved_containment,
2886            workdir_exec: self.effective_grants.workdir_exec,
2887            userns_grant: self.effective_grants.userns_grant,
2888            effective_grants: self.effective_grants.clone(),
2889            system_prompt_source: self.system_prompt_source,
2890            system_prompt_sha256: self.system_prompt_sha256.clone(),
2891            credential_source: self.credential_source,
2892            gateways: self.gateways.clone(),
2893            control: self.control.clone(),
2894            inference_choices: self.inference_choices.clone(),
2895            tool_refresh: self.tool_refresh,
2896            runtime_artifacts: self.runtime_artifacts.clone(),
2897            host_facts: self.host_facts.clone(),
2898            instruction_files: self.instruction_files.clone(),
2899            resumed_from: self.resumed_from.clone(),
2900            context_id: self.context_id.clone(),
2901            spawned_by: self.spawned_by.clone(),
2902            delegation_id: self.spawn_delegation_id.clone(),
2903            formation_id: self.formation_id.clone(),
2904            formation_member: self.formation_member.as_ref().map(|(name, _)| name.clone()),
2905            formation_callees: self
2906                .formation_member
2907                .as_ref()
2908                .map(|(_, callees)| callees.clone()),
2909        };
2910        self.write_event(&event).await?;
2911        self.session_started = true;
2912        // A firing from before the session frame existed — an `on-stage` hook's — hangs off it.
2913        self.write_call_deadlines().await
2914    }
2915
2916    /// `input_tokens`/`output_tokens` are what the session and task totals accumulate: the
2917    /// runtime's own tiktoken estimates under `transport: http`, and the counts the harness's
2918    /// driver reported under `transport: process`, where the runtime measures nothing itself.
2919    /// `None` is a turn nothing counted, which only a process driver reporting no usage
2920    /// produces, and it adds nothing to the totals.
2921    ///
2922    /// `usage` is written verbatim beside them and accumulated into nothing. Under `http` it is
2923    /// the provider's own report for the same call; under `process` it carries only the cache and
2924    /// thinking counts, because the harness's input and output are already the two above.
2925    ///
2926    /// `settings` is how an `http` call was sent, written as `max_output_tokens` and
2927    /// `driver_config`; `None` under `process`, which writes neither key.
2928    #[allow(clippy::too_many_arguments)]
2929    pub(crate) async fn write_inference(
2930        &mut self,
2931        turn: u32,
2932        input_tokens: Option<u64>,
2933        output_tokens: Option<u64>,
2934        decision: String,
2935        stop_reason: Option<&str>,
2936        tool_name: Option<String>,
2937        origin: Option<&InferenceOrigin>,
2938        usage: Option<&DriverUsage>,
2939        message_ids: Vec<String>,
2940        wire: Option<&WireCapture>,
2941        settings: Option<&InferenceCallSettings>,
2942    ) -> std::io::Result<()> {
2943        self.write_inference_event(InferenceLine {
2944            turn,
2945            input_tokens,
2946            output_tokens,
2947            decision,
2948            stop_reason,
2949            tool_name,
2950            origin,
2951            usage,
2952            message_ids,
2953            wire,
2954            choice: None,
2955            failure: None,
2956            counted: true,
2957            settings,
2958            retry: RetryNote::default(),
2959            gateway: None,
2960        })
2961        .await
2962    }
2963
2964    /// [`Self::write_inference`] for a hook's `run-inference` that succeeded, with the attempts
2965    /// its retry loop took.
2966    #[allow(clippy::too_many_arguments)]
2967    pub(crate) async fn write_hook_inference(
2968        &mut self,
2969        turn: u32,
2970        input_tokens: Option<u64>,
2971        output_tokens: Option<u64>,
2972        decision: String,
2973        origin: &InferenceOrigin,
2974        usage: Option<&DriverUsage>,
2975        settings: Option<&InferenceCallSettings>,
2976        retry: RetryNote,
2977        gateway: Option<GatewayOffsets>,
2978    ) -> std::io::Result<()> {
2979        self.write_inference_event(InferenceLine {
2980            turn,
2981            input_tokens,
2982            output_tokens,
2983            decision,
2984            stop_reason: None,
2985            tool_name: None,
2986            origin: Some(origin),
2987            usage,
2988            message_ids: Vec::new(),
2989            wire: None,
2990            choice: None,
2991            failure: None,
2992            counted: true,
2993            settings,
2994            retry,
2995            gateway,
2996        })
2997        .await
2998    }
2999
3000    /// [`Self::write_inference`] for an agent-loop turn, naming the driver choice that served it
3001    /// as `driver_choice` and `model`. `choice` is `None` on a capsule with no alternates, whose
3002    /// line is exactly what [`Self::write_inference`] writes.
3003    #[allow(clippy::too_many_arguments)]
3004    pub(crate) async fn write_turn_inference(
3005        &mut self,
3006        turn: u32,
3007        input_tokens: Option<u64>,
3008        output_tokens: Option<u64>,
3009        decision: String,
3010        stop_reason: Option<&str>,
3011        tool_name: Option<String>,
3012        usage: Option<&DriverUsage>,
3013        message_ids: Vec<String>,
3014        wire: Option<&WireCapture>,
3015        choice: Option<&crate::driver_choice::DriverChoice>,
3016        settings: Option<&InferenceCallSettings>,
3017        retry: RetryNote,
3018        gateway: Option<GatewayOffsets>,
3019    ) -> std::io::Result<()> {
3020        if stop_reason == Some("max_tokens") {
3021            self.task_capped_turns = self.task_capped_turns.saturating_add(1);
3022        }
3023        self.write_inference_event(InferenceLine {
3024            turn,
3025            input_tokens,
3026            output_tokens,
3027            decision,
3028            stop_reason,
3029            tool_name,
3030            origin: None,
3031            usage,
3032            message_ids,
3033            wire,
3034            choice,
3035            failure: None,
3036            counted: true,
3037            settings,
3038            retry,
3039            gateway,
3040        })
3041        .await
3042    }
3043
3044    /// Record a model call that failed: `decision` and `stop_reason` `"error"`, the failure's
3045    /// `error_code`, `error` (capped at [`MAX_TASK_FAILURE_REASON_BYTES`]) and `provider_status`.
3046    ///
3047    /// Never hashes or stores a wire body, whatever `trace.capture` is. Writes `input_tokens` and
3048    /// `output_tokens` only from `failed.spent`, and advances the turn and token counters only
3049    /// when `spent` is `Some`, by exactly its counts. Parents like any other `inference`: an
3050    /// agent-loop record becomes the turn node, a hook's hangs off the turn.
3051    pub(crate) async fn write_failed_inference(
3052        &mut self,
3053        failed: FailedInference<'_>,
3054    ) -> std::io::Result<()> {
3055        let error =
3056            crate::agent::truncate_on_char_boundary(failed.error, MAX_TASK_FAILURE_REASON_BYTES)
3057                .to_string();
3058        let (input_tokens, output_tokens) = failed.spent.unwrap_or((None, None));
3059        self.write_inference_event(InferenceLine {
3060            turn: failed.turn,
3061            input_tokens,
3062            output_tokens,
3063            decision: "error".to_string(),
3064            stop_reason: Some("error"),
3065            tool_name: failed.tool_name,
3066            origin: failed.origin,
3067            usage: failed.usage,
3068            message_ids: failed.message_ids,
3069            wire: None,
3070            choice: failed.choice,
3071            failure: Some((failed.error_code, error, failed.provider_status)),
3072            counted: failed.spent.is_some(),
3073            settings: failed.settings,
3074            retry: failed.retry,
3075            gateway: failed.gateway,
3076        })
3077        .await
3078    }
3079
3080    async fn write_inference_event(&mut self, line: InferenceLine<'_>) -> std::io::Result<()> {
3081        let InferenceLine {
3082            turn,
3083            input_tokens,
3084            output_tokens,
3085            decision,
3086            stop_reason,
3087            tool_name,
3088            origin,
3089            usage,
3090            message_ids,
3091            wire,
3092            choice,
3093            failure,
3094            counted,
3095            settings,
3096            retry,
3097            gateway,
3098        } = line;
3099        let (error_code, error, provider_status) = match failure {
3100            Some((code, error, status)) => (Some(code.to_string()), Some(error), status),
3101            None => (None, None, None),
3102        };
3103        // Drained under the previous turn, before this line becomes the turn node.
3104        self.write_call_deadlines().await?;
3105        let event_id = new_event_id();
3106        // The agent loop's own inference *is* the turn node — there is no separate turn line —
3107        // so it hangs off the task and everything the turn goes on to produce hangs off it. A
3108        // hook's `run-inference` (`origin: Some(_)`) is one of those products, not a new turn.
3109        let parent_id = if origin.is_none() {
3110            let parent = self.task_parent();
3111            self.turn_event_id = Some(event_id.clone());
3112            parent
3113        } else {
3114            self.turn_parent()
3115        };
3116        let (system_sha, tools_sha, response_sha, message_shas) =
3117            match wire.filter(|_| self.capture.captures_hashes()) {
3118                Some(wire) => self.record_wire(wire).await?,
3119                None => (None, None, None, Vec::new()),
3120            };
3121        let event = InferenceEvent {
3122            event_type: "inference",
3123            event_id,
3124            parent_id,
3125            session_id: self.session_id.clone(),
3126            timestamp: timestamp_ms(),
3127            turn,
3128            task_id: self.active_task_id.clone(),
3129            input_tokens,
3130            output_tokens,
3131            decision,
3132            stop_reason: stop_reason.map(str::to_string),
3133            tool_name,
3134            input_tokens_actual: usage.and_then(|u| u.input_tokens),
3135            output_tokens_actual: usage.and_then(|u| u.output_tokens),
3136            cached_tokens: usage.and_then(|u| u.cached_tokens),
3137            cache_write_tokens: usage.and_then(|u| u.cache_write_tokens),
3138            thinking_tokens: usage.and_then(|u| u.thinking_tokens),
3139            origin: origin.map(|o| o.source.clone()),
3140            driver_choice: choice.map(|choice| choice.name.clone()),
3141            model: origin
3142                .map(|o| o.model.clone())
3143                .or_else(|| choice.map(|choice| choice.model.clone())),
3144            max_output_tokens: settings.map(|settings| settings.max_output_tokens),
3145            input_budget: settings.map(|settings| settings.input_budget),
3146            driver_config: settings.map(|settings| settings.driver_config.clone()),
3147            message_ids,
3148            system_sha,
3149            tools_sha,
3150            response_sha,
3151            message_shas,
3152            error_code,
3153            error,
3154            provider_status,
3155            gateway_reached_ms: gateway.map(|gateway| gateway.reached_ms),
3156            upstream_responded_ms: gateway.map(|gateway| gateway.responded_ms),
3157            attempts: retry.attempts,
3158            retry_stopped: retry.stopped.map(RetryStop::wire_name),
3159        };
3160        self.write_event(&event).await?;
3161        if origin.is_none() {
3162            self.task_agent_turns = self.task_agent_turns.saturating_add(1);
3163        }
3164        if counted {
3165            // A turn nothing counted contributes nothing to the totals. The totals are plain
3166            // `u64` because a session's spend is a sum, and the sum of no measurements is zero;
3167            // the absent/zero distinction lives on the record itself.
3168            let (input, output) = (input_tokens.unwrap_or(0), output_tokens.unwrap_or(0));
3169            self.total_turns = self.total_turns.saturating_add(1);
3170            self.total_input_tokens = self.total_input_tokens.saturating_add(input);
3171            self.total_output_tokens = self.total_output_tokens.saturating_add(output);
3172            self.task_turns = self.task_turns.saturating_add(1);
3173            if origin.is_some() {
3174                self.task_hook_inferences = self.task_hook_inferences.saturating_add(1);
3175            }
3176            self.task_input_tokens = self.task_input_tokens.saturating_add(input);
3177            self.task_output_tokens = self.task_output_tokens.saturating_add(output);
3178        }
3179        // Every record, counted or not: the provider may have reported a call the turn and token
3180        // totals do not count.
3181        if let Some(status) = &self.session_status {
3182            status.record_inference(self.task_turns, input_tokens, output_tokens, usage);
3183        }
3184        Ok(())
3185    }
3186
3187    /// Hash the four bodies of one request and, under [`TraceCapture::Content`], store each
3188    /// behind its hash.
3189    ///
3190    /// The hash is computed in every capture mode that reaches here; only the blob write is
3191    /// gated, so `meta` and `content` name the same bytes and differ solely in whether those
3192    /// bytes are also on disk.
3193    async fn record_wire(
3194        &mut self,
3195        wire: &WireCapture,
3196    ) -> std::io::Result<(Option<String>, Option<String>, Option<String>, Vec<String>)> {
3197        let system_sha = murmur_artifact::sha256_hex(&wire.system);
3198        let tools_sha = murmur_artifact::sha256_hex(&wire.tools);
3199        let response_sha = murmur_artifact::sha256_hex(&wire.response);
3200        let message_shas: Vec<String> = wire
3201            .messages
3202            .iter()
3203            .map(|message| murmur_artifact::sha256_hex(message))
3204            .collect();
3205
3206        if self.capture.captures_content() {
3207            // Verbatim, unredacted. `write_tool_call` redacts peer handles out of its own summary
3208            // fields; a blob is not a summary, and redacting one would make its hash name bytes
3209            // no file holds — destroying the only property this store exists to provide.
3210            self.blobs.put(&system_sha, &wire.system).await?;
3211            self.blobs.put(&tools_sha, &wire.tools).await?;
3212            self.blobs.put(&response_sha, &wire.response).await?;
3213            for (sha, body) in message_shas.iter().zip(&wire.messages) {
3214                self.blobs.put(sha, body).await?;
3215            }
3216        }
3217
3218        Ok((
3219            Some(system_sha),
3220            Some(tools_sha),
3221            Some(response_sha),
3222            message_shas,
3223        ))
3224    }
3225
3226    #[allow(clippy::too_many_arguments)]
3227    pub(crate) async fn write_tool_call(
3228        &mut self,
3229        turn: u32,
3230        tool_name: String,
3231        tool_call_id: Option<String>,
3232        input: Value,
3233        input_bytes: u64,
3234        output: &str,
3235        output_bytes: u64,
3236        duration_ms: u64,
3237        status: String,
3238        state_effect: Option<String>,
3239        resource_id: Option<String>,
3240        error: Option<&str>,
3241    ) -> std::io::Result<()> {
3242        self.write_tool_call_with_attachments(
3243            turn,
3244            tool_name,
3245            tool_call_id,
3246            input,
3247            input_bytes,
3248            output,
3249            output_bytes,
3250            duration_ms,
3251            status,
3252            state_effect,
3253            resource_id,
3254            error,
3255            Vec::new(),
3256        )
3257        .await
3258    }
3259
3260    /// [`Self::write_tool_call`] for a call that returned images: `attachments` becomes the
3261    /// record's `attachments` field, which an empty list omits.
3262    #[allow(clippy::too_many_arguments)]
3263    pub(crate) async fn write_tool_call_with_attachments(
3264        &mut self,
3265        turn: u32,
3266        tool_name: String,
3267        tool_call_id: Option<String>,
3268        mut input: Value,
3269        input_bytes: u64,
3270        output: &str,
3271        output_bytes: u64,
3272        duration_ms: u64,
3273        status: String,
3274        state_effect: Option<String>,
3275        resource_id: Option<String>,
3276        error: Option<&str>,
3277        attachments: Vec<AttachmentRecord>,
3278    ) -> std::io::Result<()> {
3279        // A peer handle reaches this event by an ordinary route — it is an argument the model
3280        // passed to `fetch-peer-file`, and every call's arguments are recorded. It is also a
3281        // credential, and the trace is durable, so the two must not meet: the `handle_id` goes in
3282        // instead, which is what correlates the record with the mint and the redeem anyway.
3283        crate::peer_handoff::redact_handles_in_json(&mut input);
3284        let event = ToolCallEvent {
3285            event_type: "tool_call",
3286            event_id: new_event_id(),
3287            parent_id: self.turn_parent(),
3288            session_id: self.session_id.clone(),
3289            timestamp: timestamp_ms(),
3290            turn,
3291            task_id: self.active_task_id.clone(),
3292            tool_name,
3293            // An absent or empty provider id records as `null`: `""` would read as an id the
3294            // provider actually issued.
3295            tool_call_id: tool_call_id.filter(|id| !id.is_empty()),
3296            input,
3297            input_bytes,
3298            output: self
3299                .capture
3300                .captures_content()
3301                .then(|| crate::peer_handoff::redact_handle_tokens(output).into_owned()),
3302            output_bytes,
3303            duration_ms,
3304            status,
3305            error: error.map(|error| {
3306                let redacted = crate::peer_handoff::redact_handle_tokens(error);
3307                crate::agent::truncate_on_char_boundary(&redacted, MAX_TASK_FAILURE_REASON_BYTES)
3308                    .to_string()
3309            }),
3310            state_effect,
3311            resource_id,
3312            attachments,
3313        };
3314        self.write_event(&event).await?;
3315        self.total_tool_calls = self.total_tool_calls.saturating_add(1);
3316        self.task_tool_calls = self.task_tool_calls.saturating_add(1);
3317        Ok(())
3318    }
3319
3320    /// Records a skill invocation as a `skill_call` event. Skill calls are NOT counted in
3321    /// `total_tool_calls` — they appear in a separate `── Skill calls ──` section in `mur trace show`.
3322    ///
3323    /// `origin` is the skill's `murmur.lock` origin; the record's `trust` is derived from it
3324    /// through [`crate::origin::artifact_trust`], the same rule that decides the skill's fence.
3325    pub(crate) async fn write_skill_call(
3326        &mut self,
3327        turn: u32,
3328        skill_name: String,
3329        origin: &LockOrigin,
3330        output_bytes: u64,
3331        duration_ms: u64,
3332        status: String,
3333    ) -> std::io::Result<()> {
3334        let event = SkillCallEvent {
3335            event_type: "skill_call",
3336            event_id: new_event_id(),
3337            parent_id: self.turn_parent(),
3338            session_id: self.session_id.clone(),
3339            timestamp: timestamp_ms(),
3340            turn,
3341            task_id: self.active_task_id.clone(),
3342            skill_name,
3343            output_bytes,
3344            duration_ms,
3345            status,
3346            origin: origin.as_str(),
3347            trust: crate::origin::artifact_trust(origin).as_str(),
3348        };
3349        self.write_event(&event).await
3350        // Intentionally does not increment total_tool_calls or task_tool_calls.
3351    }
3352
3353    // One positional parameter per JSONL column, as every other `write_*` here does; the
3354    // sibling `write_tool_call` carries the same allowance.
3355    #[allow(clippy::too_many_arguments)]
3356    pub(crate) async fn write_shell(
3357        &mut self,
3358        turn: u32,
3359        binary: String,
3360        command: String,
3361        exit_code: i32,
3362        stdout_bytes: u64,
3363        stderr_bytes: u64,
3364        duration_ms: u64,
3365        resource_limit: Option<String>,
3366    ) -> std::io::Result<()> {
3367        let event = ShellEvent {
3368            event_type: "shell",
3369            event_id: new_event_id(),
3370            parent_id: self.turn_parent(),
3371            session_id: self.session_id.clone(),
3372            timestamp: timestamp_ms(),
3373            turn,
3374            task_id: self.active_task_id.clone(),
3375            binary,
3376            command,
3377            exit_code,
3378            stdout_bytes,
3379            stderr_bytes,
3380            duration_ms,
3381            resource_limit,
3382        };
3383        self.write_event(&event).await?;
3384        self.total_shell_calls = self.total_shell_calls.saturating_add(1);
3385        self.task_shell_calls = self.task_shell_calls.saturating_add(1);
3386        Ok(())
3387    }
3388
3389    /// A command demoted to the background, written from the agent loop at the moment of
3390    /// demotion.
3391    ///
3392    /// Counts as this session's shell call, exactly as a foreground one does — the pair with
3393    /// [`Self::write_shell_completed`], which deliberately does not count, so `mur trace show`
3394    /// reports each shell command once whichever way it ran.
3395    pub(crate) async fn write_shell_detached(
3396        &mut self,
3397        turn: u32,
3398        work_id: &str,
3399        binary: &str,
3400        command: &str,
3401        grace_ms: Option<u64>,
3402    ) -> std::io::Result<()> {
3403        let event = ShellDetachedEvent {
3404            event_type: "shell_detached",
3405            event_id: new_event_id(),
3406            parent_id: self.turn_parent(),
3407            session_id: self.session_id.clone(),
3408            timestamp: timestamp_ms(),
3409            turn,
3410            task_id: self.active_task_id.clone(),
3411            work_id: work_id.to_string(),
3412            binary: binary.to_string(),
3413            command: command.to_string(),
3414            grace_ms,
3415        };
3416        self.write_event(&event).await?;
3417        self.total_shell_calls = self.total_shell_calls.saturating_add(1);
3418        self.task_shell_calls = self.task_shell_calls.saturating_add(1);
3419        Ok(())
3420    }
3421
3422    /// A detached command's completion queued as the `completion`-origin task
3423    /// `completion_task_id`, written by the task loop as it enqueues it, so the two records join.
3424    pub(crate) async fn write_shell_completed(
3425        &mut self,
3426        completion: &crate::detached::DetachedCompletion,
3427        completion_task_id: &str,
3428    ) -> std::io::Result<()> {
3429        let event = ShellCompletedEvent::new(
3430            self.session_id.clone(),
3431            self.session_parent(),
3432            completion,
3433            crate::detached::Delivery::Task,
3434            Some(completion_task_id),
3435        );
3436        self.write_event(&event).await
3437    }
3438
3439    /// A detached command the session ended without waiting for.
3440    ///
3441    /// `recovered` carries the exit code, the output path and that file's byte count for a command
3442    /// that finished during teardown, and is `None` for one still running. The three travel as one
3443    /// argument because they exist together: a command with an exit code has a written log, and
3444    /// one without has neither.
3445    pub(crate) async fn write_shell_abandoned(
3446        &mut self,
3447        work_id: &str,
3448        binary: &str,
3449        command: &str,
3450        running_ms: u64,
3451        recovered: Option<(i32, &str, u64)>,
3452    ) -> std::io::Result<()> {
3453        let event = ShellAbandonedEvent {
3454            event_type: "shell_abandoned",
3455            event_id: new_event_id(),
3456            parent_id: self.session_parent(),
3457            session_id: self.session_id.clone(),
3458            timestamp: timestamp_ms(),
3459            work_id: work_id.to_string(),
3460            binary: binary.to_string(),
3461            command: command.to_string(),
3462            running_ms,
3463            exit_code: recovered.map(|(exit_code, _, _)| exit_code),
3464            output_path: recovered.map(|(_, path, _)| path.to_string()),
3465            output_bytes: recovered.map(|(_, _, bytes)| bytes),
3466        };
3467        self.write_event(&event).await
3468    }
3469
3470    /// A demotion the trace could not record, written where the failure was seen.
3471    pub(crate) async fn write_shell_detach_unrecorded(
3472        &mut self,
3473        turn: u32,
3474        work_id: &str,
3475        binary: &str,
3476        reason: &str,
3477    ) -> std::io::Result<()> {
3478        let event = ShellDetachUnrecordedEvent {
3479            event_type: "shell_detach_unrecorded",
3480            event_id: new_event_id(),
3481            parent_id: self.turn_parent(),
3482            session_id: self.session_id.clone(),
3483            timestamp: timestamp_ms(),
3484            turn,
3485            task_id: self.active_task_id.clone(),
3486            work_id: work_id.to_string(),
3487            binary: binary.to_string(),
3488            reason: reason.to_string(),
3489        };
3490        self.write_event(&event).await
3491    }
3492
3493    /// Records how one tool call's safety checks ended. Parented to the turn.
3494    pub(crate) async fn write_safety_check(
3495        &mut self,
3496        turn: u32,
3497        tool_call_id: &str,
3498        tool: &str,
3499        checks: &serde_json::Value,
3500        outcome: &'static str,
3501    ) -> std::io::Result<()> {
3502        let event = SafetyCheckEvent {
3503            event_type: "safety_check",
3504            event_id: new_event_id(),
3505            parent_id: self.turn_parent(),
3506            session_id: self.session_id.clone(),
3507            timestamp: timestamp_ms(),
3508            turn,
3509            task_id: self.active_task_id.clone(),
3510            tool_call_id: tool_call_id.to_string(),
3511            tool: tool.to_string(),
3512            checks: checks.clone(),
3513            outcome,
3514        };
3515        self.write_event(&event).await
3516    }
3517
3518    /// Records a task the door let in. `caller` is who the door let it in as and `caller_member`
3519    /// the roster name a formation token carried; a task with a member is recorded as the
3520    /// member's, whatever credential the token was minted for. The message text is hashed under
3521    /// `meta` and `content` and stored as a blob under `content`; the JSONL never holds it.
3522    #[allow(clippy::too_many_arguments)]
3523    pub(crate) async fn write_a2a_task_received(
3524        &mut self,
3525        task_id: &str,
3526        context_id: &str,
3527        message_id: &str,
3528        traceparent_from_caller: Option<&str>,
3529        caller: &crate::identity::TaskCaller,
3530        caller_member: Option<&str>,
3531        message_text: &str,
3532    ) -> std::io::Result<()> {
3533        use crate::identity::TaskCaller;
3534        let (received_from, caller_credential) = match (caller_member, caller) {
3535            (Some(_), _) => (RECEIVED_FROM_MEMBER, None),
3536            (None, TaskCaller::Operator) => (RECEIVED_FROM_OPERATOR, None),
3537            (None, TaskCaller::Public) => (RECEIVED_FROM_PUBLIC, None),
3538            (None, TaskCaller::Credential(name)) => {
3539                (RECEIVED_FROM_CREDENTIAL, Some(name.to_string()))
3540            }
3541        };
3542        let message_sha256 = if self.capture.captures_hashes() {
3543            let sha = murmur_artifact::sha256_hex(message_text.as_bytes());
3544            if self.capture.captures_content() {
3545                self.blobs.put(&sha, message_text.as_bytes()).await?;
3546            }
3547            Some(sha)
3548        } else {
3549            None
3550        };
3551        let event = A2aTaskReceivedEvent {
3552            event_type: "a2a_task_received",
3553            event_id: new_event_id(),
3554            parent_id: self.session_parent(),
3555            session_id: self.session_id.clone(),
3556            timestamp: timestamp_ms(),
3557            task_id: task_id.to_string(),
3558            context_id: context_id.to_string(),
3559            message_id: message_id.to_string(),
3560            traceparent_from_caller: traceparent_from_caller.map(str::to_string),
3561            caller: received_from,
3562            caller_member: caller_member.map(str::to_string),
3563            caller_credential,
3564            message_sha256,
3565        };
3566        self.write_event(&event).await
3567    }
3568
3569    pub(crate) async fn write_a2a_send(
3570        &mut self,
3571        peer_url: &str,
3572        message_id: &str,
3573        task_id: &str,
3574        context_id: &str,
3575        traceparent: Option<&str>,
3576        trust: TrustClass,
3577    ) -> std::io::Result<()> {
3578        let event = A2aSendEvent {
3579            event_type: "a2a_send",
3580            event_id: new_event_id(),
3581            parent_id: self.session_parent(),
3582            session_id: self.session_id.clone(),
3583            timestamp: timestamp_ms(),
3584            peer_url: peer_url.to_string(),
3585            message_id: message_id.to_string(),
3586            task_id: task_id.to_string(),
3587            context_id: context_id.to_string(),
3588            traceparent: traceparent.map(str::to_string),
3589            trust: trust.as_str().to_string(),
3590        };
3591        self.write_event(&event).await
3592    }
3593
3594    /// Records a successful pull of `pulled` (`manage.pull()` or `pull-artifact`), as the store
3595    /// summarised it after the lock was written. Parented to the session node.
3596    pub(crate) async fn write_artifact_pulled(
3597        &mut self,
3598        pulled: &crate::runtime::PulledArtifact,
3599    ) -> std::io::Result<()> {
3600        let artifact = &pulled.summary;
3601        let session = match &artifact.origin {
3602            LockOrigin::Runtime { session } => Some(session.clone()),
3603            LockOrigin::Operator => None,
3604        };
3605        let event = ArtifactPulledEvent {
3606            event_type: "artifact_pulled",
3607            event_id: new_event_id(),
3608            parent_id: self.session_parent(),
3609            session_id: self.session_id.clone(),
3610            timestamp: timestamp_ms(),
3611            name: artifact.name.clone(),
3612            version: artifact.version.clone(),
3613            runtime: artifact.runtime.as_str(),
3614            origin: artifact.origin.as_str(),
3615            session,
3616            trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
3617            fetched_from: pulled.fetched_from.clone(),
3618            owner_id: pulled.owner_id,
3619        };
3620        self.write_event(&event).await
3621    }
3622
3623    /// `part_kinds` and `reference_task_ids` describe the A2A message the task arrived as, and are
3624    /// empty for any other task.
3625    #[cfg(test)]
3626    #[allow(clippy::too_many_arguments)]
3627    pub(crate) async fn write_task_start(
3628        &mut self,
3629        task_id: &str,
3630        context_id: &str,
3631        source: &str,
3632        provenance: TaskProvenance,
3633        message_parts_bytes: u64,
3634        part_kinds: &[&'static str],
3635        reference_task_ids: &[String],
3636    ) -> std::io::Result<()> {
3637        self.write_task_start_with_attachments(
3638            task_id,
3639            context_id,
3640            source,
3641            provenance,
3642            message_parts_bytes,
3643            part_kinds,
3644            reference_task_ids,
3645            Vec::new(),
3646        )
3647        .await
3648    }
3649
3650    /// The `task_start` record. `attachments` lists the images the task's message carried, and an
3651    /// empty list omits the field.
3652    #[allow(clippy::too_many_arguments)]
3653    pub(crate) async fn write_task_start_with_attachments(
3654        &mut self,
3655        task_id: &str,
3656        context_id: &str,
3657        source: &str,
3658        provenance: TaskProvenance,
3659        message_parts_bytes: u64,
3660        part_kinds: &[&'static str],
3661        reference_task_ids: &[String],
3662        attachments: Vec<AttachmentRecord>,
3663    ) -> std::io::Result<()> {
3664        // Drained under the ids in force before this task, which the lines below replace.
3665        self.write_call_deadlines().await?;
3666        // Reset per-task counters for this task
3667        self.task_turns = 0;
3668        self.task_input_tokens = 0;
3669        self.task_output_tokens = 0;
3670        self.task_tool_calls = 0;
3671        self.task_shell_calls = 0;
3672        self.task_agent_turns = 0;
3673        self.task_hook_inferences = 0;
3674        self.task_capped_turns = 0;
3675        let started = Instant::now();
3676        self.task_start_instant = Some(started);
3677        self.active_task_id = Some(task_id.to_string());
3678        if let Some(status) = &self.session_status {
3679            status.task_started(task_id, started);
3680        }
3681
3682        let event_id = new_event_id();
3683        self.task_event_id = Some(event_id.clone());
3684        self.turn_event_id = None;
3685
3686        let event = TaskStartEvent {
3687            event_type: "task_start",
3688            event_id: event_id.clone(),
3689            parent_id: self.session_parent(),
3690            session_id: self.session_id.clone(),
3691            timestamp: timestamp_ms(),
3692            task_id: task_id.to_string(),
3693            context_id: context_id.to_string(),
3694            source: source.to_string(),
3695            origin: provenance.origin().as_str().to_string(),
3696            trust: provenance.trust().as_str().to_string(),
3697            // One rule for every call site, so the recorded lane cannot disagree with the lane
3698            // `LaneQueue` filed the task under — both read the same provenance.
3699            lane: TaskLane::for_origin(provenance.origin())
3700                .as_str()
3701                .to_string(),
3702            message_parts_bytes,
3703            part_kinds: part_kinds.to_vec(),
3704            reference_task_ids: reference_task_ids.to_vec(),
3705            attachments,
3706        };
3707        self.write_event(&event).await
3708    }
3709
3710    pub(crate) async fn write_task_end(
3711        &mut self,
3712        task_id: &str,
3713        exit_status: &str,
3714        reopen_count: u32,
3715        running_commands: Vec<String>,
3716        keep_working: Option<KeepWorkingSummary>,
3717        done_check: Option<DoneCheckSummary>,
3718    ) -> std::io::Result<()> {
3719        // Drained inside the task, before the lines below clear its ids.
3720        self.write_call_deadlines().await?;
3721        let duration_ms = self
3722            .task_start_instant
3723            .take()
3724            .map(|t| t.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
3725            .unwrap_or(0);
3726
3727        let event = TaskEndEvent {
3728            event_type: "task_end",
3729            event_id: new_event_id(),
3730            parent_id: self.task_parent(),
3731            session_id: self.session_id.clone(),
3732            timestamp: timestamp_ms(),
3733            task_id: task_id.to_string(),
3734            exit_status: exit_status.to_string(),
3735            duration_ms,
3736            turns: self.task_turns,
3737            input_tokens: self.task_input_tokens,
3738            output_tokens: self.task_output_tokens,
3739            tool_calls: self.task_tool_calls,
3740            shell_calls: self.task_shell_calls,
3741            reopen_count,
3742            capped_turns: self.task_capped_turns,
3743            running_commands,
3744            keep_working,
3745            done_check,
3746        };
3747        self.active_task_id = None;
3748        self.task_event_id = None;
3749        self.turn_event_id = None;
3750        self.task_agent_turns = 0;
3751        if let Some(status) = &self.session_status {
3752            status.task_ended();
3753        }
3754        self.write_event(&event).await
3755    }
3756
3757    /// Record that a person stopped this task, at the point the loop stopped.
3758    ///
3759    /// Leaves `active_task_id` and the task's parent id alone: for a running task the terminal
3760    /// `task_end` still follows and closes the frame. A task cancelled while queued was never
3761    /// started, so this record hangs off the session.
3762    pub(crate) async fn write_task_canceled(
3763        &mut self,
3764        task_id: &str,
3765        turn: Option<u32>,
3766        phase: &str,
3767        detached_work_ids: Vec<String>,
3768        delegation_ids: Vec<String>,
3769    ) -> std::io::Result<()> {
3770        let event = TaskCanceledEvent {
3771            event_type: "task_canceled",
3772            event_id: new_event_id(),
3773            parent_id: self.task_parent(),
3774            session_id: self.session_id.clone(),
3775            timestamp: timestamp_ms(),
3776            task_id: task_id.to_string(),
3777            turn,
3778            phase: phase.to_string(),
3779            detached_work_ids,
3780            delegation_ids,
3781        };
3782        self.write_event(&event).await
3783    }
3784
3785    /// Record that a queued task was refused when the session stopped taking work, with `cause`
3786    /// from the `TASK_REJECTED_*` vocabulary. Written whatever `trace.capture` is.
3787    ///
3788    /// Leaves `active_task_id` alone: the refused task never started, so there is no task frame
3789    /// to open or close, and with no task active the record hangs off the session.
3790    pub(crate) async fn write_task_rejected(
3791        &mut self,
3792        task_id: &str,
3793        context_id: &str,
3794        source: &str,
3795        cause: &str,
3796        reason: &str,
3797    ) -> std::io::Result<()> {
3798        let event = TaskRejectedEvent {
3799            event_type: "task_rejected",
3800            event_id: new_event_id(),
3801            parent_id: self.task_parent(),
3802            session_id: self.session_id.clone(),
3803            timestamp: timestamp_ms(),
3804            task_id: task_id.to_string(),
3805            context_id: context_id.to_string(),
3806            source: source.to_string(),
3807            cause: cause.to_string(),
3808            reason: reason.to_string(),
3809        };
3810        self.write_event(&event).await
3811    }
3812
3813    /// Record that the task attempt in progress failed, with `cause` from the `TASK_FAILED_*`
3814    /// vocabulary. Written whatever `trace.capture` is.
3815    ///
3816    /// Leaves the task frame open, as [`Self::write_task_canceled`] does: the `task_end` that
3817    /// follows closes it. The reason, capped, is kept for [`Self::last_task_failure`].
3818    pub(crate) async fn write_task_failed(
3819        &mut self,
3820        turn: Option<u32>,
3821        cause: &str,
3822        reason: &str,
3823    ) -> std::io::Result<()> {
3824        let reason = crate::agent::truncate_on_char_boundary(reason, MAX_TASK_FAILURE_REASON_BYTES)
3825            .to_string();
3826        let event = TaskFailedEvent {
3827            event_type: "task_failed",
3828            event_id: new_event_id(),
3829            parent_id: self.task_parent(),
3830            session_id: self.session_id.clone(),
3831            timestamp: timestamp_ms(),
3832            task_id: self.active_task_id.clone(),
3833            turn,
3834            cause: cause.to_string(),
3835            reason: reason.clone(),
3836        };
3837        self.task_failures_written += 1;
3838        self.last_task_failure = Some(reason);
3839        self.write_event(&event).await
3840    }
3841
3842    /// Record one refusal the workdir helper latched.
3843    pub(crate) async fn write_workdir_path_refused(
3844        &mut self,
3845        refusal: &crate::workdir_fs::WorkdirRefusal,
3846    ) -> std::io::Result<()> {
3847        let event = WorkdirPathRefusedEvent {
3848            event_type: "workdir_path_refused",
3849            event_id: new_event_id(),
3850            parent_id: self.task_parent(),
3851            session_id: self.session_id.clone(),
3852            timestamp: timestamp_ms(),
3853            task_id: self.active_task_id.clone(),
3854            path: &refusal.path,
3855            operation: refusal.operation,
3856            refused_at: &refusal.refused_at,
3857            reason: refusal.reason,
3858        };
3859        self.write_event(&event).await
3860    }
3861
3862    /// The `reason` of the last `task_failed` line this writer wrote, or `None` before the first.
3863    pub(crate) fn last_task_failure(&self) -> Option<&str> {
3864        self.last_task_failure.as_deref()
3865    }
3866
3867    /// How many `task_failed` lines this writer has written. Compared across one run to tell
3868    /// whether that run wrote its own.
3869    pub(crate) fn task_failures_written(&self) -> u64 {
3870        self.task_failures_written
3871    }
3872
3873    /// Record that an `on-task-end` hook reopened the task. Written by the runtime's
3874    /// per-task reopen loop between two agent-loop attempts, once per reopen, before
3875    /// the terminal `task_end` record. `reopen_number` is 1-based.
3876    pub(crate) async fn write_task_reopened(
3877        &mut self,
3878        task_id: &str,
3879        hook_name: &str,
3880        reason: &str,
3881        reopen_number: u32,
3882        attempt_context: &'static str,
3883        turns_remaining: Option<u32>,
3884    ) -> std::io::Result<()> {
3885        let event = TaskReopenedEvent {
3886            event_type: "task_reopened",
3887            event_id: new_event_id(),
3888            parent_id: self.task_parent(),
3889            session_id: self.session_id.clone(),
3890            timestamp: timestamp_ms(),
3891            task_id: task_id.to_string(),
3892            hook_name: hook_name.to_string(),
3893            reason: reason.to_string(),
3894            reopen_number,
3895            attempt_context,
3896            turns_remaining,
3897        };
3898        self.write_event(&event).await
3899    }
3900
3901    /// Record that the task continues with one round of handed-off work's outcomes. Written by
3902    /// the runtime's per-task loop once per continuation, before the continued attempt's first
3903    /// `inference`. `continuation_number` is 1-based.
3904    pub(crate) async fn write_task_continued(
3905        &mut self,
3906        task_id: &str,
3907        continuation_number: u32,
3908        member_calls: &[String],
3909        delegations: &[String],
3910        waited_ms: u64,
3911        turns_remaining: Option<u32>,
3912    ) -> std::io::Result<()> {
3913        let event = TaskContinuedEvent {
3914            event_type: "task_continued",
3915            event_id: new_event_id(),
3916            parent_id: self.task_parent(),
3917            session_id: self.session_id.clone(),
3918            timestamp: timestamp_ms(),
3919            task_id: task_id.to_string(),
3920            continuation_number,
3921            member_calls,
3922            delegations,
3923            waited_ms,
3924            turns_remaining,
3925        };
3926        self.write_event(&event).await
3927    }
3928
3929    /// Record that `lifecycle.keep_working` sent its message as the task's `number`th continuation
3930    /// (1-based), before the continued attempt's first `inference`.
3931    pub(crate) async fn write_keep_working(
3932        &mut self,
3933        task_id: &str,
3934        number: u32,
3935        limit: u32,
3936        turns_remaining: Option<u32>,
3937        message_bytes: u64,
3938    ) -> std::io::Result<()> {
3939        let event = KeepWorkingEvent {
3940            event_type: "keep_working",
3941            event_id: new_event_id(),
3942            parent_id: self.task_parent(),
3943            session_id: self.session_id.clone(),
3944            timestamp: timestamp_ms(),
3945            task_id: task_id.to_string(),
3946            number,
3947            limit,
3948            turns_remaining,
3949            message_bytes,
3950        };
3951        if let Some(status) = &self.session_status {
3952            status.keep_working_sent(number);
3953        }
3954        self.write_event(&event).await
3955    }
3956
3957    /// Record one run of `lifecycle.done_check`'s command. A run that reopened the task feeds the
3958    /// session status the task's reopens so far, `reopens`.
3959    pub(crate) async fn write_done_check(
3960        &mut self,
3961        run: &DoneCheckRun<'_>,
3962        reopens: u32,
3963    ) -> std::io::Result<()> {
3964        let event = DoneCheckEvent {
3965            event_type: "done_check",
3966            event_id: new_event_id(),
3967            parent_id: self.task_parent(),
3968            session_id: self.session_id.clone(),
3969            timestamp: timestamp_ms(),
3970            run,
3971        };
3972        if run.reopened {
3973            if let Some(status) = &self.session_status {
3974                status.done_check_reopened(reopens);
3975            }
3976        }
3977        self.write_event(&event).await
3978    }
3979
3980    /// Cumulative turns consumed by the active task so far, across however many
3981    /// agent-loop attempts have run since the last [`Self::write_task_start`]. Read by
3982    /// the reopen loop to compute the remaining `max_turns` budget for the next attempt
3983    /// so reopening never grants turns past the capsule's ceiling.
3984    ///
3985    /// A hook's counted `run-inference` call is one of them. `inference.max_turns` is the cap on
3986    /// inference calls per task, and `task_end.turns` and `session_end.total_turns` count the
3987    /// same calls; a hook that calls `run-inference` every turn is per-task spend that the cap
3988    /// exists to bound, and the spend ceiling is per capsule, not per task.
3989    pub(crate) fn task_turns(&self) -> u32 {
3990        self.task_turns
3991    }
3992
3993    /// How many of [`Self::task_turns`] were hook `run-inference` calls.
3994    pub(crate) fn task_hook_inferences(&self) -> u32 {
3995        self.task_hook_inferences
3996    }
3997
3998    /// The number the active task's next agent-loop turn takes: how many agent-loop `inference`
3999    /// lines it has written since [`Self::write_task_start`], across every attempt. 0 outside a
4000    /// task frame. Not the `max_turns` budget, which is [`Self::task_turns`].
4001    pub(crate) fn task_agent_turns(&self) -> u32 {
4002        self.task_agent_turns
4003    }
4004
4005    pub(crate) async fn write_compaction(
4006        &mut self,
4007        turn: u32,
4008        tokens_before: u64,
4009        tokens_before_source: OccupancyBasis,
4010        tokens_after: u64,
4011    ) -> std::io::Result<()> {
4012        let event = CompactionEvent {
4013            event_type: "compaction",
4014            event_id: new_event_id(),
4015            parent_id: self.turn_parent(),
4016            session_id: self.session_id.clone(),
4017            timestamp: timestamp_ms(),
4018            turn,
4019            task_id: self.active_task_id.clone(),
4020            tokens_before,
4021            tokens_before_source,
4022            tokens_after,
4023        };
4024        self.write_event(&event).await
4025    }
4026
4027    /// Record that compaction was attempted and declined, leaving the context over budget.
4028    /// `tokens` is the occupancy at the moment of the decline and `tokens_source` the kind of
4029    /// number it is; `reason` is
4030    /// [`COMPACTION_DECLINED_NO_HOOK_REPLACEMENT`] or
4031    /// [`COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL`].
4032    pub(crate) async fn write_compaction_declined(
4033        &mut self,
4034        turn: u32,
4035        tokens: u64,
4036        tokens_source: OccupancyBasis,
4037        reason: &str,
4038    ) -> std::io::Result<()> {
4039        let event = CompactionDeclinedEvent {
4040            event_type: "compaction_declined",
4041            event_id: new_event_id(),
4042            parent_id: self.turn_parent(),
4043            session_id: self.session_id.clone(),
4044            timestamp: timestamp_ms(),
4045            turn,
4046            task_id: self.active_task_id.clone(),
4047            tokens,
4048            tokens_source,
4049            reason: reason.to_string(),
4050        };
4051        self.write_event(&event).await
4052    }
4053
4054    /// Record what the runtime did with an `on-task-start` hook's `seed-context`.
4055    ///
4056    /// Written for every outcome, committed or not: `tokens` is what reached the context
4057    /// (`0` on a rejection), `proposed_tokens` is what the hook returned, and
4058    /// `budget_tokens` is the ceiling it was measured against. `reason` is `Some` only for
4059    /// [`SEED_OUTCOME_REJECTED`]. Task-scoped, like `task_start`/`task_end`.
4060    #[allow(clippy::too_many_arguments)]
4061    pub(crate) async fn write_context_seed(
4062        &mut self,
4063        hook_name: &str,
4064        tokens: u64,
4065        proposed_tokens: u64,
4066        budget_tokens: u64,
4067        outcome: &str,
4068        reason: Option<&str>,
4069        message_ids: Vec<String>,
4070    ) -> std::io::Result<()> {
4071        let event = ContextSeedEvent {
4072            event_type: "context_seed",
4073            event_id: new_event_id(),
4074            parent_id: self.task_parent(),
4075            session_id: self.session_id.clone(),
4076            timestamp: timestamp_ms(),
4077            task_id: self.active_task_id.clone(),
4078            hook_name: hook_name.to_string(),
4079            tokens,
4080            proposed_tokens,
4081            budget_tokens,
4082            outcome: outcome.to_string(),
4083            reason: reason.map(str::to_string),
4084            message_ids,
4085        };
4086        self.write_event(&event).await
4087    }
4088
4089    /// Record one store's deletion, naming the reason and everything that went.
4090    ///
4091    /// Session-scoped: `parent_id` is the session node, so the deletion hangs off the launch that
4092    /// performed it rather than off whatever task happened to be open. `messages_dropped` is
4093    /// `Some` only for [`RETENTION_REASON_MAX_MESSAGES`].
4094    pub(crate) async fn write_retention(
4095        &mut self,
4096        store: &str,
4097        reason: &str,
4098        targets: Vec<String>,
4099        messages_dropped: Option<u64>,
4100    ) -> std::io::Result<()> {
4101        let event = RetentionEvent {
4102            event_type: "retention",
4103            event_id: new_event_id(),
4104            parent_id: self.session_parent(),
4105            session_id: self.session_id.clone(),
4106            timestamp: timestamp_ms(),
4107            store: store.to_string(),
4108            reason: reason.to_string(),
4109            removed: u32::try_from(targets.len()).unwrap_or(u32::MAX),
4110            targets,
4111            messages_dropped,
4112        };
4113        self.write_event(&event).await
4114    }
4115
4116    /// Record that a policy hook refused a call before it ran. The only account of a call the
4117    /// model asked for and never got: nothing ran, so no `tool_call` or `shell` record
4118    /// accompanies it.
4119    pub(crate) async fn write_call_denied(
4120        &mut self,
4121        turn: u32,
4122        event: &str,
4123        hook_name: &str,
4124        target: &str,
4125        reason: &str,
4126    ) -> std::io::Result<()> {
4127        let event = CallDeniedEvent {
4128            event_type: "call_denied",
4129            event_id: new_event_id(),
4130            parent_id: self.turn_parent(),
4131            session_id: self.session_id.clone(),
4132            timestamp: timestamp_ms(),
4133            turn,
4134            event: event.to_string(),
4135            hook_name: hook_name.to_string(),
4136            target: target.to_string(),
4137            reason: reason.to_string(),
4138        };
4139        self.write_event(&event).await
4140    }
4141
4142    /// Record that a call was refused because its input lacked a field its tool's `input_schema`
4143    /// requires. A `tool_call` record with `status: "error"` follows it on both transports, so
4144    /// unlike [`Self::write_call_denied`] this is not the only line the call leaves. An empty
4145    /// `tool_call_id` records as `null`, as in [`Self::write_tool_call`].
4146    pub(crate) async fn write_tool_input_refused(
4147        &mut self,
4148        turn: u32,
4149        tool_name: &str,
4150        tool_call_id: Option<&str>,
4151        missing: &[String],
4152        reason: &str,
4153    ) -> std::io::Result<()> {
4154        let event = ToolInputRefusedEvent {
4155            event_type: "tool_input_refused",
4156            event_id: new_event_id(),
4157            parent_id: self.turn_parent(),
4158            session_id: self.session_id.clone(),
4159            timestamp: timestamp_ms(),
4160            turn,
4161            tool_name: tool_name.to_string(),
4162            tool_call_id: tool_call_id.filter(|id| !id.is_empty()).map(str::to_string),
4163            missing: missing.to_vec(),
4164            reason: reason.to_string(),
4165        };
4166        self.write_event(&event).await
4167    }
4168
4169    /// Record that a driver call failed transiently and is about to be made again after
4170    /// `retry.delay_ms`. `retry.origin` is the `hook:<name>` of a hook's `run-inference` call,
4171    /// `None` for an agent-loop turn. The error text is capped at
4172    /// [`MAX_TASK_FAILURE_REASON_BYTES`].
4173    pub(crate) async fn write_inference_retry(
4174        &mut self,
4175        turn: u32,
4176        retry: &InferenceRetryRecord,
4177    ) -> std::io::Result<()> {
4178        let event = InferenceRetryEvent {
4179            event_type: "inference_retry",
4180            event_id: new_event_id(),
4181            parent_id: self.turn_parent(),
4182            session_id: self.session_id.clone(),
4183            timestamp: timestamp_ms(),
4184            turn,
4185            task_id: self.active_task_id.clone(),
4186            attempt: retry.attempt,
4187            max_retries: retry.max_retries,
4188            reason: retry.reason.wire_name(),
4189            provider_status: retry.provider_status,
4190            error: crate::agent::truncate_on_char_boundary(
4191                &retry.error,
4192                MAX_TASK_FAILURE_REASON_BYTES,
4193            ),
4194            delay_ms: retry.delay_ms,
4195            retry_after_ms: retry.retry_after_ms,
4196            charged_tokens: retry.charged_tokens,
4197            origin: retry.origin.as_deref(),
4198        };
4199        self.write_event(&event).await
4200    }
4201
4202    /// Record that a spend ceiling refused a driver call before it was sent. `origin` is the
4203    /// `hook:<name>` of a hook's `run-inference` call, `None` for an agent-loop turn.
4204    pub(crate) async fn write_spend_ceiling_reached(
4205        &mut self,
4206        turn: u32,
4207        refusal: &crate::spend::SpendRefusal,
4208        origin: Option<&str>,
4209    ) -> std::io::Result<()> {
4210        let event = SpendCeilingReachedEvent {
4211            event_type: "spend_ceiling_reached",
4212            event_id: new_event_id(),
4213            parent_id: self.turn_parent(),
4214            session_id: self.session_id.clone(),
4215            timestamp: timestamp_ms(),
4216            turn,
4217            task_id: self.active_task_id.clone(),
4218            refusal,
4219            origin,
4220        };
4221        self.write_event(&event).await
4222    }
4223
4224    /// Record that the manifest's `capabilities.filesystem.read_only` refused a call before it
4225    /// ran. Like [`Self::write_call_denied`] it is the only account of a call the model asked for
4226    /// and never got — nothing ran, so no `tool_call` or `shell` record accompanies it.
4227    #[allow(clippy::too_many_arguments)]
4228    pub(crate) async fn write_protected_path_denied(
4229        &mut self,
4230        turn: u32,
4231        call: &str,
4232        target: &str,
4233        path: &str,
4234        rule: &str,
4235        signal: &str,
4236        reason: &str,
4237    ) -> std::io::Result<()> {
4238        let event = ProtectedPathDeniedEvent {
4239            event_type: "protected_path_denied",
4240            event_id: new_event_id(),
4241            parent_id: self.turn_parent(),
4242            session_id: self.session_id.clone(),
4243            timestamp: timestamp_ms(),
4244            turn,
4245            call: call.to_string(),
4246            target: target.to_string(),
4247            path: path.to_string(),
4248            rule: rule.to_string(),
4249            signal: signal.to_string(),
4250            reason: reason.to_string(),
4251        };
4252        self.write_event(&event).await
4253    }
4254
4255    /// Record that a hook returned a `hook-output` arm the lifecycle event does not
4256    /// honor. Written by the agent loop from a buffered [`crate::hooks::DispatchFault`]
4257    /// drained just before `session_end`. Non-fatal: the session already continued as
4258    /// if the hook had returned `none`; this only makes the discard visible to
4259    /// `mur trace show` and anything reading the session trace.
4260    pub(crate) async fn write_hook_dispatch_error(
4261        &mut self,
4262        hook_name: &str,
4263        event: &str,
4264        arm: &str,
4265    ) -> std::io::Result<()> {
4266        let event = HookDispatchErrorEvent {
4267            event_type: "hook_dispatch_error",
4268            event_id: new_event_id(),
4269            parent_id: self.session_parent(),
4270            session_id: self.session_id.clone(),
4271            timestamp: timestamp_ms(),
4272            hook_name: hook_name.to_string(),
4273            event: event.to_string(),
4274            arm: arm.to_string(),
4275        };
4276        self.write_event(&event).await
4277    }
4278
4279    // ── The harness a `transport: process` session drives ────────────────────────────────
4280    //
4281    // Eight events account for one harness run: what was spawned, what it said about itself,
4282    // what it warned or noted, how it failed, how it was stopped, and how it ended. None of them
4283    // ever carries an argument value, an environment value or a file's contents — the bridge's
4284    // bearer token reaches the harness through exactly those three, and `trace.jsonl` is durable.
4285
4286    /// Record the harness about to be spawned: written after the driver planned the run and
4287    /// before the process exists, so a spawn that goes wrong still has its plan on record.
4288    pub(crate) async fn write_harness_start(&mut self, start: HarnessStart) -> std::io::Result<()> {
4289        if self.capture.captures_content() {
4290            self.blobs
4291                .put(&start.system_prompt_sha256, start.system_prompt.as_bytes())
4292                .await?;
4293        }
4294        let event = HarnessStartEvent {
4295            event_type: "harness_start",
4296            event_id: new_event_id(),
4297            parent_id: self.task_parent(),
4298            session_id: self.session_id.clone(),
4299            timestamp: timestamp_ms(),
4300            task_id: self.active_task_id.clone(),
4301            start,
4302        };
4303        self.write_event(&event).await
4304    }
4305
4306    /// Record a warning the run raised, beside the line it printed to stderr.
4307    pub(crate) async fn write_harness_warning(
4308        &mut self,
4309        code: &str,
4310        message: &str,
4311    ) -> std::io::Result<()> {
4312        let event = HarnessWarningEvent {
4313            event_type: "harness_warning",
4314            event_id: new_event_id(),
4315            parent_id: self.task_parent(),
4316            session_id: self.session_id.clone(),
4317            timestamp: timestamp_ms(),
4318            task_id: self.active_task_id.clone(),
4319            code: code.to_string(),
4320            message: message.to_string(),
4321        };
4322        self.write_event(&event).await
4323    }
4324
4325    /// Record the session the harness reports it opened, and how it is billing for it.
4326    pub(crate) async fn write_harness_session(
4327        &mut self,
4328        harness_session_id: &str,
4329        auth: &str,
4330        model: Option<&str>,
4331    ) -> std::io::Result<()> {
4332        let event = HarnessSessionEvent {
4333            event_type: "harness_session",
4334            event_id: new_event_id(),
4335            parent_id: self.task_parent(),
4336            session_id: self.session_id.clone(),
4337            timestamp: timestamp_ms(),
4338            task_id: self.active_task_id.clone(),
4339            harness_session_id: harness_session_id.to_string(),
4340            auth: auth.to_string(),
4341            model: model.map(str::to_string),
4342        };
4343        self.write_event(&event).await
4344    }
4345
4346    /// Record the harness session a person asked this capsule to forget, and who asked.
4347    pub(crate) async fn write_harness_session_forgotten(
4348        &mut self,
4349        context_id: &str,
4350        harness_session_id: &str,
4351        requested_by: &str,
4352    ) -> std::io::Result<()> {
4353        let event = HarnessSessionForgottenEvent {
4354            event_type: "harness_session_forgotten",
4355            event_id: new_event_id(),
4356            parent_id: self.task_parent(),
4357            session_id: self.session_id.clone(),
4358            timestamp: timestamp_ms(),
4359            task_id: self.active_task_id.clone(),
4360            context_id: context_id.to_string(),
4361            harness_session_id: harness_session_id.to_string(),
4362            requested_by: requested_by.to_string(),
4363        };
4364        self.write_event(&event).await
4365    }
4366
4367    /// Record that the harness is retrying a request to its own provider.
4368    pub(crate) async fn write_harness_retry(
4369        &mut self,
4370        attempt: u32,
4371        reason: &str,
4372    ) -> std::io::Result<()> {
4373        let event = HarnessRetryEvent {
4374            event_type: "harness_retry",
4375            event_id: new_event_id(),
4376            parent_id: self.task_parent(),
4377            session_id: self.session_id.clone(),
4378            timestamp: timestamp_ms(),
4379            task_id: self.active_task_id.clone(),
4380            attempt,
4381            reason: reason.to_string(),
4382        };
4383        self.write_event(&event).await
4384    }
4385
4386    /// Record a diagnostic the driver produced for the trace alone — a line it could not read,
4387    /// or a result the runtime could not pair with a call.
4388    pub(crate) async fn write_harness_note(&mut self, text: &str) -> std::io::Result<()> {
4389        let event = HarnessTextEvent {
4390            event_type: "harness_note",
4391            event_id: new_event_id(),
4392            parent_id: self.task_parent(),
4393            session_id: self.session_id.clone(),
4394            timestamp: timestamp_ms(),
4395            task_id: self.active_task_id.clone(),
4396            text: text.to_string(),
4397        };
4398        self.write_event(&event).await
4399    }
4400
4401    /// Record one report of the subscription windows the harness says are used, already
4402    /// validated, and hand it to the session status, which keeps the latest window per name.
4403    pub(crate) async fn write_harness_quota(
4404        &mut self,
4405        windows: &[crate::process_driver::QuotaWindow],
4406    ) -> std::io::Result<()> {
4407        if let Some(status) = &self.session_status {
4408            status.record_quota(windows);
4409        }
4410        let event = HarnessQuotaEvent {
4411            event_type: "harness_quota",
4412            event_id: new_event_id(),
4413            parent_id: self.task_parent(),
4414            session_id: self.session_id.clone(),
4415            timestamp: timestamp_ms(),
4416            task_id: self.active_task_id.clone(),
4417            windows: windows
4418                .iter()
4419                .map(|window| HarnessQuotaWindow {
4420                    name: &window.name,
4421                    used: window.used,
4422                    resets_at_ms: window.resets_at,
4423                    window_minutes: window.window_minutes,
4424                    state: window
4425                        .state
4426                        .map(crate::process_driver::QuotaState::wire_name),
4427                })
4428                .collect(),
4429        };
4430        self.write_event(&event).await
4431    }
4432
4433    /// Record the harness's prediction of what the person will ask next.
4434    pub(crate) async fn write_harness_suggestion(&mut self, text: &str) -> std::io::Result<()> {
4435        let event = HarnessTextEvent {
4436            event_type: "harness_suggestion",
4437            event_id: new_event_id(),
4438            parent_id: self.task_parent(),
4439            session_id: self.session_id.clone(),
4440            timestamp: timestamp_ms(),
4441            task_id: self.active_task_id.clone(),
4442            text: text.to_string(),
4443        };
4444        self.write_event(&event).await
4445    }
4446
4447    /// Record a failed turn. `source` is `"harness"` when the harness reported the failure and
4448    /// `"runtime"` when this runtime ended the turn itself.
4449    pub(crate) async fn write_harness_failed(
4450        &mut self,
4451        kind: &str,
4452        message: &str,
4453        source: &str,
4454    ) -> std::io::Result<()> {
4455        let event = HarnessFailedEvent {
4456            event_type: "harness_failed",
4457            event_id: new_event_id(),
4458            parent_id: self.task_parent(),
4459            session_id: self.session_id.clone(),
4460            timestamp: timestamp_ms(),
4461            task_id: self.active_task_id.clone(),
4462            kind: kind.to_string(),
4463            message: message.to_string(),
4464            source: source.to_string(),
4465        };
4466        self.write_event(&event).await
4467    }
4468
4469    /// Record the interrupt a cancelled task sent its harness: the method the driver declared,
4470    /// whether it went out, and how long the harness has before it is killed.
4471    pub(crate) async fn write_harness_interrupt(
4472        &mut self,
4473        method: &str,
4474        delivered: bool,
4475        grace_ms: u64,
4476    ) -> std::io::Result<()> {
4477        let event = HarnessInterruptEvent {
4478            event_type: "harness_interrupt",
4479            event_id: new_event_id(),
4480            parent_id: self.task_parent(),
4481            session_id: self.session_id.clone(),
4482            timestamp: timestamp_ms(),
4483            task_id: self.active_task_id.clone(),
4484            method: method.to_string(),
4485            delivered,
4486            grace_ms,
4487        };
4488        self.write_event(&event).await
4489    }
4490
4491    /// Record how the harness ended: once per spawned process, whatever ended it.
4492    pub(crate) async fn write_harness_exit(&mut self, exit: HarnessExit) -> std::io::Result<()> {
4493        let event = HarnessExitEvent {
4494            event_type: "harness_exit",
4495            event_id: new_event_id(),
4496            parent_id: self.task_parent(),
4497            session_id: self.session_id.clone(),
4498            timestamp: timestamp_ms(),
4499            task_id: self.active_task_id.clone(),
4500            exit,
4501        };
4502        self.write_event(&event).await
4503    }
4504
4505    pub(crate) async fn write_session_end(&mut self, exit_status: &str) -> std::io::Result<()> {
4506        let duration_ms = self
4507            .session_start_time
4508            .elapsed()
4509            .as_millis()
4510            .try_into()
4511            .unwrap_or(u64::MAX);
4512        let event = SessionEndEvent {
4513            event_type: "session_end",
4514            event_id: new_event_id(),
4515            parent_id: self.session_parent(),
4516            session_id: self.session_id.clone(),
4517            timestamp: timestamp_ms(),
4518            total_turns: self.total_turns,
4519            total_input_tokens: self.total_input_tokens,
4520            total_output_tokens: self.total_output_tokens,
4521            total_tool_calls: self.total_tool_calls,
4522            total_shell_calls: self.total_shell_calls,
4523            duration_ms,
4524            exit_status: exit_status.to_string(),
4525        };
4526        self.write_event(&event).await?;
4527        self.session_ended = true;
4528        Ok(())
4529    }
4530
4531    /// Writes session_end with the accumulated totals if session_start was written but
4532    /// session_end was not (error exit paths that bypass the normal Ok() return sites).
4533    pub(crate) async fn write_session_end_if_not_ended(
4534        &mut self,
4535        exit_status: &str,
4536    ) -> std::io::Result<()> {
4537        if self.session_started && !self.session_ended {
4538            self.write_session_end(exit_status).await?;
4539        }
4540        Ok(())
4541    }
4542
4543    pub(crate) async fn flush(&mut self) -> std::io::Result<()> {
4544        self.writer.flush().await
4545    }
4546
4547    /// Write `event` as one line, after a `call_deadline` line for each firing the session's
4548    /// latch holds. Draining here, ahead of every line, is what puts a cut call's
4549    /// `call_deadline` before the record of that call and every one before `session_end`.
4550    async fn write_event(&mut self, event: &impl Serialize) -> std::io::Result<()> {
4551        self.write_call_deadlines().await?;
4552        self.write_line(event).await
4553    }
4554
4555    /// One `call_deadline` line per firing [`Self::call_deadlines`] holds, parented to the turn
4556    /// as `inference_retry` is. Nothing before `session_start`, which every line hangs off.
4557    async fn write_call_deadlines(&mut self) -> std::io::Result<()> {
4558        let Some(deadlines) = self
4559            .call_deadlines
4560            .as_ref()
4561            .filter(|_| self.session_started)
4562        else {
4563            return Ok(());
4564        };
4565        for cut in deadlines.take() {
4566            let event = CallDeadlineEvent {
4567                event_type: "call_deadline",
4568                event_id: new_event_id(),
4569                parent_id: self.turn_parent(),
4570                session_id: self.session_id.clone(),
4571                timestamp: timestamp_ms(),
4572                task_id: self.active_task_id.clone(),
4573                component: cut.component,
4574                role: cut.role.wire_name(),
4575                call: cut.call,
4576                deadline_seconds: cut.deadline_seconds,
4577                elapsed_ms: cut.elapsed_ms,
4578            };
4579            self.write_line(&event).await?;
4580        }
4581        Ok(())
4582    }
4583
4584    /// Writes one event as one line, and flushes.
4585    ///
4586    /// The flush is not an optimisation choice: [`ResourceTraceAppender`] appends to this same
4587    /// `trace.jsonl` while a task runs, so a line left half-buffered here would be split around
4588    /// whatever the appender wrote in between and the file would stop parsing as JSONL. The
4589    /// two writers agree on one rule — a complete line per write, then flush — and that rule is
4590    /// what makes concurrent reads during a running task safe to record.
4591    async fn write_line(&mut self, event: &impl Serialize) -> std::io::Result<()> {
4592        let mut line = serde_json::to_string(event).map_err(std::io::Error::other)?;
4593        line.push('\n');
4594        self.writer.write_all(line.as_bytes()).await?;
4595        self.writer.flush().await
4596    }
4597}
4598
4599/// One element of `session_start.gateways`: an artifact's credential gateway. Never the key.
4600#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4601pub(crate) struct SessionGateway {
4602    /// The artifact whose entry declares the gateway.
4603    pub(crate) artifact: String,
4604    /// The upstream's host, with its port when `gateway.endpoint` wrote one.
4605    pub(crate) host: String,
4606    /// `"config"`, `"environment"`, `"manifest"`, or `"none"` when the entry names no key.
4607    pub(crate) credential_source: &'static str,
4608    /// Whether the gateway is admitted against the spend meter: true only for the configured
4609    /// `transport: http` driver's.
4610    pub(crate) metered: bool,
4611}
4612
4613/// One element of `session_start.runtime_artifacts`: a staged artifact whose `murmur.lock` pin a
4614/// running capsule fetched.
4615#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4616pub(crate) struct RuntimeArtifact {
4617    name: String,
4618    version: String,
4619    /// Always `"runtime"`: only runtime-origin entries are listed.
4620    origin: &'static str,
4621    /// The session the lock entry names as the puller.
4622    session: String,
4623    /// Always `"untrusted"`, derived from `origin`.
4624    trust: &'static str,
4625}
4626
4627/// One element of `session_start.host_facts`.
4628#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4629pub(crate) struct HostFactsRecord {
4630    /// The binary, canonical.
4631    binary: String,
4632    /// `"kept"` or `"derived"`.
4633    source: &'static str,
4634    /// Why the facts were derived, as [`crate::host_facts::HostFactsReason::wire_name`] spells
4635    /// it. Omitted for `"kept"`.
4636    #[serde(skip_serializing_if = "Option::is_none")]
4637    reason: Option<&'static str>,
4638    /// The first path whose stamp differed. Written only for the reason `"changed"`.
4639    #[serde(skip_serializing_if = "Option::is_none")]
4640    changed_path: Option<String>,
4641}
4642
4643impl HostFactsRecord {
4644    fn of(used: &crate::host_facts::HostFactsUse) -> Self {
4645        use crate::host_facts::HostFactsSource;
4646
4647        let binary = used.binary.display().to_string();
4648        match &used.source {
4649            HostFactsSource::Kept => Self {
4650                binary,
4651                source: "kept",
4652                reason: None,
4653                changed_path: None,
4654            },
4655            HostFactsSource::Derived {
4656                reason,
4657                changed_path,
4658            } => Self {
4659                binary,
4660                source: "derived",
4661                reason: Some(reason.wire_name()),
4662                changed_path: changed_path.as_ref().map(|path| path.display().to_string()),
4663            },
4664        }
4665    }
4666}
4667
4668/// `session_start.control`: the names a capsule's `control:` block declares, never a value.
4669#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4670pub(crate) struct SessionControl {
4671    /// Controllable setting names, as `control.settings` lists them.
4672    pub(crate) settings: Vec<&'static str>,
4673    /// Controller-supplied secret names, as `control.secrets` lists them.
4674    pub(crate) secrets: Vec<String>,
4675    /// Setting names the agent may change, as `control.agent_settings` lists them. Omitted when
4676    /// the block grants the agent nothing.
4677    #[serde(skip_serializing_if = "Vec::is_empty")]
4678    pub(crate) agent_settings: Vec<&'static str>,
4679}
4680
4681/// One element of `session_start.inference_choices`: a driver choice and whether its credential
4682/// resolved at launch. Never a key.
4683#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4684pub(crate) struct SessionInferenceChoice {
4685    pub(crate) name: String,
4686    pub(crate) driver: String,
4687    pub(crate) model: String,
4688    /// Where the driver's gateway credential came from, as `session_start.gateways` names it, or
4689    /// `"none"` for one found nowhere.
4690    pub(crate) credential_source: &'static str,
4691    /// Whether the credential resolved at launch. An injected credential is `true` here and is
4692    /// checked again when a switch asks for it.
4693    pub(crate) available: bool,
4694    /// The choice's own output cap and context window in force, and where each came from.
4695    pub(crate) model_limits: Option<crate::model_limits::ResolvedModelLimits>,
4696}
4697
4698/// One accepted setting or secret change, as [`ResourceTraceAppender::write_control_change`]
4699/// records it. `event_id` is minted by the caller, because the setting it changed carries it
4700/// forward to the `control_applied` that names it.
4701pub(crate) struct ControlChange<'a> {
4702    pub(crate) event_id: String,
4703    /// Who made the change.
4704    pub(crate) principal: crate::control_plane::Principal,
4705    /// The control token's id for a controller's change; `None` for the agent's, which presents
4706    /// no token.
4707    pub(crate) token_id: Option<&'a str>,
4708    /// `"setting"` or `"secret"`.
4709    pub(crate) kind: &'static str,
4710    pub(crate) name: &'a str,
4711    /// `"set"` or `"forget"`.
4712    pub(crate) action: &'static str,
4713    /// A setting's value before and after. Always `None` for a secret.
4714    pub(crate) previous: Option<Value>,
4715    pub(crate) value: Option<Value>,
4716    /// When a setting change takes effect. `None` for a secret.
4717    pub(crate) applies_from: Option<&'static str>,
4718    /// For a secret `set`: whether it replaced a held value.
4719    pub(crate) replaced: Option<bool>,
4720}
4721
4722/// `control_change`: a controller changed a setting or a secret. A secret's line names it and
4723/// carries nothing of its value.
4724#[derive(Serialize)]
4725struct ControlChangeEvent<'a> {
4726    event_type: &'static str,
4727    event_id: String,
4728    parent_id: Option<String>,
4729    session_id: String,
4730    timestamp: u64,
4731    principal: &'static str,
4732    #[serde(skip_serializing_if = "Option::is_none")]
4733    token_id: Option<&'a str>,
4734    kind: &'static str,
4735    name: &'a str,
4736    action: &'static str,
4737    #[serde(skip_serializing_if = "Option::is_none")]
4738    previous: Option<Value>,
4739    #[serde(skip_serializing_if = "Option::is_none")]
4740    value: Option<Value>,
4741    #[serde(skip_serializing_if = "Option::is_none")]
4742    applies_from: Option<&'static str>,
4743    #[serde(skip_serializing_if = "Option::is_none")]
4744    replaced: Option<bool>,
4745}
4746
4747/// `control_refused`: the control plane refused a request, or the runtime refused the agent's
4748/// change. `kind`, `name` and `token_id` are written only once a controller's request
4749/// authenticated, so a `401` names nothing; the agent's refusal names its setting and no token.
4750#[derive(Serialize)]
4751struct ControlRefusedEvent<'a> {
4752    event_type: &'static str,
4753    event_id: String,
4754    parent_id: Option<String>,
4755    session_id: String,
4756    timestamp: u64,
4757    principal: &'static str,
4758    status: u16,
4759    reason: &'static str,
4760    #[serde(skip_serializing_if = "Option::is_none")]
4761    kind: Option<&'static str>,
4762    #[serde(skip_serializing_if = "Option::is_none")]
4763    name: Option<&'a str>,
4764    #[serde(skip_serializing_if = "Option::is_none")]
4765    token_id: Option<&'a str>,
4766}
4767
4768/// `control_applied`: the first inference call to use a setting value a controller set.
4769#[derive(Serialize)]
4770struct ControlAppliedEvent<'a> {
4771    event_type: &'static str,
4772    event_id: String,
4773    parent_id: Option<String>,
4774    session_id: String,
4775    timestamp: u64,
4776    turn: u32,
4777    task_id: Option<String>,
4778    name: &'a str,
4779    value: Value,
4780    change_id: &'a str,
4781}
4782
4783/// `tools_refreshed`: the first inference call to send a tool array rebuilt after an install.
4784#[derive(Serialize)]
4785struct ToolsRefreshedEvent<'a> {
4786    event_type: &'static str,
4787    event_id: String,
4788    parent_id: Option<String>,
4789    session_id: String,
4790    timestamp: u64,
4791    turn: u32,
4792    task_id: Option<String>,
4793    trigger: &'a str,
4794    added: &'a [String],
4795    removed: &'a [String],
4796    tools: &'a [String],
4797}
4798
4799/// `gateway_credential`: the `inference_credential` event for the credential of any artifact other
4800/// than the configured inference driver, with the artifact's name.
4801#[derive(Serialize)]
4802struct GatewayCredentialEvent<'a> {
4803    event_type: &'static str,
4804    event_id: String,
4805    parent_id: Option<String>,
4806    session_id: String,
4807    timestamp: u64,
4808    artifact: &'a str,
4809    source: &'static str,
4810    #[serde(skip_serializing_if = "Option::is_none")]
4811    credential: Option<&'a str>,
4812    change: &'static str,
4813    #[serde(skip_serializing_if = "Option::is_none")]
4814    trigger: Option<&'static str>,
4815    #[serde(skip_serializing_if = "Option::is_none")]
4816    status: Option<u16>,
4817    #[serde(skip_serializing_if = "Option::is_none")]
4818    retried: Option<bool>,
4819    #[serde(skip_serializing_if = "Option::is_none")]
4820    reason: Option<&'static str>,
4821}
4822
4823/// `inference_credential`: a rotation, rejection or unreadable source for the session's inference
4824/// credential. `trigger` is written only with `change: "rotated"`, `status` and `retried` only with
4825/// `"rejected"`, `reason` only with `"unreadable"`, and `credential` whenever the manifest named one.
4826#[derive(Serialize)]
4827struct InferenceCredentialEvent<'a> {
4828    event_type: &'static str,
4829    event_id: String,
4830    parent_id: Option<String>,
4831    session_id: String,
4832    timestamp: u64,
4833    source: &'static str,
4834    #[serde(skip_serializing_if = "Option::is_none")]
4835    credential: Option<&'a str>,
4836    change: &'static str,
4837    #[serde(skip_serializing_if = "Option::is_none")]
4838    trigger: Option<&'static str>,
4839    #[serde(skip_serializing_if = "Option::is_none")]
4840    status: Option<u16>,
4841    #[serde(skip_serializing_if = "Option::is_none")]
4842    retried: Option<bool>,
4843    #[serde(skip_serializing_if = "Option::is_none")]
4844    reason: Option<&'static str>,
4845}
4846
4847/// A credential change as the `change`, `trigger`, `status`, `retried` and `reason` fields of an
4848/// `inference_credential` or `gateway_credential` event.
4849#[allow(clippy::type_complexity)]
4850fn credential_change_fields(
4851    change: CredentialChange,
4852) -> (
4853    &'static str,
4854    Option<&'static str>,
4855    Option<u16>,
4856    Option<bool>,
4857    Option<&'static str>,
4858) {
4859    match change {
4860        CredentialChange::Rotated { trigger } => ("rotated", Some(trigger), None, None, None),
4861        CredentialChange::Rejected { status, retried } => {
4862            ("rejected", None, Some(status), Some(retried), None)
4863        }
4864        CredentialChange::Unreadable { reason } => ("unreadable", None, None, None, Some(reason)),
4865    }
4866}
4867
4868// ── Resource-plane appender ───────────────────────────────────────────────────
4869
4870/// A second, independent `O_APPEND` handle to the session's `trace.jsonl`, used by the resource
4871/// plane, by the peer plane's listener, and by the peer-handoff tools in the agent loop.
4872///
4873/// [`TraceWriter`] is `&mut`-owned by the agent loop and cannot be shared, and the motivating
4874/// case for a resource-plane event — a gateway reading a finished-but-alive capsule — happens
4875/// after `session_end` has already been written. So the plane gets its own handle rather than a
4876/// borrow of the loop's, and every event is written at the moment of the request rather than
4877/// deferred to a task boundary: a denied read that only surfaced at the next task end would be a
4878/// record of the wrong thing at the wrong time.
4879///
4880/// Interleaving is safe because both writers emit exactly one complete line per `write_all` and
4881/// flush it, and `O_APPEND` makes each such write atomic against the other's.
4882pub struct ResourceTraceAppender {
4883    /// Async rather than a `std::sync::Mutex`: this is held across an `await` on the write.
4884    file: tokio::sync::Mutex<File>,
4885    session_id: String,
4886    /// The session node every line this appender writes hangs off — [`TraceWriter`]'s own
4887    /// [`TraceWriter::session_event_id`], handed over at open time. The plane has no turn or
4888    /// task of its own: a read served after `session_end` belongs to the launch, not to
4889    /// whatever task happened to run last.
4890    session_event_id: String,
4891}
4892
4893impl ResourceTraceAppender {
4894    pub async fn open(
4895        records_root: &WorkdirRoot,
4896        session_id: String,
4897        session_event_id: String,
4898    ) -> std::io::Result<Self> {
4899        let file = File::from_std(records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?);
4900        Ok(Self {
4901            file: tokio::sync::Mutex::new(file),
4902            session_id,
4903            session_event_id,
4904        })
4905    }
4906
4907    /// Records one `list`. Failures are swallowed: a trace that cannot be written must not turn
4908    /// a served read into an error, and the alternative — refusing the read — would let anyone
4909    /// who can fill the disk take the plane down.
4910    #[allow(clippy::too_many_arguments)]
4911    pub(crate) async fn write_resource_list(
4912        &self,
4913        root: &str,
4914        entry_count: usize,
4915        total_bytes: u64,
4916        generation: u64,
4917        containment_achieved: ContainmentClass,
4918        outcome: &str,
4919        reason: Option<String>,
4920    ) {
4921        let event = ResourceListEvent {
4922            event_type: "resource_list",
4923            event_id: new_event_id(),
4924            parent_id: Some(self.session_event_id.clone()),
4925            session_id: self.session_id.clone(),
4926            timestamp: timestamp_ms(),
4927            root: root.to_string(),
4928            entry_count,
4929            total_bytes,
4930            generation,
4931            containment_achieved,
4932            outcome: outcome.to_string(),
4933            reason,
4934        };
4935        self.append(&event).await;
4936    }
4937
4938    /// Records one `read`, served or refused. `bytes` and `sha256` are `None` on every
4939    /// non-`ok` outcome — a refusal must not carry a hash of something it did not serve.
4940    #[allow(clippy::too_many_arguments)]
4941    pub(crate) async fn write_resource_read(
4942        &self,
4943        path: &str,
4944        outcome: &str,
4945        bytes: Option<u64>,
4946        sha256: Option<String>,
4947        generation: u64,
4948        containment_achieved: ContainmentClass,
4949        reason: Option<String>,
4950    ) {
4951        let event = ResourceReadEvent {
4952            event_type: "resource_read",
4953            event_id: new_event_id(),
4954            parent_id: Some(self.session_event_id.clone()),
4955            session_id: self.session_id.clone(),
4956            timestamp: timestamp_ms(),
4957            path: path.to_string(),
4958            outcome: outcome.to_string(),
4959            bytes,
4960            sha256,
4961            generation,
4962            containment_achieved,
4963            reason,
4964        };
4965        self.append(&event).await;
4966    }
4967
4968    /// Records `formation_ended`, hung off the session node. Failures are swallowed: a member
4969    /// whose trace cannot be written still winds down.
4970    pub(crate) async fn write_formation_ended(&self, formation_id: &crate::FormationId) {
4971        let event = FormationEndedEvent {
4972            event_type: "formation_ended",
4973            event_id: new_event_id(),
4974            parent_id: Some(self.session_event_id.clone()),
4975            session_id: self.session_id.clone(),
4976            timestamp: timestamp_ms(),
4977            formation_id: formation_id.as_str().to_string(),
4978        };
4979        self.append(&event).await;
4980    }
4981
4982    /// Records one `session-status` call, hung off the session node. Failures are swallowed: the
4983    /// call is answered whether or not its record lands.
4984    pub(crate) async fn write_session_status(
4985        &self,
4986        task_id: Option<String>,
4987        status: &crate::session_status::StatusReport,
4988    ) {
4989        let event = SessionStatusEvent {
4990            event_type: "session_status",
4991            event_id: new_event_id(),
4992            parent_id: Some(self.session_event_id.clone()),
4993            session_id: self.session_id.clone(),
4994            timestamp: timestamp_ms(),
4995            task_id,
4996            status,
4997        };
4998        self.append(&event).await;
4999    }
5000
5001    /// Records one answered `background-command` call, hung off the session node. Failures are
5002    /// swallowed: the call is answered whether or not its record lands.
5003    #[allow(clippy::too_many_arguments)]
5004    pub(crate) async fn write_background_command(
5005        &self,
5006        task_id: Option<String>,
5007        work_id: &str,
5008        operation: &'static str,
5009        state: &'static str,
5010        exit_code: Option<i32>,
5011        waited_ms: Option<u64>,
5012        output_bytes: u64,
5013    ) {
5014        let event = BackgroundCommandEvent {
5015            event_type: "background_command",
5016            event_id: new_event_id(),
5017            parent_id: Some(self.session_event_id.clone()),
5018            session_id: self.session_id.clone(),
5019            timestamp: timestamp_ms(),
5020            task_id,
5021            work_id,
5022            operation,
5023            state,
5024            exit_code,
5025            waited_ms,
5026            output_bytes,
5027        };
5028        self.append(&event).await;
5029    }
5030
5031    /// Records a program a WASM tool started through `murmur:process`, hung off the session node.
5032    /// Failures are swallowed, as every line this appender writes.
5033    pub(crate) async fn write_process_start(&self, line: ProcessStartLine<'_>) {
5034        let event = ProcessStartEvent {
5035            event_type: "process_start",
5036            event_id: new_event_id(),
5037            parent_id: Some(self.session_event_id.clone()),
5038            session_id: self.session_id.clone(),
5039            timestamp: timestamp_ms(),
5040            line,
5041        };
5042        self.append(&event).await;
5043    }
5044
5045    /// Records the exit of a program a WASM tool started through `murmur:process`, hung off the
5046    /// session node.
5047    pub(crate) async fn write_process_exit(&self, line: ProcessExitLine<'_>) {
5048        let event = ProcessExitEvent {
5049            event_type: "process_exit",
5050            event_id: new_event_id(),
5051            parent_id: Some(self.session_event_id.clone()),
5052            session_id: self.session_id.clone(),
5053            timestamp: timestamp_ms(),
5054            line,
5055        };
5056        self.append(&event).await;
5057    }
5058
5059    /// Records a `murmur:process` `run`, `start` or `connect` a rule refused, hung off the session
5060    /// node.
5061    pub(crate) async fn write_process_refused(&self, line: ProcessRefusedLine<'_>) {
5062        let event = ProcessRefusedEvent {
5063            event_type: "process_refused",
5064            event_id: new_event_id(),
5065            parent_id: Some(self.session_event_id.clone()),
5066            session_id: self.session_id.clone(),
5067            timestamp: timestamp_ms(),
5068            line,
5069        };
5070        self.append(&event).await;
5071    }
5072
5073    /// Records a detached command's completion that a `background-command` call just returned to
5074    /// the agent, hung off the session node with no `completion_task_id`. Written after that call's
5075    /// `background_command` line. Failures are swallowed, as that line's are.
5076    pub(crate) async fn write_shell_completed(
5077        &self,
5078        completion: &crate::detached::DetachedCompletion,
5079    ) {
5080        let event = ShellCompletedEvent::new(
5081            self.session_id.clone(),
5082            Some(self.session_event_id.clone()),
5083            completion,
5084            crate::detached::Delivery::BackgroundCommand,
5085            None,
5086        );
5087        self.append(&event).await;
5088    }
5089
5090    /// Records the deadline notice `tool_name`'s result carried, hung off the session node.
5091    pub(crate) async fn write_deadline_notice(
5092        &self,
5093        task_id: Option<String>,
5094        tool_name: &str,
5095        notice: &crate::session_status::DeadlineNotice,
5096    ) {
5097        let event = DeadlineNoticeEvent {
5098            event_type: "deadline_notice",
5099            event_id: new_event_id(),
5100            parent_id: Some(self.session_event_id.clone()),
5101            session_id: self.session_id.clone(),
5102            timestamp: timestamp_ms(),
5103            task_id,
5104            tool_name,
5105            deadline_at: &notice.at,
5106            left_secs: notice.left_secs,
5107            notice_secs: notice.notice_secs,
5108        };
5109        self.append(&event).await;
5110    }
5111
5112    /// Records `deadline_reached`, hung off the session node. Failures are swallowed: a session
5113    /// whose trace cannot be written still ends at its deadline.
5114    pub(crate) async fn write_deadline_reached(
5115        &self,
5116        deadline: &crate::session_status::SessionDeadline,
5117    ) {
5118        let event = DeadlineReachedEvent {
5119            event_type: "deadline_reached",
5120            event_id: new_event_id(),
5121            parent_id: Some(self.session_event_id.clone()),
5122            session_id: self.session_id.clone(),
5123            timestamp: timestamp_ms(),
5124            deadline_at: deadline.at_rfc3339(),
5125            deadline_secs: deadline.secs(),
5126            source: deadline.source().as_str(),
5127        };
5128        self.append(&event).await;
5129    }
5130
5131    /// Records `spawner_ended`, hung off the session node, naming the session that spawned this
5132    /// one and the delegation it was spawned under. Failures are swallowed: a child whose trace
5133    /// cannot be written still winds down.
5134    pub(crate) async fn write_spawner_ended(
5135        &self,
5136        spawned_by: Option<&str>,
5137        delegation_id: Option<&str>,
5138    ) {
5139        let event = SpawnerEndedEvent {
5140            event_type: "spawner_ended",
5141            event_id: new_event_id(),
5142            parent_id: Some(self.session_event_id.clone()),
5143            session_id: self.session_id.clone(),
5144            timestamp: timestamp_ms(),
5145            spawned_by: spawned_by.map(str::to_string),
5146            delegation_id: delegation_id.map(str::to_string),
5147        };
5148        self.append(&event).await;
5149    }
5150
5151    /// Records one mint. `handle_id` and `expires_at_ms` are `None` on every non-`ok` outcome —
5152    /// a refused mint produced no token, and an audit record must not imply one exists.
5153    #[allow(clippy::too_many_arguments)]
5154    pub(crate) async fn write_peer_handle_mint(
5155        &self,
5156        handle_id: Option<String>,
5157        path: &str,
5158        audience: &str,
5159        expires_at_ms: Option<u64>,
5160        outcome: &str,
5161        reason: Option<String>,
5162    ) {
5163        let event = PeerHandleMintEvent {
5164            event_type: "peer_handle_mint",
5165            event_id: new_event_id(),
5166            parent_id: Some(self.session_event_id.clone()),
5167            session_id: self.session_id.clone(),
5168            timestamp: timestamp_ms(),
5169            handle_id,
5170            path: path.to_string(),
5171            audience: audience.to_string(),
5172            expires_at_ms,
5173            outcome: outcome.to_string(),
5174            reason,
5175        };
5176        self.append(&event).await;
5177    }
5178
5179    /// Records one redeem, served or refused. The token itself is never written — only its
5180    /// `handle_id`.
5181    #[allow(clippy::too_many_arguments)]
5182    pub(crate) async fn write_peer_handle_redeem(
5183        &self,
5184        handle_id: &str,
5185        path: Option<String>,
5186        generation: u64,
5187        audience_asserted: Option<String>,
5188        bytes: Option<u64>,
5189        sha256: Option<String>,
5190        outcome: &str,
5191        reason: Option<String>,
5192    ) {
5193        let event = PeerHandleRedeemEvent {
5194            event_type: "peer_handle_redeem",
5195            event_id: new_event_id(),
5196            parent_id: Some(self.session_event_id.clone()),
5197            session_id: self.session_id.clone(),
5198            timestamp: timestamp_ms(),
5199            handle_id: handle_id.to_string(),
5200            path,
5201            generation,
5202            audience_asserted,
5203            bytes,
5204            sha256,
5205            outcome: outcome.to_string(),
5206            reason,
5207        };
5208        self.append(&event).await;
5209    }
5210
5211    /// Records one fetch on the ingesting side, served or refused.
5212    #[allow(clippy::too_many_arguments)]
5213    pub(crate) async fn write_peer_file_fetch(
5214        &self,
5215        peer: &str,
5216        handle_id: &str,
5217        stored_path: Option<String>,
5218        bytes: Option<u64>,
5219        sha256: Option<String>,
5220        outcome: &str,
5221        reason: Option<String>,
5222    ) {
5223        let event = PeerFileFetchEvent {
5224            event_type: "peer_file_fetch",
5225            event_id: new_event_id(),
5226            parent_id: Some(self.session_event_id.clone()),
5227            session_id: self.session_id.clone(),
5228            timestamp: timestamp_ms(),
5229            peer: peer.to_string(),
5230            handle_id: handle_id.to_string(),
5231            stored_path,
5232            bytes,
5233            sha256,
5234            outcome: outcome.to_string(),
5235            reason,
5236        };
5237        self.append(&event).await;
5238    }
5239
5240    /// Records one launched child, while its delegation is still running.
5241    ///
5242    /// Every field is known by the time the child has reported its session id, so none is
5243    /// optional: a delegation with nothing to say here was never launched and writes no line.
5244    #[allow(clippy::too_many_arguments)]
5245    pub(crate) async fn write_delegation_start(
5246        &self,
5247        delegation_id: &str,
5248        capsule: &str,
5249        version: &str,
5250        child_session_id: &str,
5251        child_workdir: &str,
5252        child_records_dir: &str,
5253        workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
5254    ) {
5255        let event = DelegationStartEvent::new(
5256            &self.session_id,
5257            &self.session_event_id,
5258            delegation_id,
5259            capsule,
5260            version,
5261            child_session_id,
5262            child_workdir,
5263            child_records_dir,
5264            workdir_snapshot,
5265        );
5266        self.append(&event).await;
5267    }
5268
5269    /// Records one delegation ending. `delegation_id` and `child_session_id` are `None` when no
5270    /// child was launched — a delegation that never started names no id, and an audit record must
5271    /// not imply one exists.
5272    #[allow(clippy::too_many_arguments)]
5273    pub(crate) async fn write_delegation(
5274        &self,
5275        capsule: &str,
5276        version: &str,
5277        delegation_id: Option<String>,
5278        child_session_id: Option<String>,
5279        duration_ms: u64,
5280        outcome: &str,
5281        reason: Option<String>,
5282    ) {
5283        let event = DelegationEvent::new(
5284            &self.session_id,
5285            &self.session_event_id,
5286            capsule,
5287            version,
5288            delegation_id,
5289            child_session_id,
5290            duration_ms,
5291            outcome,
5292            reason,
5293        );
5294        self.append(&event).await;
5295    }
5296
5297    /// Records a `call-member` call whose callee now holds the task.
5298    pub(crate) async fn write_member_call_start(
5299        &self,
5300        task_id: &str,
5301        call_id: &str,
5302        member: &str,
5303        member_task_id: &str,
5304    ) {
5305        let event = self.member_call_start(task_id, call_id, member, member_task_id);
5306        self.append(&event).await;
5307    }
5308
5309    /// [`Self::write_member_call_start`] when `admit`, run with the trace held, returns `true`;
5310    /// returns what `admit` did. No other line lands between `admit` and this one.
5311    pub(crate) async fn write_member_call_start_if(
5312        &self,
5313        task_id: &str,
5314        call_id: &str,
5315        member: &str,
5316        member_task_id: &str,
5317        admit: impl FnOnce() -> bool,
5318    ) -> bool {
5319        let event = self.member_call_start(task_id, call_id, member, member_task_id);
5320        self.append_if(&event, admit).await
5321    }
5322
5323    fn member_call_start(
5324        &self,
5325        task_id: &str,
5326        call_id: &str,
5327        member: &str,
5328        member_task_id: &str,
5329    ) -> MemberCallStartEvent {
5330        MemberCallStartEvent {
5331            event_type: "member_call_start",
5332            event_id: new_event_id(),
5333            parent_id: Some(self.session_event_id.clone()),
5334            session_id: self.session_id.clone(),
5335            timestamp: timestamp_ms(),
5336            task_id: task_id.to_string(),
5337            call_id: call_id.to_string(),
5338            member: member.to_string(),
5339            member_task_id: member_task_id.to_string(),
5340        }
5341    }
5342
5343    /// Records the `offer`th offer of a `call-member` call turned away busy, `waited_ms` after the
5344    /// call was made, with the door's status `message`.
5345    pub(crate) async fn write_member_call_busy(
5346        &self,
5347        task_id: &str,
5348        call_id: &str,
5349        member: &str,
5350        offer: u32,
5351        waited_ms: u64,
5352        message: &str,
5353    ) {
5354        let event = MemberCallBusyEvent {
5355            event_type: "member_call_busy",
5356            event_id: new_event_id(),
5357            parent_id: Some(self.session_event_id.clone()),
5358            session_id: self.session_id.clone(),
5359            timestamp: timestamp_ms(),
5360            task_id: task_id.to_string(),
5361            call_id: call_id.to_string(),
5362            member: member.to_string(),
5363            offer,
5364            waited_ms,
5365            message: message.to_string(),
5366        };
5367        self.append(&event).await;
5368    }
5369
5370    /// Records one `call-member` call's ending, and whether its outcome reached the calling task.
5371    pub(crate) async fn write_member_call(
5372        &self,
5373        task_id: &str,
5374        outcome: &crate::member_call::MemberCallOutcome,
5375        delivered: bool,
5376    ) {
5377        let event = MemberCallEvent {
5378            event_type: "member_call",
5379            event_id: new_event_id(),
5380            parent_id: Some(self.session_event_id.clone()),
5381            session_id: self.session_id.clone(),
5382            timestamp: timestamp_ms(),
5383            task_id: task_id.to_string(),
5384            call_id: outcome.call_id.clone(),
5385            member: outcome.member.clone(),
5386            member_task_id: outcome.member_task_id.clone(),
5387            status: outcome.status.as_str().to_string(),
5388            duration_ms: outcome.duration_ms,
5389            output: outcome.output.clone(),
5390            truncated: outcome.truncated,
5391            delivered,
5392            no_answer_below: outcome
5393                .below
5394                .iter()
5395                .map(|(member, status)| NoAnswerBelow {
5396                    member: member.clone(),
5397                    status: status.as_str().to_string(),
5398                })
5399                .collect(),
5400        };
5401        self.append(&event).await;
5402    }
5403
5404    /// Records one change to the session's inference credential. Carries where the credential
5405    /// comes from and what happened to it, never the value.
5406    pub(crate) async fn write_inference_credential(
5407        &self,
5408        source: &'static str,
5409        credential: Option<&str>,
5410        change: CredentialChange,
5411    ) {
5412        let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
5413        let event = InferenceCredentialEvent {
5414            event_type: "inference_credential",
5415            event_id: new_event_id(),
5416            parent_id: Some(self.session_event_id.clone()),
5417            session_id: self.session_id.clone(),
5418            timestamp: timestamp_ms(),
5419            source,
5420            credential,
5421            change: change_name,
5422            trigger,
5423            status,
5424            retried,
5425            reason,
5426        };
5427        self.append(&event).await;
5428    }
5429
5430    /// Writes a `gateway_credential` event for `artifact`'s credential: the fields
5431    /// [`Self::write_inference_credential`] writes, plus `artifact`.
5432    pub(crate) async fn write_gateway_credential(
5433        &self,
5434        artifact: &str,
5435        source: &'static str,
5436        credential: Option<&str>,
5437        change: CredentialChange,
5438    ) {
5439        let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
5440        let event = GatewayCredentialEvent {
5441            event_type: "gateway_credential",
5442            event_id: new_event_id(),
5443            parent_id: Some(self.session_event_id.clone()),
5444            session_id: self.session_id.clone(),
5445            timestamp: timestamp_ms(),
5446            artifact,
5447            source,
5448            credential,
5449            change: change_name,
5450            trigger,
5451            status,
5452            retried,
5453            reason,
5454        };
5455        self.append(&event).await;
5456    }
5457
5458    /// Records one accepted control-plane change, under the `event_id` the caller minted.
5459    pub(crate) async fn write_control_change(&self, change: ControlChange<'_>) {
5460        let event = ControlChangeEvent {
5461            event_type: "control_change",
5462            event_id: change.event_id,
5463            parent_id: Some(self.session_event_id.clone()),
5464            session_id: self.session_id.clone(),
5465            timestamp: timestamp_ms(),
5466            principal: change.principal.as_str(),
5467            token_id: change.token_id,
5468            kind: change.kind,
5469            name: change.name,
5470            action: change.action,
5471            previous: change.previous,
5472            value: change.value,
5473            applies_from: change.applies_from,
5474            replaced: change.replaced,
5475        };
5476        self.append(&event).await;
5477    }
5478
5479    /// Records one refused change. `kind`, `name` and `token_id` are `None` for a controller's
5480    /// request that did not authenticate.
5481    pub(crate) async fn write_control_refused(
5482        &self,
5483        principal: crate::control_plane::Principal,
5484        status: u16,
5485        reason: &'static str,
5486        kind: Option<&'static str>,
5487        name: Option<&str>,
5488        token_id: Option<&str>,
5489    ) {
5490        let event = ControlRefusedEvent {
5491            event_type: "control_refused",
5492            event_id: new_event_id(),
5493            parent_id: Some(self.session_event_id.clone()),
5494            session_id: self.session_id.clone(),
5495            timestamp: timestamp_ms(),
5496            principal: principal.as_str(),
5497            status,
5498            reason,
5499            kind,
5500            name,
5501            token_id,
5502        };
5503        self.append(&event).await;
5504    }
5505
5506    async fn append(&self, event: &impl Serialize) {
5507        self.append_if(event, || true).await;
5508    }
5509
5510    /// [`Self::append`] when `admit`, run with the trace held, returns `true`; returns what
5511    /// `admit` did. No other line lands between `admit` and this one.
5512    async fn append_if(&self, event: &impl Serialize, admit: impl FnOnce() -> bool) -> bool {
5513        let Ok(mut line) = serde_json::to_string(event) else {
5514            return admit();
5515        };
5516        line.push('\n');
5517        let mut file = self.file.lock().await;
5518        if !admit() {
5519            return false;
5520        }
5521        if file.write_all(line.as_bytes()).await.is_ok() {
5522            let _ = file.flush().await;
5523        }
5524        true
5525    }
5526}
5527
5528// -- Prior-session appender ---------------------------------------------------
5529
5530/// An `O_APPEND` handle to a *previous* session's `trace.jsonl`, opened by a resuming launch to
5531/// account for demoted commands that session left unreported.
5532///
5533/// The marker goes in the file that holds the unmatched `shell_detached` line rather than in the
5534/// resuming session's own trace, which keeps clearing O(1) instead of a scan of every sibling
5535/// session, and means [`crate::retention::prune_sessions`] removes a marker and the work it
5536/// accounts for together.
5537///
5538/// Writing to another session's trace is safe because that session's writer is dead — that is the
5539/// precondition for there being anything unaccounted — and because this obeys the same rule
5540/// [`ResourceTraceAppender`] does: one complete line per `write_all`, then flush, which `O_APPEND`
5541/// makes atomic against any other writer.
5542pub(crate) struct PriorSessionTraceAppender {
5543    file: File,
5544    /// The prior session's id, which is what its own lines carry.
5545    session_id: String,
5546    /// That session's `session_start` node, read back from the same file.
5547    session_event_id: Option<String>,
5548    /// Whether the file's last record is unterminated, which a writer killed mid-`write_all`
5549    /// leaves behind. The first appended line opens with a newline when it is, so the marker is
5550    /// a line of its own rather than spliced onto the torn one.
5551    terminate_torn_tail: bool,
5552}
5553
5554impl PriorSessionTraceAppender {
5555    /// `trace` is the resumed trace's file name in `trace_dir`, the directory
5556    /// [`crate::ResumeRequest::trace_dir`] names. Errors when it cannot be read or opened for
5557    /// appending, which the caller reports rather than reporting work it could not mark.
5558    pub(crate) async fn open(
5559        trace_dir: &WorkdirRoot,
5560        trace: &str,
5561        session_id: String,
5562        session_event_id: Option<String>,
5563    ) -> std::io::Result<Self> {
5564        let contents = trace_dir
5565            .read_file(trace, u64::MAX)?
5566            .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))?;
5567        // An empty file counts as terminated: there is no record for an appended line to run into.
5568        let terminate_torn_tail = contents.last().is_some_and(|last| *last != b'\n');
5569        let file = File::from_std(trace_dir.open_append(trace, DEFAULT_FILE_MODE)?);
5570        Ok(Self {
5571            file,
5572            session_id,
5573            session_event_id,
5574            terminate_torn_tail,
5575        })
5576    }
5577
5578    /// Records one demoted command as lost, and reports whether the line landed.
5579    ///
5580    /// The result is not swallowed: a work id whose marker did not land must be dropped from the
5581    /// report rather than reported unmarked, because an unmarked work id is reported again by
5582    /// every later resume of the same session.
5583    pub(crate) async fn write_shell_lost(
5584        &mut self,
5585        work: &crate::detached::LostWork,
5586        reconciled_by_session: &str,
5587        reconciled_task_id: &str,
5588    ) -> std::io::Result<()> {
5589        let event = ShellLostEvent {
5590            event_type: "shell_lost",
5591            event_id: new_event_id(),
5592            parent_id: self.session_event_id.clone(),
5593            session_id: self.session_id.clone(),
5594            timestamp: timestamp_ms(),
5595            work_id: work.work_id.clone(),
5596            binary: work.binary.clone(),
5597            command: work.command.clone(),
5598            detached_at_ms: work.detached_at_ms,
5599            reconciled_by_session: reconciled_by_session.to_string(),
5600            reconciled_task_id: reconciled_task_id.to_string(),
5601        };
5602        let mut line = if self.terminate_torn_tail {
5603            "\n".to_string()
5604        } else {
5605            String::new()
5606        };
5607        line.push_str(&serde_json::to_string(&event).map_err(std::io::Error::other)?);
5608        line.push('\n');
5609        self.file.write_all(line.as_bytes()).await?;
5610        self.file.flush().await?;
5611        self.terminate_torn_tail = false;
5612        Ok(())
5613    }
5614}
5615
5616pub(crate) fn timestamp_ms() -> u64 {
5617    SystemTime::now()
5618        .duration_since(UNIX_EPOCH)
5619        .unwrap_or_default()
5620        .as_millis() as u64
5621}
5622
5623// ── Plan-scheduler events ────────────────────────────────────────────────────
5624
5625/// One step of a plan's DAG as authored, written into `plan_start`.
5626///
5627/// The shape is recorded once, up front, so the run stays legible for a step that never ran:
5628/// the trace names every step the plan declared whether or not the scheduler reached it.
5629#[derive(Serialize, Clone)]
5630pub(crate) struct PlanStepShape {
5631    pub(crate) step_id: String,
5632    /// `"tool"`, `"shell"` or `"capsule"`; `"unknown"` for a step whose kind cannot be inferred,
5633    /// which is a step the plan validator refuses.
5634    pub(crate) kind: &'static str,
5635    pub(crate) depends_on: Vec<String>,
5636    /// Whether the step carries an `if`, and so may settle without ever being dispatched.
5637    pub(crate) has_condition: bool,
5638}
5639
5640/// The plan's structure, written once before any step runs. Its `event_id` is the plan node
5641/// every other line of the run hangs off.
5642#[derive(Serialize)]
5643struct PlanStartEvent {
5644    event_type: &'static str,
5645    event_id: String,
5646    parent_id: Option<String>,
5647    session_id: String,
5648    timestamp: u64,
5649    plan_id: String,
5650    step_count: usize,
5651    steps: Vec<PlanStepShape>,
5652}
5653
5654/// One dispatched step, written as the scheduler hands it to a worker.
5655///
5656/// A step that settled without ever being dispatched — an `if` that evaluated false, a
5657/// dependency that never resolved — writes no line here, only a terminal `plan_step`. Joins to
5658/// that line on `(plan_id, step_id)`.
5659#[derive(Serialize)]
5660struct PlanStepStartEvent {
5661    event_type: &'static str,
5662    event_id: String,
5663    parent_id: Option<String>,
5664    session_id: String,
5665    timestamp: u64,
5666    plan_id: String,
5667    step_id: String,
5668    kind: &'static str,
5669    depends_on: Vec<String>,
5670}
5671
5672/// A settled step, written by the scheduler loop after the step's `on_error` policy has been
5673/// applied — which is why the worker thread does not write it: a step that failed under
5674/// `on_error: skip` settles as skipped in the returned report and must read as skipped here.
5675#[derive(Serialize)]
5676struct PlanStepEvent {
5677    event_type: &'static str,
5678    event_id: String,
5679    parent_id: Option<String>,
5680    session_id: String,
5681    timestamp: u64,
5682    plan_id: String,
5683    step_id: String,
5684    kind: &'static str,
5685    status: &'static str,
5686    attempts: u32,
5687    duration_ms: u64,
5688    #[serde(skip_serializing_if = "Option::is_none")]
5689    error: Option<String>,
5690    /// The interpolated input the step dispatched, with peer handle tokens redacted. Tool steps
5691    /// only — a shell step's command and a capsule step's task text are not carried here.
5692    #[serde(skip_serializing_if = "Option::is_none")]
5693    input: Option<Value>,
5694    /// The tool's self-declared `state_effect` for this step, lifted verbatim from
5695    /// `tool-result.metadata`. Absent when the tool declared nothing. Feeds the same
5696    /// redundant-call analysis `tool_call.state_effect` does.
5697    #[serde(skip_serializing_if = "Option::is_none")]
5698    state_effect: Option<String>,
5699    /// The resource this step addressed, as the tool declared it. Opaque and never parsed here.
5700    #[serde(skip_serializing_if = "Option::is_none")]
5701    resource_id: Option<String>,
5702}
5703
5704/// The run's summary, written once as `plan::execute` returns, whatever ended it.
5705#[derive(Serialize)]
5706struct PlanEndEvent {
5707    event_type: &'static str,
5708    event_id: String,
5709    parent_id: Option<String>,
5710    session_id: String,
5711    timestamp: u64,
5712    plan_id: String,
5713    outcome: &'static str,
5714    #[serde(skip_serializing_if = "Option::is_none")]
5715    failed_step: Option<String>,
5716    steps_total: usize,
5717    steps_succeeded: usize,
5718    steps_failed: usize,
5719    steps_skipped: usize,
5720    duration_ms: u64,
5721    /// Why the run ended when the reason was not a step's own failure: a plan that would not
5722    /// parse, a cgroup scope the host refused, a DAG with nothing left to run.
5723    #[serde(skip_serializing_if = "Option::is_none")]
5724    reason: Option<String>,
5725}
5726
5727/// Everything the scheduler loop knows about a settled step, as one argument.
5728pub(crate) struct PlanStepRecord {
5729    pub(crate) plan_id: String,
5730    pub(crate) step_id: String,
5731    pub(crate) kind: &'static str,
5732    /// `"success"`, `"failed"` or `"skipped"` — the status the returned `ExecutionReport` carries
5733    /// for this step, so the two can never disagree.
5734    pub(crate) status: &'static str,
5735    /// How many times the step was dispatched. `0` for a step that settled without dispatch.
5736    pub(crate) attempts: u32,
5737    pub(crate) duration_ms: u64,
5738    pub(crate) error: Option<String>,
5739    pub(crate) input: Option<Value>,
5740    pub(crate) state_effect: Option<String>,
5741    pub(crate) resource_id: Option<String>,
5742}
5743
5744/// Everything the scheduler knows about a finished run, as one argument.
5745pub(crate) struct PlanEndRecord {
5746    pub(crate) plan_id: String,
5747    pub(crate) outcome: &'static str,
5748    pub(crate) failed_step: Option<String>,
5749    pub(crate) steps_total: usize,
5750    pub(crate) steps_succeeded: usize,
5751    pub(crate) steps_failed: usize,
5752    pub(crate) steps_skipped: usize,
5753    pub(crate) duration_ms: u64,
5754    pub(crate) reason: Option<String>,
5755}
5756
5757/// A third `O_APPEND` handle to the session's `trace.jsonl`, held by a plan run.
5758///
5759/// Synchronous where [`ResourceTraceAppender`] is async, because [`crate::plan::execute`] is: it
5760/// runs its ready set on plain threads inside a `thread::scope`, so there is no runtime to await
5761/// on and no `await` for a lock to be held across. Interleaving with [`TraceWriter`] and with
5762/// the resource plane is safe on the same terms they interleave with each other — one complete
5763/// line per `write_all`, then flush, which `O_APPEND` makes atomic against the other writers.
5764///
5765/// Handing one of these to a [`crate::plan::SchedulerContext`] is the whole of the opt-in: a
5766/// context carrying `None` opens no file and writes no line.
5767pub struct PlanTraceAppender {
5768    file: std::sync::Mutex<std::fs::File>,
5769    session_id: String,
5770    /// The session node a run's `plan_start` hangs off — [`TraceWriter`]'s own
5771    /// `session_start`, handed over at open time so an appended line names a real parent.
5772    session_event_id: String,
5773}
5774
5775impl PlanTraceAppender {
5776    pub fn open(
5777        records_root: &WorkdirRoot,
5778        session_id: String,
5779        session_event_id: String,
5780    ) -> std::io::Result<Self> {
5781        let file = records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?;
5782        Ok(Self {
5783            file: std::sync::Mutex::new(file),
5784            session_id,
5785            session_event_id,
5786        })
5787    }
5788
5789    /// Records the plan's DAG and returns the plan node every later line of this run hangs off.
5790    pub(crate) fn write_plan_start(&self, plan_id: &str, steps: Vec<PlanStepShape>) -> String {
5791        let event_id = new_event_id();
5792        let event = PlanStartEvent {
5793            event_type: "plan_start",
5794            event_id: event_id.clone(),
5795            parent_id: Some(self.session_event_id.clone()),
5796            session_id: self.session_id.clone(),
5797            timestamp: timestamp_ms(),
5798            plan_id: plan_id.to_string(),
5799            step_count: steps.len(),
5800            steps,
5801        };
5802        self.append(&event);
5803        event_id
5804    }
5805
5806    pub(crate) fn write_plan_step_start(
5807        &self,
5808        plan_node: &str,
5809        plan_id: &str,
5810        step_id: &str,
5811        kind: &'static str,
5812        depends_on: Vec<String>,
5813    ) {
5814        let event = PlanStepStartEvent {
5815            event_type: "plan_step_start",
5816            event_id: new_event_id(),
5817            parent_id: Some(plan_node.to_string()),
5818            session_id: self.session_id.clone(),
5819            timestamp: timestamp_ms(),
5820            plan_id: plan_id.to_string(),
5821            step_id: step_id.to_string(),
5822            kind,
5823            depends_on,
5824        };
5825        self.append(&event);
5826    }
5827
5828    pub(crate) fn write_plan_step(&self, plan_node: &str, record: PlanStepRecord) {
5829        // On the same terms `TraceWriter::write_tool_call` redacts a tool call's input: a peer
5830        // handle is a credential and this file is durable.
5831        let input = record.input.map(|mut value| {
5832            crate::peer_handoff::redact_handles_in_json(&mut value);
5833            value
5834        });
5835        let event = PlanStepEvent {
5836            event_type: "plan_step",
5837            event_id: new_event_id(),
5838            parent_id: Some(plan_node.to_string()),
5839            session_id: self.session_id.clone(),
5840            timestamp: timestamp_ms(),
5841            plan_id: record.plan_id,
5842            step_id: record.step_id,
5843            kind: record.kind,
5844            status: record.status,
5845            attempts: record.attempts,
5846            duration_ms: record.duration_ms,
5847            error: record.error,
5848            input,
5849            state_effect: record.state_effect,
5850            resource_id: record.resource_id,
5851        };
5852        self.append(&event);
5853    }
5854
5855    /// Records the run's summary. `plan_node` is `None` for a run that never had one — a plan
5856    /// file that would not parse describes no DAG — and the line hangs off the session instead.
5857    pub(crate) fn write_plan_end(&self, plan_node: Option<&str>, record: PlanEndRecord) {
5858        let event = PlanEndEvent {
5859            event_type: "plan_end",
5860            event_id: new_event_id(),
5861            parent_id: Some(
5862                plan_node
5863                    .map(str::to_string)
5864                    .unwrap_or_else(|| self.session_event_id.clone()),
5865            ),
5866            session_id: self.session_id.clone(),
5867            timestamp: timestamp_ms(),
5868            plan_id: record.plan_id,
5869            outcome: record.outcome,
5870            failed_step: record.failed_step,
5871            steps_total: record.steps_total,
5872            steps_succeeded: record.steps_succeeded,
5873            steps_failed: record.steps_failed,
5874            steps_skipped: record.steps_skipped,
5875            duration_ms: record.duration_ms,
5876            reason: record.reason,
5877        };
5878        self.append(&event);
5879    }
5880
5881    /// Records one child a `capsule` step launched, while its delegation is still running.
5882    ///
5883    /// The synchronous twin of [`ResourceTraceAppender::write_delegation_start`]. Both go through
5884    /// [`DelegationStartEvent::new`], so the two lines are identical but for their ids and
5885    /// clocks — held by there being one constructor rather than by a test.
5886    #[allow(clippy::too_many_arguments)]
5887    pub(crate) fn write_delegation_start(
5888        &self,
5889        delegation_id: &str,
5890        capsule: &str,
5891        version: &str,
5892        child_session_id: &str,
5893        child_workdir: &str,
5894        child_records_dir: &str,
5895        workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
5896    ) {
5897        let event = DelegationStartEvent::new(
5898            &self.session_id,
5899            &self.session_event_id,
5900            delegation_id,
5901            capsule,
5902            version,
5903            child_session_id,
5904            child_workdir,
5905            child_records_dir,
5906            workdir_snapshot,
5907        );
5908        self.append(&event);
5909    }
5910
5911    /// Records one `capsule` step's delegation ending. `delegation_id` and `child_session_id` are
5912    /// `None` when no child was launched, on the same terms
5913    /// [`ResourceTraceAppender::write_delegation`] passes them.
5914    ///
5915    /// One pair per *attempt*: a step with retries delegates once per attempt, so it writes one
5916    /// of these per attempt. Two `failed` lines and a `completed` one is what happened, not a
5917    /// duplicate of one thing.
5918    #[allow(clippy::too_many_arguments)]
5919    pub(crate) fn write_delegation(
5920        &self,
5921        capsule: &str,
5922        version: &str,
5923        delegation_id: Option<String>,
5924        child_session_id: Option<String>,
5925        duration_ms: u64,
5926        outcome: &str,
5927        reason: Option<String>,
5928    ) {
5929        let event = DelegationEvent::new(
5930            &self.session_id,
5931            &self.session_event_id,
5932            capsule,
5933            version,
5934            delegation_id,
5935            child_session_id,
5936            duration_ms,
5937            outcome,
5938            reason,
5939        );
5940        self.append(&event);
5941    }
5942
5943    /// Writes one complete line and flushes it. Every failure is swallowed, exactly as
5944    /// [`ResourceTraceAppender::append`] swallows one: a trace that cannot be written must never
5945    /// turn a step that ran into a step that failed.
5946    fn append(&self, event: &impl Serialize) {
5947        use std::io::Write as _;
5948
5949        let Ok(mut line) = serde_json::to_string(event) else {
5950            return;
5951        };
5952        line.push('\n');
5953        let Ok(mut file) = self.file.lock() else {
5954            return;
5955        };
5956        if file.write_all(line.as_bytes()).is_ok() {
5957            let _ = file.flush();
5958        }
5959    }
5960}
5961
5962// ── Unit tests ────────────────────────────────────────────────────────────────
5963
5964#[cfg(test)]
5965mod tests {
5966    use super::*;
5967    use crate::{
5968        sandbox::EnforcementTier, sealed::UsernsGrant, trace_blobs::BLOB_DIR_NAME,
5969        types::CapabilityPolicy,
5970    };
5971    use murmur_artifact::{ArtifactRuntime, InterpreterRuntimeDir, InterpreterRuntimeGrant};
5972    use serde_json::Value;
5973    use std::path::Path;
5974
5975    use crate::origin::TaskOrigin;
5976
5977    /// The class an A2A task carrying no origin claim resolves to.
5978    fn event_provenance() -> TaskProvenance {
5979        TaskProvenance::derive(TaskOrigin::Event, None)
5980    }
5981
5982    /// A [`ScopeReport`] built the way production builds one — through
5983    /// `containment::report_for` with an *explicit* tier, never a live probe, so these
5984    /// tests assert the same values on a Landlock-capable host and a laptop that has none.
5985    fn report_for(
5986        declared: ContainmentClass,
5987        tier: EnforcementTier,
5988        workdir_exec: bool,
5989    ) -> ScopeReport {
5990        crate::containment::report_for(
5991            &CapabilityPolicy {
5992                workdir_exec_allowed: workdir_exec,
5993                ..CapabilityPolicy::default()
5994            },
5995            declared,
5996            tier,
5997            None,
5998            None,
5999            None,
6000            None,
6001            Vec::new(),
6002            Vec::new(),
6003            Vec::new(),
6004            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6005            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(tier, declared)),
6006            crate::cgroup::IoMaxReport::default(),
6007        )
6008    }
6009
6010    fn demotion_info() -> crate::detached::DetachedDispatchInfo {
6011        crate::detached::DetachedDispatchInfo {
6012            work_id: "wrk_0a1b2c3d4e5f6a7b".to_string(),
6013            binary: "/usr/bin/bash".to_string(),
6014            command: "make -j8".to_string(),
6015            grace_ms: Some(1_000),
6016        }
6017    }
6018
6019    /// A demotion whose marker cannot be written stands anyway: the write failure is reported and
6020    /// swallowed, and the session carries on with the command running in the background.
6021    #[tokio::test]
6022    async fn a_demotion_marker_that_cannot_be_written_does_not_fail_the_demotion() {
6023        let dir = tempfile::tempdir().unwrap();
6024        // `/dev/full` opens and fails every write with `ENOSPC`, which is the one way to get a
6025        // trace handle that is valid and unwritable without root. Swapped in under the writer,
6026        // because a link at `trace.jsonl` is refused when the writer opens it.
6027        let mut writer = make_writer(dir.path()).await;
6028        let full = std::fs::OpenOptions::new()
6029            .write(true)
6030            .open("/dev/full")
6031            .unwrap();
6032        writer.writer = BufWriter::new(File::from_std(full));
6033        let info = demotion_info();
6034
6035        assert!(
6036            writer
6037                .write_shell_detached(1, &info.work_id, &info.binary, &info.command, info.grace_ms)
6038                .await
6039                .is_err(),
6040            "the sink under this writer must be unwritable for the claim below to mean anything"
6041        );
6042        crate::agent::record_demotion(&mut writer, 1, &info).await;
6043    }
6044
6045    /// The ordinary path writes one `shell_detached` and nothing else.
6046    #[tokio::test]
6047    async fn a_recorded_demotion_writes_only_its_own_marker() {
6048        let dir = tempfile::tempdir().unwrap();
6049        let mut writer = make_writer(dir.path()).await;
6050        crate::agent::record_demotion(&mut writer, 3, &demotion_info()).await;
6051
6052        let events = read_events(dir.path());
6053        assert_eq!(events.len(), 1);
6054        assert_eq!(events[0]["event_type"], "shell_detached");
6055        assert_eq!(events[0]["work_id"], "wrk_0a1b2c3d4e5f6a7b");
6056        assert_eq!(events[0]["turn"], 3);
6057        assert_eq!(events[0]["grace_ms"], 1_000);
6058    }
6059
6060    /// A demotion with no grace in force carries no `grace_ms` key at all.
6061    #[tokio::test]
6062    async fn a_demotion_with_no_grace_writes_no_grace_ms() {
6063        let dir = tempfile::tempdir().unwrap();
6064        let mut writer = make_writer(dir.path()).await;
6065        let info = crate::detached::DetachedDispatchInfo {
6066            grace_ms: None,
6067            ..demotion_info()
6068        };
6069        crate::agent::record_demotion(&mut writer, 3, &info).await;
6070
6071        let events = read_events(dir.path());
6072        assert_eq!(events.len(), 1);
6073        assert_eq!(events[0]["event_type"], "shell_detached");
6074        assert!(events[0].get("grace_ms").is_none(), "{}", events[0]);
6075    }
6076
6077    #[tokio::test]
6078    async fn a_safety_check_event_carries_the_checks_verbatim_and_its_outcome() {
6079        let dir = tempfile::tempdir().unwrap();
6080        let mut writer = make_writer(dir.path()).await;
6081        let checks = serde_json::json!([{"id": "sc_1", "code": "c", "message": "m", "x": 1}]);
6082        writer
6083            .write_safety_check(2, "call_1_0", "computer", &checks, "declined")
6084            .await
6085            .unwrap();
6086
6087        let events = read_events(dir.path());
6088        assert_eq!(events.len(), 1);
6089        assert_eq!(events[0]["event_type"], "safety_check");
6090        assert_eq!(events[0]["turn"], 2);
6091        assert_eq!(events[0]["tool_call_id"], "call_1_0");
6092        assert_eq!(events[0]["tool"], "computer");
6093        assert_eq!(events[0]["checks"], checks);
6094        assert_eq!(events[0]["outcome"], "declined");
6095        assert!(events[0]["event_id"].is_string() && events[0]["session_id"].is_string());
6096    }
6097
6098    #[tokio::test]
6099    async fn an_unrecorded_demotion_names_the_work_id_and_the_write_error() {
6100        let dir = tempfile::tempdir().unwrap();
6101        let mut writer = make_writer(dir.path()).await;
6102        writer
6103            .write_shell_detach_unrecorded(2, "wrk_0a1b", "/usr/bin/bash", "No space left")
6104            .await
6105            .unwrap();
6106
6107        let events = read_events(dir.path());
6108        assert_eq!(events.len(), 1);
6109        assert_eq!(events[0]["event_type"], "shell_detach_unrecorded");
6110        assert_eq!(events[0]["turn"], 2);
6111        assert_eq!(events[0]["work_id"], "wrk_0a1b");
6112        assert_eq!(events[0]["binary"], "/usr/bin/bash");
6113        assert_eq!(events[0]["reason"], "No space left");
6114    }
6115
6116    /// A command still running when the session ended has no exit code, no output file and no
6117    /// byte count, and its line carries none of the three — omitted rather than `null`, so the
6118    /// shape is identical to a line written before those fields existed.
6119    #[tokio::test]
6120    async fn an_abandoned_command_still_running_omits_the_fields_it_cannot_know() {
6121        let dir = tempfile::tempdir().unwrap();
6122        let mut writer = make_writer(dir.path()).await;
6123        writer
6124            .write_shell_abandoned(
6125                "wrk_0a1b2c3d4e5f6a7b",
6126                "/usr/bin/bash",
6127                "sleep 45; echo done",
6128                45_012,
6129                None,
6130            )
6131            .await
6132            .unwrap();
6133
6134        let events = read_events(dir.path());
6135        assert_eq!(events.len(), 1);
6136        let event = &events[0];
6137        assert_eq!(event["event_type"], "shell_abandoned");
6138        assert_eq!(event["work_id"], "wrk_0a1b2c3d4e5f6a7b");
6139        assert_eq!(event["binary"], "/usr/bin/bash");
6140        assert_eq!(event["command"], "sleep 45; echo done");
6141        assert_eq!(event["running_ms"], 45_012);
6142        for absent in ["exit_code", "output_path", "output_bytes"] {
6143            assert!(
6144                event.get(absent).is_none(),
6145                "{absent} must be omitted, not written as null: {event}"
6146            );
6147        }
6148    }
6149
6150    /// A command that finished during teardown carries the exit code, the output path and the
6151    /// byte count that do exist for it, so a trace reader can tell it from one still running
6152    /// without reading anything else.
6153    #[tokio::test]
6154    async fn an_abandoned_command_that_finished_carries_its_result() {
6155        let dir = tempfile::tempdir().unwrap();
6156        let mut writer = make_writer(dir.path()).await;
6157        writer
6158            .write_shell_abandoned(
6159                "wrk_0a1b2c3d4e5f6a7b",
6160                "/usr/bin/bash",
6161                "sleep 2; echo done",
6162                2_014,
6163                Some((
6164                    0,
6165                    "/home/test/.murmur/records/work/ses_1/logs/wrk_0a1b2c3d4e5f6a7b.log",
6166                    91,
6167                )),
6168            )
6169            .await
6170            .unwrap();
6171
6172        let event = &read_events(dir.path())[0];
6173        assert_eq!(event["event_type"], "shell_abandoned");
6174        assert_eq!(event["running_ms"], 2_014);
6175        assert_eq!(event["exit_code"], 0);
6176        assert_eq!(
6177            event["output_path"],
6178            "/home/test/.murmur/records/work/ses_1/logs/wrk_0a1b2c3d4e5f6a7b.log"
6179        );
6180        assert_eq!(event["output_bytes"], 91);
6181    }
6182
6183    async fn make_writer(dir: &std::path::Path) -> TraceWriter {
6184        make_writer_with_opts(dir, TraceCapture::Meta).await
6185    }
6186
6187    async fn make_writer_with_opts(dir: &std::path::Path, capture: TraceCapture) -> TraceWriter {
6188        make_writer_with_prompt(dir, capture, None, false).await
6189    }
6190
6191    async fn make_writer_with_prompt(
6192        dir: &std::path::Path,
6193        capture: TraceCapture,
6194        system_prompt: Option<&str>,
6195        system_prompt_overridden: bool,
6196    ) -> TraceWriter {
6197        TraceWriter::open(
6198            &crate::workdir_fs::WorkdirRoot::open(dir).unwrap(),
6199            "test-session-id".to_string(),
6200            "test-capsule".to_string(),
6201            "1.0.0".to_string(),
6202            "claude-test".to_string(),
6203            vec!["shell".to_string()],
6204            report_for(
6205                ContainmentClass::Advisory,
6206                EnforcementTier::EnvironmentOnly,
6207                false,
6208            ),
6209            capture,
6210            system_prompt.map(str::to_string),
6211            system_prompt_overridden,
6212            None,
6213            None,
6214            None,
6215            None,
6216        )
6217        .await
6218        .unwrap()
6219    }
6220
6221    fn read_events(dir: &std::path::Path) -> Vec<Value> {
6222        let content = std::fs::read_to_string(dir.join("trace.jsonl")).unwrap();
6223        content
6224            .lines()
6225            .filter(|l| !l.is_empty())
6226            .map(|l| serde_json::from_str(l).unwrap())
6227            .collect()
6228    }
6229
6230    #[tokio::test]
6231    async fn session_start_fields() {
6232        let dir = tempfile::tempdir().unwrap();
6233        let mut w = make_writer(dir.path()).await;
6234        w.write_session_start(Some(10), None, vec!["bash".to_string()])
6235            .await
6236            .unwrap();
6237        w.flush().await.unwrap();
6238
6239        let events = read_events(dir.path());
6240        assert_eq!(events.len(), 1);
6241        let e = &events[0];
6242        assert_eq!(e["event_type"], "session_start");
6243        assert_eq!(e["session_id"], "test-session-id");
6244        assert_eq!(e["capsule_name"], "test-capsule");
6245        assert_eq!(e["capsule_version"], "1.0.0");
6246        assert_eq!(e["model"], "claude-test");
6247        assert_eq!(e["max_turns"], 10);
6248        assert_eq!(e["capabilities"], serde_json::json!(["shell"]));
6249        assert_eq!(e["tools_declared"], serde_json::json!(["bash"]));
6250        assert_eq!(e["containment_declared"], "advisory");
6251        assert_eq!(e["containment_achieved"], "advisory");
6252        assert_eq!(e["workdir_exec"], false);
6253        assert!(e["timestamp"].as_u64().unwrap() > 0);
6254        assert_eq!(e["model_limits"], serde_json::Value::Null);
6255    }
6256
6257    /// No turn cap is written as `null`, beside the primary's limits and their sources.
6258    #[tokio::test]
6259    async fn session_start_writes_no_turn_cap_and_the_model_limits() {
6260        let dir = tempfile::tempdir().unwrap();
6261        let mut w = make_writer(dir.path()).await;
6262        let limits = crate::model_limits::ResolvedModelLimits {
6263            max_output_tokens: 65536,
6264            max_output_tokens_source: crate::model_limits::LimitSource::Driver,
6265            context_window: None,
6266            context_window_source: crate::model_limits::LimitSource::None,
6267            max_input_tokens: None,
6268            max_input_tokens_source: crate::model_limits::LimitSource::None,
6269            input_budget: None,
6270            input_budget_basis: crate::model_limits::BudgetBasis::None,
6271        };
6272        w.write_session_start(None, Some(limits), Vec::new())
6273            .await
6274            .unwrap();
6275        w.flush().await.unwrap();
6276
6277        let e = &read_events(dir.path())[0];
6278        assert!(e.get("max_turns").is_some_and(serde_json::Value::is_null));
6279        assert_eq!(
6280            e["model_limits"],
6281            serde_json::json!({
6282                "max_output_tokens": 65536,
6283                "max_output_tokens_source": "driver",
6284                "context_window": null,
6285                "context_window_source": "none",
6286                "max_input_tokens": null,
6287                "max_input_tokens_source": "none",
6288                "input_budget": null,
6289                "input_budget_basis": "none",
6290            })
6291        );
6292    }
6293
6294    /// A member's formation is on the trace's first line, which is all a reader grouping sessions
6295    /// reads; a session in no formation writes no key at all.
6296    #[tokio::test]
6297    async fn session_start_is_the_first_line_and_carries_a_members_formation() {
6298        let formation = FormationId::mint();
6299        for member in [Some(&formation), None] {
6300            let dir = tempfile::tempdir().unwrap();
6301            let mut w = make_writer(dir.path()).await;
6302            w.set_formation_id(member);
6303            w.write_session_start(Some(10), None, Vec::new())
6304                .await
6305                .unwrap();
6306            w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6307                .await
6308                .unwrap();
6309            w.flush().await.unwrap();
6310
6311            let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6312            let first_line = content.lines().next().unwrap();
6313            let first: Value = serde_json::from_str(first_line).unwrap();
6314            assert_eq!(first["event_type"], "session_start");
6315            match member {
6316                Some(id) => {
6317                    assert_eq!(first["formation_id"], id.as_str());
6318                    assert!(
6319                        first_line.contains(&format!("\"formation_id\":\"{id}\"")),
6320                        "{first_line}"
6321                    );
6322                }
6323                None => assert!(!first_line.contains("formation_id"), "{first_line}"),
6324            }
6325            // Only the session frame carries it.
6326            assert_eq!(
6327                content.matches("formation_id").count(),
6328                usize::from(member.is_some())
6329            );
6330        }
6331    }
6332
6333    /// A member's `session_start` names it and its callees, `[]` for none, and only a member's
6334    /// does; `a2a_task_received.caller_member` is present only for a task a formation token let
6335    /// in.
6336    #[tokio::test]
6337    async fn a_members_trace_names_it_its_callees_and_its_callers() {
6338        let authority = crate::formation_credentials::FormationAuthority::for_test();
6339        let cases = [
6340            (Some(("coder", vec!["reviewer"])), Some("planner")),
6341            (Some(("reviewer", vec![])), None),
6342            (None, None),
6343        ];
6344        for (member, caller) in cases {
6345            let member = member.map(|(name, callees)| {
6346                crate::formation_credentials::FormationMember::from_bundle(
6347                    authority.member_bundle(name, &callees),
6348                )
6349            });
6350            let dir = tempfile::tempdir().unwrap();
6351            let mut w = make_writer(dir.path()).await;
6352            w.set_formation_member(member.as_ref());
6353            w.write_session_start(Some(10), None, Vec::new())
6354                .await
6355                .unwrap();
6356            let door_caller = match caller {
6357                Some(caller) => crate::identity::TaskCaller::Credential(format!("member:{caller}")),
6358                None => crate::identity::TaskCaller::Operator,
6359            };
6360            w.write_a2a_task_received("tsk_1", "ctx_1", "msg_1", None, &door_caller, caller, "hi")
6361                .await
6362                .unwrap();
6363            w.flush().await.unwrap();
6364            let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6365            let lines: Vec<Value> = content
6366                .lines()
6367                .map(|line| serde_json::from_str(line).unwrap())
6368                .collect();
6369            let start = &lines[0];
6370            let received = lines
6371                .iter()
6372                .find(|line| line["event_type"] == "a2a_task_received")
6373                .unwrap();
6374            match &member {
6375                Some(member) => {
6376                    assert_eq!(start["formation_member"], member.name());
6377                    assert_eq!(
6378                        start["formation_callees"],
6379                        serde_json::json!(member.callees().collect::<Vec<_>>())
6380                    );
6381                }
6382                None => {
6383                    assert!(start.get("formation_member").is_none(), "{start}");
6384                    assert!(start.get("formation_callees").is_none(), "{start}");
6385                }
6386            }
6387            match caller {
6388                Some(caller) => {
6389                    assert_eq!(received["caller"], "member");
6390                    assert_eq!(received["caller_member"], caller);
6391                }
6392                None => {
6393                    assert_eq!(received["caller"], "operator");
6394                    assert!(received.get("caller_member").is_none(), "{received}");
6395                }
6396            }
6397            assert!(received.get("caller_credential").is_none(), "{received}");
6398            assert!(!content.contains("mft1."), "{content}");
6399        }
6400    }
6401
6402    /// `a2a_task_received.caller` names each way a door lets a task in, with `caller_member` only
6403    /// for a member and `caller_credential` only for a declared credential; a formation token's
6404    /// task is the member's whatever credential the token carries.
6405    #[tokio::test]
6406    async fn a_received_task_names_who_the_door_let_it_in_as() {
6407        use crate::identity::TaskCaller;
6408        let cases = [
6409            (TaskCaller::Operator, None, "operator", None),
6410            (TaskCaller::Public, None, "public", None),
6411            (
6412                TaskCaller::Credential("alice".to_string()),
6413                None,
6414                "credential",
6415                Some("alice"),
6416            ),
6417            (
6418                TaskCaller::Credential("member:planner".to_string()),
6419                Some("planner"),
6420                "member",
6421                None,
6422            ),
6423        ];
6424        for (door_caller, member, caller, credential) in cases {
6425            let dir = tempfile::tempdir().unwrap();
6426            let mut w = make_writer(dir.path()).await;
6427            w.write_session_start(Some(10), None, Vec::new())
6428                .await
6429                .unwrap();
6430            w.write_a2a_task_received("tsk_1", "ctx_1", "msg_1", None, &door_caller, member, "hi")
6431                .await
6432                .unwrap();
6433            w.flush().await.unwrap();
6434            let received = read_events(dir.path())
6435                .into_iter()
6436                .find(|line| line["event_type"] == "a2a_task_received")
6437                .unwrap();
6438            assert_eq!(received["caller"], caller, "{received}");
6439            match member {
6440                Some(member) => assert_eq!(received["caller_member"], member, "{received}"),
6441                None => assert!(received.get("caller_member").is_none(), "{received}"),
6442            }
6443            match credential {
6444                Some(name) => assert_eq!(received["caller_credential"], name, "{received}"),
6445                None => assert!(received.get("caller_credential").is_none(), "{received}"),
6446            }
6447        }
6448    }
6449
6450    /// The received message's hash is written under `meta` and `content`, and only `content`
6451    /// stores the text behind it; `none` writes neither. The text never reaches the JSONL.
6452    #[tokio::test]
6453    async fn a_received_message_is_hashed_and_stored_by_capture_mode() {
6454        let text = "Name three colours, please.";
6455        let sha = murmur_artifact::sha256_hex(text.as_bytes());
6456        for capture in [
6457            TraceCapture::None,
6458            TraceCapture::Meta,
6459            TraceCapture::Content,
6460        ] {
6461            let dir = tempfile::tempdir().unwrap();
6462            let mut w = make_writer_with_opts(dir.path(), capture).await;
6463            w.write_a2a_task_received(
6464                "tsk_1",
6465                "ctx_1",
6466                "msg_1",
6467                None,
6468                &crate::identity::TaskCaller::Operator,
6469                None,
6470                text,
6471            )
6472            .await
6473            .unwrap();
6474            w.flush().await.unwrap();
6475            let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6476            assert!(!content.contains(text), "{content}");
6477            let received = read_events(dir.path())
6478                .into_iter()
6479                .find(|line| line["event_type"] == "a2a_task_received")
6480                .unwrap();
6481            let blob = std::fs::read(blob_path(dir.path(), &sha)).ok();
6482            match capture {
6483                TraceCapture::None => {
6484                    assert!(received.get("message_sha256").is_none(), "{received}");
6485                    assert_eq!(blob, None);
6486                }
6487                TraceCapture::Meta => {
6488                    assert_eq!(received["message_sha256"], sha.as_str(), "{received}");
6489                    assert_eq!(blob, None);
6490                }
6491                TraceCapture::Content => {
6492                    assert_eq!(received["message_sha256"], sha.as_str(), "{received}");
6493                    assert_eq!(blob.as_deref(), Some(text.as_bytes()));
6494                }
6495            }
6496        }
6497    }
6498
6499    /// The three `system_prompt_source` values, each paired with the hash the same call derives.
6500    /// A manifest prompt and a CLI override carrying the *same* text differ only in the source
6501    /// field — which is the whole reason the override flag is threaded through separately rather
6502    /// than inferred from the resolved prompt.
6503    #[tokio::test]
6504    async fn session_start_records_system_prompt_source_and_hash() {
6505        let expected_sha = murmur_artifact::sha256_hex(b"Be terse.");
6506
6507        for (prompt, overridden, source, sha) in [
6508            (Some("Be terse."), false, "manifest", Some(&expected_sha)),
6509            (Some("Be terse."), true, "cli", Some(&expected_sha)),
6510            (None, false, "none", None),
6511            // `--system-prompt ""` cleared the manifest's prompt: no prompt is in effect, but the
6512            // operator made that call, so the source is the CLI and not `"none"`.
6513            (None, true, "cli", None),
6514        ] {
6515            let dir = tempfile::tempdir().unwrap();
6516            let mut w =
6517                make_writer_with_prompt(dir.path(), TraceCapture::Meta, prompt, overridden).await;
6518            w.write_session_start(Some(1), None, Vec::new())
6519                .await
6520                .unwrap();
6521            w.flush().await.unwrap();
6522
6523            let e = &read_events(dir.path())[0];
6524            assert_eq!(e["system_prompt_source"], source, "prompt={prompt:?}");
6525            match sha {
6526                Some(sha) => assert_eq!(e["system_prompt_sha256"], sha.as_str()),
6527                None => assert!(
6528                    e["system_prompt_sha256"].is_null(),
6529                    "no prompt in effect must hash to null, got {}",
6530                    e["system_prompt_sha256"]
6531                ),
6532            }
6533        }
6534    }
6535
6536    /// `session_start` never carries the prompt text. The hash is written in every capture
6537    /// mode; under `content` the bytes behind it are a blob named by that same hash, which is
6538    /// how a reader gets from the record to the prompt.
6539    #[tokio::test]
6540    async fn session_start_records_the_prompt_by_hash_and_stores_it_only_under_content() {
6541        let sha = murmur_artifact::sha256_hex(b"Be terse.");
6542
6543        let dir = tempfile::tempdir().unwrap();
6544        let mut w =
6545            make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), false).await;
6546        w.write_session_start(Some(1), None, Vec::new())
6547            .await
6548            .unwrap();
6549        w.flush().await.unwrap();
6550
6551        let e = &read_events(dir.path())[0];
6552        assert!(
6553            e.get("system_prompt").is_none(),
6554            "the verbatim prompt is not a session_start field, got {e}"
6555        );
6556        assert_eq!(e["system_prompt_sha256"], sha.as_str());
6557        assert!(
6558            !dir.path().join(BLOB_DIR_NAME).exists(),
6559            "meta stores no bodies"
6560        );
6561
6562        let opted_in = tempfile::tempdir().unwrap();
6563        let mut w = make_writer_with_prompt(
6564            opted_in.path(),
6565            TraceCapture::Content,
6566            Some("Be terse."),
6567            false,
6568        )
6569        .await;
6570        w.write_session_start(Some(1), None, Vec::new())
6571            .await
6572            .unwrap();
6573        w.flush().await.unwrap();
6574
6575        let event = &read_events(opted_in.path())[0];
6576        assert!(event.get("system_prompt").is_none());
6577        assert_eq!(event["system_prompt_sha256"], sha.as_str());
6578        let blob = opted_in.path().join(BLOB_DIR_NAME).join(&sha);
6579        assert_eq!(std::fs::read(&blob).unwrap(), b"Be terse.");
6580    }
6581
6582    /// Opting in cannot conjure a prompt that was never in effect — and a session with no
6583    /// prompt and nothing else to store leaves no blob directory at all.
6584    #[tokio::test]
6585    async fn content_capture_stores_no_prompt_blob_when_there_is_no_prompt() {
6586        let dir = tempfile::tempdir().unwrap();
6587        let mut w = make_writer_with_prompt(dir.path(), TraceCapture::Content, None, false).await;
6588        w.write_session_start(Some(1), None, Vec::new())
6589            .await
6590            .unwrap();
6591        w.flush().await.unwrap();
6592
6593        let e = &read_events(dir.path())[0];
6594        assert!(e.get("system_prompt").is_none());
6595        assert!(e["system_prompt_sha256"].is_null());
6596        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
6597    }
6598
6599    /// The prompt is a session constant, like `model` — every task's `session_start` repeats it.
6600    #[tokio::test]
6601    async fn every_task_session_start_repeats_the_same_system_prompt_record() {
6602        let dir = tempfile::tempdir().unwrap();
6603        let mut w =
6604            make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), true).await;
6605        w.write_session_start(Some(1), None, Vec::new())
6606            .await
6607            .unwrap();
6608        w.write_session_start(Some(1), None, Vec::new())
6609            .await
6610            .unwrap();
6611        w.flush().await.unwrap();
6612
6613        let events = read_events(dir.path());
6614        assert_eq!(events.len(), 2);
6615        assert_eq!(
6616            events[0]["system_prompt_source"],
6617            events[1]["system_prompt_source"]
6618        );
6619        assert_eq!(
6620            events[0]["system_prompt_sha256"],
6621            events[1]["system_prompt_sha256"]
6622        );
6623        assert_eq!(events[1]["system_prompt_source"], "cli");
6624    }
6625
6626    /// The pairing an operator reads a trace for: `workdir_exec: true` next to the `advisory` it
6627    /// forces, on a session whose *declared* floor was higher. Without the flag on the record, a
6628    /// trace showing `achieved: advisory` is indistinguishable from one written on a host with no
6629    /// Landlock at all.
6630    #[tokio::test]
6631    async fn session_start_records_workdir_exec_next_to_the_class_it_forced() {
6632        let dir = tempfile::tempdir().unwrap();
6633        let mut w = TraceWriter::open(
6634            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6635            "test-session-id".to_string(),
6636            "test-capsule".to_string(),
6637            "1.0.0".to_string(),
6638            "claude-test".to_string(),
6639            Vec::new(),
6640            // A host that could back `scoped`, capped to `advisory` by the declaration alone.
6641            report_for(
6642                ContainmentClass::Advisory,
6643                EnforcementTier::KernelFull,
6644                true,
6645            ),
6646            TraceCapture::Meta,
6647            None,
6648            false,
6649            None,
6650            None,
6651            None,
6652            None,
6653        )
6654        .await
6655        .unwrap();
6656        w.write_session_start(Some(1), None, Vec::new())
6657            .await
6658            .unwrap();
6659        w.flush().await.unwrap();
6660
6661        let events = read_events(dir.path());
6662        assert_eq!(events[0]["workdir_exec"], true);
6663        assert_eq!(events[0]["containment_achieved"], "advisory");
6664    }
6665
6666    /// A declared floor and an achieved class are recorded independently — the trace shows
6667    /// what was asked for next to what the host actually gave, not one derived from the other.
6668    #[tokio::test]
6669    async fn session_start_records_declared_and_achieved_containment_separately() {
6670        let dir = tempfile::tempdir().unwrap();
6671        let mut w = TraceWriter::open(
6672            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6673            "test-session-id".to_string(),
6674            "test-capsule".to_string(),
6675            "1.0.0".to_string(),
6676            "claude-test".to_string(),
6677            Vec::new(),
6678            report_for(ContainmentClass::Sealed, EnforcementTier::KernelFull, false),
6679            TraceCapture::Meta,
6680            None,
6681            false,
6682            None,
6683            None,
6684            None,
6685            None,
6686        )
6687        .await
6688        .unwrap();
6689        w.write_session_start(Some(1), None, Vec::new())
6690            .await
6691            .unwrap();
6692        w.flush().await.unwrap();
6693
6694        let events = read_events(dir.path());
6695        assert_eq!(events[0]["containment_declared"], "sealed");
6696        assert_eq!(events[0]["containment_achieved"], "scoped");
6697    }
6698
6699    /// The audit property the containment feature exists to provide: two sessions that reached
6700    /// the *same* achieved class through *different* host permissions must not produce the same
6701    /// record. A `sealed` result obtained through the shipped AppArmor profile and one obtained on
6702    /// a host whose unprivileged-userns hardening is switched off for every binary differ in
6703    /// nothing else the event carries, so `userns_grant` is what keeps the two apart.
6704    ///
6705    /// The tier is a literal on both sides, so the only thing that differs is the grant.
6706    #[tokio::test]
6707    async fn session_start_distinguishes_two_hosts_with_the_same_achieved_class() {
6708        async fn event_for(grant: UsernsGrant) -> Value {
6709            let report = crate::containment::report_for(
6710                &CapabilityPolicy::default(),
6711                ContainmentClass::Sealed,
6712                EnforcementTier::KernelSealed,
6713                None,
6714                Some(grant),
6715                None,
6716                None,
6717                Vec::new(),
6718                Vec::new(),
6719                Vec::new(),
6720                &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6721                crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6722                    EnforcementTier::KernelSealed,
6723                    ContainmentClass::Sealed,
6724                )),
6725                crate::cgroup::IoMaxReport::default(),
6726            );
6727            let dir = tempfile::tempdir().unwrap();
6728            let mut w = TraceWriter::open(
6729                &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6730                "test-session-id".to_string(),
6731                "test-capsule".to_string(),
6732                "1.0.0".to_string(),
6733                "claude-test".to_string(),
6734                Vec::new(),
6735                report,
6736                TraceCapture::Meta,
6737                None,
6738                false,
6739                None,
6740                None,
6741                None,
6742                None,
6743            )
6744            .await
6745            .unwrap();
6746            w.write_session_start(Some(1), None, Vec::new())
6747                .await
6748                .unwrap();
6749            w.flush().await.unwrap();
6750            let mut event = read_events(dir.path()).remove(0);
6751            // The one field that legitimately differs between two runs, removed so the assertion
6752            // below is about the grant and nothing else.
6753            event.as_object_mut().unwrap().remove("timestamp");
6754            event
6755        }
6756
6757        let through_profile = event_for(UsernsGrant::ProfileConfining).await;
6758        let host_wide = event_for(UsernsGrant::RestrictionDisabledHostWide).await;
6759
6760        assert_eq!(through_profile["containment_achieved"], "sealed");
6761        assert_eq!(host_wide["containment_achieved"], "sealed");
6762        assert_eq!(through_profile["userns_grant"], "profile_confining");
6763        assert_eq!(host_wide["userns_grant"], "restriction_disabled_host_wide");
6764        assert_ne!(
6765            through_profile, host_wide,
6766            "two hosts granting the user namespace by different mechanisms must not write the \
6767             same session_start record"
6768        );
6769
6770        // Written for every Linux host, never skipped — its absence identifies a runtime that
6771        // predates the key rather than a host that was not asked.
6772        let unprobed = crate::containment::report_for(
6773            &CapabilityPolicy::default(),
6774            ContainmentClass::Advisory,
6775            EnforcementTier::EnvironmentOnly,
6776            None,
6777            None,
6778            None,
6779            None,
6780            Vec::new(),
6781            Vec::new(),
6782            Vec::new(),
6783            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6784            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6785                EnforcementTier::EnvironmentOnly,
6786                ContainmentClass::Advisory,
6787            )),
6788            crate::cgroup::IoMaxReport::default(),
6789        );
6790        assert!(
6791            serde_json::to_value(&unprobed).unwrap()["userns_grant"].is_null(),
6792            "off Linux the key is present and null, not absent"
6793        );
6794    }
6795
6796    /// Central property: `session_start.effective_grants` is the *whole* `ScopeReport`,
6797    /// serialized byte-for-byte as `mur run --explain-scope --json` prints it — not a re-derived
6798    /// summary of it. Asserted by comparing against `serde_json::to_value` of the very report
6799    /// handed to the writer, so any field added to `ScopeReport` later is covered without this
6800    /// test being touched.
6801    ///
6802    /// The tier is a literal, never a probe: this asserts the recording, not the host.
6803    #[tokio::test]
6804    async fn session_start_records_the_whole_scope_report_as_effective_grants() {
6805        let policy = CapabilityPolicy {
6806            network_allow: vec!["https://api.example.com".to_string()],
6807            unix_sockets_allowed: false,
6808            filesystem_scope: Some("workdir".to_string()),
6809            shell_allow: vec!["python3".to_string()],
6810            spawn_allow: vec!["helper".to_string()],
6811            env_allow: vec!["TZ".to_string()],
6812            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6813                binary: "python3".to_string(),
6814                dirs: vec![InterpreterRuntimeDir {
6815                    path: "/usr/lib/python3.11".to_string(),
6816                    list_dir: true,
6817                }],
6818            }],
6819            ..CapabilityPolicy::default()
6820        };
6821        let expected = crate::containment::report_for(
6822            &policy,
6823            ContainmentClass::Scoped,
6824            EnforcementTier::KernelFull,
6825            None,
6826            None,
6827            // A kernel above the cap, so `landlock_abi` is written as an integer, not `null`.
6828            Some(8),
6829            None,
6830            // Populated rather than empty so the whole-report assertion below really covers this
6831            // field: an empty vec would pass whether or not it was written at all.
6832            vec![crate::containment::StateStoreReport {
6833                artifact: "notes-tool".to_string(),
6834                store: "shey".to_string(),
6835                host_path: "/home/dev/.murmur/state/shey".to_string(),
6836            }],
6837            // Populated for the same reason as `state_stores` above.
6838            vec!["config-echo".to_string()],
6839            vec![crate::containment::PreopenReport {
6840                artifact: "notes-tool".to_string(),
6841                role: "tool".to_string(),
6842                scope: None,
6843                surface: crate::containment::PreopenSurface::WholeWorkdir,
6844                runtime_dir_reach: Some(crate::containment::RuntimeDirReach::MurmurRuntime),
6845                // Populated for the same reason as `state_stores` above.
6846                extra_roots: vec![crate::containment::ExtraRootReport {
6847                    guest_path: "murmur-host/home/dev/.cargo".to_string(),
6848                    path: "/home/dev/.cargo".to_string(),
6849                    access: crate::host_paths::HostPathAccess::Read,
6850                    source: crate::guest_roots::GuestRootSource::HostPaths,
6851                }],
6852            }],
6853            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6854            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6855                EnforcementTier::KernelFull,
6856                ContainmentClass::Scoped,
6857            )),
6858            crate::cgroup::IoMaxReport::default(),
6859        );
6860
6861        let dir = tempfile::tempdir().unwrap();
6862        let mut w = TraceWriter::open(
6863            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6864            "test-session-id".to_string(),
6865            "test-capsule".to_string(),
6866            "1.0.0".to_string(),
6867            "claude-test".to_string(),
6868            vec!["network".to_string(), "shell".to_string()],
6869            expected.clone(),
6870            TraceCapture::Meta,
6871            None,
6872            false,
6873            None,
6874            None,
6875            None,
6876            None,
6877        )
6878        .await
6879        .unwrap();
6880        w.write_session_start(Some(1), None, Vec::new())
6881            .await
6882            .unwrap();
6883        w.flush().await.unwrap();
6884
6885        let events = read_events(dir.path());
6886        assert_eq!(
6887            events[0]["effective_grants"],
6888            serde_json::to_value(&expected).unwrap()
6889        );
6890
6891        // The category-name summary that predates this field is unchanged, and the three
6892        // top-level containment fields agree with the report they are now read from.
6893        assert_eq!(
6894            events[0]["capabilities"],
6895            serde_json::json!(["network", "shell"])
6896        );
6897        assert_eq!(
6898            events[0]["effective_grants"]["state_stores"],
6899            serde_json::json!([{
6900                "artifact": "notes-tool",
6901                "store": "shey",
6902                "host_path": "/home/dev/.murmur/state/shey",
6903            }])
6904        );
6905        // Names only: what an artifact is configured *with* is operator plaintext and reaches
6906        // no trace record.
6907        assert_eq!(
6908            events[0]["effective_grants"]["configured_artifacts"],
6909            serde_json::json!(["config-echo"])
6910        );
6911        // The per-artifact preopen set reaches the trace with no trace-side plumbing: the report
6912        // is cloned whole, so `filesystem_scope` above (which narrows no guest) and the surface a
6913        // guest is actually opened into are both on the record.
6914        assert_eq!(
6915            events[0]["effective_grants"]["preopens"],
6916            serde_json::json!([{
6917                "artifact": "notes-tool",
6918                "role": "tool",
6919                "scope": null,
6920                "surface": "whole-workdir",
6921                "runtime_dir_reach": "murmur-runtime",
6922                "extra_roots": [{
6923                    "guest_path": "murmur-host/home/dev/.cargo",
6924                    "path": "/home/dev/.cargo",
6925                    "access": "read",
6926                    "source": "host_paths",
6927                }],
6928            }])
6929        );
6930        assert_eq!(events[0]["containment_declared"], "scoped");
6931        assert_eq!(events[0]["containment_achieved"], "scoped");
6932        assert_eq!(events[0]["workdir_exec"], false);
6933        assert_eq!(
6934            events[0]["effective_grants"]["network_allow"],
6935            serde_json::json!(["https://api.example.com"])
6936        );
6937        assert_eq!(
6938            events[0]["effective_grants"]["interpreter_runtime_grants"],
6939            serde_json::json!(["python3: /usr/lib/python3.11 (list_dir)"])
6940        );
6941    }
6942
6943    #[tokio::test]
6944    async fn inference_fields_and_snake_case() {
6945        let dir = tempfile::tempdir().unwrap();
6946        let mut w = make_writer(dir.path()).await;
6947        w.write_inference(
6948            0,
6949            Some(100),
6950            Some(50),
6951            "tool_call".to_string(),
6952            Some("tool_call"),
6953            Some("bash".to_string()),
6954            None,
6955            None,
6956            vec!["msg_0000000000000000000000000000000a".to_string()],
6957            None,
6958            None,
6959        )
6960        .await
6961        .unwrap();
6962        w.flush().await.unwrap();
6963
6964        let events = read_events(dir.path());
6965        let e = &events[0];
6966        assert_eq!(e["event_type"], "inference");
6967        assert_eq!(
6968            e["message_ids"],
6969            serde_json::json!(["msg_0000000000000000000000000000000a"]),
6970            "an agent-loop turn names the messages its request embedded"
6971        );
6972        assert_eq!(e["turn"], 0);
6973        assert_eq!(e["input_tokens"], 100);
6974        assert_eq!(e["output_tokens"], 50);
6975        assert_eq!(e["decision"], "tool_call");
6976        assert_eq!(e["tool_name"], "bash");
6977        // Confirm snake_case (not camelCase)
6978        assert!(e.get("inputTokens").is_none(), "must use snake_case");
6979        assert!(e.get("toolName").is_none(), "must use snake_case");
6980        // No driver reported usage, so no key claims one was reported.
6981        for key in [
6982            "input_tokens_actual",
6983            "output_tokens_actual",
6984            "cached_tokens",
6985            "cache_write_tokens",
6986        ] {
6987            assert!(e.get(key).is_none(), "{key} must be omitted, not null or 0");
6988        }
6989    }
6990
6991    /// A partially-populated `usage` writes only the members the driver reported: a member it
6992    /// left out stays off the line entirely rather than being recorded as a zero.
6993    #[tokio::test]
6994    async fn inference_writes_only_the_reported_usage_members() {
6995        let dir = tempfile::tempdir().unwrap();
6996        let mut w = make_writer(dir.path()).await;
6997        let usage = DriverUsage {
6998            input_tokens: Some(12043),
6999            output_tokens: None,
7000            cached_tokens: Some(0),
7001            cache_write_tokens: None,
7002            thinking_tokens: None,
7003        };
7004        w.write_inference(
7005            0,
7006            Some(100),
7007            Some(50),
7008            "end_turn".to_string(),
7009            Some("end_turn"),
7010            None,
7011            None,
7012            Some(&usage),
7013            Vec::new(),
7014            None,
7015            None,
7016        )
7017        .await
7018        .unwrap();
7019        w.flush().await.unwrap();
7020
7021        let events = read_events(dir.path());
7022        let e = &events[0];
7023        assert_eq!(e["input_tokens"], 100, "the estimate is untouched");
7024        assert_eq!(e["input_tokens_actual"], 12043);
7025        assert_eq!(e["cached_tokens"], 0, "a reported zero is written");
7026        assert!(e.get("output_tokens_actual").is_none());
7027        assert!(e.get("cache_write_tokens").is_none());
7028    }
7029
7030    #[tokio::test]
7031    async fn failed_inference_writes_its_keys_and_counts_only_what_it_spent() {
7032        let dir = tempfile::tempdir().unwrap();
7033        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7034        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
7035            .await
7036            .unwrap();
7037        let usage = DriverUsage {
7038            input_tokens: Some(7),
7039            ..DriverUsage::default()
7040        };
7041        let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
7042        w.write_failed_inference(FailedInference {
7043            turn: 0,
7044            error_code: INFERENCE_ERROR_CREDENTIAL_REJECTED,
7045            error: &long,
7046            provider_status: Some(401),
7047            usage: Some(&usage),
7048            origin: None,
7049            choice: None,
7050            message_ids: vec!["msg_1".to_string()],
7051            tool_name: None,
7052            spent: None,
7053            settings: None,
7054            retry: RetryNote::default(),
7055            gateway: None,
7056        })
7057        .await
7058        .unwrap();
7059        assert_eq!(w.task_turns(), 0, "an uncounted record moves no counter");
7060        assert_eq!((w.total_turns, w.total_input_tokens), (0, 0));
7061        assert_eq!((w.task_input_tokens, w.task_output_tokens), (0, 0));
7062
7063        w.write_failed_inference(FailedInference {
7064            turn: 1,
7065            error_code: INFERENCE_ERROR_HARNESS_AUTH,
7066            error: "bad login",
7067            provider_status: None,
7068            usage: None,
7069            origin: None,
7070            choice: None,
7071            message_ids: Vec::new(),
7072            tool_name: None,
7073            spent: Some((Some(10), Some(5))),
7074            settings: None,
7075            retry: RetryNote::default(),
7076            gateway: None,
7077        })
7078        .await
7079        .unwrap();
7080        assert_eq!(w.task_turns(), 1);
7081        assert_eq!(w.total_turns, 1);
7082        assert_eq!((w.total_input_tokens, w.total_output_tokens), (10, 5));
7083        assert_eq!((w.task_input_tokens, w.task_output_tokens), (10, 5));
7084        w.flush().await.unwrap();
7085
7086        let events = read_events(dir.path());
7087        let failed: Vec<&Value> = events
7088            .iter()
7089            .filter(|e| e["event_type"] == "inference")
7090            .collect();
7091        let keys = |e: &Value| {
7092            let mut keys: Vec<String> = e.as_object().unwrap().keys().cloned().collect();
7093            keys.sort();
7094            keys
7095        };
7096        assert_eq!(
7097            keys(failed[0]),
7098            [
7099                "decision",
7100                "error",
7101                "error_code",
7102                "event_id",
7103                "event_type",
7104                "input_tokens_actual",
7105                "message_ids",
7106                "parent_id",
7107                "provider_status",
7108                "session_id",
7109                "stop_reason",
7110                "task_id",
7111                "timestamp",
7112                "tool_name",
7113                "turn",
7114            ]
7115        );
7116        assert_eq!(failed[0]["decision"], "error");
7117        assert_eq!(failed[0]["stop_reason"], "error");
7118        assert_eq!(failed[0]["error_code"], "credential_rejected");
7119        assert_eq!(failed[0]["provider_status"], 401);
7120        assert_eq!(failed[0]["input_tokens_actual"], 7);
7121        let error = failed[0]["error"].as_str().unwrap();
7122        assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
7123        assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
7124
7125        assert_eq!(failed[1]["error_code"], "harness_auth");
7126        assert_eq!(failed[1]["error"], "bad login");
7127        assert_eq!(failed[1]["input_tokens"], 10);
7128        assert_eq!(failed[1]["output_tokens"], 5);
7129        assert!(failed[1].get("provider_status").is_none());
7130        assert!(
7131            !dir.path().join("blobs").exists(),
7132            "a failed record stores no blob, even under trace.capture: content"
7133        );
7134        // The agent-loop failed record is the turn node, parented to the task.
7135        let task_start = events
7136            .iter()
7137            .find(|e| e["event_type"] == "task_start")
7138            .unwrap();
7139        assert_eq!(failed[0]["parent_id"], task_start["event_id"]);
7140    }
7141
7142    /// The gateway timing keys: absent with no runtime gateway, `null` for a moment that never
7143    /// came, numbers for one that did.
7144    #[tokio::test]
7145    async fn gateway_timing_keys_follow_the_gateway() {
7146        let dir = tempfile::tempdir().unwrap();
7147        let mut w = make_writer(dir.path()).await;
7148        let failed = |turn, gateway| FailedInference {
7149            turn,
7150            error_code: INFERENCE_ERROR_DRIVER_FAILED,
7151            error: "stopped",
7152            provider_status: None,
7153            usage: None,
7154            origin: None,
7155            choice: None,
7156            message_ids: Vec::new(),
7157            tool_name: None,
7158            spent: None,
7159            settings: None,
7160            retry: RetryNote::default(),
7161            gateway,
7162        };
7163        w.write_failed_inference(failed(0, None)).await.unwrap();
7164        w.write_failed_inference(failed(1, Some(GatewayOffsets::default())))
7165            .await
7166            .unwrap();
7167        let answered = GatewayOffsets {
7168            reached_ms: Some(4),
7169            responded_ms: Some(90),
7170        };
7171        w.write_failed_inference(failed(2, Some(answered)))
7172            .await
7173            .unwrap();
7174        w.flush().await.unwrap();
7175
7176        let events = read_events(dir.path());
7177        assert!(events[0].get("gateway_reached_ms").is_none());
7178        assert!(events[0].get("upstream_responded_ms").is_none());
7179        assert_eq!(events[1]["gateway_reached_ms"], Value::Null);
7180        assert_eq!(events[1]["upstream_responded_ms"], Value::Null);
7181        assert!(events[1]
7182            .as_object()
7183            .unwrap()
7184            .contains_key("gateway_reached_ms"));
7185        assert!(events[1]
7186            .as_object()
7187            .unwrap()
7188            .contains_key("upstream_responded_ms"));
7189        assert_eq!(events[2]["gateway_reached_ms"], 4);
7190        assert_eq!(events[2]["upstream_responded_ms"], 90);
7191    }
7192
7193    /// A firing in the latch is written as one `call_deadline` line ahead of the next line the
7194    /// writer writes, inside the task and under the previous turn, and is not written twice. One
7195    /// from before the session frame follows `session_start`.
7196    #[tokio::test]
7197    async fn call_deadlines_are_drained_ahead_of_the_next_line() {
7198        let dir = tempfile::tempdir().unwrap();
7199        let mut w = make_writer(dir.path()).await;
7200        let deadlines = crate::limits::CallDeadlines::default();
7201        w.set_call_deadlines(deadlines.clone());
7202        let staged = |component: &str, call: &str| crate::limits::CallDeadline {
7203            component: component.to_string(),
7204            role: crate::limits::ComponentRole::Hook,
7205            call: call.to_string(),
7206            deadline_seconds: 2,
7207            elapsed_ms: 4_000,
7208        };
7209        deadlines.push_for_test(staged("stager", "on-stage"));
7210        w.write_session_start(None, None, Vec::new()).await.unwrap();
7211        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
7212            .await
7213            .unwrap();
7214        deadlines.push_for_test(crate::limits::CallDeadline {
7215            component: "stall-driver".to_string(),
7216            role: crate::limits::ComponentRole::Driver,
7217            call: "run".to_string(),
7218            deadline_seconds: 3,
7219            elapsed_ms: 5_001,
7220        });
7221        w.write_failed_inference(FailedInference {
7222            turn: 0,
7223            error_code: INFERENCE_ERROR_DRIVER_FAILED,
7224            error: "stopped",
7225            provider_status: None,
7226            usage: None,
7227            origin: None,
7228            choice: None,
7229            message_ids: Vec::new(),
7230            tool_name: None,
7231            spent: None,
7232            settings: None,
7233            retry: RetryNote::default(),
7234            gateway: Some(GatewayOffsets::default()),
7235        })
7236        .await
7237        .unwrap();
7238        w.write_session_end("ok").await.unwrap();
7239        w.flush().await.unwrap();
7240
7241        let events = read_events(dir.path());
7242        let types: Vec<&str> = events
7243            .iter()
7244            .map(|e| e["event_type"].as_str().unwrap())
7245            .collect();
7246        assert_eq!(
7247            types,
7248            [
7249                "session_start",
7250                "call_deadline",
7251                "task_start",
7252                "call_deadline",
7253                "inference",
7254                "session_end"
7255            ]
7256        );
7257        assert_eq!(events[1]["call"], "on-stage");
7258        assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
7259        assert!(events[1].get("task_id").is_none(), "outside a task");
7260        let cut = &events[3];
7261        assert_eq!(cut["component"], "stall-driver");
7262        assert_eq!(cut["role"], "driver");
7263        assert_eq!(cut["call"], "run");
7264        assert_eq!(cut["deadline_seconds"], 3);
7265        assert_eq!(cut["elapsed_ms"], 5_001);
7266        assert_eq!(cut["task_id"], "tsk_1");
7267        assert_eq!(cut["parent_id"], events[2]["event_id"]);
7268        assert!(cut["event_id"].as_str().is_some());
7269        assert!(cut["timestamp"].as_u64().is_some());
7270    }
7271
7272    #[tokio::test]
7273    async fn a_failed_hook_record_hangs_off_the_turn_and_keeps_hook_accounting() {
7274        let dir = tempfile::tempdir().unwrap();
7275        let mut w = make_writer(dir.path()).await;
7276        w.write_inference(
7277            0,
7278            Some(10),
7279            Some(5),
7280            "end_turn".to_string(),
7281            Some("end_turn"),
7282            None,
7283            None,
7284            None,
7285            Vec::new(),
7286            None,
7287            None,
7288        )
7289        .await
7290        .unwrap();
7291        let origin = InferenceOrigin {
7292            source: "hook:compactor".to_string(),
7293            model: "m".to_string(),
7294        };
7295        w.write_failed_inference(FailedInference {
7296            turn: 0,
7297            error_code: INFERENCE_ERROR_DRIVER_ERROR,
7298            error: "HTTP 500: boom",
7299            provider_status: Some(500),
7300            usage: None,
7301            origin: Some(&origin),
7302            choice: None,
7303            message_ids: Vec::new(),
7304            tool_name: None,
7305            spent: Some((Some(40), None)),
7306            settings: None,
7307            retry: RetryNote::default(),
7308            gateway: None,
7309        })
7310        .await
7311        .unwrap();
7312        assert_eq!(w.total_turns, 2);
7313        assert_eq!((w.total_input_tokens, w.total_output_tokens), (50, 5));
7314        w.flush().await.unwrap();
7315
7316        let events = read_events(dir.path());
7317        let turn = events.iter().find(|e| e["decision"] == "end_turn").unwrap();
7318        let hook = events.iter().find(|e| e["decision"] == "error").unwrap();
7319        assert_eq!(hook["parent_id"], turn["event_id"]);
7320        assert_eq!(hook["origin"], "hook:compactor");
7321        assert_eq!(hook["model"], "m");
7322        assert_eq!(hook["input_tokens"], 40);
7323        assert!(hook.get("output_tokens").is_none());
7324    }
7325
7326    /// Every `http` line names how its call was sent: the cap, the input budget and the driver
7327    /// config, every key present, `input_budget` written as `null` with no window and
7328    /// `driver_config` as `null` when the driver saw none.
7329    #[tokio::test]
7330    async fn inference_call_settings_are_written_on_every_kind_of_http_line() {
7331        let dir = tempfile::tempdir().unwrap();
7332        let mut w = make_writer(dir.path()).await;
7333        let turn = InferenceCallSettings {
7334            max_output_tokens: 4096,
7335            input_budget: Some(195_904),
7336            driver_config: Some(serde_json::json!({"reasoning_effort": "high"})),
7337        };
7338        let unconfigured = InferenceCallSettings {
7339            max_output_tokens: 1024,
7340            input_budget: None,
7341            driver_config: None,
7342        };
7343        let hook = InferenceCallSettings {
7344            max_output_tokens: 2000,
7345            input_budget: Some(198_000),
7346            driver_config: Some(serde_json::json!({"reasoning_effort": "low"})),
7347        };
7348        let origin = InferenceOrigin {
7349            source: "hook:compactor".to_string(),
7350            model: "m".to_string(),
7351        };
7352        w.write_turn_inference(
7353            0,
7354            Some(10),
7355            Some(5),
7356            "end_turn".to_string(),
7357            Some("end_turn"),
7358            None,
7359            None,
7360            Vec::new(),
7361            None,
7362            None,
7363            Some(&turn),
7364            RetryNote::default(),
7365            None,
7366        )
7367        .await
7368        .unwrap();
7369        w.write_turn_inference(
7370            1,
7371            Some(10),
7372            Some(5),
7373            "end_turn".to_string(),
7374            Some("end_turn"),
7375            None,
7376            None,
7377            Vec::new(),
7378            None,
7379            None,
7380            Some(&unconfigured),
7381            RetryNote::default(),
7382            None,
7383        )
7384        .await
7385        .unwrap();
7386        w.write_inference(
7387            1,
7388            Some(30),
7389            Some(8),
7390            "end_turn".to_string(),
7391            None,
7392            None,
7393            Some(&origin),
7394            None,
7395            Vec::new(),
7396            None,
7397            Some(&hook),
7398        )
7399        .await
7400        .unwrap();
7401        w.write_failed_inference(FailedInference {
7402            turn: 2,
7403            error_code: INFERENCE_ERROR_DRIVER_ERROR,
7404            error: "HTTP 500: boom",
7405            provider_status: Some(500),
7406            usage: None,
7407            origin: None,
7408            choice: None,
7409            message_ids: Vec::new(),
7410            tool_name: None,
7411            spent: None,
7412            settings: Some(&turn),
7413            retry: RetryNote::default(),
7414            gateway: None,
7415        })
7416        .await
7417        .unwrap();
7418        w.flush().await.unwrap();
7419
7420        let events = read_events(dir.path());
7421        let settings: Vec<(Value, Value, Value, Value)> = events
7422            .iter()
7423            .filter(|e| e["event_type"] == "inference")
7424            .map(|e| {
7425                (
7426                    e["decision"].clone(),
7427                    e["max_output_tokens"].clone(),
7428                    e.get("input_budget")
7429                        .cloned()
7430                        .expect("input_budget is written, null or not"),
7431                    e.get("driver_config")
7432                        .cloned()
7433                        .expect("driver_config is written, null or not"),
7434                )
7435            })
7436            .collect();
7437        assert_eq!(
7438            settings,
7439            [
7440                (
7441                    serde_json::json!("end_turn"),
7442                    serde_json::json!(4096),
7443                    serde_json::json!(195_904),
7444                    serde_json::json!({"reasoning_effort": "high"})
7445                ),
7446                (
7447                    serde_json::json!("end_turn"),
7448                    serde_json::json!(1024),
7449                    Value::Null,
7450                    Value::Null
7451                ),
7452                (
7453                    serde_json::json!("end_turn"),
7454                    serde_json::json!(2000),
7455                    serde_json::json!(198_000),
7456                    serde_json::json!({"reasoning_effort": "low"})
7457                ),
7458                (
7459                    serde_json::json!("error"),
7460                    serde_json::json!(4096),
7461                    serde_json::json!(195_904),
7462                    serde_json::json!({"reasoning_effort": "high"})
7463                ),
7464            ]
7465        );
7466    }
7467
7468    /// A line written without settings — every `process` line — carries none of the keys.
7469    #[tokio::test]
7470    async fn an_inference_line_without_call_settings_writes_neither_key() {
7471        let dir = tempfile::tempdir().unwrap();
7472        let mut w = make_writer(dir.path()).await;
7473        w.write_inference(
7474            0,
7475            Some(10),
7476            Some(5),
7477            "end_turn".to_string(),
7478            None,
7479            None,
7480            None,
7481            None,
7482            Vec::new(),
7483            None,
7484            None,
7485        )
7486        .await
7487        .unwrap();
7488        w.write_failed_inference(FailedInference {
7489            turn: 1,
7490            error_code: INFERENCE_ERROR_HARNESS_AUTH,
7491            error: "bad login",
7492            provider_status: None,
7493            usage: None,
7494            origin: None,
7495            choice: None,
7496            message_ids: Vec::new(),
7497            tool_name: None,
7498            spent: None,
7499            settings: None,
7500            retry: RetryNote::default(),
7501            gateway: None,
7502        })
7503        .await
7504        .unwrap();
7505        w.flush().await.unwrap();
7506
7507        let events = read_events(dir.path());
7508        let lines: Vec<&Value> = events
7509            .iter()
7510            .filter(|e| e["event_type"] == "inference")
7511            .collect();
7512        assert_eq!(lines.len(), 2);
7513        for line in lines {
7514            assert!(line.get("max_output_tokens").is_none(), "{line}");
7515            assert!(line.get("input_budget").is_none(), "{line}");
7516            assert!(line.get("driver_config").is_none(), "{line}");
7517        }
7518    }
7519
7520    fn driver_config_env(value: &str) -> Vec<(String, String)> {
7521        vec![
7522            ("MURMUR_INFERENCE_MODEL".to_string(), "gpt-5".to_string()),
7523            (
7524                "MURMUR_INFERENCE_DRIVER_CONFIG".to_string(),
7525                value.to_string(),
7526            ),
7527        ]
7528    }
7529
7530    fn redact_secret(text: &str) -> String {
7531        text.replace("sk-secret", crate::gateway_credential::REDACTED)
7532    }
7533
7534    #[test]
7535    fn recorded_driver_config_is_none_without_the_variable() {
7536        let env = vec![("MURMUR_INFERENCE_MODEL".to_string(), "gpt-5".to_string())];
7537        assert_eq!(recorded_driver_config(&env, redact_secret), None);
7538    }
7539
7540    #[test]
7541    fn recorded_driver_config_is_the_parsed_object() {
7542        assert_eq!(
7543            recorded_driver_config(
7544                &driver_config_env(r#"{"reasoning_effort":"low"}"#),
7545                redact_secret
7546            ),
7547            Some(serde_json::json!({"reasoning_effort": "low"}))
7548        );
7549    }
7550
7551    #[test]
7552    fn recorded_driver_config_redacts_keys_and_string_leaves_at_any_depth() {
7553        let env = driver_config_env(
7554            r#"{"note":"x-sk-secret-y","nested":{"list":["sk-secret"]},"sk-secret":1}"#,
7555        );
7556        assert_eq!(
7557            recorded_driver_config(&env, redact_secret),
7558            Some(serde_json::json!({
7559                "note": "x-[redacted]-y",
7560                "nested": {"list": ["[redacted]"]},
7561                "[redacted]": 1,
7562            }))
7563        );
7564    }
7565
7566    #[test]
7567    fn recorded_driver_config_replaces_a_number_the_redactor_changes() {
7568        let redact_pin = |text: &str| text.replace("424242", crate::gateway_credential::REDACTED);
7569        let env = driver_config_env(r#"{"pin":424242,"retries":3,"store":false,"seed":null}"#);
7570        assert_eq!(
7571            recorded_driver_config(&env, redact_pin),
7572            Some(serde_json::json!({
7573                "pin": "[redacted]",
7574                "retries": 3,
7575                "store": false,
7576                "seed": null,
7577            }))
7578        );
7579    }
7580
7581    #[test]
7582    fn recorded_driver_config_records_text_that_is_not_json_as_a_redacted_string() {
7583        assert_eq!(
7584            recorded_driver_config(&driver_config_env("effort=sk-secret"), redact_secret),
7585            Some(Value::String("effort=[redacted]".to_string()))
7586        );
7587    }
7588
7589    #[tokio::test]
7590    async fn tool_call_fields() {
7591        let dir = tempfile::tempdir().unwrap();
7592        let mut w = make_writer(dir.path()).await;
7593        w.write_tool_call(
7594            1,
7595            "bash".to_string(),
7596            None,
7597            serde_json::json!({"command": "echo hi"}),
7598            42,
7599            "hi\n",
7600            100,
7601            55,
7602            "ok".to_string(),
7603            None,
7604            None,
7605            None,
7606        )
7607        .await
7608        .unwrap();
7609        w.flush().await.unwrap();
7610
7611        let events = read_events(dir.path());
7612        let e = &events[0];
7613        assert_eq!(e["event_type"], "tool_call");
7614        assert_eq!(e["turn"], 1);
7615        assert_eq!(e["tool_name"], "bash");
7616        assert_eq!(e["input_bytes"], 42);
7617        assert_eq!(e["output_bytes"], 100);
7618        assert_eq!(e["duration_ms"], 55);
7619        assert_eq!(e["status"], "ok");
7620        assert!(
7621            e.get("state_effect").is_none(),
7622            "state_effect must be omitted when the tool declared nothing"
7623        );
7624    }
7625
7626    #[tokio::test]
7627    async fn tool_call_records_declared_state_effect() {
7628        let dir = tempfile::tempdir().unwrap();
7629        let mut w = make_writer(dir.path()).await;
7630        w.write_tool_call(
7631            0,
7632            "murmur-tool-editor".to_string(),
7633            None,
7634            serde_json::json!({"operation": "read_file", "path": "a.txt"}),
7635            10,
7636            "hi\n",
7637            3,
7638            5,
7639            "ok".to_string(),
7640            Some("read".to_string()),
7641            None,
7642            None,
7643        )
7644        .await
7645        .unwrap();
7646        w.flush().await.unwrap();
7647
7648        let events = read_events(dir.path());
7649        assert_eq!(
7650            events[0]["state_effect"], "read",
7651            "declared state_effect must be recorded verbatim on the tool_call event"
7652        );
7653    }
7654
7655    #[tokio::test]
7656    async fn tool_call_records_declared_resource_id() {
7657        let dir = tempfile::tempdir().unwrap();
7658        let mut w = make_writer(dir.path()).await;
7659        w.write_tool_call(
7660            0,
7661            "murmur-tool-code-graph".to_string(),
7662            None,
7663            serde_json::json!({"symbol": "Foo::bar"}),
7664            10,
7665            "hi\n",
7666            3,
7667            5,
7668            "ok".to_string(),
7669            Some("read".to_string()),
7670            Some("sym:Foo".to_string()),
7671            None,
7672        )
7673        .await
7674        .unwrap();
7675        w.flush().await.unwrap();
7676
7677        let events = read_events(dir.path());
7678        assert_eq!(
7679            events[0]["resource_id"], "sym:Foo",
7680            "declared resource_id must reach trace.jsonl verbatim, unparsed"
7681        );
7682    }
7683
7684    #[tokio::test]
7685    async fn tool_call_omits_undeclared_resource_id() {
7686        let dir = tempfile::tempdir().unwrap();
7687        let mut w = make_writer(dir.path()).await;
7688        w.write_tool_call(
7689            0,
7690            "bash".to_string(),
7691            None,
7692            serde_json::json!({"command": "echo hi"}),
7693            10,
7694            "hi\n",
7695            3,
7696            5,
7697            "ok".to_string(),
7698            None,
7699            None,
7700            None,
7701        )
7702        .await
7703        .unwrap();
7704        w.flush().await.unwrap();
7705
7706        let events = read_events(dir.path());
7707        assert!(
7708            events[0].get("resource_id").is_none(),
7709            "resource_id must be omitted entirely (not null) when the tool declared nothing"
7710        );
7711    }
7712
7713    #[tokio::test]
7714    async fn shell_fields() {
7715        let dir = tempfile::tempdir().unwrap();
7716        let mut w = make_writer(dir.path()).await;
7717        w.write_shell(
7718            0,
7719            "/usr/bin/echo".to_string(),
7720            "echo hi".to_string(),
7721            0,
7722            7,
7723            0,
7724            10,
7725            None,
7726        )
7727        .await
7728        .unwrap();
7729        w.flush().await.unwrap();
7730
7731        let events = read_events(dir.path());
7732        let e = &events[0];
7733        assert_eq!(e["event_type"], "shell");
7734        // `binary` names what ran; `command` still carries only the argument list, so
7735        // neither can be derived from the other.
7736        assert_eq!(e["binary"], "/usr/bin/echo");
7737        assert_eq!(e["command"], "echo hi");
7738        assert_eq!(e["exit_code"], 0);
7739        assert_eq!(e["stdout_bytes"], 7);
7740        assert_eq!(e["stderr_bytes"], 0);
7741        assert_eq!(e["duration_ms"], 10);
7742    }
7743
7744    #[tokio::test]
7745    async fn compaction_fields() {
7746        let dir = tempfile::tempdir().unwrap();
7747        let mut w = make_writer(dir.path()).await;
7748        w.write_compaction(3, 80000, OccupancyBasis::Actual, 20000)
7749            .await
7750            .unwrap();
7751        w.write_compaction(4, 30000, OccupancyBasis::Estimate, 10000)
7752            .await
7753            .unwrap();
7754        w.flush().await.unwrap();
7755
7756        let events = read_events(dir.path());
7757        let e = &events[0];
7758        assert_eq!(e["event_type"], "compaction");
7759        assert_eq!(e["turn"], 3);
7760        assert_eq!(e["tokens_before"], 80000);
7761        assert_eq!(e["tokens_before_source"], "actual");
7762        assert_eq!(e["tokens_after"], 20000);
7763        assert_eq!(events[1]["tokens_before_source"], "estimate");
7764    }
7765
7766    #[tokio::test]
7767    async fn hook_dispatch_error_fields() {
7768        let dir = tempfile::tempdir().unwrap();
7769        let mut w = make_writer(dir.path()).await;
7770        w.write_hook_dispatch_error("my-hook", "on-tool-call", "write-manifests")
7771            .await
7772            .unwrap();
7773        w.flush().await.unwrap();
7774
7775        let events = read_events(dir.path());
7776        let e = &events[0];
7777        assert_eq!(e["event_type"], "hook_dispatch_error");
7778        assert_eq!(e["session_id"], "test-session-id");
7779        assert_eq!(e["hook_name"], "my-hook");
7780        assert_eq!(e["event"], "on-tool-call");
7781        assert_eq!(e["arm"], "write-manifests");
7782        assert!(e["timestamp"].as_u64().unwrap() > 0);
7783        // snake_case checks
7784        assert!(e.get("hookName").is_none(), "must use snake_case");
7785    }
7786
7787    #[tokio::test]
7788    async fn session_end_fields_and_snake_case() {
7789        let dir = tempfile::tempdir().unwrap();
7790        let mut w = make_writer(dir.path()).await;
7791        w.write_inference(
7792            0,
7793            Some(100),
7794            Some(50),
7795            "tool_call".to_string(),
7796            Some("tool_call"),
7797            None,
7798            None,
7799            None,
7800            Vec::new(),
7801            None,
7802            None,
7803        )
7804        .await
7805        .unwrap();
7806        w.write_tool_call(
7807            0,
7808            "bash".to_string(),
7809            None,
7810            serde_json::json!({"command": "ls"}),
7811            10,
7812            "file.txt\n",
7813            20,
7814            5,
7815            "ok".to_string(),
7816            None,
7817            None,
7818            None,
7819        )
7820        .await
7821        .unwrap();
7822        w.write_shell(0, "/bin/ls".to_string(), "ls".to_string(), 0, 3, 0, 2, None)
7823            .await
7824            .unwrap();
7825        w.write_session_end("ok").await.unwrap();
7826        w.flush().await.unwrap();
7827
7828        let events = read_events(dir.path());
7829        let se = events.last().unwrap();
7830        assert_eq!(se["event_type"], "session_end");
7831        assert_eq!(se["total_turns"], 1);
7832        assert_eq!(se["total_input_tokens"], 100);
7833        assert_eq!(se["total_output_tokens"], 50);
7834        assert_eq!(se["total_tool_calls"], 1);
7835        assert_eq!(se["total_shell_calls"], 1);
7836        assert_eq!(se["exit_status"], "ok");
7837        assert!(se["duration_ms"].as_u64().is_some());
7838        // snake_case checks
7839        assert!(se.get("exitStatus").is_none(), "must use snake_case");
7840        assert!(se.get("totalTurns").is_none(), "must use snake_case");
7841    }
7842
7843    #[tokio::test]
7844    async fn write_session_end_if_not_ended_is_noop_when_already_ended() {
7845        let dir = tempfile::tempdir().unwrap();
7846        let mut w = make_writer(dir.path()).await;
7847        w.write_session_start(Some(10), None, vec![]).await.unwrap();
7848        w.write_session_end("ok").await.unwrap();
7849        w.write_session_end_if_not_ended("failed").await.unwrap();
7850        w.flush().await.unwrap();
7851
7852        let events = read_events(dir.path());
7853        assert_eq!(
7854            events
7855                .iter()
7856                .filter(|e| e["event_type"] == "session_end")
7857                .count(),
7858            1,
7859            "session_end should appear exactly once"
7860        );
7861    }
7862
7863    #[tokio::test]
7864    async fn write_session_end_if_not_ended_writes_when_started_not_ended() {
7865        let dir = tempfile::tempdir().unwrap();
7866        let mut w = make_writer(dir.path()).await;
7867        w.write_session_start(Some(10), None, vec![]).await.unwrap();
7868        // Simulate error path — session_end_if_not_ended in launch_session
7869        w.write_session_end_if_not_ended("failed").await.unwrap();
7870        w.flush().await.unwrap();
7871
7872        let events = read_events(dir.path());
7873        let se = events
7874            .iter()
7875            .find(|e| e["event_type"] == "session_end")
7876            .unwrap();
7877        assert_eq!(se["exit_status"], "failed");
7878    }
7879
7880    #[tokio::test]
7881    async fn no_session_end_written_when_session_never_started() {
7882        let dir = tempfile::tempdir().unwrap();
7883        let mut w = make_writer(dir.path()).await;
7884        // session_start never written — fallback should be no-op
7885        w.write_session_end_if_not_ended("failed").await.unwrap();
7886        w.flush().await.unwrap();
7887
7888        let events = read_events(dir.path());
7889        assert!(
7890            events.iter().all(|e| e["event_type"] != "session_end"),
7891            "session_end must not appear when session_start was never written"
7892        );
7893    }
7894
7895    #[tokio::test]
7896    async fn session_id_on_every_event() {
7897        let dir = tempfile::tempdir().unwrap();
7898        let mut w = make_writer(dir.path()).await;
7899        w.write_session_start(Some(10), None, vec!["bash".to_string()])
7900            .await
7901            .unwrap();
7902        w.write_inference(
7903            0,
7904            Some(10),
7905            Some(5),
7906            "end_turn".to_string(),
7907            Some("end_turn"),
7908            None,
7909            None,
7910            None,
7911            Vec::new(),
7912            None,
7913            None,
7914        )
7915        .await
7916        .unwrap();
7917        w.write_session_end("ok").await.unwrap();
7918        w.flush().await.unwrap();
7919
7920        let events = read_events(dir.path());
7921        for e in &events {
7922            assert_eq!(e["session_id"], "test-session-id");
7923        }
7924    }
7925
7926    #[tokio::test]
7927    async fn counts_match_per_event_sum() {
7928        let dir = tempfile::tempdir().unwrap();
7929        let mut w = make_writer(dir.path()).await;
7930        w.write_session_start(Some(10), None, vec![]).await.unwrap();
7931        w.write_inference(
7932            0,
7933            Some(50),
7934            Some(25),
7935            "tool_call".to_string(),
7936            Some("tool_call"),
7937            Some("bash".to_string()),
7938            None,
7939            None,
7940            vec!["msg_0000000000000000000000000000000a".to_string()],
7941            None,
7942            None,
7943        )
7944        .await
7945        .unwrap();
7946        w.write_tool_call(
7947            0,
7948            "bash".to_string(),
7949            None,
7950            serde_json::json!({"command": "echo"}),
7951            5,
7952            "ok",
7953            10,
7954            3,
7955            "ok".to_string(),
7956            None,
7957            None,
7958            None,
7959        )
7960        .await
7961        .unwrap();
7962        w.write_shell(
7963            0,
7964            "/bin/echo".to_string(),
7965            "echo".to_string(),
7966            0,
7967            4,
7968            0,
7969            1,
7970            None,
7971        )
7972        .await
7973        .unwrap();
7974        w.write_inference(
7975            1,
7976            Some(60),
7977            Some(30),
7978            "end_turn".to_string(),
7979            Some("end_turn"),
7980            None,
7981            None,
7982            None,
7983            Vec::new(),
7984            None,
7985            None,
7986        )
7987        .await
7988        .unwrap();
7989        w.write_session_end("ok").await.unwrap();
7990        w.flush().await.unwrap();
7991
7992        let events = read_events(dir.path());
7993        let inference_count = events
7994            .iter()
7995            .filter(|e| e["event_type"] == "inference")
7996            .count();
7997        let tool_count = events
7998            .iter()
7999            .filter(|e| e["event_type"] == "tool_call")
8000            .count();
8001        let shell_count = events.iter().filter(|e| e["event_type"] == "shell").count();
8002
8003        let se = events
8004            .iter()
8005            .find(|e| e["event_type"] == "session_end")
8006            .unwrap();
8007        assert_eq!(
8008            se["total_turns"].as_u64().unwrap() as usize,
8009            inference_count
8010        );
8011        assert_eq!(
8012            se["total_tool_calls"].as_u64().unwrap() as usize,
8013            tool_count
8014        );
8015        assert_eq!(
8016            se["total_shell_calls"].as_u64().unwrap() as usize,
8017            shell_count
8018        );
8019    }
8020
8021    #[tokio::test]
8022    async fn tool_call_input_always_present() {
8023        let dir = tempfile::tempdir().unwrap();
8024        let mut w = make_writer(dir.path()).await; // TraceCapture::Meta
8025        w.write_tool_call(
8026            0,
8027            "bash".to_string(),
8028            None,
8029            serde_json::json!({"command": "ls -la"}),
8030            20,
8031            "total 8\n",
8032            80,
8033            12,
8034            "ok".to_string(),
8035            None,
8036            None,
8037            None,
8038        )
8039        .await
8040        .unwrap();
8041        w.flush().await.unwrap();
8042
8043        let events = read_events(dir.path());
8044        let e = &events[0];
8045        assert_eq!(e["event_type"], "tool_call");
8046        assert_eq!(e["input"]["command"], "ls -la");
8047        assert!(e.get("input").is_some(), "input must always be present");
8048    }
8049
8050    /// The `tool_call` record `w` writes for one failed call carrying `error`, read back.
8051    async fn tool_call_with_error(
8052        dir: &std::path::Path,
8053        capture: TraceCapture,
8054        error: Option<&str>,
8055    ) -> Value {
8056        let mut w = make_writer_with_opts(dir, capture).await;
8057        w.write_tool_call(
8058            0,
8059            "tool-fixture".to_string(),
8060            Some("toolu_1".to_string()),
8061            serde_json::json!({}),
8062            2,
8063            "shell execution failed early",
8064            28,
8065            3,
8066            "error".to_string(),
8067            None,
8068            None,
8069            error,
8070        )
8071        .await
8072        .unwrap();
8073        w.flush().await.unwrap();
8074        read_events(dir).remove(0)
8075    }
8076
8077    #[tokio::test]
8078    async fn tool_call_error_is_written_without_output_under_meta() {
8079        let dir = tempfile::tempdir().unwrap();
8080        let text = "sandbox: shell enforcement setup failed before exec: Landlock: \
8081                    Too many open files (os error 24)";
8082        let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(text)).await;
8083        assert_eq!(e["error"], text);
8084        assert!(e.get("output").is_none(), "meta stores no output: {e}");
8085    }
8086
8087    #[tokio::test]
8088    async fn tool_call_error_is_written_under_every_capture() {
8089        for capture in [
8090            TraceCapture::None,
8091            TraceCapture::Meta,
8092            TraceCapture::Content,
8093        ] {
8094            let dir = tempfile::tempdir().unwrap();
8095            let e = tool_call_with_error(dir.path(), capture, Some("spawn failed")).await;
8096            assert_eq!(e["error"], "spawn failed", "{capture:?}: {e}");
8097        }
8098    }
8099
8100    #[tokio::test]
8101    async fn tool_call_error_is_capped_on_a_char_boundary() {
8102        let dir = tempfile::tempdir().unwrap();
8103        // 3,000 characters: one ASCII byte, then two-byte characters, so the cap's byte offset
8104        // falls inside a character.
8105        let long = format!("a{}", "é".repeat(2_999));
8106        assert_eq!(long.chars().count(), 3_000);
8107        let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(&long)).await;
8108        let error = e["error"].as_str().unwrap();
8109        assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
8110        assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
8111        assert!(long.starts_with(error));
8112    }
8113
8114    #[tokio::test]
8115    async fn tool_call_error_redacts_a_peer_handle() {
8116        let dir = tempfile::tempdir().unwrap();
8117        let token = crate::peer_handoff::mint(
8118            &crate::peer_handoff::PeerMintKey::generate().unwrap(),
8119            &crate::peer_handoff::HandlePayload {
8120                v: 1,
8121                iss: "ses_1".to_string(),
8122                p: "report.md".to_string(),
8123                exp: timestamp_ms() + 3_600_000,
8124                n: "0123456789abcdef0123456789abcdef".to_string(),
8125            },
8126            "a@b",
8127        )
8128        .unwrap();
8129        let text = format!("fetch-peer-file {token} failed");
8130        let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(&text)).await;
8131        let id = crate::peer_handoff::handle_id(&token);
8132        assert_eq!(e["error"], format!("fetch-peer-file <handle:{id}> failed"));
8133    }
8134
8135    #[tokio::test]
8136    async fn tool_call_without_an_error_has_no_error_key() {
8137        let dir = tempfile::tempdir().unwrap();
8138        let e = tool_call_with_error(dir.path(), TraceCapture::Meta, None).await;
8139        assert!(e.get("error").is_none(), "{e}");
8140    }
8141
8142    #[tokio::test]
8143    async fn tool_call_output_absent_by_default() {
8144        let dir = tempfile::tempdir().unwrap();
8145        let mut w = make_writer(dir.path()).await; // TraceCapture::Meta
8146        w.write_tool_call(
8147            0,
8148            "bash".to_string(),
8149            None,
8150            serde_json::json!({"command": "ls"}),
8151            10,
8152            "file.txt\n",
8153            90,
8154            8,
8155            "ok".to_string(),
8156            None,
8157            None,
8158            None,
8159        )
8160        .await
8161        .unwrap();
8162        w.flush().await.unwrap();
8163
8164        let events = read_events(dir.path());
8165        let e = &events[0];
8166        assert!(
8167            e.get("output").is_none(),
8168            "output must be absent under a capture mode that stores no bodies"
8169        );
8170        assert_eq!(e["output_bytes"], 90, "output_bytes must still be recorded");
8171    }
8172
8173    #[tokio::test]
8174    async fn skill_call_writes_skill_call_event() {
8175        let dir = tempfile::tempdir().unwrap();
8176        let mut w = make_writer(dir.path()).await;
8177        w.write_skill_call(
8178            2,
8179            "my-skill".to_string(),
8180            &LockOrigin::Operator,
8181            1024,
8182            8,
8183            "ok".to_string(),
8184        )
8185        .await
8186        .unwrap();
8187        w.flush().await.unwrap();
8188
8189        let events = read_events(dir.path());
8190        let e = &events[0];
8191        assert_eq!(e["event_type"], "skill_call");
8192        assert_eq!(e["turn"], 2);
8193        assert_eq!(e["skill_name"], "my-skill");
8194        assert_eq!(e["output_bytes"], 1024);
8195        assert_eq!(e["duration_ms"], 8);
8196        assert_eq!(e["status"], "ok");
8197    }
8198
8199    #[tokio::test]
8200    async fn skill_call_does_not_increment_total_tool_calls() {
8201        let dir = tempfile::tempdir().unwrap();
8202        let mut w = make_writer(dir.path()).await;
8203        w.write_session_start(Some(10), None, vec![]).await.unwrap();
8204        w.write_tool_call(
8205            0,
8206            "real-tool".to_string(),
8207            None,
8208            serde_json::json!({}),
8209            2,
8210            "data",
8211            4,
8212            5,
8213            "ok".to_string(),
8214            None,
8215            None,
8216            None,
8217        )
8218        .await
8219        .unwrap();
8220        w.write_skill_call(
8221            1,
8222            "my-skill".to_string(),
8223            &LockOrigin::Operator,
8224            512,
8225            3,
8226            "ok".to_string(),
8227        )
8228        .await
8229        .unwrap();
8230        w.write_session_end("ok").await.unwrap();
8231        w.flush().await.unwrap();
8232
8233        let events = read_events(dir.path());
8234        let se = events
8235            .iter()
8236            .find(|e| e["event_type"] == "session_end")
8237            .unwrap();
8238        assert_eq!(
8239            se["total_tool_calls"], 1,
8240            "skill_call must not inflate total_tool_calls"
8241        );
8242    }
8243
8244    #[tokio::test]
8245    async fn tool_call_output_present_when_opted_in() {
8246        let dir = tempfile::tempdir().unwrap();
8247        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
8248        w.write_tool_call(
8249            0,
8250            "bash".to_string(),
8251            None,
8252            serde_json::json!({"command": "echo hello"}),
8253            22,
8254            "hello\n",
8255            60,
8256            5,
8257            "ok".to_string(),
8258            None,
8259            None,
8260            None,
8261        )
8262        .await
8263        .unwrap();
8264        w.flush().await.unwrap();
8265
8266        let events = read_events(dir.path());
8267        let e = &events[0];
8268        assert_eq!(
8269            e["output"].as_str().unwrap(),
8270            "hello\n",
8271            "output must be present under capture: content"
8272        );
8273        assert_eq!(e["output_bytes"], 60);
8274    }
8275
8276    #[tokio::test]
8277    async fn task_end_carries_reopen_count() {
8278        let dir = tempfile::tempdir().unwrap();
8279        let mut w = make_writer(dir.path()).await;
8280        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8281            .await
8282            .unwrap();
8283        w.write_task_end("tsk_1", "ok", 2, Vec::new(), None, None)
8284            .await
8285            .unwrap();
8286        w.flush().await.unwrap();
8287
8288        let events = read_events(dir.path());
8289        let end = events
8290            .iter()
8291            .find(|e| e["event_type"] == "task_end")
8292            .unwrap();
8293        assert_eq!(end["task_id"], "tsk_1");
8294        assert_eq!(end["exit_status"], "ok");
8295        assert_eq!(end["reopen_count"], 2);
8296        assert_eq!(
8297            end["capped_turns"], 0,
8298            "always present, 0 when no turn was capped"
8299        );
8300    }
8301
8302    /// `task_end.capped_turns` counts the task's agent-loop `inference` lines with
8303    /// `stop_reason: "max_tokens"`, and a new task starts it at 0.
8304    #[tokio::test]
8305    async fn task_end_counts_capped_turns_per_task() {
8306        let dir = tempfile::tempdir().unwrap();
8307        let mut w = make_writer(dir.path()).await;
8308        for task in ["tsk_1", "tsk_2"] {
8309            w.write_task_start(task, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8310                .await
8311                .unwrap();
8312            let stops: &[&str] = if task == "tsk_1" {
8313                &["max_tokens", "tool_call", "max_tokens", "end_turn"]
8314            } else {
8315                &["end_turn"]
8316            };
8317            for (turn, stop) in stops.iter().enumerate() {
8318                let decision = if *stop == "max_tokens" {
8319                    "capped"
8320                } else {
8321                    stop
8322                };
8323                w.write_turn_inference(
8324                    u32::try_from(turn).unwrap(),
8325                    Some(10),
8326                    Some(5),
8327                    decision.to_string(),
8328                    Some(stop),
8329                    None,
8330                    None,
8331                    Vec::new(),
8332                    None,
8333                    None,
8334                    None,
8335                    RetryNote::default(),
8336                    None,
8337                )
8338                .await
8339                .unwrap();
8340            }
8341            w.write_task_end(task, "ok", 0, Vec::new(), None, None)
8342                .await
8343                .unwrap();
8344        }
8345        w.flush().await.unwrap();
8346
8347        let capped: Vec<_> = read_events(dir.path())
8348            .iter()
8349            .filter(|e| e["event_type"] == "task_end")
8350            .map(|e| e["capped_turns"].clone())
8351            .collect();
8352        assert_eq!(capped, vec![serde_json::json!(2), serde_json::json!(0)]);
8353    }
8354
8355    /// `task_end.keep_working` is the summary the runtime passes, `stopped_by` `null` when unset,
8356    /// and absent when the capsule does not set `lifecycle.keep_working`.
8357    #[tokio::test]
8358    async fn task_end_carries_keep_working_only_when_set() {
8359        let dir = tempfile::tempdir().unwrap();
8360        let mut w = make_writer(dir.path()).await;
8361        for (task_id, keep_working) in [
8362            (
8363                "tsk_1",
8364                Some(KeepWorkingSummary {
8365                    used: 2,
8366                    limit: 2,
8367                    stopped_by: Some("limit"),
8368                }),
8369            ),
8370            (
8371                "tsk_2",
8372                Some(KeepWorkingSummary {
8373                    used: 1,
8374                    limit: 5,
8375                    stopped_by: None,
8376                }),
8377            ),
8378            ("tsk_3", None),
8379        ] {
8380            w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8381                .await
8382                .unwrap();
8383            w.write_task_end(task_id, "ok", 0, Vec::new(), keep_working, None)
8384                .await
8385                .unwrap();
8386        }
8387        w.flush().await.unwrap();
8388
8389        let events = read_events(dir.path());
8390        let ends: Vec<_> = events
8391            .iter()
8392            .filter(|e| e["event_type"] == "task_end")
8393            .collect();
8394        assert_eq!(
8395            ends[0]["keep_working"],
8396            serde_json::json!({"used": 2, "limit": 2, "stopped_by": "limit"})
8397        );
8398        assert_eq!(
8399            ends[1]["keep_working"],
8400            serde_json::json!({"used": 1, "limit": 5, "stopped_by": null})
8401        );
8402        assert!(ends[2].get("keep_working").is_none(), "{}", ends[2]);
8403    }
8404
8405    /// One `keep_working` record per continuation, parented on the task, and the session-status
8406    /// count follows it.
8407    #[tokio::test]
8408    async fn keep_working_record_carries_its_fields_and_feeds_session_status() {
8409        let dir = tempfile::tempdir().unwrap();
8410        let mut w = make_writer(dir.path()).await;
8411        let status = std::sync::Arc::new(crate::session_status::SessionStatus::new(
8412            std::time::Instant::now(),
8413            false,
8414            None,
8415            None,
8416            0,
8417            Some(3),
8418            None,
8419        ));
8420        w.set_session_status(std::sync::Arc::clone(&status));
8421        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8422            .await
8423            .unwrap();
8424        w.write_keep_working("tsk_1", 1, 3, Some(4), 66)
8425            .await
8426            .unwrap();
8427        w.write_keep_working("tsk_1", 2, 3, None, 66).await.unwrap();
8428        w.flush().await.unwrap();
8429
8430        let events = read_events(dir.path());
8431        let start = events
8432            .iter()
8433            .find(|e| e["event_type"] == "task_start")
8434            .unwrap();
8435        let records: Vec<_> = events
8436            .iter()
8437            .filter(|e| e["event_type"] == "keep_working")
8438            .collect();
8439        assert_eq!(records.len(), 2);
8440        for (record, (number, turns)) in records
8441            .iter()
8442            .zip([(1, serde_json::json!(4)), (2, serde_json::Value::Null)])
8443        {
8444            assert_eq!(record["task_id"], "tsk_1");
8445            assert_eq!(record["number"], number);
8446            assert_eq!(record["limit"], 3);
8447            assert_eq!(record["turns_remaining"], turns);
8448            assert_eq!(record["message_bytes"], 66);
8449            assert_eq!(record["parent_id"], start["event_id"]);
8450            assert!(record["event_id"].is_string());
8451        }
8452        let spend = crate::session_status::SpendReading {
8453            used: 0,
8454            ceiling: None,
8455            machine: None,
8456        };
8457        assert_eq!(
8458            serde_json::to_value(status.snapshot(spend)).unwrap()["keep_working"],
8459            serde_json::json!({"used": 2, "left": 1})
8460        );
8461    }
8462
8463    /// `task_end.done_check` is the summary the runtime passes and is absent with the option off.
8464    #[tokio::test]
8465    async fn task_end_carries_done_check_only_when_set() {
8466        let dir = tempfile::tempdir().unwrap();
8467        let mut w = make_writer(dir.path()).await;
8468        for (task_id, done_check) in [
8469            (
8470                "tsk_1",
8471                Some(DoneCheckSummary {
8472                    runs: 3,
8473                    reopens: 2,
8474                    limit: 2,
8475                    met: Some(false),
8476                    stopped_by: Some("limit"),
8477                }),
8478            ),
8479            (
8480                "tsk_2",
8481                Some(DoneCheckSummary {
8482                    runs: 0,
8483                    reopens: 0,
8484                    limit: 1,
8485                    met: None,
8486                    stopped_by: None,
8487                }),
8488            ),
8489            ("tsk_3", None),
8490        ] {
8491            w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8492                .await
8493                .unwrap();
8494            w.write_task_end(task_id, "ok", 0, Vec::new(), None, done_check)
8495                .await
8496                .unwrap();
8497        }
8498        w.flush().await.unwrap();
8499
8500        let events = read_events(dir.path());
8501        let ends: Vec<_> = events
8502            .iter()
8503            .filter(|e| e["event_type"] == "task_end")
8504            .collect();
8505        assert_eq!(
8506            ends[0]["done_check"],
8507            serde_json::json!({"runs": 3, "reopens": 2, "limit": 2, "met": false,
8508                               "stopped_by": "limit"})
8509        );
8510        assert_eq!(
8511            ends[1]["done_check"],
8512            serde_json::json!({"runs": 0, "reopens": 0, "limit": 1, "met": null,
8513                               "stopped_by": null})
8514        );
8515        assert!(ends[2].get("done_check").is_none(), "{}", ends[2]);
8516    }
8517
8518    /// One `done_check` record per run, parented on the task, with its fields in the documented
8519    /// order; a reopening run feeds the session-status count and a non-reopening one does not.
8520    #[tokio::test]
8521    async fn done_check_record_carries_its_fields_and_feeds_session_status() {
8522        let dir = tempfile::tempdir().unwrap();
8523        let mut w = make_writer(dir.path()).await;
8524        let status = std::sync::Arc::new(crate::session_status::SessionStatus::new(
8525            std::time::Instant::now(),
8526            false,
8527            None,
8528            None,
8529            0,
8530            None,
8531            Some(3),
8532        ));
8533        w.set_session_status(std::sync::Arc::clone(&status));
8534        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8535            .await
8536            .unwrap();
8537        let mut run = DoneCheckRun {
8538            task_id: "tsk_1",
8539            number: 1,
8540            command: "bash -c 'exit 1'",
8541            outcome: "failed",
8542            exit_code: Some(1),
8543            duration_ms: 12,
8544            stdout_bytes: 5,
8545            stderr_bytes: 0,
8546            truncated: false,
8547            resource_limit: None,
8548            reopened: true,
8549            reopens_left: 2,
8550            turns_remaining: Some(4),
8551        };
8552        w.write_done_check(&run, 1).await.unwrap();
8553        run.number = 2;
8554        run.outcome = "timed_out";
8555        run.exit_code = None;
8556        run.reopened = false;
8557        run.turns_remaining = None;
8558        w.write_done_check(&run, 1).await.unwrap();
8559        w.flush().await.unwrap();
8560
8561        let text = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
8562        let line = text
8563            .lines()
8564            .find(|line| line.contains("\"done_check\""))
8565            .unwrap();
8566        let order: Vec<usize> = [
8567            "event_type",
8568            "event_id",
8569            "parent_id",
8570            "session_id",
8571            "timestamp",
8572            "task_id",
8573            "number",
8574            "command",
8575            "outcome",
8576            "exit_code",
8577            "duration_ms",
8578            "stdout_bytes",
8579            "stderr_bytes",
8580            "truncated",
8581            "resource_limit",
8582            "reopened",
8583            "reopens_left",
8584            "turns_remaining",
8585        ]
8586        .iter()
8587        .map(|key| line.find(&format!("\"{key}\":")).unwrap())
8588        .collect();
8589        assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{line}");
8590
8591        let events = read_events(dir.path());
8592        let start = events
8593            .iter()
8594            .find(|e| e["event_type"] == "task_start")
8595            .unwrap();
8596        let records: Vec<_> = events
8597            .iter()
8598            .filter(|e| e["event_type"] == "done_check")
8599            .collect();
8600        assert_eq!(records.len(), 2);
8601        assert_eq!(records[0]["parent_id"], start["event_id"]);
8602        assert_eq!(records[0]["command"], "bash -c 'exit 1'");
8603        assert_eq!(records[0]["exit_code"], 1);
8604        assert_eq!(records[0]["reopened"], true);
8605        assert_eq!(records[0]["turns_remaining"], 4);
8606        assert_eq!(records[1]["outcome"], "timed_out");
8607        assert_eq!(records[1]["exit_code"], serde_json::Value::Null);
8608        assert_eq!(records[1]["resource_limit"], serde_json::Value::Null);
8609        assert_eq!(records[1]["turns_remaining"], serde_json::Value::Null);
8610        let spend = crate::session_status::SpendReading {
8611            used: 0,
8612            ceiling: None,
8613            machine: None,
8614        };
8615        assert_eq!(
8616            serde_json::to_value(status.snapshot(spend)).unwrap()["done_check"],
8617            serde_json::json!({"used": 1, "left": 2})
8618        );
8619    }
8620
8621    /// `task_end` names the background commands still running, and has no such field when there
8622    /// were none.
8623    #[tokio::test]
8624    async fn task_end_names_the_commands_still_running() {
8625        let dir = tempfile::tempdir().unwrap();
8626        let mut w = make_writer(dir.path()).await;
8627        for (task_id, running) in [
8628            ("tsk_1", vec!["wrk_0a".to_string(), "wrk_0b".to_string()]),
8629            ("tsk_2", Vec::new()),
8630        ] {
8631            w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8632                .await
8633                .unwrap();
8634            w.write_task_end(task_id, "ok", 0, running, None, None)
8635                .await
8636                .unwrap();
8637        }
8638        w.flush().await.unwrap();
8639
8640        let events = read_events(dir.path());
8641        let ends: Vec<_> = events
8642            .iter()
8643            .filter(|e| e["event_type"] == "task_end")
8644            .collect();
8645        assert_eq!(
8646            ends[0]["running_commands"],
8647            serde_json::json!(["wrk_0a", "wrk_0b"])
8648        );
8649        assert!(ends[1].get("running_commands").is_none(), "{}", ends[1]);
8650    }
8651
8652    /// The two writers of `shell_completed` build one record: a completion a `background-command`
8653    /// call delivered carries no `completion_task_id`, one queued as a task names it, and both
8654    /// carry when the command exited and which way it reached the agent.
8655    #[tokio::test]
8656    async fn shell_completed_names_its_delivery_and_exit_time() {
8657        let dir = tempfile::tempdir().unwrap();
8658        let mut w = make_writer(dir.path()).await;
8659        let appender = ResourceTraceAppender::open(
8660            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
8661            "test-session-id".to_string(),
8662            w.session_event_id().to_string(),
8663        )
8664        .await
8665        .unwrap();
8666        let completion = crate::detached::DetachedCompletion {
8667            work_id: "wrk_0a".to_string(),
8668            binary: "/usr/bin/bash".to_string(),
8669            command: "sleep 3".to_string(),
8670            exit_code: 0,
8671            duration_ms: 3_000,
8672            exited_at_ms: 1_750,
8673            output_path: "/records/logs/wrk_0a.log".to_string(),
8674            output_bytes: 10,
8675            resource_limit: None,
8676            output_holders: crate::shell::OutputHolders::Closed,
8677            context_id: "ctx_1".to_string(),
8678            provenance: crate::origin::TaskProvenance::derive(
8679                crate::origin::TaskOrigin::Completion,
8680                None,
8681            ),
8682            error: None,
8683        };
8684        w.write_session_start(None, None, Vec::new()).await.unwrap();
8685        w.flush().await.unwrap();
8686        appender.write_shell_completed(&completion).await;
8687        w.write_shell_completed(&completion, "tsk_2").await.unwrap();
8688        w.flush().await.unwrap();
8689
8690        let events = read_events(dir.path());
8691        let completed: Vec<_> = events
8692            .iter()
8693            .filter(|e| e["event_type"] == "shell_completed")
8694            .collect();
8695        assert_eq!(completed.len(), 2);
8696        assert_eq!(completed[0]["delivered_via"], "background_command");
8697        assert!(
8698            completed[0].get("completion_task_id").is_none(),
8699            "{}",
8700            completed[0]
8701        );
8702        assert_eq!(completed[1]["delivered_via"], "task");
8703        assert_eq!(completed[1]["completion_task_id"], "tsk_2");
8704        for record in &completed {
8705            assert_eq!(record["exited_at_ms"], 1_750);
8706            assert_eq!(record["work_id"], "wrk_0a");
8707            assert_eq!(record["status"], "ok");
8708            assert_eq!(record["duration_ms"], 3_000);
8709            assert_eq!(record["session_id"], "test-session-id");
8710            assert_eq!(record["parent_id"].as_str(), Some(w.session_event_id()));
8711        }
8712    }
8713
8714    #[tokio::test]
8715    async fn task_reopened_names_hook_reason_and_ordinal() {
8716        let dir = tempfile::tempdir().unwrap();
8717        let mut w = make_writer(dir.path()).await;
8718        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8719            .await
8720            .unwrap();
8721        w.write_task_reopened(
8722            "tsk_1",
8723            "gatekeeper",
8724            "tests still fail",
8725            1,
8726            REOPEN_CONTEXT_CONTINUED,
8727            Some(7),
8728        )
8729        .await
8730        .unwrap();
8731        w.flush().await.unwrap();
8732
8733        let events = read_events(dir.path());
8734        let re = events
8735            .iter()
8736            .find(|e| e["event_type"] == "task_reopened")
8737            .unwrap();
8738        assert_eq!(re["task_id"], "tsk_1");
8739        assert_eq!(re["hook_name"], "gatekeeper");
8740        assert_eq!(re["reason"], "tests still fail");
8741        assert_eq!(re["reopen_number"], 1);
8742        assert_eq!(re["attempt_context"], "continued");
8743        assert_eq!(re["turns_remaining"], 7);
8744
8745        w.write_task_reopened(
8746            "tsk_1",
8747            "gatekeeper",
8748            "again",
8749            2,
8750            REOPEN_CONTEXT_CONTINUED,
8751            None,
8752        )
8753        .await
8754        .unwrap();
8755        w.flush().await.unwrap();
8756        let uncapped = read_events(dir.path())
8757            .into_iter()
8758            .filter(|e| e["event_type"] == "task_reopened")
8759            .nth(1)
8760            .unwrap();
8761        assert!(uncapped
8762            .get("turns_remaining")
8763            .is_some_and(serde_json::Value::is_null));
8764        assert!(re["timestamp"].as_u64().unwrap() > 0);
8765    }
8766
8767    #[tokio::test]
8768    async fn task_continued_names_the_round_under_the_task_node() {
8769        let dir = tempfile::tempdir().unwrap();
8770        let mut w = make_writer(dir.path()).await;
8771        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8772            .await
8773            .unwrap();
8774        w.write_task_continued(
8775            "tsk_1",
8776            1,
8777            &["mcl_b".to_string(), "mcl_a".to_string()],
8778            &["dlg_1".to_string()],
8779            1234,
8780            Some(8),
8781        )
8782        .await
8783        .unwrap();
8784        w.flush().await.unwrap();
8785
8786        let events = read_events(dir.path());
8787        let start = events
8788            .iter()
8789            .find(|e| e["event_type"] == "task_start")
8790            .unwrap();
8791        let continued = events
8792            .iter()
8793            .find(|e| e["event_type"] == "task_continued")
8794            .unwrap();
8795        assert_eq!(continued["parent_id"], start["event_id"]);
8796        assert_eq!(continued["task_id"], "tsk_1");
8797        assert_eq!(continued["continuation_number"], 1);
8798        assert_eq!(
8799            continued["member_calls"],
8800            serde_json::json!(["mcl_b", "mcl_a"])
8801        );
8802        assert_eq!(continued["delegations"], serde_json::json!(["dlg_1"]));
8803        assert_eq!(continued["waited_ms"], 1234);
8804        assert_eq!(continued["turns_remaining"], 8);
8805    }
8806
8807    /// `task_turns()` reports the cumulative per-task turn count the reopen loop reads to
8808    /// compute the remaining `max_turns` budget: it advances with each `write_inference`
8809    /// and resets on `write_task_start`.
8810    #[tokio::test]
8811    async fn task_turns_accessor_tracks_and_resets() {
8812        let dir = tempfile::tempdir().unwrap();
8813        let mut w = make_writer(dir.path()).await;
8814        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8815            .await
8816            .unwrap();
8817        assert_eq!(w.task_turns(), 0);
8818        w.write_inference(
8819            0,
8820            Some(10),
8821            Some(5),
8822            "end_turn".to_string(),
8823            Some("end_turn"),
8824            None,
8825            None,
8826            None,
8827            Vec::new(),
8828            None,
8829            None,
8830        )
8831        .await
8832        .unwrap();
8833        w.write_inference(
8834            1,
8835            Some(10),
8836            Some(5),
8837            "end_turn".to_string(),
8838            Some("end_turn"),
8839            None,
8840            None,
8841            None,
8842            Vec::new(),
8843            None,
8844            None,
8845        )
8846        .await
8847        .unwrap();
8848        assert_eq!(w.task_turns(), 2);
8849        // A new task resets the per-task counter.
8850        w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
8851            .await
8852            .unwrap();
8853        assert_eq!(w.task_turns(), 0);
8854    }
8855
8856    /// `task_agent_turns()` counts the agent loop's own `inference` lines and not a hook's
8857    /// `run-inference` record, which `task_turns()` does count; both task boundaries reset it.
8858    #[tokio::test]
8859    async fn task_agent_turns_counts_agent_loop_inference_only() {
8860        let dir = tempfile::tempdir().unwrap();
8861        let mut w = make_writer(dir.path()).await;
8862        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8863            .await
8864            .unwrap();
8865        assert_eq!(w.task_agent_turns(), 0);
8866        let hook = InferenceOrigin {
8867            source: "hook:x".into(),
8868            model: "m".into(),
8869        };
8870        for (turn, origin) in [(0, None), (0, Some(&hook)), (1, None)] {
8871            w.write_inference(
8872                turn,
8873                Some(10),
8874                Some(5),
8875                "end_turn".to_string(),
8876                Some("end_turn"),
8877                None,
8878                origin,
8879                None,
8880                Vec::new(),
8881                None,
8882                None,
8883            )
8884            .await
8885            .unwrap();
8886        }
8887        assert_eq!(w.task_agent_turns(), 2);
8888        assert_eq!(w.task_turns(), 3);
8889        assert_eq!(w.task_hook_inferences(), 1);
8890        w.write_task_end("tsk_1", "completed", 0, Vec::new(), None, None)
8891            .await
8892            .unwrap();
8893        assert_eq!(
8894            w.task_agent_turns(),
8895            0,
8896            "a run outside a task frame starts at 0"
8897        );
8898        w.write_inference(
8899            0,
8900            Some(10),
8901            Some(5),
8902            "end_turn".to_string(),
8903            Some("end_turn"),
8904            None,
8905            None,
8906            None,
8907            Vec::new(),
8908            None,
8909            None,
8910        )
8911        .await
8912        .unwrap();
8913        w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
8914            .await
8915            .unwrap();
8916        assert_eq!(w.task_agent_turns(), 0);
8917    }
8918
8919    // ── Event identity and parenting ──────────────────────────────────────────
8920
8921    /// The full tree one task produces, walked the way a reader walks it: every line is
8922    /// identified, every non-null `parent_id` names a line already written, and the shape is
8923    /// session → task → turn → {tool_call, shell}.
8924    #[tokio::test]
8925    async fn every_event_carries_a_unique_id_and_parents_to_an_earlier_event() {
8926        let dir = tempfile::tempdir().unwrap();
8927        let mut w = make_writer(dir.path()).await;
8928        w.write_session_start(Some(10), None, vec!["bash".to_string()])
8929            .await
8930            .unwrap();
8931        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8932            .await
8933            .unwrap();
8934        w.write_inference(
8935            0,
8936            Some(10),
8937            Some(5),
8938            "tool_call".to_string(),
8939            Some("tool_call"),
8940            None,
8941            None,
8942            None,
8943            Vec::new(),
8944            None,
8945            None,
8946        )
8947        .await
8948        .unwrap();
8949        w.write_tool_call(
8950            0,
8951            "bash".to_string(),
8952            Some("toolu_1".to_string()),
8953            serde_json::json!({"command": "echo hi"}),
8954            10,
8955            "hi\n",
8956            3,
8957            5,
8958            "ok".to_string(),
8959            None,
8960            None,
8961            None,
8962        )
8963        .await
8964        .unwrap();
8965        w.write_shell(
8966            0,
8967            "/bin/bash".to_string(),
8968            "echo hi".to_string(),
8969            0,
8970            3,
8971            0,
8972            2,
8973            None,
8974        )
8975        .await
8976        .unwrap();
8977        w.write_artifact_pulled(&pulled(
8978            runtime_summary("pulled-style", ArtifactRuntime::Skill),
8979            "global store",
8980            None,
8981        ))
8982        .await
8983        .unwrap();
8984        w.write_task_end("tsk_1", "ok", 0, Vec::new(), None, None)
8985            .await
8986            .unwrap();
8987        w.write_session_end("ok").await.unwrap();
8988        w.flush().await.unwrap();
8989
8990        let events = read_events(dir.path());
8991        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8992        for (i, event) in events.iter().enumerate() {
8993            let id = event["event_id"].as_str().unwrap();
8994            assert!(id.starts_with("evt_") && id.len() == 36, "event {i}: {id}");
8995            assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()), "event {i}");
8996            assert!(seen.insert(id.to_string()), "event {i} reuses {id}");
8997            assert!(
8998                event.get("parent_id").is_some(),
8999                "event {i} has no parent_id"
9000            );
9001            if let Some(parent) = event["parent_id"].as_str() {
9002                assert!(seen.contains(parent), "event {i} names dangling {parent}");
9003            }
9004        }
9005
9006        let by_type = |ty: &str| {
9007            events
9008                .iter()
9009                .find(|e| e["event_type"] == ty)
9010                .unwrap_or_else(|| panic!("no {ty}"))
9011                .clone()
9012        };
9013        let session = by_type("session_start");
9014        assert!(session["parent_id"].is_null());
9015        assert_eq!(session["event_id"], w.session_event_id());
9016
9017        let task = by_type("task_start");
9018        assert_eq!(task["parent_id"], session["event_id"]);
9019
9020        let turn = by_type("inference");
9021        assert_eq!(turn["parent_id"], task["event_id"]);
9022
9023        assert_eq!(by_type("tool_call")["parent_id"], turn["event_id"]);
9024        assert_eq!(by_type("shell")["parent_id"], turn["event_id"]);
9025        assert_eq!(by_type("task_end")["parent_id"], task["event_id"]);
9026        assert_eq!(by_type("session_end")["parent_id"], session["event_id"]);
9027        assert_eq!(by_type("artifact_pulled")["parent_id"], session["event_id"]);
9028    }
9029
9030    fn runtime_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
9031        InstalledArtifactSummary {
9032            name: name.to_string(),
9033            version: "1.0.0".to_string(),
9034            runtime,
9035            origin: LockOrigin::Runtime {
9036                session: "ses_puller".to_string(),
9037            },
9038        }
9039    }
9040
9041    fn operator_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
9042        InstalledArtifactSummary {
9043            origin: LockOrigin::Operator,
9044            ..runtime_summary(name, runtime)
9045        }
9046    }
9047
9048    /// A pull of `summary` from `fetched_from`, with `owner_id` when the place records one.
9049    fn pulled(
9050        summary: InstalledArtifactSummary,
9051        fetched_from: &str,
9052        owner_id: Option<u64>,
9053    ) -> crate::runtime::PulledArtifact {
9054        crate::runtime::PulledArtifact {
9055            summary,
9056            fetched_from: fetched_from.to_string(),
9057            owner_id,
9058            notes: Vec::new(),
9059        }
9060    }
9061
9062    /// `instruction_files` is always written: `null` without the key, the records in manifest
9063    /// order with it, and `[]` for an empty list.
9064    #[tokio::test]
9065    async fn session_start_instruction_files_is_null_without_the_key() {
9066        let work = tempfile::tempdir().unwrap();
9067        std::fs::write(work.path().join("AGENTS.md"), "x").unwrap();
9068        let read = crate::instruction_files::InstructionFiles::read(
9069            work.path(),
9070            &["AGENTS.md".to_string(), "CLAUDE.md".to_string()],
9071        );
9072        let empty = crate::instruction_files::InstructionFiles::read(work.path(), &[]);
9073        for (files, expected) in [
9074            (None, serde_json::Value::Null),
9075            (Some(&empty), serde_json::json!([])),
9076            (
9077                Some(&read),
9078                serde_json::json!([
9079                    {
9080                        "path": "AGENTS.md",
9081                        "status": "read",
9082                        "resolved": "AGENTS.md",
9083                        "bytes": 1,
9084                        "included_bytes": 1,
9085                        "truncated": false,
9086                        "sha256": murmur_artifact::sha256_hex(b"x"),
9087                    },
9088                    {"path": "CLAUDE.md", "status": "missing"},
9089                ]),
9090            ),
9091        ] {
9092            let dir = tempfile::tempdir().unwrap();
9093            let mut w = make_writer(dir.path()).await;
9094            w.set_instruction_files(files);
9095            w.write_session_start(Some(10), None, Vec::new())
9096                .await
9097                .unwrap();
9098            w.flush().await.unwrap();
9099            let events = read_events(dir.path());
9100            assert!(events[0].get("instruction_files").is_some());
9101            assert_eq!(events[0]["instruction_files"], expected);
9102        }
9103    }
9104
9105    fn harness_start_with_prompt(prompt: &str) -> HarnessStart {
9106        HarnessStart {
9107            driver: "local/fixture-process-driver".to_string(),
9108            driver_version: "0.1.0".to_string(),
9109            harness: "fixture".to_string(),
9110            binary: "/bin/true".to_string(),
9111            binary_source: "describe".to_string(),
9112            harness_version: None,
9113            version_tested: false,
9114            harness_session_id: "h".to_string(),
9115            session_mode: "new".to_string(),
9116            args_count: 0,
9117            env_names: Vec::new(),
9118            account_env_unset: Vec::new(),
9119            files: Vec::new(),
9120            bridge_tools: Vec::new(),
9121            stdin_bytes: 0,
9122            keep_stdin_open: false,
9123            system_prompt_sha256: murmur_artifact::sha256_hex(prompt.as_bytes()),
9124            system_prompt: prompt.to_string(),
9125        }
9126    }
9127
9128    /// `harness_start` names the prompt handed to the driver by its hash, never carries the text,
9129    /// and under content capture stores the text as that blob.
9130    #[tokio::test]
9131    async fn harness_start_records_the_system_prompt_hash_and_blob() {
9132        let prompt = "facts\n\nMANIFEST\n\n[Instruction file: AGENTS.md]\nx";
9133        let sha = murmur_artifact::sha256_hex(prompt.as_bytes());
9134        for capture in [TraceCapture::Meta, TraceCapture::Content] {
9135            let dir = tempfile::tempdir().unwrap();
9136            let mut w = make_writer_with_opts(dir.path(), capture).await;
9137            w.write_harness_start(harness_start_with_prompt(prompt))
9138                .await
9139                .unwrap();
9140            w.flush().await.unwrap();
9141            let event = &read_events(dir.path())[0];
9142            assert_eq!(event["event_type"], "harness_start");
9143            assert_eq!(event["system_prompt_sha256"], sha.as_str());
9144            assert!(event.get("system_prompt").is_none(), "{event}");
9145            let blob = dir.path().join(BLOB_DIR_NAME).join(&sha);
9146            if capture == TraceCapture::Content {
9147                assert_eq!(std::fs::read(&blob).unwrap(), prompt.as_bytes());
9148            } else {
9149                assert!(!blob.exists());
9150            }
9151        }
9152    }
9153
9154    /// `runtime_artifacts` is always written: `[]` for a session whose every artifact the operator
9155    /// pinned, including one that never called the setter.
9156    #[tokio::test]
9157    async fn runtime_origin_session_start_lists_nothing_for_an_operator_session() {
9158        for set in [false, true] {
9159            let dir = tempfile::tempdir().unwrap();
9160            let mut w = make_writer(dir.path()).await;
9161            if set {
9162                w.set_runtime_artifacts(&[
9163                    operator_summary("house-style", ArtifactRuntime::Skill),
9164                    operator_summary("fetcher", ArtifactRuntime::Tool),
9165                ]);
9166            }
9167            w.write_session_start(Some(10), None, Vec::new())
9168                .await
9169                .unwrap();
9170            w.flush().await.unwrap();
9171            let events = read_events(dir.path());
9172            assert_eq!(events[0]["runtime_artifacts"], serde_json::json!([]));
9173        }
9174    }
9175
9176    /// Only runtime-origin entries are listed, in the order staging recorded them, each naming
9177    /// its puller.
9178    #[tokio::test]
9179    async fn runtime_origin_session_start_lists_runtime_artifacts_in_order() {
9180        let dir = tempfile::tempdir().unwrap();
9181        let mut w = make_writer(dir.path()).await;
9182        w.set_runtime_artifacts(&[
9183            runtime_summary("zeta-tool", ArtifactRuntime::Tool),
9184            operator_summary("house-style", ArtifactRuntime::Skill),
9185            InstalledArtifactSummary {
9186                origin: LockOrigin::Runtime {
9187                    session: "ses_other".to_string(),
9188                },
9189                ..runtime_summary("alpha-style", ArtifactRuntime::Skill)
9190            },
9191        ]);
9192        w.write_session_start(Some(10), None, Vec::new())
9193            .await
9194            .unwrap();
9195        w.flush().await.unwrap();
9196
9197        let events = read_events(dir.path());
9198        assert_eq!(
9199            events[0]["runtime_artifacts"],
9200            serde_json::json!([
9201                {"name": "zeta-tool", "version": "1.0.0", "origin": "runtime",
9202                 "session": "ses_puller", "trust": "untrusted"},
9203                {"name": "alpha-style", "version": "1.0.0", "origin": "runtime",
9204                 "session": "ses_other", "trust": "untrusted"},
9205            ])
9206        );
9207    }
9208
9209    #[tokio::test]
9210    async fn runtime_origin_skill_call_carries_origin_and_trust() {
9211        let dir = tempfile::tempdir().unwrap();
9212        let mut w = make_writer(dir.path()).await;
9213        for origin in [
9214            LockOrigin::Operator,
9215            LockOrigin::Runtime {
9216                session: "ses_puller".to_string(),
9217            },
9218        ] {
9219            w.write_skill_call(0, "a-skill".to_string(), &origin, 1, 1, "ok".to_string())
9220                .await
9221                .unwrap();
9222        }
9223        w.flush().await.unwrap();
9224
9225        let events = read_events(dir.path());
9226        assert_eq!(events[0]["origin"], "operator");
9227        assert_eq!(events[0]["trust"], "trusted");
9228        assert_eq!(events[1]["origin"], "runtime");
9229        assert_eq!(events[1]["trust"], "untrusted");
9230    }
9231
9232    /// Every key is written, `session` included: `null` when the lock entry is the operator's.
9233    /// `owner_id` is written only when the place the payload came from records one.
9234    #[tokio::test]
9235    async fn runtime_origin_artifact_pulled_writes_every_key() {
9236        let dir = tempfile::tempdir().unwrap();
9237        let mut w = make_writer(dir.path()).await;
9238        w.write_artifact_pulled(&pulled(
9239            runtime_summary("pulled-style", ArtifactRuntime::Skill),
9240            "github:ana/artifacts",
9241            Some(4242),
9242        ))
9243        .await
9244        .unwrap();
9245        w.write_artifact_pulled(&pulled(
9246            operator_summary("fetcher", ArtifactRuntime::Tool),
9247            "project store",
9248            None,
9249        ))
9250        .await
9251        .unwrap();
9252        w.flush().await.unwrap();
9253
9254        let events = read_events(dir.path());
9255        let runtime = &events[0];
9256        assert_eq!(runtime["event_type"], "artifact_pulled");
9257        assert_eq!(runtime["name"], "pulled-style");
9258        assert_eq!(runtime["version"], "1.0.0");
9259        assert_eq!(runtime["runtime"], "skill");
9260        assert_eq!(runtime["origin"], "runtime");
9261        assert_eq!(runtime["session"], "ses_puller");
9262        assert_eq!(runtime["trust"], "untrusted");
9263        assert_eq!(runtime["fetched_from"], "github:ana/artifacts");
9264        assert_eq!(runtime["owner_id"], 4242);
9265        assert!(
9266            runtime["parent_id"].is_null(),
9267            "no session_start was written"
9268        );
9269
9270        let operator = &events[1];
9271        assert_eq!(operator["runtime"], "tool");
9272        assert_eq!(operator["origin"], "operator");
9273        assert!(operator.get("session").is_some_and(Value::is_null));
9274        assert_eq!(operator["trust"], "trusted");
9275        assert_eq!(operator["fetched_from"], "project store");
9276        assert!(operator.get("owner_id").is_none());
9277    }
9278
9279    /// A hook's `run-inference` record is a product of the turn it ran inside, not a turn of
9280    /// its own: it parents to the agent loop's inference and never replaces it as the node the
9281    /// turn's other events hang off.
9282    #[tokio::test]
9283    async fn hook_origin_inference_hangs_off_the_turn_rather_than_becoming_one() {
9284        let dir = tempfile::tempdir().unwrap();
9285        let mut w = make_writer(dir.path()).await;
9286        w.write_session_start(Some(10), None, Vec::new())
9287            .await
9288            .unwrap();
9289        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9290            .await
9291            .unwrap();
9292        w.write_inference(
9293            0,
9294            Some(10),
9295            Some(5),
9296            "tool_call".to_string(),
9297            Some("tool_call"),
9298            None,
9299            None,
9300            None,
9301            Vec::new(),
9302            None,
9303            None,
9304        )
9305        .await
9306        .unwrap();
9307        let origin = InferenceOrigin {
9308            source: "hook:gatekeeper".to_string(),
9309            model: "claude-test".to_string(),
9310        };
9311        w.write_inference(
9312            0,
9313            Some(1),
9314            Some(1),
9315            "end_turn".to_string(),
9316            // A hook's `run-inference` saw no provider stop reason, so it records none.
9317            None,
9318            None,
9319            Some(&origin),
9320            None,
9321            Vec::new(),
9322            None,
9323            None,
9324        )
9325        .await
9326        .unwrap();
9327        w.flush().await.unwrap();
9328
9329        let events = read_events(dir.path());
9330        let turn = &events[2];
9331        let hook = &events[3];
9332        assert!(turn["origin"].is_null());
9333        assert_eq!(hook["origin"], "hook:gatekeeper");
9334        assert_eq!(hook["parent_id"], turn["event_id"]);
9335        // The hook sent a message list the runtime never minted, so the key is absent rather
9336        // than empty.
9337        assert!(hook.get("message_ids").is_none(), "{hook}");
9338    }
9339
9340    /// A writer with no session frame behind it names no parent: naming one would dangle.
9341    #[tokio::test]
9342    async fn a2a_send_parents_to_null_without_a_session_frame_and_to_it_with_one() {
9343        let bare = tempfile::tempdir().unwrap();
9344        let mut w = make_writer(bare.path()).await;
9345        w.write_a2a_send(
9346            "http://peer",
9347            "msg_1",
9348            "tsk_1",
9349            "ctx_1",
9350            None,
9351            TrustClass::Untrusted,
9352        )
9353        .await
9354        .unwrap();
9355        w.flush().await.unwrap();
9356        assert!(read_events(bare.path())[0]["parent_id"].is_null());
9357
9358        let framed = tempfile::tempdir().unwrap();
9359        let mut w = make_writer(framed.path()).await;
9360        w.write_session_start(Some(1), None, Vec::new())
9361            .await
9362            .unwrap();
9363        w.write_a2a_send(
9364            "http://peer",
9365            "msg_1",
9366            "tsk_1",
9367            "ctx_1",
9368            None,
9369            TrustClass::Untrusted,
9370        )
9371        .await
9372        .unwrap();
9373        w.flush().await.unwrap();
9374        let events = read_events(framed.path());
9375        assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
9376    }
9377
9378    /// `task_rejected` carries every field and, with no task active, hangs off `session_start`.
9379    #[tokio::test]
9380    async fn task_rejected_names_the_task_source_cause_and_reason_under_the_session() {
9381        let dir = tempfile::tempdir().unwrap();
9382        let mut w = make_writer(dir.path()).await;
9383        w.write_session_start(Some(10), None, Vec::new())
9384            .await
9385            .unwrap();
9386        w.write_task_start("tsk_ran", "ctx_ran", "a2a", event_provenance(), 3, &[], &[])
9387            .await
9388            .unwrap();
9389        w.write_task_end("tsk_ran", "ok", 0, Vec::new(), None, None)
9390            .await
9391            .unwrap();
9392        w.write_task_rejected(
9393            "tsk_1",
9394            "ctx_1",
9395            "detached_shell",
9396            TASK_REJECTED_SESSION_ENDED,
9397            "task rejected: the session ended before this task started",
9398        )
9399        .await
9400        .unwrap();
9401        w.flush().await.unwrap();
9402
9403        let events = read_events(dir.path());
9404        let session = events
9405            .iter()
9406            .find(|e| e["event_type"] == "session_start")
9407            .unwrap();
9408        let rejected: Vec<_> = events
9409            .iter()
9410            .filter(|e| e["event_type"] == "task_rejected")
9411            .collect();
9412        assert_eq!(rejected.len(), 1);
9413        let rejected = rejected[0];
9414        assert_eq!(rejected["parent_id"], session["event_id"], "{rejected}");
9415        assert_eq!(rejected["session_id"], session["session_id"]);
9416        assert_eq!(rejected["task_id"], "tsk_1");
9417        assert_eq!(rejected["context_id"], "ctx_1");
9418        assert_eq!(rejected["source"], "detached_shell");
9419        assert_eq!(rejected["cause"], "session_ended");
9420        assert_eq!(
9421            rejected["reason"],
9422            "task rejected: the session ended before this task started"
9423        );
9424        assert!(rejected["event_id"].is_string());
9425        assert!(rejected["timestamp"].is_u64());
9426        assert_eq!(rejected.as_object().unwrap().len(), 10, "{rejected}");
9427    }
9428
9429    /// `task_failed` hangs off the task it failed, and a reason past the cap is cut on a character
9430    /// boundary. Both optional fields are absent, not null, when there is nothing to say.
9431    #[tokio::test]
9432    async fn task_failed_names_the_task_turn_cause_and_capped_reason() {
9433        let dir = tempfile::tempdir().unwrap();
9434        let mut w = make_writer(dir.path()).await;
9435        w.write_session_start(Some(10), None, Vec::new())
9436            .await
9437            .unwrap();
9438        assert_eq!(w.last_task_failure(), None);
9439        w.write_task_failed(None, TASK_FAILED_RUNTIME_ERROR, "before any task")
9440            .await
9441            .unwrap();
9442        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9443            .await
9444            .unwrap();
9445        let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
9446        w.write_task_failed(Some(2), TASK_FAILED_DRIVER_ERROR, &long)
9447            .await
9448            .unwrap();
9449        w.write_task_end("tsk_1", "failed", 0, Vec::new(), None, None)
9450            .await
9451            .unwrap();
9452        w.flush().await.unwrap();
9453
9454        assert_eq!(w.task_failures_written(), 2);
9455        let events = read_events(dir.path());
9456        let failed: Vec<_> = events
9457            .iter()
9458            .filter(|e| e["event_type"] == "task_failed")
9459            .collect();
9460        assert_eq!(failed.len(), 2);
9461
9462        assert!(failed[0].get("task_id").is_none(), "{}", failed[0]);
9463        assert!(failed[0].get("turn").is_none(), "{}", failed[0]);
9464        assert_eq!(failed[0]["cause"], "runtime_error");
9465        assert_eq!(failed[0]["reason"], "before any task");
9466
9467        let start = events
9468            .iter()
9469            .find(|e| e["event_type"] == "task_start")
9470            .unwrap();
9471        assert_eq!(failed[1]["task_id"], "tsk_1");
9472        assert_eq!(failed[1]["turn"], 2);
9473        assert_eq!(failed[1]["cause"], "driver_error");
9474        assert_eq!(failed[1]["parent_id"], start["event_id"]);
9475        assert_eq!(failed[1]["session_id"], start["session_id"]);
9476        assert!(failed[1]["event_id"].is_string());
9477        assert!(failed[1]["timestamp"].is_u64());
9478        let reason = failed[1]["reason"].as_str().unwrap();
9479        assert!(reason.len() <= MAX_TASK_FAILURE_REASON_BYTES);
9480        assert!(reason.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
9481        assert_eq!(w.last_task_failure(), Some(reason));
9482    }
9483
9484    /// Turn-level events carry the enclosing task's id, and `null` once the task has ended —
9485    /// `task_id` is a record of scope, not a value the writer holds on to.
9486    #[tokio::test]
9487    async fn turn_level_events_carry_the_active_task_id() {
9488        let dir = tempfile::tempdir().unwrap();
9489        let mut w = make_writer(dir.path()).await;
9490        w.write_session_start(Some(10), None, Vec::new())
9491            .await
9492            .unwrap();
9493        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9494            .await
9495            .unwrap();
9496        w.write_inference(
9497            0,
9498            Some(10),
9499            Some(5),
9500            "tool_call".to_string(),
9501            Some("tool_call"),
9502            None,
9503            None,
9504            None,
9505            Vec::new(),
9506            None,
9507            None,
9508        )
9509        .await
9510        .unwrap();
9511        w.write_skill_call(
9512            0,
9513            "house-style".to_string(),
9514            &LockOrigin::Operator,
9515            12,
9516            1,
9517            "ok".to_string(),
9518        )
9519        .await
9520        .unwrap();
9521        w.write_compaction(0, 100, OccupancyBasis::Estimate, 40)
9522            .await
9523            .unwrap();
9524        w.write_task_end("tsk_1", "ok", 0, Vec::new(), None, None)
9525            .await
9526            .unwrap();
9527        w.write_inference(
9528            1,
9529            Some(1),
9530            Some(1),
9531            "end_turn".to_string(),
9532            Some("end_turn"),
9533            None,
9534            None,
9535            None,
9536            Vec::new(),
9537            None,
9538            None,
9539        )
9540        .await
9541        .unwrap();
9542        w.flush().await.unwrap();
9543
9544        let events = read_events(dir.path());
9545        for event in events
9546            .iter()
9547            .filter(|e| {
9548                matches!(
9549                    e["event_type"].as_str(),
9550                    Some("inference" | "skill_call" | "compaction")
9551                )
9552            })
9553            .take(3)
9554        {
9555            assert_eq!(event["task_id"], "tsk_1", "{event}");
9556        }
9557        let after = events.last().unwrap();
9558        assert_eq!(after["event_type"], "inference");
9559        assert!(
9560            after["task_id"].is_null(),
9561            "an inference outside any task carries a null task_id, not the last task's"
9562        );
9563        // Once the task has ended, its events fall back to the session node.
9564        assert_eq!(after["parent_id"], events[0]["event_id"]);
9565    }
9566
9567    /// The provider's id is recorded verbatim; an absent or empty one records as `null`, never
9568    /// as an id the provider never issued.
9569    #[tokio::test]
9570    async fn tool_call_records_the_provider_id_verbatim_and_empty_as_null() {
9571        for (given, expected) in [
9572            (Some("toolu_1".to_string()), Some("toolu_1")),
9573            (Some(String::new()), None),
9574            (None, None),
9575        ] {
9576            let dir = tempfile::tempdir().unwrap();
9577            let mut w = make_writer(dir.path()).await;
9578            w.write_tool_call(
9579                0,
9580                "bash".to_string(),
9581                given.clone(),
9582                serde_json::json!({}),
9583                2,
9584                "",
9585                0,
9586                1,
9587                "ok".to_string(),
9588                None,
9589                None,
9590                None,
9591            )
9592            .await
9593            .unwrap();
9594            w.flush().await.unwrap();
9595            let e = &read_events(dir.path())[0];
9596            match expected {
9597                Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
9598                None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
9599            }
9600        }
9601    }
9602
9603    /// `tool_input_refused` carries the tool, its call id (`null` when absent or empty), the
9604    /// missing names and the reason, and is written under every capture mode alike.
9605    #[tokio::test]
9606    async fn tool_input_refused_records_its_fields_in_every_capture_mode() {
9607        for capture in [
9608            TraceCapture::None,
9609            TraceCapture::Meta,
9610            TraceCapture::Content,
9611        ] {
9612            for (given, expected) in [
9613                (Some("toolu_1"), Some("toolu_1")),
9614                (Some(""), None),
9615                (None, None),
9616            ] {
9617                let dir = tempfile::tempdir().unwrap();
9618                let mut w = make_writer_with_opts(dir.path(), capture).await;
9619                w.write_tool_input_refused(
9620                    2,
9621                    "murmur-tool-editor",
9622                    given,
9623                    &["operation".to_string(), "dest_path".to_string()],
9624                    "murmur-tool-editor: missing required fields",
9625                )
9626                .await
9627                .unwrap();
9628                w.flush().await.unwrap();
9629                let e = &read_events(dir.path())[0];
9630                assert_eq!(e["event_type"], "tool_input_refused", "{capture:?}");
9631                assert!(e["event_id"].is_string());
9632                assert!(e["session_id"].is_string());
9633                assert!(e["timestamp"].is_u64());
9634                assert_eq!(e["turn"], 2);
9635                assert_eq!(e["tool_name"], "murmur-tool-editor");
9636                match expected {
9637                    Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
9638                    None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
9639                }
9640                assert_eq!(e["missing"], serde_json::json!(["operation", "dest_path"]));
9641                assert_eq!(e["reason"], "murmur-tool-editor: missing required fields");
9642            }
9643        }
9644    }
9645
9646    /// `tools_refreshed` carries the turn, the task, the trigger and three sorted name lists,
9647    /// hangs off the task node rather than the previous turn, and `session_start` records the
9648    /// declared trigger — `null` under `transport: process`.
9649    #[tokio::test]
9650    async fn tools_refreshed_event_records_trigger_names_and_task_parent() {
9651        let dir = tempfile::tempdir().unwrap();
9652        let mut w = make_writer(dir.path()).await;
9653        w.set_tool_refresh(Some("immediate"));
9654        w.write_session_start(Some(10), None, Vec::new())
9655            .await
9656            .unwrap();
9657        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9658            .await
9659            .unwrap();
9660        w.write_inference(
9661            0,
9662            Some(10),
9663            Some(5),
9664            "tool_call".to_string(),
9665            Some("tool_call"),
9666            None,
9667            None,
9668            None,
9669            Vec::new(),
9670            None,
9671            None,
9672        )
9673        .await
9674        .unwrap();
9675        w.write_tools_refreshed(
9676            1,
9677            "immediate",
9678            &["aaa-late-skill".to_string()],
9679            &[],
9680            &[
9681                "aaa-late-skill".to_string(),
9682                "zzz-existing-tool".to_string(),
9683            ],
9684        )
9685        .await
9686        .unwrap();
9687        w.flush().await.unwrap();
9688
9689        let events = read_events(dir.path());
9690        assert_eq!(events[0]["tool_refresh"], "immediate");
9691        let task = events
9692            .iter()
9693            .find(|e| e["event_type"] == "task_start")
9694            .unwrap();
9695        let refreshed: Vec<&Value> = events
9696            .iter()
9697            .filter(|e| e["event_type"] == "tools_refreshed")
9698            .collect();
9699        assert_eq!(refreshed.len(), 1);
9700        let r = refreshed[0];
9701        assert!(r["event_id"].as_str().unwrap().starts_with("evt_"));
9702        assert_eq!(r["parent_id"], task["event_id"]);
9703        assert_eq!(r["session_id"], "test-session-id");
9704        assert!(r["timestamp"].as_u64().is_some());
9705        assert_eq!(r["turn"], 1);
9706        assert_eq!(r["task_id"], "tsk_1");
9707        assert_eq!(r["trigger"], "immediate");
9708        assert_eq!(r["added"], serde_json::json!(["aaa-late-skill"]));
9709        assert_eq!(r["removed"], serde_json::json!([]));
9710        assert_eq!(
9711            r["tools"],
9712            serde_json::json!(["aaa-late-skill", "zzz-existing-tool"])
9713        );
9714
9715        let process_dir = tempfile::tempdir().unwrap();
9716        let mut process = make_writer(process_dir.path()).await;
9717        process.set_tool_refresh(None);
9718        process
9719            .write_session_start(Some(10), None, Vec::new())
9720            .await
9721            .unwrap();
9722        process.flush().await.unwrap();
9723        let start = &read_events(process_dir.path())[0];
9724        assert!(start.as_object().unwrap().contains_key("tool_refresh"));
9725        assert!(start["tool_refresh"].is_null());
9726    }
9727
9728    /// A declined compaction is a full record of the decline, not a bare marker: it names the
9729    /// turn, the task, the occupancy the session went on running over, and the reason.
9730    #[tokio::test]
9731    async fn compaction_declined_records_turn_task_tokens_and_reason() {
9732        let dir = tempfile::tempdir().unwrap();
9733        let mut w = make_writer(dir.path()).await;
9734        w.write_session_start(Some(10), None, Vec::new())
9735            .await
9736            .unwrap();
9737        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9738            .await
9739            .unwrap();
9740        w.write_inference(
9741            2,
9742            Some(10),
9743            Some(5),
9744            "tool_call".to_string(),
9745            Some("tool_call"),
9746            None,
9747            None,
9748            None,
9749            Vec::new(),
9750            None,
9751            None,
9752        )
9753        .await
9754        .unwrap();
9755        w.write_compaction_declined(
9756            2,
9757            4321,
9758            OccupancyBasis::Actual,
9759            COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL,
9760        )
9761        .await
9762        .unwrap();
9763        w.flush().await.unwrap();
9764
9765        let events = read_events(dir.path());
9766        let declined: Vec<&Value> = events
9767            .iter()
9768            .filter(|e| e["event_type"] == "compaction_declined")
9769            .collect();
9770        assert_eq!(declined.len(), 1);
9771        let d = declined[0];
9772        assert_eq!(d["reason"], "unresolved_tool_call");
9773        assert_eq!(d["turn"], 2);
9774        assert_eq!(d["task_id"], "tsk_1");
9775        assert_eq!(d["tokens"], 4321);
9776        assert_eq!(d["tokens_source"], "actual");
9777        assert!(d["event_id"].as_str().unwrap().starts_with("evt_"));
9778        assert_eq!(
9779            d["parent_id"],
9780            events
9781                .iter()
9782                .find(|e| e["event_type"] == "inference")
9783                .unwrap()["event_id"]
9784        );
9785    }
9786
9787    /// `evt_` ids sort by mint time and carry their own millisecond timestamp, the same
9788    /// property `mur trace list --since` reads off a `ses_` id.
9789    #[tokio::test]
9790    async fn event_ids_sort_by_mint_time() {
9791        let dir = tempfile::tempdir().unwrap();
9792        let mut w = make_writer(dir.path()).await;
9793        for turn in 0..5 {
9794            w.write_inference(
9795                turn,
9796                Some(1),
9797                Some(1),
9798                "end_turn".to_string(),
9799                Some("end_turn"),
9800                None,
9801                None,
9802                None,
9803                Vec::new(),
9804                None,
9805                None,
9806            )
9807            .await
9808            .unwrap();
9809        }
9810        w.flush().await.unwrap();
9811
9812        let ids: Vec<String> = read_events(dir.path())
9813            .iter()
9814            .map(|e| e["event_id"].as_str().unwrap().to_string())
9815            .collect();
9816        let mut sorted = ids.clone();
9817        sorted.sort();
9818        assert_eq!(ids, sorted, "evt_ ids must sort into mint order");
9819    }
9820
9821    // ── Wire capture: hashes, blobs and the capture gate ─────────────────────
9822
9823    /// A payload in the shape `build_driver_payload` produces, with `n` messages.
9824    fn wire_payload(system: &str, messages: usize) -> Value {
9825        let messages: Vec<Value> = (0..messages)
9826            .map(|i| serde_json::json!({"role": "user", "content": format!("m{i}")}))
9827            .collect();
9828        serde_json::json!({
9829            "model": "claude-test",
9830            "max_tokens": 1024,
9831            "messages": messages,
9832            "tools": [{"name": "bash"}],
9833            "params": {},
9834            "system": system,
9835        })
9836    }
9837
9838    async fn write_turn(w: &mut TraceWriter, turn: u32, payload: &Value, response: &str) {
9839        let wire = WireCapture::from_driver_payload(payload, response);
9840        w.write_inference(
9841            turn,
9842            Some(10),
9843            Some(5),
9844            "end_turn".to_string(),
9845            Some("end_turn"),
9846            None,
9847            None,
9848            None,
9849            vec!["msg_0000000000000000000000000000000a".to_string()],
9850            Some(&wire),
9851            None,
9852        )
9853        .await
9854        .unwrap();
9855    }
9856
9857    fn blob_path(dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
9858        dir.join(BLOB_DIR_NAME).join(sha)
9859    }
9860
9861    /// Every hash an `inference` event names under `content` is the filename of a real file whose
9862    /// own digest is that filename — the whole point of the store.
9863    #[tokio::test]
9864    async fn content_capture_names_blobs_that_exist_and_rehash_to_their_names() {
9865        let dir = tempfile::tempdir().unwrap();
9866        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
9867        let payload = wire_payload("You are a capsule.", 3);
9868        write_turn(&mut w, 0, &payload, r#"{"stop_reason":"end_turn"}"#).await;
9869        w.flush().await.unwrap();
9870
9871        let e = &read_events(dir.path())[0];
9872        let mut named: Vec<String> = ["system_sha", "tools_sha", "response_sha"]
9873            .iter()
9874            .map(|key| {
9875                e[*key]
9876                    .as_str()
9877                    .unwrap_or_else(|| panic!("{key} missing from {e}"))
9878                    .to_string()
9879            })
9880            .collect();
9881        let shas = e["message_shas"].as_array().expect("message_shas");
9882        assert_eq!(shas.len(), 3);
9883        named.extend(shas.iter().map(|s| s.as_str().unwrap().to_string()));
9884
9885        for sha in &named {
9886            let path = blob_path(dir.path(), sha);
9887            let bytes =
9888                std::fs::read(&path).unwrap_or_else(|err| panic!("blob {sha} must exist: {err}"));
9889            assert_eq!(murmur_artifact::sha256_hex(&bytes), *sha);
9890            // A bare sha256 names content; `evt_`/`msg_`/`ses_` name entities. Neither a prefix
9891            // nor an extension may creep onto a blob name.
9892            assert_eq!(sha.len(), 64, "{sha}");
9893            assert!(
9894                sha.chars()
9895                    .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
9896                "{sha}"
9897            );
9898        }
9899    }
9900
9901    /// The hashed system body is the payload's `system` string, as text — so the blob reads as a
9902    /// prompt rather than as a JSON-quoted one.
9903    #[tokio::test]
9904    async fn the_system_blob_holds_the_prompt_as_readable_text() {
9905        let dir = tempfile::tempdir().unwrap();
9906        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
9907        let payload = wire_payload("[Capsule] you are cap.\nBe terse.", 1);
9908        write_turn(&mut w, 0, &payload, "{}").await;
9909        w.flush().await.unwrap();
9910
9911        let sha = read_events(dir.path())[0]["system_sha"]
9912            .as_str()
9913            .unwrap()
9914            .to_string();
9915        assert_eq!(
9916            std::fs::read_to_string(blob_path(dir.path(), &sha)).unwrap(),
9917            "[Capsule] you are cap.\nBe terse."
9918        );
9919    }
9920
9921    /// `meta` is the default and hashes exactly what `content` hashes; the difference is only
9922    /// whether the bodies are on disk.
9923    #[tokio::test]
9924    async fn meta_writes_the_same_hashes_and_no_bodies() {
9925        let payload = wire_payload("You are a capsule.", 2);
9926        let response = r#"{"stop_reason":"end_turn"}"#;
9927
9928        let meta = tempfile::tempdir().unwrap();
9929        let mut w = make_writer_with_opts(meta.path(), TraceCapture::Meta).await;
9930        write_turn(&mut w, 0, &payload, response).await;
9931        w.flush().await.unwrap();
9932
9933        let content = tempfile::tempdir().unwrap();
9934        let mut w = make_writer_with_opts(content.path(), TraceCapture::Content).await;
9935        write_turn(&mut w, 0, &payload, response).await;
9936        w.flush().await.unwrap();
9937
9938        let meta_event = read_events(meta.path()).remove(0);
9939        let content_event = read_events(content.path()).remove(0);
9940        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
9941            assert_eq!(meta_event[key], content_event[key], "{key}");
9942        }
9943        assert!(
9944            !meta.path().join(BLOB_DIR_NAME).exists(),
9945            "meta must not create the blob directory"
9946        );
9947        assert!(content.path().join(BLOB_DIR_NAME).is_dir());
9948    }
9949
9950    /// The default a writer gets when the manifest declares no `trace:` block.
9951    #[tokio::test]
9952    async fn the_default_capture_mode_is_meta() {
9953        assert_eq!(TraceCapture::default(), TraceCapture::Meta);
9954        let dir = tempfile::tempdir().unwrap();
9955        let mut w = make_writer_with_prompt(dir.path(), TraceCapture::default(), None, false).await;
9956        write_turn(&mut w, 0, &wire_payload("s", 1), "{}").await;
9957        w.flush().await.unwrap();
9958
9959        let e = &read_events(dir.path())[0];
9960        assert!(e.get("system_sha").is_some());
9961        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
9962    }
9963
9964    /// Under `none` not one of the four hash keys is present, and every other field is.
9965    #[tokio::test]
9966    async fn none_omits_every_hash_and_leaves_the_rest_of_the_event_intact() {
9967        let payload = wire_payload("You are a capsule.", 2);
9968
9969        let off = tempfile::tempdir().unwrap();
9970        let mut w = make_writer_with_opts(off.path(), TraceCapture::None).await;
9971        write_turn(&mut w, 3, &payload, "{}").await;
9972        w.flush().await.unwrap();
9973
9974        let e = read_events(off.path()).remove(0);
9975        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
9976            assert!(
9977                e.get(key).is_none(),
9978                "{key} must be absent under none, got {e}"
9979            );
9980        }
9981        assert!(!off.path().join(BLOB_DIR_NAME).exists());
9982        for key in [
9983            "event_type",
9984            "event_id",
9985            "parent_id",
9986            "session_id",
9987            "timestamp",
9988            "turn",
9989            "task_id",
9990            "input_tokens",
9991            "output_tokens",
9992            "decision",
9993            "tool_name",
9994            "message_ids",
9995        ] {
9996            assert!(
9997                e.get(key).is_some(),
9998                "{key} must survive under none, got {e}"
9999            );
10000        }
10001        assert_eq!(e["turn"], 3);
10002        assert_eq!(e["message_ids"].as_array().unwrap().len(), 1);
10003    }
10004
10005    /// A prompt that does not change across ten turns is one blob, written once and never
10006    /// rewritten — the file's bytes are replaced by hand after the first turn and must survive.
10007    #[tokio::test]
10008    async fn an_unchanged_system_prompt_is_stored_once() {
10009        let dir = tempfile::tempdir().unwrap();
10010        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10011        let payload = wire_payload("You are a capsule.", 1);
10012        write_turn(&mut w, 0, &payload, "{}").await;
10013
10014        let sha = {
10015            w.flush().await.unwrap();
10016            read_events(dir.path())[0]["system_sha"]
10017                .as_str()
10018                .unwrap()
10019                .to_string()
10020        };
10021        let path = blob_path(dir.path(), &sha);
10022        std::fs::write(&path, b"sentinel").unwrap();
10023
10024        for turn in 1..10 {
10025            write_turn(&mut w, turn, &payload, "{}").await;
10026        }
10027        w.flush().await.unwrap();
10028
10029        let events = read_events(dir.path());
10030        assert_eq!(events.len(), 10);
10031        for e in &events {
10032            assert_eq!(e["system_sha"], sha.as_str());
10033        }
10034        assert_eq!(
10035            std::fs::read(&path).unwrap(),
10036            b"sentinel",
10037            "an existing blob is never rewritten"
10038        );
10039        let matching = std::fs::read_dir(dir.path().join(BLOB_DIR_NAME))
10040            .unwrap()
10041            .filter(|entry| entry.as_ref().unwrap().file_name() == sha.as_str())
10042            .count();
10043        assert_eq!(matching, 1, "exactly one file bears that name");
10044    }
10045
10046    /// A record written for a completion the runtime parsed no driver response for names no stop
10047    /// reason: the key is absent, never an empty string. Absence means "no driver response was
10048    /// parsed here", so a reader can tell it apart from a driver that reported nothing — which is
10049    /// recorded as `""`.
10050    #[tokio::test]
10051    async fn stop_reason_is_absent_when_no_driver_response_was_parsed() {
10052        let dir = tempfile::tempdir().unwrap();
10053        let mut w = make_writer(dir.path()).await;
10054        w.write_inference(
10055            0,
10056            Some(10),
10057            Some(5),
10058            "end_turn".to_string(),
10059            None,
10060            None,
10061            None,
10062            None,
10063            Vec::new(),
10064            None,
10065            None,
10066        )
10067        .await
10068        .unwrap();
10069        w.flush().await.unwrap();
10070
10071        let event = read_events(dir.path()).remove(0);
10072        assert!(
10073            event.get("stop_reason").is_none(),
10074            "stop_reason must be absent, got {event}"
10075        );
10076    }
10077
10078    /// A hook's `run-inference` sends a payload the runtime never built, so its record names no
10079    /// hashes — absent from the JSON, not empty strings.
10080    #[tokio::test]
10081    async fn a_hook_origin_record_carries_no_hashes() {
10082        let dir = tempfile::tempdir().unwrap();
10083        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10084        let origin = InferenceOrigin {
10085            source: "hook:gatekeeper".to_string(),
10086            model: "claude-test".to_string(),
10087        };
10088        w.write_inference(
10089            0,
10090            Some(1),
10091            Some(1),
10092            "end_turn".to_string(),
10093            // A hook's `run-inference` saw no provider stop reason, so it records none.
10094            None,
10095            None,
10096            Some(&origin),
10097            None,
10098            Vec::new(),
10099            None,
10100            None,
10101        )
10102        .await
10103        .unwrap();
10104        w.flush().await.unwrap();
10105
10106        let e = read_events(dir.path()).remove(0);
10107        assert_eq!(e["origin"], "hook:gatekeeper");
10108        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
10109            assert!(e.get(key).is_none(), "{key} must be absent, got {e}");
10110        }
10111        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
10112    }
10113
10114    /// Two runs sharing a prefix agree on that prefix's `message_shas` and disagree from the
10115    /// changed message onwards — the divergence index is the first unequal position.
10116    #[tokio::test]
10117    async fn message_shas_locate_the_index_two_runs_diverge_at() {
10118        async fn shas_for(payload: &Value) -> Vec<String> {
10119            let dir = tempfile::tempdir().unwrap();
10120            let mut w = make_writer_with_opts(dir.path(), TraceCapture::Meta).await;
10121            write_turn(&mut w, 0, payload, "{}").await;
10122            w.flush().await.unwrap();
10123            read_events(dir.path())[0]["message_shas"]
10124                .as_array()
10125                .unwrap()
10126                .iter()
10127                .map(|s| s.as_str().unwrap().to_string())
10128                .collect()
10129        }
10130
10131        let first = wire_payload("You are a capsule.", 4);
10132        let mut second = first.clone();
10133        second["messages"][2]["content"] = serde_json::json!("diverged");
10134
10135        let a = shas_for(&first).await;
10136        let b = shas_for(&second).await;
10137        assert_eq!(a.len(), 4);
10138        let divergence = a.iter().zip(&b).position(|(x, y)| x != y);
10139        assert_eq!(divergence, Some(2));
10140        assert_eq!(a[3], b[3], "only the changed message changes its own sha");
10141    }
10142
10143    /// A message repeated inside one request hashes to one name and one file. Ids never repeat;
10144    /// shas repeat exactly when content does, and that is what makes them comparable.
10145    #[tokio::test]
10146    async fn identical_messages_share_one_sha_and_one_blob() {
10147        let dir = tempfile::tempdir().unwrap();
10148        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10149        let payload = serde_json::json!({
10150            "messages": [{"role": "user", "content": "same"}, {"role": "user", "content": "same"}],
10151            "tools": [],
10152            "system": "s",
10153        });
10154        write_turn(&mut w, 0, &payload, "{}").await;
10155        w.flush().await.unwrap();
10156
10157        let shas = read_events(dir.path())[0]["message_shas"]
10158            .as_array()
10159            .unwrap()
10160            .iter()
10161            .map(|s| s.as_str().unwrap().to_string())
10162            .collect::<Vec<_>>();
10163        assert_eq!(shas[0], shas[1]);
10164        assert!(blob_path(dir.path(), &shas[0]).is_file());
10165    }
10166
10167    /// Blobs are the bytes as sent, so a peer handle a tool call would have had redacted out of
10168    /// its summary stays in the body a `content` capture stores.
10169    #[tokio::test]
10170    async fn a_message_blob_is_the_body_verbatim() {
10171        let dir = tempfile::tempdir().unwrap();
10172        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10173        let message = serde_json::json!({"role": "user", "content": "hello"});
10174        let payload = serde_json::json!({
10175            "messages": [message.clone()],
10176            "tools": [],
10177            "system": "s",
10178        });
10179        write_turn(&mut w, 0, &payload, "{}").await;
10180        w.flush().await.unwrap();
10181
10182        let sha = read_events(dir.path())[0]["message_shas"][0]
10183            .as_str()
10184            .unwrap()
10185            .to_string();
10186        assert_eq!(
10187            std::fs::read(blob_path(dir.path(), &sha)).unwrap(),
10188            serde_json::to_vec(&message).unwrap()
10189        );
10190    }
10191
10192    /// A delegation record a plan's `capsule` step wrote and one `delegate-task` wrote are the
10193    /// same record.
10194    ///
10195    /// Identical key sets and identical values on everything but the two fields that are
10196    /// per-line by construction — the event id and the clock. That is what makes a trace
10197    /// unable to say which surface launched a child, and it holds because both appenders
10198    /// serialize the same struct rather than because this test watches them.
10199    #[tokio::test]
10200    async fn a_plan_written_delegation_record_matches_a_delegate_task_one() {
10201        let plan_dir = tempfile::tempdir().unwrap();
10202        let tool_dir = tempfile::tempdir().unwrap();
10203        let session = "ses_0000000000004000800000000000plan".to_string();
10204        let node = "evt_0000000000004000800000000000node".to_string();
10205
10206        let plan = PlanTraceAppender::open(
10207            &crate::workdir_fs::WorkdirRoot::open(plan_dir.path()).unwrap(),
10208            session.clone(),
10209            node.clone(),
10210        )
10211        .unwrap();
10212        let tool = ResourceTraceAppender::open(
10213            &crate::workdir_fs::WorkdirRoot::open(tool_dir.path()).unwrap(),
10214            session.clone(),
10215            node.clone(),
10216        )
10217        .await
10218        .unwrap();
10219
10220        let snapshot = crate::workdir_snapshot::WorkdirSnapshot {
10221            source: "/home/you/project".to_string(),
10222            files: 2,
10223            dirs: 0,
10224            symlinks: 0,
10225            repointed_symlinks: 0,
10226            bytes: 12,
10227            skipped_count: 0,
10228            skipped: Vec::new(),
10229        };
10230        plan.write_delegation_start(
10231            "dlg_abc123",
10232            "worker",
10233            "0.1.0",
10234            "ses_child",
10235            ".murmur/c",
10236            "/r/ses_child",
10237            Some(&snapshot),
10238        );
10239        tool.write_delegation_start(
10240            "dlg_abc123",
10241            "worker",
10242            "0.1.0",
10243            "ses_child",
10244            ".murmur/c",
10245            "/r/ses_child",
10246            Some(&snapshot),
10247        )
10248        .await;
10249        plan.write_delegation(
10250            "worker",
10251            "0.1.0",
10252            Some("dlg_abc123".to_string()),
10253            Some("ses_child".to_string()),
10254            42,
10255            "completed",
10256            None,
10257        );
10258        tool.write_delegation(
10259            "worker",
10260            "0.1.0",
10261            Some("dlg_abc123".to_string()),
10262            Some("ses_child".to_string()),
10263            42,
10264            "completed",
10265            None,
10266        )
10267        .await;
10268
10269        let read = |dir: &Path| -> Vec<serde_json::Map<String, Value>> {
10270            std::fs::read_to_string(dir.join("trace.jsonl"))
10271                .unwrap()
10272                .lines()
10273                .map(|line| serde_json::from_str::<serde_json::Map<_, _>>(line).unwrap())
10274                .collect()
10275        };
10276        let from_plan = read(plan_dir.path());
10277        let from_tool = read(tool_dir.path());
10278        assert_eq!(from_plan.len(), 2);
10279        assert_eq!(from_tool.len(), 2);
10280        assert_eq!(
10281            from_plan[0]["workdir_snapshot"],
10282            serde_json::to_value(&snapshot).unwrap()
10283        );
10284
10285        for (planned, called, event_type) in [
10286            (&from_plan[0], &from_tool[0], "delegation_start"),
10287            (&from_plan[1], &from_tool[1], "delegation"),
10288        ] {
10289            assert_eq!(planned["event_type"], event_type);
10290            assert_eq!(called["event_type"], event_type);
10291            assert_eq!(
10292                planned.keys().collect::<Vec<_>>(),
10293                called.keys().collect::<Vec<_>>(),
10294                "{planned:?} vs {called:?}"
10295            );
10296            assert_eq!(planned["parent_id"], node.as_str());
10297            assert_eq!(called["parent_id"], node.as_str());
10298            for (key, value) in planned {
10299                if key == "event_id" || key == "timestamp" {
10300                    continue;
10301                }
10302                assert_eq!(value, &called[key], "{key}: {planned:?} vs {called:?}");
10303            }
10304            for key in ["plan_id", "step_id", "surface", "kind"] {
10305                assert!(!planned.contains_key(key), "{key} is on {planned:?}");
10306                assert!(!called.contains_key(key), "{key} is on {called:?}");
10307            }
10308        }
10309    }
10310
10311    /// `spawner_ended` carries the lineage `session_start` does, on the same terms: both keys
10312    /// present for a delegated child, both absent — not null — for a session launched with a
10313    /// lifeline but no spawner handle.
10314    #[tokio::test]
10315    async fn spawner_ended_names_the_lineage_or_omits_it() {
10316        let dir = tempfile::tempdir().unwrap();
10317        let session = "ses_0000000000004000800000000child".to_string();
10318        let node = "evt_0000000000004000800000000000node".to_string();
10319        let appender = ResourceTraceAppender::open(
10320            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
10321            session.clone(),
10322            node.clone(),
10323        )
10324        .await
10325        .unwrap();
10326
10327        appender
10328            .write_spawner_ended(Some("ses_parent"), Some("dlg_abc123"))
10329            .await;
10330        appender.write_spawner_ended(None, None).await;
10331
10332        let lines: Vec<serde_json::Map<String, Value>> =
10333            std::fs::read_to_string(dir.path().join("trace.jsonl"))
10334                .unwrap()
10335                .lines()
10336                .map(|line| serde_json::from_str(line).unwrap())
10337                .collect();
10338        assert_eq!(lines.len(), 2);
10339        let named = &lines[0];
10340        assert_eq!(named["event_type"], "spawner_ended");
10341        assert_eq!(named["parent_id"], node.as_str());
10342        assert_eq!(named["session_id"], session.as_str());
10343        assert_eq!(named["spawned_by"], "ses_parent");
10344        assert_eq!(named["delegation_id"], "dlg_abc123");
10345        assert_eq!(
10346            named.keys().collect::<Vec<_>>(),
10347            [
10348                "delegation_id",
10349                "event_id",
10350                "event_type",
10351                "parent_id",
10352                "session_id",
10353                "spawned_by",
10354                "timestamp"
10355            ]
10356        );
10357        let bare = &lines[1];
10358        assert_eq!(bare["event_type"], "spawner_ended");
10359        assert!(!bare.contains_key("spawned_by"), "{bare:?}");
10360        assert!(!bare.contains_key("delegation_id"), "{bare:?}");
10361    }
10362}