Skip to main content

capsule_runtime/
trace.rs

1use std::{
2    path::Path,
3    time::{Instant, SystemTime, UNIX_EPOCH},
4};
5
6use murmur_artifact::{ContainmentClass, LockOrigin, TraceCapture};
7use serde::Serialize;
8use serde_json::Value;
9use tokio::{
10    fs::{File, OpenOptions},
11    io::{AsyncWriteExt, BufWriter},
12};
13
14use crate::{
15    agent::DriverUsage,
16    containment::ScopeReport,
17    formation::FormationId,
18    gateway_credential::CredentialChange,
19    lanes::TaskLane,
20    origin::{TaskProvenance, TrustClass},
21    trace_blobs::BlobStore,
22    types::InstalledArtifactSummary,
23};
24
25pub(crate) struct TraceWriter {
26    writer: BufWriter<File>,
27    session_id: String,
28    capsule_name: String,
29    capsule_version: String,
30    model: String,
31    capabilities: Vec<String>,
32    /// The session's complete effective grant set, computed once at stage time. Written whole to
33    /// `session_start` as `effective_grants`, and also the source of that event's
34    /// `containment_declared`/`containment_achieved`/`workdir_exec` summary fields — they are read
35    /// off this report rather than passed in beside it, so a trace cannot claim one containment
36    /// class at the top level and another inside the report.
37    effective_grants: ScopeReport,
38    /// How much of each turn's driver request this trace keeps — see [`TraceCapture`]. Decides
39    /// whether an `inference` event carries content hashes at all, whether the bodies behind
40    /// them reach [`Self::blobs`], and whether `tool_call.output` is written.
41    capture: TraceCapture,
42    /// The content-addressed bodies this session stored, at `<workdir>/blobs/<sha256>`. Only
43    /// written to under [`TraceCapture::Content`]; the directory is created on the first blob.
44    blobs: BlobStore,
45    /// Where the effective system prompt came from: `"manifest"`, `"cli"` or `"none"`. Derived
46    /// once in [`TraceWriter::open`] from the resolved prompt and the override flag, then repeated
47    /// on every `session_start` the same way `model` is — the prompt cannot change between the
48    /// tasks of one session.
49    system_prompt_source: &'static str,
50    /// SHA-256 (lowercase hex) of the resolved system prompt, or `None` when no prompt is in
51    /// effect. Always written to `session_start` (as `null` when absent) so a trace records *that*
52    /// a prompt was in effect and which one, even when the text itself is withheld.
53    system_prompt_sha256: Option<String>,
54    /// Where the inference credential came from: `"config"`, `"environment"`, `"manifest"` or
55    /// `"none"`. Set by [`Self::set_credential_source`] before `session_start` is written.
56    credential_source: &'static str,
57    /// Every credential gateway the session holds. Set by [`Self::set_gateways`] before
58    /// `session_start` is written; empty until then.
59    gateways: Vec<SessionGateway>,
60    /// What a controller may change on this session. Set by [`Self::set_control`] before
61    /// `session_start` is written; `None` for a capsule with no `control:` block.
62    control: Option<SessionControl>,
63    /// The driver choices. Set by [`Self::set_inference_choices`] before `session_start` is
64    /// written; empty for a capsule with no alternates.
65    inference_choices: Vec<SessionInferenceChoice>,
66    /// `inference.tool_refresh` as `ToolRefresh::wire_name` spells it, or `None` under
67    /// `transport: process`. Set by [`Self::set_tool_refresh`] before `session_start` is written.
68    tool_refresh: Option<&'static str>,
69    /// The staged artifacts whose `murmur.lock` pin a running capsule fetched. Set by
70    /// [`Self::set_runtime_artifacts`] before `session_start` is written; empty until then.
71    runtime_artifacts: Vec<RuntimeArtifact>,
72    /// The session `mur run --resume` continued, verbatim as the operator's address resolved it.
73    /// `None` on every ordinary launch. Written to `session_start` on both, so its absence
74    /// identifies a trace from a runtime that predates the key.
75    resumed_from: Option<String>,
76    /// The launch-scoped context id: the `--context` value, or the id `--resume` resolved to.
77    /// `None` when each task of this launch mints its own.
78    context_id: Option<String>,
79    /// The session that spawned this one, and the delegation that created it, read out of the
80    /// handle the spawning capsule's launcher injected. Both `None` for a capsule nobody
81    /// delegated, and both omitted from `session_start` when absent rather than written as null.
82    spawned_by: Option<String>,
83    spawn_delegation_id: Option<String>,
84    /// The formation this session is a member of. Set by [`Self::set_formation_id`] before
85    /// `session_start` is written; `None` for a session in no formation.
86    formation_id: Option<FormationId>,
87    /// This session's roster name and callees, set by [`Self::set_formation_member`] for a
88    /// formation member.
89    formation_member: Option<(String, Vec<String>)>,
90    session_start_time: Instant,
91    /// The session node of the event tree: the `event_id` `session_start` carries, and the
92    /// `parent_id` every launch-scoped event names. Minted in [`TraceWriter::open`] rather than
93    /// at write time so [`ResourceTraceAppender`], which is opened before the frame is written,
94    /// can be handed the same value.
95    session_event_id: String,
96    session_started: bool,
97    session_ended: bool,
98    /// The task node: the `event_id` of the active task's `task_start`. Set by
99    /// [`Self::write_task_start`], cleared by [`Self::write_task_end`], `None` between tasks.
100    task_event_id: Option<String>,
101    /// The turn node: the `event_id` of the active turn's own agent-loop `inference`. Every
102    /// event a turn produces hangs off it. Cleared at both task boundaries so a new task never
103    /// parents its first events to the previous task's last turn.
104    turn_event_id: Option<String>,
105    // Running totals — updated as events are written, used for fallback session_end on error exit
106    total_turns: u32,
107    total_input_tokens: u64,
108    total_output_tokens: u64,
109    total_tool_calls: u32,
110    total_shell_calls: u32,
111    // Per-task counters — reset on each write_task_start; consumed by write_task_end
112    task_turns: u32,
113    task_input_tokens: u64,
114    task_output_tokens: u64,
115    task_tool_calls: u32,
116    task_shell_calls: u32,
117    /// Agent-loop `inference` lines (`origin` absent) written since `task_start`, counted or
118    /// not; a hook's `run-inference` record is not one. Where an attempt's turn numbering starts,
119    /// so a task's turns number straight through its reopens and continuations. Reset at both
120    /// task boundaries, so a run outside any task frame starts at 0.
121    task_agent_turns: u32,
122    /// Counted hook `run-inference` lines (`origin` present) written since `task_start`: the part
123    /// of [`Self::task_turns`] no agent-loop turn spent. Reset with the other per-task counters.
124    task_hook_inferences: u32,
125    task_start_instant: Option<Instant>,
126    pub(crate) active_task_id: Option<String>,
127    /// How many `task_failed` lines this writer has written, so a caller can tell whether one run
128    /// wrote its own without comparing reasons.
129    task_failures_written: u64,
130    /// The `reason` of the last `task_failed` line, as written (already capped).
131    last_task_failure: Option<String>,
132    /// The spend ceilings `session_start` records. Set by [`Self::set_spend_ceilings`].
133    max_session_tokens: Option<u64>,
134    machine_tokens_per_day: Option<u64>,
135}
136
137/// Provenance of an inference record that did **not** come from the agent
138/// loop's own turn — today, only a hook's `run-inference` call. `None` at a
139/// `write_inference`/`emit_inference` call site means "ordinary agent-loop
140/// turn", which serializes exactly as it did before this existed.
141#[derive(Debug, Clone)]
142pub(crate) struct InferenceOrigin {
143    /// `hook:<manifest name>` of the hook that made the call.
144    pub(crate) source: String,
145    /// Model string actually sent (the attempted model, on failure).
146    pub(crate) model: String,
147}
148
149/// `compaction_declined.reason` when the threshold was crossed but no bound hook returned
150/// `replace-context`.
151pub(crate) const COMPACTION_DECLINED_NO_HOOK_REPLACEMENT: &str = "no_hook_replacement";
152
153/// `compaction_declined.reason` when a hook's replacement was rejected because its tool calls
154/// and tool results did not pair up.
155pub(crate) const COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL: &str = "unresolved_tool_call";
156
157/// `context_seed.outcome` when the whole proposal fit the budget and was committed as-is.
158pub(crate) const SEED_OUTCOME_SEEDED: &str = "seeded";
159
160/// `context_seed.outcome` when the proposal was over budget and the oldest messages were
161/// dropped from its front until the rest fit.
162pub(crate) const SEED_OUTCOME_TRIMMED: &str = "trimmed";
163
164/// `context_seed.outcome` when the overflowing front was handed to the compaction hook and
165/// its summary became the seed's first message.
166pub(crate) const SEED_OUTCOME_COMPACTED: &str = "compacted";
167
168/// `context_seed.outcome` when nothing was committed. Always paired with a `reason`.
169pub(crate) const SEED_OUTCOME_REJECTED: &str = "rejected";
170
171/// `context_seed.reason` when one message alone is wider than the whole budget. Trimming
172/// cannot help — dropping everything else still leaves it over — so nothing is committed.
173pub(crate) const SEED_REJECTED_MESSAGE_OVER_BUDGET: &str = "message_over_budget";
174
175/// `context_seed.reason` when the proposal overflows the budget by more multiples of it than
176/// the runtime will summarize. A seed that far over is a broken hook, not a full memory.
177pub(crate) const SEED_REJECTED_OVERFLOW_OVER_LIMIT: &str = "overflow_over_limit";
178
179/// `context_seed.reason` when the capsule declares no `context.max_tokens`, so
180/// `context.seed_budget` is a fraction of nothing and there is no ceiling to enforce.
181pub(crate) const SEED_REJECTED_NO_BUDGET: &str = "no_budget";
182
183/// `context_seed.reason` when the session runs `inference.transport: process`, which owns its
184/// own context and has no host-side message list to seed.
185pub(crate) const SEED_REJECTED_UNSUPPORTED_TRANSPORT: &str = "unsupported_transport";
186
187/// `retention.store` for the session directories under a workdir.
188pub(crate) const RETENTION_STORE_SESSIONS: &str = "sessions";
189
190/// `retention.store` for the conversation records under `~/.murmur/conversations/`.
191pub(crate) const RETENTION_STORE_RECORDS: &str = "records";
192
193/// `retention.reason` when `trace.retain.max_sessions` put a session outside the newest N.
194pub(crate) const RETENTION_REASON_MAX_SESSIONS: &str = "max_sessions";
195
196/// `retention.reason` when `trace.retain.max_age` or `context.retain.max_age` put a session or a
197/// record outside the window.
198pub(crate) const RETENTION_REASON_MAX_AGE: &str = "max_age";
199
200/// `retention.reason` when `context.retain.max_messages` truncated the front of a record.
201pub(crate) const RETENTION_REASON_MAX_MESSAGES: &str = "max_messages";
202
203/// Mint one event identity: `evt_` + a UUID v7 in simple form, the same scheme `ses_`, `tsk_`,
204/// `ctx_`, `dep_` and `req_` use. Ids therefore sort by mint time and carry their own
205/// millisecond timestamp, so a trace can be ordered and time-bounded without reading any
206/// payload field.
207///
208/// Called once per line at the moment of the write. An id is never reused, never derived from
209/// the event's content, and never reconstructed from anything else in the file.
210pub(crate) fn new_event_id() -> String {
211    format!("evt_{}", uuid::Uuid::now_v7().simple())
212}
213
214// ── Event structs (Serialize → JSONL lines) ──────────────────────────────────
215//
216// Every struct opens with `event_type`, `event_id`, `parent_id`, `session_id` and `timestamp`,
217// in that order, so a human scanning the file reads a line's identity before its payload.
218// `parent_id` is the `event_id` of the event this one hangs off — see [`TraceWriter`]'s
219// `session_parent`/`task_parent`/`turn_parent` for which node each event names, and
220// [`new_event_id`] for the id format.
221//
222// The turn-level events (`inference`, `tool_call`, `skill_call`, `shell`, `compaction`,
223// `compaction_declined`) additionally carry `task_id`, taken from the writer's active task and
224// always written — `null` when no task is in scope. `task_start`/`task_end` carry their own and
225// are not duplicated.
226
227#[derive(Serialize)]
228struct SessionStartEvent {
229    event_type: &'static str,
230    event_id: String,
231    parent_id: Option<String>,
232    session_id: String,
233    timestamp: u64,
234    capsule_name: String,
235    capsule_version: String,
236    model: String,
237    max_turns: u32,
238    /// `inference.max_session_tokens` as this session enforces it. Always written, `null` when
239    /// no session ceiling applies.
240    max_session_tokens: Option<u64>,
241    /// `spend.machine_tokens_per_day` when this session keeps the machine ledger. Always written,
242    /// `null` when the machine ceiling does not cover this session.
243    machine_tokens_per_day: Option<u64>,
244    capabilities: Vec<String>,
245    tools_declared: Vec<String>,
246    /// Strongest containment class any source asked for. A *requirement*, not an observation.
247    containment_declared: ContainmentClass,
248    /// What this session actually ran under: the probed enforcement tier's ceiling, capped by
249    /// `workdir_exec` below. Reading these two together is how a session's trace shows whether the
250    /// capsule ran under the containment its operator asked for.
251    containment_achieved: ContainmentClass,
252    /// `capabilities.filesystem.workdir_exec`. Always written, including `false`, so its absence
253    /// identifies a trace from a runtime that predates the key rather than a capsule that declined
254    /// it.
255    ///
256    /// `true` is the reason a trace can show `containment_achieved: advisory` on a host whose
257    /// `session_start` would otherwise say `scoped` — and it is the record that this session's
258    /// `capabilities.shell.allow` was advisory too, since anything in the workdir could run.
259    workdir_exec: bool,
260    /// Where this host's permission to create an unprivileged user namespace came from, as
261    /// `UsernsGrant::wire_name` — `null` only off Linux, where AppArmor does not exist.
262    ///
263    /// Mirrored to the top level from `effective_grants` for the same reason `workdir_exec` is:
264    /// an auditor should not have to descend into a nested object to answer it. Recorded because
265    /// `containment_achieved: sealed` reached through the shipped AppArmor profile and the same
266    /// class reached on a host whose unprivileged-userns hardening is switched off for every
267    /// binary are two very different records, and this key is the only thing separating them.
268    ///
269    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
270    /// runtime that predates the key.
271    userns_grant: Option<crate::sealed::UsernsGrant>,
272    /// The complete grant set this session ran under — every destination, binary, path and
273    /// environment variable the policy actually opened, plus the probed enforcement tier — in the
274    /// exact shape `mur run --explain-scope --json` prints for the same policy on the same host.
275    ///
276    /// `capabilities` above stays what it always was: a list of category *names*. It answers "did
277    /// this session have network access?" but not "to where?", which is the question an auditor
278    /// reading a finished trace actually has. This field answers it without re-parsing the
279    /// manifest, which by then may have changed or moved.
280    effective_grants: ScopeReport,
281    /// Where the system prompt this session ran with came from: `"manifest"` when the manifest's
282    /// own `inference.system_prompt`/`system_prompt_file`/`system_prompt_artifact` supplied it,
283    /// `"cli"` when `mur run --system-prompt` overrode it (including when the override was empty
284    /// and therefore cleared it), `"none"` when no prompt was in effect at all.
285    ///
286    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
287    /// runtime that predates the key, not a session that had no prompt.
288    system_prompt_source: &'static str,
289    /// SHA-256 (lowercase hex) of the resolved prompt — the text as `resolve_system_prompt`
290    /// returned it, before the `[Capsule]` identity block is prepended — or `null` when no prompt
291    /// was in effect. Always written, so two sessions can be compared for prompt equality without
292    /// either trace having to carry the prompt itself.
293    ///
294    /// Deliberately not the same value as `inference.system_sha`, which covers the augmented
295    /// prompt that actually went on the wire: this one names the prompt the operator wrote.
296    /// Under `trace.capture: content` those resolved bytes are also the blob this hash names.
297    system_prompt_sha256: Option<String>,
298    /// Where the inference credential this session attaches came from: `"config"`,
299    /// `"environment"`, `"manifest"` or `"none"`. Never the value.
300    credential_source: &'static str,
301    /// Every credential gateway the session holds, the inference gateway first. Always written,
302    /// and empty for a session with none, so an auditor can tell an unmetered third-party
303    /// upstream from spend the ceilings cover.
304    gateways: Vec<SessionGateway>,
305    /// What a controller holding this session's control token may change: the setting names and
306    /// secret names `control:` declares. Omitted entirely for a capsule with no `control:` block.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    control: Option<SessionControl>,
309    /// Every driver choice the agent loop may be switched to, the primary first, each with its
310    /// credential's source and whether it resolved at launch. Omitted entirely for a capsule that
311    /// declares no `inference.alternates`.
312    #[serde(skip_serializing_if = "Vec::is_empty")]
313    inference_choices: Vec<SessionInferenceChoice>,
314    /// `inference.tool_refresh`: `"compaction"` or `"immediate"` under `transport: http`, `null`
315    /// under `transport: process`, where the harness owns its tool list. Always written.
316    tool_refresh: Option<&'static str>,
317    /// Every staged artifact whose `murmur.lock` origin is `runtime` — pinned by a running
318    /// capsule's `manage.pull()`, never vetted by the operator — in staging order. Each reaches
319    /// the model marked untrusted. Always written, `[]` when there are none.
320    runtime_artifacts: Vec<RuntimeArtifact>,
321    /// The session `mur run --resume` continued, or `null` on an ordinary launch. Together with
322    /// `context_id` below it is what makes a resumed conversation followable back through the
323    /// sessions that built it.
324    ///
325    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
326    /// runtime that predates the key.
327    resumed_from: Option<String>,
328    /// The context id every task of this launch runs under — the `mur run --context` value, or
329    /// the id `--resume` resolved to — and `null` when each task mints its own. `task_start`
330    /// carries the id a task actually ran under either way.
331    ///
332    /// Always written, on the same terms as `resumed_from`.
333    context_id: Option<String>,
334    /// The `ses_` id of the session that spawned this one, for a capsule launched by another
335    /// capsule's `delegate-task`. Omitted from the record entirely for every other launch: the
336    /// field is absent, not null.
337    #[serde(skip_serializing_if = "Option::is_none")]
338    spawned_by: Option<String>,
339    /// The `dlg_` id of the delegation that created this session, character-identical to the id
340    /// in the spawning session's own `delegation_start`. Present exactly when `spawned_by` is.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    delegation_id: Option<String>,
343    /// The formation this session is a member of. Omitted entirely for a session in no formation,
344    /// absent rather than null, on the same terms as `spawned_by`. Being on `session_start`, it is
345    /// on the trace's first line, which is the only line a reader grouping sessions reads.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    formation_id: Option<FormationId>,
348    /// This session's roster name, for a session its formation launcher handed credentials to.
349    /// Omitted for every other session, a delegated child of a member included.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    formation_member: Option<String>,
352    /// The members this one may call, in roster order: `[]` for a member that may call nobody.
353    /// Present exactly when `formation_member` is.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    formation_callees: Option<Vec<String>>,
356}
357
358#[derive(Serialize)]
359struct InferenceEvent {
360    event_type: &'static str,
361    event_id: String,
362    parent_id: Option<String>,
363    session_id: String,
364    timestamp: u64,
365    turn: u32,
366    task_id: Option<String>,
367    /// The input this turn cost, and the number the task and session totals accumulate.
368    ///
369    /// Under `transport: http` it is the runtime's own tiktoken estimate of the request, counted
370    /// before the request was sent, and the provider's own count sits beside it in
371    /// `input_tokens_actual`. Under `transport: process` the runtime estimates nothing — the
372    /// harness holds the conversation — so this *is* the harness's own reported count and
373    /// `input_tokens_actual` is absent: there is one measurement on that transport and it is
374    /// recorded once.
375    ///
376    /// Absent means no count was made at all: a `process` driver reporting no usage, or a failed
377    /// call that is not counted (see [`TraceWriter::write_failed_inference`]). Never conflate it
378    /// with `0`, which is a count of none.
379    #[serde(skip_serializing_if = "Option::is_none")]
380    input_tokens: Option<u64>,
381    /// The output this turn cost, on the same terms as [`Self::input_tokens`]: the runtime's own
382    /// tiktoken estimate of the raw driver response under `http`, the harness's own reported
383    /// count under `process`.
384    #[serde(skip_serializing_if = "Option::is_none")]
385    output_tokens: Option<u64>,
386    decision: String,
387    /// The provider's own stop reason for this turn, verbatim as the agent loop dispatched on
388    /// it. Present on every agent-loop turn, including one whose driver reported nothing — an
389    /// empty string then, because that is what the loop dispatched on. `"error"` on every failed
390    /// call, on either transport and for a hook's `run-inference`. Otherwise absent means no
391    /// driver response was parsed at all: a hook's `run-inference` and the `process` transport
392    /// both name their `decision` locally and never saw a provider stop reason.
393    #[serde(skip_serializing_if = "Option::is_none")]
394    stop_reason: Option<String>,
395    tool_name: Option<String>,
396    /// The provider's own counts for this call, each written only when the driver reported
397    /// it. Absent means the driver reported nothing, never zero.
398    #[serde(skip_serializing_if = "Option::is_none")]
399    input_tokens_actual: Option<u64>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    output_tokens_actual: Option<u64>,
402    #[serde(skip_serializing_if = "Option::is_none")]
403    cached_tokens: Option<u64>,
404    #[serde(skip_serializing_if = "Option::is_none")]
405    cache_write_tokens: Option<u64>,
406    /// The part of the output the provider or harness reports as reasoning — a subset of
407    /// `output_tokens`, never an addition to it. Absent when nothing reported one.
408    #[serde(skip_serializing_if = "Option::is_none")]
409    thinking_tokens: Option<u64>,
410    /// Where this inference came from. Absent for an ordinary agent-loop turn,
411    /// so every pre-existing consumer sees a byte-identical record; `"hook:<name>"`
412    /// for a completion that hook ran through `murmur:runtime/inference`'s
413    /// `run-inference`.
414    #[serde(skip_serializing_if = "Option::is_none")]
415    origin: Option<String>,
416    /// The driver choice that served an agent-loop turn, by name. Written only for a capsule that
417    /// declares `inference.alternates`, beside `model`.
418    #[serde(skip_serializing_if = "Option::is_none")]
419    driver_choice: Option<String>,
420    /// Model string actually sent for this call (the attempted model, on failure). Written
421    /// alongside `origin` for a hook's completion, and alongside `driver_choice` for an agent-loop
422    /// turn of a capsule that declares alternates. Any other turn's model is the session-start
423    /// record's and is not repeated per turn.
424    #[serde(skip_serializing_if = "Option::is_none")]
425    model: Option<String>,
426    /// Identities of the messages this request embedded, in the order they sat in it. Under an
427    /// active driver continuation only the tail past the acked length is sent, and this names
428    /// exactly that tail. Empty — and therefore absent — for a hook's own `run-inference` and for
429    /// the `process` transport, neither of which sends a message list the runtime minted.
430    #[serde(skip_serializing_if = "Vec::is_empty")]
431    message_ids: Vec<String>,
432    /// SHA-256 (lowercase hex) of the UTF-8 bytes of this request's `system` string — the
433    /// augmented prompt with the `[Capsule]` block already in it.
434    ///
435    /// These are the bytes Murmur **sent**, not what the model **saw**: provider-side injection,
436    /// tokenizer differences and safety layers all happen past the wire and are invisible to the
437    /// runtime. Absent under `trace.capture: none`, and for a record the runtime did not build
438    /// the payload for — a hook's `run-inference`, and the `process` transport.
439    #[serde(skip_serializing_if = "Option::is_none")]
440    system_sha: Option<String>,
441    /// SHA-256 (lowercase hex) of this request's serialized `tools` array. The bytes Murmur
442    /// sent, on the same terms as [`Self::system_sha`].
443    #[serde(skip_serializing_if = "Option::is_none")]
444    tools_sha: Option<String>,
445    /// SHA-256 (lowercase hex) of the raw driver response body, as the loop read it before
446    /// parsing. What the driver returned to Murmur, byte for byte.
447    #[serde(skip_serializing_if = "Option::is_none")]
448    response_sha: Option<String>,
449    /// SHA-256 (lowercase hex) of each message this request embedded, in send order — one entry
450    /// per `message_ids` entry, over the same messages after the runtime's identity keys are
451    /// stripped. The bytes Murmur sent, on the same terms as [`Self::system_sha`].
452    ///
453    /// Not redundant with `message_ids`: an id names an entity and is freshly minted every run,
454    /// so comparing two runs' id arrays only says every id differs. Comparing these says where
455    /// the two prompts stopped agreeing — the divergence index is the first unequal position.
456    #[serde(skip_serializing_if = "Vec::is_empty")]
457    message_shas: Vec<String>,
458    /// Why the call failed, from the `INFERENCE_ERROR_*` vocabulary. Present exactly on a record
459    /// whose `decision` and `stop_reason` are both `"error"`; absent on every call that returned
460    /// an answer, however unusable the loop then found it.
461    #[serde(skip_serializing_if = "Option::is_none")]
462    error_code: Option<String>,
463    /// The failure in words, present exactly when `error_code` is. Every credential value the
464    /// session holds is already replaced with `[redacted]`, and the text is capped at
465    /// [`MAX_TASK_FAILURE_REASON_BYTES`] on a character boundary.
466    #[serde(skip_serializing_if = "Option::is_none")]
467    error: Option<String>,
468    /// The HTTP status the runtime's credential gateway received for the failed call. Absent when
469    /// no request reached the provider, on the `process` transport, and for a driver whose
470    /// gateway the runtime does not own.
471    #[serde(skip_serializing_if = "Option::is_none")]
472    provider_status: Option<u16>,
473}
474
475/// The bodies one driver request put on the wire, split into the pieces an `inference` event
476/// hashes.
477///
478/// Built from the payload [`crate::agent::build_driver_payload`] produced and the raw response
479/// string the driver returned, so what the trace names and what the driver received cannot
480/// drift: there is no second serialization for them to drift apart in.
481///
482/// Every piece is the bytes Murmur **sent** (or, for the response, was handed back), never what
483/// the model saw.
484pub(crate) struct WireCapture {
485    /// The payload's `system` string value as UTF-8 text — the augmented prompt, `[Capsule]`
486    /// block included. Stored as text so its blob `cat`s as a readable prompt.
487    system: Vec<u8>,
488    /// The payload's `tools` array, serialized.
489    tools: Vec<u8>,
490    /// Each element of the payload's `messages` array, serialized, in send order. That array is
491    /// the `wire_messages` slice after `strip_message_identity`, so no message identity key can
492    /// reach a hash or a blob.
493    messages: Vec<Vec<u8>>,
494    /// The raw driver response body, exactly as the loop read it.
495    response: Vec<u8>,
496}
497
498impl WireCapture {
499    /// Split a built driver payload and its raw response into the four bodies the trace hashes.
500    ///
501    /// `payload` must be the value that was serialized and handed to the driver; taking the
502    /// pieces out of it, rather than rebuilding them from the loop's own state, is what makes a
503    /// blob byte-identical to the substring of the request it came from.
504    pub(crate) fn from_driver_payload(payload: &Value, response: &str) -> Self {
505        Self {
506            system: payload
507                .get("system")
508                .and_then(Value::as_str)
509                .unwrap_or_default()
510                .as_bytes()
511                .to_vec(),
512            tools: payload
513                .get("tools")
514                .map(|tools| serde_json::to_vec(tools).unwrap_or_default())
515                .unwrap_or_default(),
516            messages: payload
517                .get("messages")
518                .and_then(Value::as_array)
519                .map(|messages| {
520                    messages
521                        .iter()
522                        .map(|message| serde_json::to_vec(message).unwrap_or_default())
523                        .collect()
524                })
525                .unwrap_or_default(),
526            response: response.as_bytes().to_vec(),
527        }
528    }
529}
530
531#[derive(Serialize)]
532struct ToolCallEvent {
533    event_type: &'static str,
534    event_id: String,
535    parent_id: Option<String>,
536    session_id: String,
537    timestamp: u64,
538    turn: u32,
539    task_id: Option<String>,
540    tool_name: String,
541    /// The provider's own id for this call, as it appeared on the driver response block that
542    /// asked for it. Recorded verbatim and never parsed — the same contract `resource_id` has.
543    /// `null` when the provider named none, which is how every host-synthesized call reads.
544    tool_call_id: Option<String>,
545    input: Value,
546    input_bytes: u64,
547    #[serde(skip_serializing_if = "Option::is_none")]
548    output: Option<String>,
549    output_bytes: u64,
550    duration_ms: u64,
551    status: String,
552    /// The tool's self-declared `state_effect` for this call (`read`/`mutate`),
553    /// lifted verbatim from `tool-result.metadata`. Absent when the tool declared
554    /// nothing; consumers treat absence conservatively. See `wit/tool.wit`.
555    #[serde(skip_serializing_if = "Option::is_none")]
556    state_effect: Option<String>,
557    /// The resource this call addressed, as declared by the tool and lifted verbatim
558    /// from `tool-result.metadata`. An opaque, tool-defined string — never parsed here.
559    /// Absent when the tool declared nothing, in which case consumers fall back to
560    /// guessing the resource from the call's input. See `wit/tool.wit`.
561    #[serde(skip_serializing_if = "Option::is_none")]
562    resource_id: Option<String>,
563}
564
565#[derive(Serialize)]
566struct SkillCallEvent {
567    event_type: &'static str,
568    event_id: String,
569    parent_id: Option<String>,
570    session_id: String,
571    timestamp: u64,
572    turn: u32,
573    task_id: Option<String>,
574    skill_name: String,
575    output_bytes: u64,
576    duration_ms: u64,
577    status: String,
578    /// The skill's `murmur.lock` origin: `"operator"` or `"runtime"`. Always written.
579    origin: &'static str,
580    /// `"trusted"` for an operator pin, `"untrusted"` for a runtime one, whose guidance reached
581    /// the model fenced. Always written, in the spelling `task_start.trust` uses.
582    trust: &'static str,
583}
584
585#[derive(Serialize)]
586struct ShellEvent {
587    event_type: &'static str,
588    event_id: String,
589    parent_id: Option<String>,
590    session_id: String,
591    timestamp: u64,
592    turn: u32,
593    task_id: Option<String>,
594    /// The program that ran — a canonical absolute path when the host `PATH` resolved
595    /// the invoked name, else the bare name. `command` carries the argument list alone,
596    /// so this is the only field that says *what* ran.
597    binary: String,
598    command: String,
599    exit_code: i32,
600    stdout_bytes: u64,
601    stderr_bytes: u64,
602    duration_ms: u64,
603    /// The `capabilities.resources` field this subprocess was killed for exceeding, when the
604    /// kernel's own evidence names exactly one (`SIGXCPU`/`SIGXFSZ`, or a cgroup
605    /// `memory.events`/`pids.events` counter that moved). Omitted from the JSONL entirely
606    /// otherwise — including for a subprocess that died for a reason no single limit can be
607    /// pinned to, which must not read as "no limit was involved".
608    #[serde(skip_serializing_if = "Option::is_none")]
609    resource_limit: Option<String>,
610}
611
612/// A shell command that outran its grace period and moved to the background. Hangs off the turn
613/// that started it, so `mur trace steps` shows it where the model saw it.
614#[derive(Serialize)]
615struct ShellDetachedEvent {
616    event_type: &'static str,
617    event_id: String,
618    parent_id: Option<String>,
619    session_id: String,
620    timestamp: u64,
621    turn: u32,
622    task_id: Option<String>,
623    work_id: String,
624    binary: String,
625    command: String,
626    /// The `lifecycle.shell_grace_secs` in force, in milliseconds — what this command outran.
627    grace_ms: u64,
628}
629
630/// A detached command finishing, written where the task loop drains it. Hangs off the session
631/// rather than a turn: by the time it lands, the turn that started the command is over.
632#[derive(Serialize)]
633struct ShellCompletedEvent {
634    event_type: &'static str,
635    event_id: String,
636    parent_id: Option<String>,
637    session_id: String,
638    timestamp: u64,
639    work_id: String,
640    binary: String,
641    command: String,
642    exit_code: i32,
643    duration_ms: u64,
644    /// Workdir-relative path of the file holding the command's full stdout and stderr.
645    output_path: String,
646    output_bytes: u64,
647    /// Attributed the same way [`ShellEvent::resource_limit`] is, and omitted on the same terms.
648    #[serde(skip_serializing_if = "Option::is_none")]
649    resource_limit: Option<String>,
650    /// `"ok"` or `"error"` — the field that tells a failed background command from a successful
651    /// one without reading its prose.
652    status: String,
653    /// The `completion`-origin task this completion was enqueued as, which is what makes "which
654    /// command produced this task" answerable from `trace.jsonl` alone.
655    completion_task_id: String,
656}
657
658/// A detached command the session ended without carrying its result back. No task will ever report
659/// it; this record and the operator report beside it are what keep the loss visible.
660///
661/// The command was either still running at the sweep or finished during teardown, and the second
662/// case knows more: [`Self::exit_code`], [`Self::output_path`] and [`Self::output_bytes`] are
663/// written together for it and omitted together for the first. A command still running therefore
664/// produces a line byte-identical in shape to one written before those fields existed — omitted,
665/// never `null`, because `null` would read as a known-absent exit code rather than an unknown one.
666#[derive(Serialize)]
667struct ShellAbandonedEvent {
668    event_type: &'static str,
669    event_id: String,
670    parent_id: Option<String>,
671    session_id: String,
672    timestamp: u64,
673    work_id: String,
674    binary: String,
675    command: String,
676    /// How long the command had been running when the session gave up on it. For a command that
677    /// finished during teardown, its full duration from spawn to exit.
678    running_ms: u64,
679    /// `128 + signal` for a signal kill, as on `shell_completed`. Absent for a command still
680    /// running.
681    #[serde(skip_serializing_if = "Option::is_none")]
682    exit_code: Option<i32>,
683    /// Workdir-relative path of the file holding the command's full stdout and stderr, always
684    /// `logs/<work_id>.log`. Absent for a command still running, for which that file was never
685    /// written and never will be.
686    #[serde(skip_serializing_if = "Option::is_none")]
687    output_path: Option<String>,
688    /// Size of that file. Present exactly when [`Self::output_path`] is.
689    #[serde(skip_serializing_if = "Option::is_none")]
690    output_bytes: Option<u64>,
691}
692
693/// A demoted command a later launch found unaccounted for, appended to the `trace.jsonl` of the
694/// session that started it.
695///
696/// Carries no `exit_code`, no `status`, no `duration_ms`, no `output_path` and no `output_bytes`:
697/// none of them exists for a command whose runtime died, and a record that named any of them
698/// would be readable as a result. The event type is the whole discriminator against
699/// [`ShellCompletedEvent`].
700///
701/// Its presence is also what stops the same work id being reported by a second resume, so this
702/// line and the `shell_detached` it answers live in the same file and are pruned together by
703/// [`crate::retention::prune_sessions`].
704#[derive(Serialize)]
705struct ShellLostEvent {
706    event_type: &'static str,
707    event_id: String,
708    /// The prior session's own `session_start` node. Omitted from the line when that record could
709    /// not be read back — a line must never name a parent the file does not hold.
710    #[serde(skip_serializing_if = "Option::is_none")]
711    parent_id: Option<String>,
712    /// The prior session's id, not the resuming one's, so the file stays internally consistent.
713    session_id: String,
714    timestamp: u64,
715    work_id: String,
716    binary: String,
717    command: String,
718    /// The `shell_detached` line's own timestamp.
719    detached_at_ms: u64,
720    /// The session that found the work unaccounted for and reported it.
721    reconciled_by_session: String,
722    /// The `completion`-origin task that session enqueued to report it, which is what makes "which
723    /// task reported this loss" answerable from the two files alone.
724    reconciled_task_id: String,
725}
726
727/// A demotion whose own `shell_detached` line could not be written.
728///
729/// Best-effort and usually absent: it goes to the file that just failed. When it does land, it is
730/// the record that the work id below has no `shell_detached` line and so will never be reported
731/// as lost.
732#[derive(Serialize)]
733struct ShellDetachUnrecordedEvent {
734    event_type: &'static str,
735    event_id: String,
736    parent_id: Option<String>,
737    session_id: String,
738    timestamp: u64,
739    turn: u32,
740    task_id: Option<String>,
741    work_id: String,
742    binary: String,
743    /// The write error, as the operating system reported it.
744    reason: String,
745}
746
747#[derive(Serialize)]
748struct CompactionEvent {
749    event_type: &'static str,
750    event_id: String,
751    parent_id: Option<String>,
752    session_id: String,
753    timestamp: u64,
754    turn: u32,
755    task_id: Option<String>,
756    tokens_before: u64,
757    tokens_after: u64,
758}
759
760/// The compaction threshold was crossed and the context was left as it was. The session
761/// continues over budget, so the record of *why* is the only thing separating this from a
762/// session that never needed compacting at all.
763#[derive(Serialize)]
764struct CompactionDeclinedEvent {
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    /// Context occupancy at the moment of the decline — the same measurement `compaction`
773    /// records as `tokens_before`, and the budget the session went on running over.
774    tokens: u64,
775    /// `"no_hook_replacement"` when no bound hook returned `replace-context`;
776    /// `"unresolved_tool_call"` when the replacement's tool calls and tool results did not pair.
777    reason: String,
778}
779
780/// An `on-task-start` hook proposed context and the runtime decided what to do with it.
781///
782/// Written once per task that had a seeding hook return something, whatever the decision —
783/// including a rejection, where `tokens` is `0`. A seed that was silently discarded is
784/// indistinguishable from a capsule with no memory hook at all, which is the failure this
785/// record exists to make impossible.
786#[derive(Serialize)]
787struct ContextSeedEvent {
788    event_type: &'static str,
789    event_id: String,
790    parent_id: Option<String>,
791    session_id: String,
792    timestamp: u64,
793    task_id: Option<String>,
794    /// Manifest name of the hook that returned the `seed-context`.
795    hook_name: String,
796    /// Tokens actually committed to the head of the context: `0` for a rejection, the
797    /// proposal's own count for `seeded`, and the surviving count for `trimmed`/`compacted`.
798    tokens: u64,
799    /// Tokens the hook proposed, before any trim or summarization. Together with
800    /// `budget_tokens` this is what makes a rejection readable without the hook's own log.
801    proposed_tokens: u64,
802    /// The ceiling in force: `floor(context.max_tokens * context.seed_budget)`, or `0` when
803    /// the capsule declares no `context.max_tokens`.
804    budget_tokens: u64,
805    /// `"seeded"`, `"trimmed"`, `"compacted"` or `"rejected"` — see the `SEED_OUTCOME_*`
806    /// constants.
807    outcome: String,
808    /// Why nothing was committed, on `"rejected"` only — see the `SEED_REJECTED_*`
809    /// constants. Absent from the JSONL for every other outcome.
810    #[serde(skip_serializing_if = "Option::is_none")]
811    reason: Option<String>,
812    /// The `msg_`-prefixed id of every message committed, in the order they were placed.
813    /// Empty on a rejection. These are the ids the runtime minted; they never reach the
814    /// driver payload, so this record is the only place they are visible.
815    message_ids: Vec<String>,
816}
817
818/// Retention deleted something. One line per (store, reason) pair that removed anything, in the
819/// trace of the session that performed the deletion — the only place the pruning of session N's
820/// predecessors can go.
821///
822/// Session-scoped, and written immediately after `session_start`: a session directory that
823/// vanishes with no explanation makes "where did my trace go" unanswerable, and the answer has to
824/// be in the trace of the run that did it.
825#[derive(Serialize)]
826struct RetentionEvent {
827    event_type: &'static str,
828    event_id: String,
829    parent_id: Option<String>,
830    session_id: String,
831    timestamp: u64,
832    /// [`RETENTION_STORE_SESSIONS`] or [`RETENTION_STORE_RECORDS`].
833    store: String,
834    /// [`RETENTION_REASON_MAX_SESSIONS`], [`RETENTION_REASON_MAX_AGE`] or
835    /// [`RETENTION_REASON_MAX_MESSAGES`].
836    reason: String,
837    /// How many units this reason removed from this store: session directories, context
838    /// directories, or — for a truncation — the one record that was rewritten. Never zero;
839    /// nothing removed means no event.
840    removed: u32,
841    /// What went: `ses_` directory names, or context ids.
842    targets: Vec<String>,
843    /// Messages dropped from the front of the record. Written for
844    /// [`RETENTION_REASON_MAX_MESSAGES`] only, and absent from the JSONL otherwise.
845    #[serde(skip_serializing_if = "Option::is_none")]
846    messages_dropped: Option<u64>,
847}
848
849#[derive(Serialize)]
850struct A2aTaskReceivedEvent {
851    event_type: &'static str,
852    event_id: String,
853    parent_id: Option<String>,
854    session_id: String,
855    timestamp: u64,
856    task_id: String,
857    context_id: String,
858    message_id: String,
859    traceparent_from_caller: Option<String>,
860    /// The formation member that called, when the door let the task in on a formation token.
861    /// Omitted for every other task.
862    #[serde(skip_serializing_if = "Option::is_none")]
863    caller_member: Option<String>,
864}
865
866#[derive(Serialize)]
867struct A2aSendEvent {
868    event_type: &'static str,
869    event_id: String,
870    parent_id: Option<String>,
871    session_id: String,
872    timestamp: u64,
873    peer_url: String,
874    message_id: String,
875    task_id: String,
876    context_id: String,
877    traceparent: Option<String>,
878    /// The class this runtime stamped on the outgoing request, so a chain of capsules reads the
879    /// same way from the sending end as the receiving end's `task_start` reads it.
880    trust: String,
881}
882
883/// A successful `manage.pull()`: the artifact a running capsule fetched and pinned in
884/// `murmur.lock`. A refused or failed pull writes nothing.
885#[derive(Serialize)]
886struct ArtifactPulledEvent {
887    event_type: &'static str,
888    event_id: String,
889    parent_id: Option<String>,
890    session_id: String,
891    timestamp: u64,
892    name: String,
893    version: String,
894    /// `"skill"` or `"tool"`, the only roles a pull installs.
895    runtime: &'static str,
896    /// The origin the lock entry holds after the pull: `"runtime"`, or `"operator"` when the
897    /// operator had already pinned the same version.
898    origin: &'static str,
899    /// The session the lock entry names as the puller — this one, unless an earlier pull of the
900    /// same pin recorded another. `null` for an `"operator"` entry.
901    session: Option<String>,
902    trust: &'static str,
903}
904
905#[derive(Serialize)]
906struct SessionEndEvent {
907    event_type: &'static str,
908    event_id: String,
909    parent_id: Option<String>,
910    session_id: String,
911    timestamp: u64,
912    total_turns: u32,
913    total_input_tokens: u64,
914    total_output_tokens: u64,
915    total_tool_calls: u32,
916    total_shell_calls: u32,
917    duration_ms: u64,
918    exit_status: String,
919}
920
921#[derive(Serialize)]
922struct TaskStartEvent {
923    event_type: &'static str,
924    event_id: String,
925    parent_id: Option<String>,
926    session_id: String,
927    timestamp: u64,
928    task_id: String,
929    context_id: String,
930    /// Which door the task came through. `source` and `origin` answer different questions —
931    /// `"a2a"` covers both a trusted peer and an untyped webhook.
932    source: String,
933    origin: String,
934    trust: String,
935    /// The queue lane this task waited in, derived from `origin`. Recorded so the order two
936    /// tasks ran in is answerable from the trace alone.
937    lane: String,
938    message_parts_bytes: u64,
939}
940
941#[derive(Serialize)]
942struct TaskEndEvent {
943    event_type: &'static str,
944    event_id: String,
945    parent_id: Option<String>,
946    session_id: String,
947    timestamp: u64,
948    task_id: String,
949    exit_status: String,
950    duration_ms: u64,
951    turns: u32,
952    input_tokens: u64,
953    output_tokens: u64,
954    tool_calls: u32,
955    shell_calls: u32,
956    /// How many times this task's agent loop was reopened by an `on-task-end` hook
957    /// before the terminal outcome. `0` for a task that ran once (the common case).
958    reopen_count: u32,
959}
960
961/// A person stopped this task. Written where the loop actually stopped, so the record names the
962/// wait that was interrupted and everything that was still running at that moment.
963///
964/// Distinct from the `task_end` that follows it: `task_end` says the task is over and with which
965/// `exit_status`, this says a person made that happen and what it left behind. A task cancelled
966/// while still queued has no `task_start` and gets no `task_end`, so this is its only record.
967#[derive(Serialize)]
968struct TaskCanceledEvent {
969    event_type: &'static str,
970    event_id: String,
971    parent_id: Option<String>,
972    session_id: String,
973    timestamp: u64,
974    task_id: String,
975    /// The turn that was in flight, 0-based. Absent for a task cancelled before it ever ran.
976    #[serde(skip_serializing_if = "Option::is_none")]
977    turn: Option<u32>,
978    /// Where cancellation landed: `queued`, `turn`, `inference`, `input`, `delegation` or
979    /// `harness`.
980    phase: String,
981    /// Demoted shell commands still running when the loop stopped. They keep their own
982    /// lifecycle — nothing here was killed.
983    detached_work_ids: Vec<String>,
984    /// Delegations in flight when the loop stopped, named before the cancelled task ends them.
985    delegation_ids: Vec<String>,
986}
987
988/// `task_rejected.cause` when the session ended on its own — close-out, `after_task: exit`, a
989/// failed launch task or the idle timeout — with the task still queued.
990pub(crate) const TASK_REJECTED_SESSION_ENDED: &str = "session_ended";
991
992/// `task_rejected.cause` when `mur stop` (`SIGTERM`) ended the session with the task still queued.
993pub(crate) const TASK_REJECTED_SESSION_STOPPED: &str = "session_stopped";
994
995/// The session stopped taking work while this task was still queued, so it was refused rather
996/// than run. Written once per refused task, after the task loop has ended and before
997/// `session_end`.
998///
999/// A refused task never started, so it has no `task_start`, no `task_end` and never a
1000/// `task_failed`: this is its only record. It hangs off the session node.
1001#[derive(Serialize)]
1002struct TaskRejectedEvent {
1003    event_type: &'static str,
1004    event_id: String,
1005    parent_id: Option<String>,
1006    session_id: String,
1007    timestamp: u64,
1008    task_id: String,
1009    context_id: String,
1010    /// The `IncomingTask::source` the task was enqueued with: `a2a`, `detached_shell` or
1011    /// `detached_lost`.
1012    source: String,
1013    /// One of the `TASK_REJECTED_*` constants.
1014    cause: String,
1015    /// The `status.message` the task's final `rejected` frame carries.
1016    reason: String,
1017}
1018
1019/// `task_failed.cause` when the driver returned an error status or a response whose
1020/// `stop_reason` is `"error"`, and no credential rejection is pending.
1021pub(crate) const TASK_FAILED_DRIVER_ERROR: &str = "driver_error";
1022
1023/// `task_failed.cause` for a driver error while the gateway holds a credential rejection: the
1024/// provider keeps refusing the key, and the reason is the `E-RUN-027` message.
1025pub(crate) const TASK_FAILED_CREDENTIAL_REJECTED: &str = "credential_rejected";
1026
1027/// `task_failed.cause` for a response the loop cannot act on: `stop_reason: "tool_call"` with no
1028/// `tool_call` block, or a `stop_reason` that is missing or unsupported.
1029pub(crate) const TASK_FAILED_MALFORMED_RESPONSE: &str = "malformed_response";
1030
1031/// `task_failed.cause` when a bound compaction hook returned `Err`.
1032pub(crate) const TASK_FAILED_COMPACTION_HOOK: &str = "compaction_hook";
1033
1034/// `task_failed.cause` when a `request-input` wait passed `lifecycle.input_timeout_secs`.
1035pub(crate) const TASK_FAILED_INPUT_TIMEOUT: &str = "input_timeout";
1036
1037/// `task_failed.cause` when an `on-task-end` hook still asked to reopen and
1038/// `lifecycle.max_task_reopens` or `inference.max_turns` refused it.
1039pub(crate) const TASK_FAILED_REOPEN_BUDGET_EXHAUSTED: &str = "reopen_budget_exhausted";
1040
1041/// `task_failed.cause` for an attempt that ended in a runtime error no other cause names. The
1042/// reason is the error's own text.
1043pub(crate) const TASK_FAILED_RUNTIME_ERROR: &str = "runtime_error";
1044
1045/// `inference.error_code` when the provider refused the inference credential: it answered `401`
1046/// and the gateway's re-read and resend were refused too. The record's `error` is the runtime's
1047/// own `E-RUN-027` message, never the provider's body.
1048pub(crate) const INFERENCE_ERROR_CREDENTIAL_REJECTED: &str = TASK_FAILED_CREDENTIAL_REJECTED;
1049
1050/// `inference.error_code` when the driver reported the call failed and no credential rejection is
1051/// pending: a provider HTTP error, the driver's own error, a gateway refusal surfaced to the
1052/// driver, or a response whose `stop_reason` is `"error"`.
1053pub(crate) const INFERENCE_ERROR_DRIVER_ERROR: &str = TASK_FAILED_DRIVER_ERROR;
1054
1055/// `inference.error_code` when the driver component could not be invoked or trapped:
1056/// instantiation or import failure, the execution deadline, or a memory limit.
1057pub(crate) const INFERENCE_ERROR_DRIVER_FAILED: &str = "driver_failed";
1058
1059/// `inference.error_code` when the driver reported success but returned no body, or a body that
1060/// is not JSON.
1061pub(crate) const INFERENCE_ERROR_MALFORMED_RESPONSE: &str = TASK_FAILED_MALFORMED_RESPONSE;
1062
1063/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind `auth`.
1064pub(crate) const INFERENCE_ERROR_HARNESS_AUTH: &str = "harness_auth";
1065
1066/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind `quota`.
1067pub(crate) const INFERENCE_ERROR_HARNESS_QUOTA: &str = "harness_quota";
1068
1069/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind
1070/// `harness-error`.
1071pub(crate) const INFERENCE_ERROR_HARNESS_ERROR: &str = "harness_error";
1072
1073/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind `other`.
1074pub(crate) const INFERENCE_ERROR_HARNESS_OTHER: &str = "harness_other";
1075
1076/// `inference.error_code` when a `process` harness went silent past the inactivity timeout.
1077pub(crate) const INFERENCE_ERROR_HARNESS_INACTIVE: &str = "harness_inactive";
1078
1079/// One model call that failed, as [`TraceWriter::write_failed_inference`] records it.
1080pub(crate) struct FailedInference<'a> {
1081    pub(crate) turn: u32,
1082    /// One of the `INFERENCE_ERROR_*` constants.
1083    pub(crate) error_code: &'static str,
1084    /// The failure in words, already redacted. Capped by the writer.
1085    pub(crate) error: &'a str,
1086    /// The HTTP status the credential gateway received for this call, when it received one.
1087    pub(crate) provider_status: Option<u16>,
1088    /// What the provider itself reported, when the failure carried a `usage` block.
1089    pub(crate) usage: Option<&'a DriverUsage>,
1090    /// `Some` for a hook's `run-inference`; `None` for an agent-loop call.
1091    pub(crate) origin: Option<&'a InferenceOrigin>,
1092    /// The driver choice the call was dispatched to, on a capsule that declares alternates.
1093    pub(crate) choice: Option<&'a crate::driver_choice::DriverChoice>,
1094    /// The messages the failed request embedded.
1095    pub(crate) message_ids: Vec<String>,
1096    pub(crate) tool_name: Option<String>,
1097    /// `(input_tokens, output_tokens)` this call counts toward the turn and token totals. `None`
1098    /// is a call that is not counted at all — no turn, no tokens, and neither key on the record.
1099    pub(crate) spent: Option<(Option<u64>, Option<u64>)>,
1100}
1101
1102/// One `inference` line, as the writers that share [`TraceWriter::write_inference_event`] hand
1103/// it over.
1104struct InferenceLine<'a> {
1105    turn: u32,
1106    input_tokens: Option<u64>,
1107    output_tokens: Option<u64>,
1108    decision: String,
1109    stop_reason: Option<&'a str>,
1110    tool_name: Option<String>,
1111    origin: Option<&'a InferenceOrigin>,
1112    usage: Option<&'a DriverUsage>,
1113    message_ids: Vec<String>,
1114    wire: Option<&'a WireCapture>,
1115    choice: Option<&'a crate::driver_choice::DriverChoice>,
1116    /// `(error_code, error, provider_status)` for a failed call.
1117    failure: Option<(&'static str, String, Option<u16>)>,
1118    /// Whether the line advances the turn and token counters.
1119    counted: bool,
1120}
1121
1122/// The most `task_failed.reason` carries, in bytes. A driver's error text is provider-supplied
1123/// and unbounded; the whole of it stays in `out/result.txt`.
1124pub(crate) const MAX_TASK_FAILURE_REASON_BYTES: usize = 2_000;
1125
1126/// A task attempt failed, and why. Written once per failing attempt, before its task's
1127/// `task_end`, by whichever part of the runtime saw the failure.
1128///
1129/// `task_end.exit_status` says `failed`; this names the cause from a closed vocabulary (the
1130/// `TASK_FAILED_*` constants) and carries the reason in words. Never written for a task that
1131/// ended `ok`, `max_turns_reached`, `spend_ceiling_reached` or `canceled`: those have their own
1132/// records.
1133#[derive(Serialize)]
1134struct TaskFailedEvent {
1135    event_type: &'static str,
1136    event_id: String,
1137    parent_id: Option<String>,
1138    session_id: String,
1139    timestamp: u64,
1140    /// The task in progress. Absent for the run a launch makes from `input.txt` when no task
1141    /// arrived, which has no `task_start`.
1142    #[serde(skip_serializing_if = "Option::is_none")]
1143    task_id: Option<String>,
1144    /// The turn that failed, 0-based. Absent when the failure is the task's rather than one
1145    /// turn's.
1146    #[serde(skip_serializing_if = "Option::is_none")]
1147    turn: Option<u32>,
1148    cause: String,
1149    /// Capped at [`MAX_TASK_FAILURE_REASON_BYTES`] on a character boundary.
1150    reason: String,
1151}
1152
1153/// `task_reopened.attempt_context` for a next attempt that continues the task's own
1154/// conversation, with the hook's feedback as the one message it adds.
1155pub(crate) const REOPEN_CONTEXT_CONTINUED: &str = "continued";
1156
1157/// `task_reopened.attempt_context` for a next attempt that starts from a fresh context built
1158/// from the rewritten `task.md`, because the previous attempt left no conversation to continue.
1159pub(crate) const REOPEN_CONTEXT_RESTARTED: &str = "restarted";
1160
1161/// One `on-task-end` hook reopened the task: its agent loop is about to run again with
1162/// the hook's feedback. Written between two agent-loop attempts for the same task, so
1163/// `mur trace show` can show which hook drove each reopen, why, and what the next attempt
1164/// starts from.
1165#[derive(Serialize)]
1166struct TaskReopenedEvent {
1167    event_type: &'static str,
1168    event_id: String,
1169    parent_id: Option<String>,
1170    session_id: String,
1171    timestamp: u64,
1172    task_id: String,
1173    /// Manifest name of the `on-task-end` hook that returned `reopen-task`.
1174    hook_name: String,
1175    /// Feedback text the hook asked to inject into the reopened task content.
1176    reason: String,
1177    /// 1-based ordinal of this reopen within the task (first reopen = 1).
1178    reopen_number: u32,
1179    /// [`REOPEN_CONTEXT_CONTINUED`] or [`REOPEN_CONTEXT_RESTARTED`].
1180    attempt_context: &'static str,
1181    /// The turns the next attempt is handed: `inference.max_turns` less every turn the task's
1182    /// attempts have spent so far.
1183    turns_remaining: u32,
1184}
1185
1186/// A task continued once with everything it had handed off: its attempt ended with a turn left,
1187/// it waited until no `call-member` call and no `delegate-task` delegation was outstanding, and
1188/// the next attempt is handed every answer and outcome in one message. Written after that round's
1189/// `member_call` and `delegation` lines and before the continued attempt's first `inference`.
1190#[derive(Serialize)]
1191struct TaskContinuedEvent<'a> {
1192    event_type: &'static str,
1193    event_id: String,
1194    parent_id: Option<String>,
1195    session_id: String,
1196    timestamp: u64,
1197    task_id: String,
1198    /// 1-based ordinal of this continuation within the task, counted across reopens.
1199    continuation_number: u32,
1200    /// The `mcl_` id of every call whose outcome this continuation delivers, in arrival order.
1201    member_calls: &'a [String],
1202    /// The `dlg_` id of every delegation whose outcome this continuation delivers, arrived or
1203    /// ended by the backstop, in id order.
1204    delegations: &'a [String],
1205    /// How long the task waited, from its attempt's end until nothing was outstanding.
1206    waited_ms: u64,
1207    /// The turns the continued attempt is handed: `inference.max_turns` less every turn the
1208    /// task's attempts have spent so far.
1209    turns_remaining: u32,
1210}
1211
1212/// A spend ceiling refused a driver call before it was sent. No `inference` line accompanies it,
1213/// because no call was made.
1214#[derive(Serialize)]
1215struct SpendCeilingReachedEvent<'a> {
1216    event_type: &'static str,
1217    event_id: String,
1218    parent_id: Option<String>,
1219    session_id: String,
1220    timestamp: u64,
1221    turn: u32,
1222    task_id: Option<String>,
1223    #[serde(flatten)]
1224    refusal: &'a crate::spend::SpendRefusal,
1225    /// `hook:<name>` for a hook's `run-inference` call; absent for an agent-loop turn.
1226    #[serde(skip_serializing_if = "Option::is_none")]
1227    origin: Option<&'a str>,
1228}
1229
1230/// What a `transport: process` session spawned, and what the driver planned for it.
1231///
1232/// Separate from [`HarnessStartEvent`] so the runner builds one value rather than passing a dozen
1233/// positional arguments. Flattened into the event, so the record has no nested object.
1234#[derive(Serialize)]
1235pub(crate) struct HarnessStart {
1236    /// The process driver artifact and its version.
1237    pub driver: String,
1238    pub driver_version: String,
1239    /// What the driver said it drives, and the executable actually spawned.
1240    pub harness: String,
1241    /// The absolute path spawned — not the name the manifest or the driver asked for.
1242    pub binary: String,
1243    /// `inference.command`, or the process driver's `describe()`.
1244    pub binary_source: String,
1245    /// What the harness printed for its version arguments, or `null` when it could not be read.
1246    pub harness_version: Option<String>,
1247    /// Whether [`Self::harness_version`] matched one of the driver's `tested-versions`.
1248    pub version_tested: bool,
1249    /// The harness session id the runtime minted for this run, and whether it started or resumed.
1250    pub harness_session_id: String,
1251    pub session_mode: String,
1252    /// How many arguments the plan carried. Never the arguments themselves: a driver is free to
1253    /// put the bridge's bearer token in one.
1254    pub args_count: usize,
1255    /// The names of the variables actually delivered, sorted. Never their values.
1256    pub env_names: Vec<String>,
1257    /// The names of the files written into the run's private directory. Never their contents.
1258    pub files: Vec<String>,
1259    /// The capsule tool names the bridge offered, bare. Empty when no bridge was bound.
1260    pub bridge_tools: Vec<String>,
1261    /// How many bytes were written to the harness's stdin, and whether it stayed open after.
1262    pub stdin_bytes: usize,
1263    pub keep_stdin_open: bool,
1264}
1265
1266#[derive(Serialize)]
1267struct HarnessStartEvent {
1268    event_type: &'static str,
1269    event_id: String,
1270    parent_id: Option<String>,
1271    session_id: String,
1272    timestamp: u64,
1273    task_id: Option<String>,
1274    #[serde(flatten)]
1275    start: HarnessStart,
1276}
1277
1278/// A `W-*` warning the run raised. The same text goes to stderr; this is the durable copy, so a
1279/// run read back later still says what it warned about.
1280#[derive(Serialize)]
1281struct HarnessWarningEvent {
1282    event_type: &'static str,
1283    event_id: String,
1284    parent_id: Option<String>,
1285    session_id: String,
1286    timestamp: u64,
1287    task_id: Option<String>,
1288    code: String,
1289    message: String,
1290}
1291
1292/// The session the harness itself reports, which is not this session: the runtime's `session_id`
1293/// names the capsule run, `harness_session_id` names the conversation inside the harness.
1294#[derive(Serialize)]
1295struct HarnessSessionEvent {
1296    event_type: &'static str,
1297    event_id: String,
1298    parent_id: Option<String>,
1299    session_id: String,
1300    timestamp: u64,
1301    task_id: Option<String>,
1302    harness_session_id: String,
1303    /// How the harness says it is billing. Anything but `subscription` is also warned about.
1304    auth: String,
1305    /// The model the harness says it is running, when it says.
1306    model: Option<String>,
1307}
1308
1309/// The harness session a person asked this capsule to drop, recorded because forgetting is not
1310/// the same as never having known: the turn that follows starts a new conversation under a context
1311/// that had one, and this is what says so.
1312#[derive(Serialize)]
1313struct HarnessSessionForgottenEvent {
1314    event_type: &'static str,
1315    event_id: String,
1316    parent_id: Option<String>,
1317    session_id: String,
1318    timestamp: u64,
1319    task_id: Option<String>,
1320    /// The context whose entry was dropped.
1321    context_id: String,
1322    /// The id that was dropped, which the harness is no longer asked to continue.
1323    harness_session_id: String,
1324    /// Which surface carried the request: `cli` or `a2a`.
1325    requested_by: String,
1326}
1327
1328#[derive(Serialize)]
1329struct HarnessRetryEvent {
1330    event_type: &'static str,
1331    event_id: String,
1332    parent_id: Option<String>,
1333    session_id: String,
1334    timestamp: u64,
1335    task_id: Option<String>,
1336    attempt: u32,
1337    reason: String,
1338}
1339
1340#[derive(Serialize)]
1341struct HarnessNoteEvent {
1342    event_type: &'static str,
1343    event_id: String,
1344    parent_id: Option<String>,
1345    session_id: String,
1346    timestamp: u64,
1347    task_id: Option<String>,
1348    text: String,
1349}
1350
1351#[derive(Serialize)]
1352struct HarnessFailedEvent {
1353    event_type: &'static str,
1354    event_id: String,
1355    parent_id: Option<String>,
1356    session_id: String,
1357    timestamp: u64,
1358    task_id: Option<String>,
1359    /// The driver's `failure-kind`, spelled as the WIT spells it, or `max-turns` from the runtime.
1360    kind: String,
1361    message: String,
1362    /// `harness` when the harness reported it, `runtime` when this runtime ended the turn.
1363    source: String,
1364}
1365
1366/// How the runtime tried to stop a harness a person cancelled the task of.
1367#[derive(Serialize)]
1368struct HarnessInterruptEvent {
1369    event_type: &'static str,
1370    event_id: String,
1371    parent_id: Option<String>,
1372    session_id: String,
1373    timestamp: u64,
1374    task_id: Option<String>,
1375    /// The driver's `interrupt-method`, spelled as the WIT spells it.
1376    method: String,
1377    /// Whether a graceful interrupt actually reached the harness.
1378    delivered: bool,
1379    /// How long the harness has to end on its own before it is killed. `0` when it is killed at
1380    /// once, which is every ending where `delivered` is false.
1381    grace_ms: u64,
1382}
1383
1384/// How one spawned harness ended. Written once per spawn, on every path out of the run.
1385#[derive(Serialize)]
1386pub(crate) struct HarnessExit {
1387    /// The process's exit code, or `null` when a signal ended it.
1388    pub code: Option<i32>,
1389    /// The signal that ended the process, or `null` when it exited on its own.
1390    pub signal: Option<i32>,
1391    /// What ended the run: `terminal`, `eof`, `inactivity`, `max_turns`, `driver_error` or
1392    /// `canceled`.
1393    pub cause: String,
1394    /// Whether the runtime had to kill the process rather than wait for it.
1395    pub killed: bool,
1396    /// Wall time from spawn to reaped.
1397    pub duration_ms: u64,
1398}
1399
1400#[derive(Serialize)]
1401struct HarnessExitEvent {
1402    event_type: &'static str,
1403    event_id: String,
1404    parent_id: Option<String>,
1405    session_id: String,
1406    timestamp: u64,
1407    task_id: Option<String>,
1408    #[serde(flatten)]
1409    exit: HarnessExit,
1410}
1411
1412/// A policy hook refused a call before it ran. The one record that says a call the model asked
1413/// for never happened — there is no `tool_call` or `shell` line for a denied call, because
1414/// nothing ran.
1415#[derive(Serialize)]
1416struct CallDeniedEvent {
1417    event_type: &'static str,
1418    event_id: String,
1419    parent_id: Option<String>,
1420    session_id: String,
1421    timestamp: u64,
1422    turn: u32,
1423    /// The gated lifecycle function whose decision point refused: `"on-shell"` or
1424    /// `"on-tool-call"`.
1425    event: String,
1426    /// Manifest name of the policy hook that refused.
1427    hook_name: String,
1428    /// What was refused: the resolved executable path for a shell call, the tool name
1429    /// otherwise.
1430    target: String,
1431    /// The hook's own reason, or the runtime's when the hook supplied none it could use.
1432    reason: String,
1433}
1434
1435/// A tool call was refused before it ran because its input lacked a name the tool's
1436/// `input_schema` lists in `required`. Written in every capture mode: under the default `meta`
1437/// the accompanying `tool_call` record carries no `output`, so this is where the reason lands.
1438#[derive(Serialize)]
1439struct ToolInputRefusedEvent {
1440    event_type: &'static str,
1441    event_id: String,
1442    parent_id: Option<String>,
1443    session_id: String,
1444    timestamp: u64,
1445    turn: u32,
1446    tool_name: String,
1447    /// The provider's id for the call, or `null` where the call arrived without one — every
1448    /// call bridged from a `transport: process` harness.
1449    tool_call_id: Option<String>,
1450    /// The required names the input lacked, in the schema's declared order.
1451    missing: Vec<String>,
1452    /// The refusal text the model was handed.
1453    reason: String,
1454}
1455
1456/// The manifest's own read-only rule refused a call before it ran. Distinct from
1457/// [`CallDeniedEvent`] because it names a different authority and a different subject: no hook was
1458/// asked, and what it carries is the path, the declared rule and the evidence that identified the
1459/// call as a write.
1460#[derive(Serialize)]
1461struct ProtectedPathDeniedEvent {
1462    event_type: &'static str,
1463    event_id: String,
1464    parent_id: Option<String>,
1465    session_id: String,
1466    timestamp: u64,
1467    turn: u32,
1468    /// Which dispatch path was refused: `"shell"` or `"tool"`.
1469    call: String,
1470    /// What was refused: the resolved executable path for a shell call, the tool name otherwise.
1471    target: String,
1472    /// The resolved workdir-relative path, never the string the model typed, so two spellings of
1473    /// one file produce one comparable record.
1474    path: String,
1475    /// The `capabilities.filesystem.read_only` entry that covers `path`, as declared.
1476    rule: String,
1477    /// What identified the call as a write — a redirection operator, a write-verb argument
1478    /// position, or the tool-input key pairing.
1479    signal: String,
1480    /// The same text the model was given, so the trace and the agent agree on why.
1481    reason: String,
1482}
1483
1484#[derive(Serialize)]
1485struct HookDispatchErrorEvent {
1486    event_type: &'static str,
1487    event_id: String,
1488    parent_id: Option<String>,
1489    session_id: String,
1490    timestamp: u64,
1491    hook_name: String,
1492    event: String,
1493    arm: String,
1494}
1495
1496/// A formation member's lifeline read EOF: the formation it belongs to has ended, and this
1497/// session is winding down because of it. Written only when the lifeline is what began the
1498/// session's termination.
1499#[derive(Serialize)]
1500struct FormationEndedEvent {
1501    event_type: &'static str,
1502    event_id: String,
1503    parent_id: Option<String>,
1504    session_id: String,
1505    timestamp: u64,
1506    formation_id: String,
1507}
1508
1509/// A delegated child's spawner lifeline read EOF: the process that spawned this session has
1510/// ended, and this session is winding down because of it. Written only when that lifeline is what
1511/// began the session's termination.
1512///
1513/// The lineage keys are the ones `session_start` carries, and like there they are absent rather
1514/// than null for a session launched with a lifeline but no `MURMUR_SPAWNER`.
1515#[derive(Serialize)]
1516struct SpawnerEndedEvent {
1517    event_type: &'static str,
1518    event_id: String,
1519    parent_id: Option<String>,
1520    session_id: String,
1521    timestamp: u64,
1522    #[serde(skip_serializing_if = "Option::is_none")]
1523    spawned_by: Option<String>,
1524    #[serde(skip_serializing_if = "Option::is_none")]
1525    delegation_id: Option<String>,
1526}
1527
1528#[derive(Serialize)]
1529struct ResourceListEvent {
1530    event_type: &'static str,
1531    event_id: String,
1532    parent_id: Option<String>,
1533    session_id: String,
1534    timestamp: u64,
1535    /// `exports.files.root` verbatim, or `""` when the capsule declares no export — a refusal on
1536    /// an undeclared plane is still a complete record of what was asked for.
1537    root: String,
1538    entry_count: usize,
1539    total_bytes: u64,
1540    /// Which completed turn the listing is as of.
1541    generation: u64,
1542    containment_achieved: ContainmentClass,
1543    /// `"ok"`, or the wire error code the request was refused with.
1544    outcome: String,
1545    /// `null` on `ok`, one short sentence otherwise.
1546    reason: Option<String>,
1547}
1548
1549#[derive(Serialize)]
1550struct ResourceReadEvent {
1551    event_type: &'static str,
1552    event_id: String,
1553    parent_id: Option<String>,
1554    session_id: String,
1555    timestamp: u64,
1556    /// The requested path *after* percent-decoding, so `%2e%2e%2f` and `../` read as the same
1557    /// attempt rather than as two unrelated findings.
1558    path: String,
1559    outcome: String,
1560    /// `null` on every non-`ok` outcome.
1561    bytes: Option<u64>,
1562    /// `null` on every non-`ok` outcome.
1563    sha256: Option<String>,
1564    generation: u64,
1565    containment_achieved: ContainmentClass,
1566    reason: Option<String>,
1567}
1568
1569/// One `share-file` call, served or refused, written at the moment of the mint.
1570#[derive(Serialize)]
1571struct PeerHandleMintEvent {
1572    event_type: &'static str,
1573    event_id: String,
1574    parent_id: Option<String>,
1575    session_id: String,
1576    timestamp: u64,
1577    /// `null` on every non-`ok` outcome: a mint that was refused produced no token, so there is
1578    /// no handle to identify.
1579    handle_id: Option<String>,
1580    /// The path relative to `exports.peer_files.root`, canonicalised, or the path the agent asked
1581    /// for when the mint was refused. Never a host path and never the workdir-relative form.
1582    path: String,
1583    audience: String,
1584    /// `null` on every non-`ok` outcome.
1585    expires_at_ms: Option<u64>,
1586    outcome: String,
1587    reason: Option<String>,
1588}
1589
1590/// One redeem against `/resources/peer/<handle>`, written by the listener concurrently with a
1591/// possibly-running task.
1592#[derive(Serialize)]
1593struct PeerHandleRedeemEvent {
1594    event_type: &'static str,
1595    event_id: String,
1596    parent_id: Option<String>,
1597    session_id: String,
1598    timestamp: u64,
1599    handle_id: String,
1600    /// `null` until the MAC has verified. A payload that failed the MAC is caller-controlled and
1601    /// must not be copied into this capsule's own audit record as if it were fact.
1602    path: Option<String>,
1603    /// The runtime's own current counter, never a value taken from the token — so it is always
1604    /// present and always true.
1605    generation: u64,
1606    /// The `x-murmur-audience` header exactly as asserted, or `null` when none was sent.
1607    audience_asserted: Option<String>,
1608    /// `null` on every non-`ok` outcome.
1609    bytes: Option<u64>,
1610    /// `null` on every non-`ok` outcome.
1611    sha256: Option<String>,
1612    outcome: String,
1613    reason: Option<String>,
1614}
1615
1616/// One `fetch-peer-file` call on the ingesting side, served or refused.
1617#[derive(Serialize)]
1618struct PeerFileFetchEvent {
1619    event_type: &'static str,
1620    event_id: String,
1621    parent_id: Option<String>,
1622    session_id: String,
1623    timestamp: u64,
1624    peer: String,
1625    handle_id: String,
1626    /// Where the bytes landed, relative to the accessible workdir. `null` on every non-`ok`
1627    /// outcome.
1628    stored_path: Option<String>,
1629    bytes: Option<u64>,
1630    sha256: Option<String>,
1631    outcome: String,
1632    reason: Option<String>,
1633}
1634
1635/// One launched child, written the moment it is up.
1636///
1637/// Two surfaces launch children — the agent's `delegate-task` tool and a plan's `capsule` step —
1638/// and both write this line, off the same struct and off the same session node. Which one made a
1639/// given delegation is not recorded, because it is not a fact about the delegation.
1640///
1641/// Separate from the `delegation` line below because the two answer different questions and land
1642/// at different times: this one reaches disk while the delegation is still in flight, so a child
1643/// that hangs, crashes or is ended at its deadline is attributable from the parent's side whatever
1644/// happens next. A delegation the daemon refused writes none of these — nothing was launched.
1645#[derive(Serialize)]
1646struct DelegationStartEvent {
1647    event_type: &'static str,
1648    event_id: String,
1649    parent_id: Option<String>,
1650    session_id: String,
1651    timestamp: u64,
1652    /// The `dlg_` id the launcher minted, character-identical to the one on this delegation's
1653    /// terminal `delegation` line and in the child's own `session_start`.
1654    delegation_id: String,
1655    capsule: String,
1656    version: String,
1657    /// The session id the child's runtime minted for itself.
1658    child_session_id: String,
1659    /// The child's directory, relative to this capsule's accessible workdir — join the two to
1660    /// reach the child's own `trace.jsonl`.
1661    child_workdir: String,
1662}
1663
1664impl DelegationStartEvent {
1665    /// The one place this record is built, so the two appenders that write it cannot drift into
1666    /// two shapes. Which surface launched the child is not an argument, because it is not a field.
1667    fn new(
1668        session_id: &str,
1669        session_event_id: &str,
1670        delegation_id: &str,
1671        capsule: &str,
1672        version: &str,
1673        child_session_id: &str,
1674        child_workdir: &str,
1675    ) -> Self {
1676        Self {
1677            event_type: "delegation_start",
1678            event_id: new_event_id(),
1679            parent_id: Some(session_event_id.to_string()),
1680            session_id: session_id.to_string(),
1681            timestamp: timestamp_ms(),
1682            delegation_id: delegation_id.to_string(),
1683            capsule: capsule.to_string(),
1684            version: version.to_string(),
1685            child_session_id: child_session_id.to_string(),
1686            child_workdir: child_workdir.to_string(),
1687        }
1688    }
1689}
1690
1691/// A `call-member` call whose callee holds the task, written as the tool call returns — or later,
1692/// by the call's watcher, when a callee that was busy takes the task.
1693///
1694/// Carries the callee's roster name and the id of the task its door holds, never its door's URL
1695/// or the token the call presented.
1696#[derive(Serialize)]
1697struct MemberCallStartEvent {
1698    event_type: &'static str,
1699    event_id: String,
1700    parent_id: Option<String>,
1701    session_id: String,
1702    timestamp: u64,
1703    /// The calling task.
1704    task_id: String,
1705    /// The `mcl_` id the call returned to the model.
1706    call_id: String,
1707    /// The callee's roster name.
1708    member: String,
1709    /// The task the callee's door holds, as its `tasks/get` names it.
1710    member_task_id: String,
1711}
1712
1713/// One offer of a `call-member` call that the callee's door turned away busy: it answered
1714/// `message/send` with `rejected` and [`crate::a2a::REJECTED_BUSY_MESSAGE`]. The first is written
1715/// as the tool call returns, each later one by the call's watcher as it offers the task again.
1716///
1717/// Carries the callee's roster name, never its door's URL or the token the call presented.
1718#[derive(Serialize)]
1719struct MemberCallBusyEvent {
1720    event_type: &'static str,
1721    event_id: String,
1722    parent_id: Option<String>,
1723    session_id: String,
1724    timestamp: u64,
1725    /// The calling task.
1726    task_id: String,
1727    /// The `mcl_` id the call returned to the model.
1728    call_id: String,
1729    /// The callee's roster name.
1730    member: String,
1731    /// Which offer of the call this was, from 1.
1732    offer: u32,
1733    /// From the tool call that made the call to this refusal.
1734    waited_ms: u64,
1735    /// The door's status message.
1736    message: String,
1737}
1738
1739/// One `call-member` call, written once it is accounted for: delivered to the calling task, or
1740/// left behind when that task ended.
1741///
1742/// A call that never started writes only this, with no `member_task_id`. Carries no URL and no
1743/// token.
1744#[derive(Serialize)]
1745struct MemberCallEvent {
1746    event_type: &'static str,
1747    event_id: String,
1748    parent_id: Option<String>,
1749    session_id: String,
1750    timestamp: u64,
1751    task_id: String,
1752    call_id: String,
1753    member: String,
1754    /// Absent, not null, for a call the callee never held.
1755    #[serde(skip_serializing_if = "Option::is_none")]
1756    member_task_id: Option<String>,
1757    /// `completed`, `failed`, `canceled`, `rejected`, `timed_out`, `unreachable`, `abandoned` or
1758    /// `no_answer`.
1759    status: String,
1760    duration_ms: u64,
1761    /// The callee's answer, its status message, or why the call ended.
1762    output: String,
1763    truncated: bool,
1764    /// Whether the calling task received `output`: as the tool result for a call that never
1765    /// started, as a continuation for one that did.
1766    delivered: bool,
1767    /// The members further down that gave the callee no answer, as its `tasks/get` reported them.
1768    /// Absent when it reported none.
1769    #[serde(skip_serializing_if = "Vec::is_empty")]
1770    no_answer_below: Vec<NoAnswerBelow>,
1771}
1772
1773/// One member a callee reported as giving it no answer.
1774#[derive(Serialize)]
1775struct NoAnswerBelow {
1776    member: String,
1777    /// A `member_call` status other than `completed`.
1778    status: String,
1779}
1780
1781/// One delegation, written when it ends.
1782///
1783/// One line per call whatever happened, including a call the daemon refused: a delegation that was
1784/// asked for and not made is as much a fact of the run as one that produced an answer. When it is
1785/// written depends on how far the delegation got — a `delegate-task` call that started a child
1786/// writes it as that child's completion arrives, out of the child's own `completion.json`; one that
1787/// never started a child writes it within the call. A plan `capsule` step waits for its child's
1788/// answer, so it always writes this line within the step. Carries neither the task text nor the
1789/// child's answer — both are the agent's own conversation, which the `tool_call` line for the
1790/// same call already records under the session's `trace.capture` setting.
1791#[derive(Serialize)]
1792struct DelegationEvent {
1793    event_type: &'static str,
1794    event_id: String,
1795    parent_id: Option<String>,
1796    session_id: String,
1797    timestamp: u64,
1798    /// The sub-capsule the agent named, and the exact version it named.
1799    capsule: String,
1800    version: String,
1801    /// The `dlg_` id naming this delegation. `null` on a `refused` outcome, which made none.
1802    delegation_id: Option<String>,
1803    /// The child's own session id, so its trace is findable. `null` when no child ran.
1804    child_session_id: Option<String>,
1805    /// How long the child ran, for a delegation that started; how long the call took, for one that
1806    /// never started.
1807    duration_ms: u64,
1808    /// How the delegation ended. Which vocabulary it is drawn from depends on whether the
1809    /// delegating call waited for the answer itself:
1810    ///
1811    /// - A delegation whose outcome arrived later as a completion reads `ok`, `error`, `crashed`
1812    ///   or `terminated` from [`crate::delegation::DelegationStatus`], or `unknown` when the
1813    ///   completion left no readable file.
1814    /// - One the caller waited out reads `completed` or `timed_out` from
1815    ///   [`crate::delegation_plane::DelegationStatus`].
1816    /// - One that never started reads `failed` or `refused`, from that same enum, either way.
1817    outcome: String,
1818    /// `null` where nothing needs saying. Otherwise the completion's `detail`, or the same
1819    /// sentence the model was given.
1820    reason: Option<String>,
1821}
1822
1823impl DelegationEvent {
1824    /// The one place this record is built, so the two appenders that write it cannot drift into
1825    /// two shapes. Which surface launched the child is not an argument, because it is not a field.
1826    #[allow(clippy::too_many_arguments)]
1827    fn new(
1828        session_id: &str,
1829        session_event_id: &str,
1830        capsule: &str,
1831        version: &str,
1832        delegation_id: Option<String>,
1833        child_session_id: Option<String>,
1834        duration_ms: u64,
1835        outcome: &str,
1836        reason: Option<String>,
1837    ) -> Self {
1838        Self {
1839            event_type: "delegation",
1840            event_id: new_event_id(),
1841            parent_id: Some(session_event_id.to_string()),
1842            session_id: session_id.to_string(),
1843            timestamp: timestamp_ms(),
1844            capsule: capsule.to_string(),
1845            version: version.to_string(),
1846            delegation_id,
1847            child_session_id,
1848            duration_ms,
1849            outcome: outcome.to_string(),
1850            reason,
1851        }
1852    }
1853}
1854
1855// ── TraceWriter impl ─────────────────────────────────────────────────────────
1856
1857impl TraceWriter {
1858    #[allow(clippy::too_many_arguments)]
1859    pub(crate) async fn open(
1860        workdir: &Path,
1861        session_id: String,
1862        capsule_name: String,
1863        capsule_version: String,
1864        model: String,
1865        capabilities: Vec<String>,
1866        effective_grants: ScopeReport,
1867        capture: TraceCapture,
1868        system_prompt: Option<String>,
1869        system_prompt_overridden: bool,
1870        resumed_from: Option<String>,
1871        context_id: Option<String>,
1872        spawned_by: Option<String>,
1873        spawn_delegation_id: Option<String>,
1874    ) -> std::io::Result<Self> {
1875        let file = OpenOptions::new()
1876            .create(true)
1877            .append(true)
1878            .open(workdir.join("trace.jsonl"))
1879            .await?;
1880        // `"cli"` wins over `"none"` when the override cleared the prompt: the operator passed
1881        // `--system-prompt ""`, which is a decision worth recording, not an absence.
1882        let system_prompt_source = match (system_prompt_overridden, system_prompt.is_some()) {
1883            (true, _) => "cli",
1884            (false, true) => "manifest",
1885            (false, false) => "none",
1886        };
1887        let system_prompt_sha256 = system_prompt
1888            .as_ref()
1889            .map(|prompt| murmur_artifact::sha256_hex(prompt.as_bytes()));
1890        let blobs = BlobStore::new(workdir);
1891        // The prompt the operator wrote, kept under the hash `session_start` already carried.
1892        // Written here rather than at `session_start` because a session writes that frame once
1893        // per task and the blob is the same one every time.
1894        if let (true, Some(prompt), Some(sha)) = (
1895            capture.captures_content(),
1896            system_prompt.as_ref(),
1897            system_prompt_sha256.as_deref(),
1898        ) {
1899            blobs.put(sha, prompt.as_bytes()).await?;
1900        }
1901        Ok(Self {
1902            writer: BufWriter::new(file),
1903            session_id,
1904            capsule_name,
1905            capsule_version,
1906            model,
1907            capabilities,
1908            effective_grants,
1909            capture,
1910            blobs,
1911            system_prompt_source,
1912            system_prompt_sha256,
1913            credential_source: "none",
1914            gateways: Vec::new(),
1915            control: None,
1916            inference_choices: Vec::new(),
1917            tool_refresh: None,
1918            runtime_artifacts: Vec::new(),
1919            resumed_from,
1920            context_id,
1921            spawned_by,
1922            spawn_delegation_id,
1923            formation_id: None,
1924            formation_member: None,
1925            session_start_time: Instant::now(),
1926            session_event_id: new_event_id(),
1927            session_started: false,
1928            session_ended: false,
1929            task_event_id: None,
1930            turn_event_id: None,
1931            total_turns: 0,
1932            total_input_tokens: 0,
1933            total_output_tokens: 0,
1934            total_tool_calls: 0,
1935            total_shell_calls: 0,
1936            task_turns: 0,
1937            task_input_tokens: 0,
1938            task_output_tokens: 0,
1939            task_tool_calls: 0,
1940            task_shell_calls: 0,
1941            task_agent_turns: 0,
1942            task_hook_inferences: 0,
1943            task_start_instant: None,
1944            active_task_id: None,
1945            task_failures_written: 0,
1946            last_task_failure: None,
1947            max_session_tokens: None,
1948            machine_tokens_per_day: None,
1949        })
1950    }
1951
1952    /// The spend ceilings this session runs under, written into every later `session_start`.
1953    pub(crate) fn set_spend_ceilings(
1954        &mut self,
1955        max_session_tokens: Option<u64>,
1956        machine_tokens_per_day: Option<u64>,
1957    ) {
1958        self.max_session_tokens = max_session_tokens;
1959        self.machine_tokens_per_day = machine_tokens_per_day;
1960    }
1961
1962    /// The session node of this writer's event tree — the `event_id` its `session_start` will
1963    /// carry, available before that line is written so [`ResourceTraceAppender`] can be opened
1964    /// against the same node.
1965    pub(crate) fn session_event_id(&self) -> &str {
1966        &self.session_event_id
1967    }
1968
1969    /// Whether this session's `inference` records carry content hashes — everything but
1970    /// `trace.capture: none`. The agent loop asks before splitting a payload into a
1971    /// [`WireCapture`], so `none` costs no serialization at all.
1972    pub(crate) fn captures_wire(&self) -> bool {
1973        self.capture.captures_hashes()
1974    }
1975
1976    /// Parent for a launch-scoped event: the session node, or `None` on a writer that has not
1977    /// written `session_start`. A trace line must never name a parent that has no line behind
1978    /// it, and the script-capsule `a2a_send` drain opens a writer over a file with no session
1979    /// frame at all.
1980    fn session_parent(&self) -> Option<String> {
1981        self.session_started.then(|| self.session_event_id.clone())
1982    }
1983
1984    /// Parent for a task-scoped event: the task node, falling back to the session node when no
1985    /// task is active.
1986    fn task_parent(&self) -> Option<String> {
1987        self.task_event_id.clone().or_else(|| self.session_parent())
1988    }
1989
1990    /// Parent for a turn-scoped event: the turn node, falling back to the task node and then to
1991    /// the session node. The fallbacks are what keeps a tool call written outside any turn — a
1992    /// hook's, or the process transport's, before its first `inference` — attached to the tree.
1993    fn turn_parent(&self) -> Option<String> {
1994        self.turn_event_id.clone().or_else(|| self.task_parent())
1995    }
1996
1997    /// Records where the inference credential came from, as
1998    /// [`crate::gateway_credential::CredentialSource::trace_name`] names it. `"none"` until set.
1999    pub(crate) fn set_credential_source(&mut self, source: &'static str) {
2000        self.credential_source = source;
2001    }
2002
2003    /// Records the session's credential gateways for `session_start.gateways`.
2004    pub(crate) fn set_gateways(&mut self, gateways: Vec<SessionGateway>) {
2005        self.gateways = gateways;
2006    }
2007
2008    /// Records what `control:` declares for `session_start.control`.
2009    pub(crate) fn set_control(&mut self, control: Option<SessionControl>) {
2010        self.control = control;
2011    }
2012
2013    /// Records the driver choices for `session_start.inference_choices`. Left empty, the key is
2014    /// not written.
2015    pub(crate) fn set_inference_choices(&mut self, choices: Vec<SessionInferenceChoice>) {
2016        self.inference_choices = choices;
2017    }
2018
2019    /// Records the runtime-origin entries of `installed` for `session_start.runtime_artifacts`,
2020    /// in `installed`'s order. Operator-declared entries are left out.
2021    pub(crate) fn set_runtime_artifacts(&mut self, installed: &[InstalledArtifactSummary]) {
2022        self.runtime_artifacts = installed
2023            .iter()
2024            .filter_map(|artifact| match &artifact.origin {
2025                LockOrigin::Runtime { session } => Some(RuntimeArtifact {
2026                    name: artifact.name.clone(),
2027                    version: artifact.version.clone(),
2028                    origin: artifact.origin.as_str(),
2029                    session: session.clone(),
2030                    trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
2031                }),
2032                LockOrigin::Operator => None,
2033            })
2034            .collect();
2035    }
2036
2037    /// Records that this inference call is the first to use a setting value a controller set:
2038    /// `turn` is the call's zero-based turn, the same number its `inference` line carries, and
2039    /// `change_id` the `event_id` of the `control_change` that set `value`.
2040    ///
2041    /// Written before the call is sent, so it precedes the turn's `inference` line and hangs off
2042    /// the task.
2043    pub(crate) async fn write_control_applied(
2044        &mut self,
2045        turn: u32,
2046        name: &str,
2047        value: Value,
2048        change_id: &str,
2049    ) -> std::io::Result<()> {
2050        let event = ControlAppliedEvent {
2051            event_type: "control_applied",
2052            event_id: new_event_id(),
2053            parent_id: self.task_parent(),
2054            session_id: self.session_id.clone(),
2055            timestamp: timestamp_ms(),
2056            turn,
2057            task_id: self.active_task_id.clone(),
2058            name,
2059            value,
2060            change_id,
2061        };
2062        self.write_event(&event).await
2063    }
2064
2065    /// Records the formation this session is a member of for `session_start.formation_id`.
2066    pub(crate) fn set_formation_id(&mut self, formation_id: Option<&FormationId>) {
2067        self.formation_id = formation_id.cloned();
2068    }
2069
2070    /// Records `session_start.formation_member` and `session_start.formation_callees` for a
2071    /// session its formation launcher handed credentials to.
2072    pub(crate) fn set_formation_member(
2073        &mut self,
2074        member: Option<&crate::formation_credentials::FormationMember>,
2075    ) {
2076        self.formation_member = member.map(|member| {
2077            (
2078                member.name().to_string(),
2079                member.callees().map(str::to_string).collect(),
2080            )
2081        });
2082    }
2083
2084    /// Records `inference.tool_refresh` for `session_start.tool_refresh`: the wire name under
2085    /// `transport: http`, `None` under `transport: process`.
2086    pub(crate) fn set_tool_refresh(&mut self, tool_refresh: Option<&'static str>) {
2087        self.tool_refresh = tool_refresh;
2088    }
2089
2090    /// Records that the tool array the http agent loop sends changed before this call:
2091    /// `trigger` is why (`"immediate"` or `"compaction"`), `added` and `removed` the sorted
2092    /// names that entered and left it, and `tools` every name it now offers, in array order.
2093    ///
2094    /// Written before the call is sent, so it precedes the turn's `inference` line and hangs off
2095    /// the task, as `control_applied` does.
2096    pub(crate) async fn write_tools_refreshed(
2097        &mut self,
2098        turn: u32,
2099        trigger: &str,
2100        added: &[String],
2101        removed: &[String],
2102        tools: &[String],
2103    ) -> std::io::Result<()> {
2104        let event = ToolsRefreshedEvent {
2105            event_type: "tools_refreshed",
2106            event_id: new_event_id(),
2107            parent_id: self.task_parent(),
2108            session_id: self.session_id.clone(),
2109            timestamp: timestamp_ms(),
2110            turn,
2111            task_id: self.active_task_id.clone(),
2112            trigger,
2113            added,
2114            removed,
2115            tools,
2116        };
2117        self.write_event(&event).await
2118    }
2119
2120    pub(crate) async fn write_session_start(
2121        &mut self,
2122        max_turns: u32,
2123        tools_declared: Vec<String>,
2124    ) -> std::io::Result<()> {
2125        let event = SessionStartEvent {
2126            event_type: "session_start",
2127            event_id: self.session_event_id.clone(),
2128            parent_id: None,
2129            session_id: self.session_id.clone(),
2130            timestamp: timestamp_ms(),
2131            capsule_name: self.capsule_name.clone(),
2132            capsule_version: self.capsule_version.clone(),
2133            model: self.model.clone(),
2134            max_turns,
2135            max_session_tokens: self.max_session_tokens,
2136            machine_tokens_per_day: self.machine_tokens_per_day,
2137            capabilities: self.capabilities.clone(),
2138            tools_declared,
2139            containment_declared: self.effective_grants.declared_containment,
2140            containment_achieved: self.effective_grants.achieved_containment,
2141            workdir_exec: self.effective_grants.workdir_exec,
2142            userns_grant: self.effective_grants.userns_grant,
2143            effective_grants: self.effective_grants.clone(),
2144            system_prompt_source: self.system_prompt_source,
2145            system_prompt_sha256: self.system_prompt_sha256.clone(),
2146            credential_source: self.credential_source,
2147            gateways: self.gateways.clone(),
2148            control: self.control.clone(),
2149            inference_choices: self.inference_choices.clone(),
2150            tool_refresh: self.tool_refresh,
2151            runtime_artifacts: self.runtime_artifacts.clone(),
2152            resumed_from: self.resumed_from.clone(),
2153            context_id: self.context_id.clone(),
2154            spawned_by: self.spawned_by.clone(),
2155            delegation_id: self.spawn_delegation_id.clone(),
2156            formation_id: self.formation_id.clone(),
2157            formation_member: self.formation_member.as_ref().map(|(name, _)| name.clone()),
2158            formation_callees: self
2159                .formation_member
2160                .as_ref()
2161                .map(|(_, callees)| callees.clone()),
2162        };
2163        self.write_event(&event).await?;
2164        self.session_started = true;
2165        Ok(())
2166    }
2167
2168    /// `input_tokens`/`output_tokens` are what the session and task totals accumulate: the
2169    /// runtime's own tiktoken estimates under `transport: http`, and the counts the harness's
2170    /// driver reported under `transport: process`, where the runtime measures nothing itself.
2171    /// `None` is a turn nothing counted, which only a process driver reporting no usage
2172    /// produces, and it adds nothing to the totals.
2173    ///
2174    /// `usage` is written verbatim beside them and accumulated into nothing. Under `http` it is
2175    /// the provider's own report for the same call; under `process` it carries only the cache and
2176    /// thinking counts, because the harness's input and output are already the two above.
2177    #[allow(clippy::too_many_arguments)]
2178    pub(crate) async fn write_inference(
2179        &mut self,
2180        turn: u32,
2181        input_tokens: Option<u64>,
2182        output_tokens: Option<u64>,
2183        decision: String,
2184        stop_reason: Option<&str>,
2185        tool_name: Option<String>,
2186        origin: Option<&InferenceOrigin>,
2187        usage: Option<&DriverUsage>,
2188        message_ids: Vec<String>,
2189        wire: Option<&WireCapture>,
2190    ) -> std::io::Result<()> {
2191        self.write_inference_event(InferenceLine {
2192            turn,
2193            input_tokens,
2194            output_tokens,
2195            decision,
2196            stop_reason,
2197            tool_name,
2198            origin,
2199            usage,
2200            message_ids,
2201            wire,
2202            choice: None,
2203            failure: None,
2204            counted: true,
2205        })
2206        .await
2207    }
2208
2209    /// [`Self::write_inference`] for an agent-loop turn, naming the driver choice that served it
2210    /// as `driver_choice` and `model`. `choice` is `None` on a capsule with no alternates, whose
2211    /// line is exactly what [`Self::write_inference`] writes.
2212    #[allow(clippy::too_many_arguments)]
2213    pub(crate) async fn write_turn_inference(
2214        &mut self,
2215        turn: u32,
2216        input_tokens: Option<u64>,
2217        output_tokens: Option<u64>,
2218        decision: String,
2219        stop_reason: Option<&str>,
2220        tool_name: Option<String>,
2221        usage: Option<&DriverUsage>,
2222        message_ids: Vec<String>,
2223        wire: Option<&WireCapture>,
2224        choice: Option<&crate::driver_choice::DriverChoice>,
2225    ) -> std::io::Result<()> {
2226        self.write_inference_event(InferenceLine {
2227            turn,
2228            input_tokens,
2229            output_tokens,
2230            decision,
2231            stop_reason,
2232            tool_name,
2233            origin: None,
2234            usage,
2235            message_ids,
2236            wire,
2237            choice,
2238            failure: None,
2239            counted: true,
2240        })
2241        .await
2242    }
2243
2244    /// Record a model call that failed: `decision` and `stop_reason` `"error"`, the failure's
2245    /// `error_code`, `error` (capped at [`MAX_TASK_FAILURE_REASON_BYTES`]) and `provider_status`.
2246    ///
2247    /// Never hashes or stores a wire body, whatever `trace.capture` is. Writes `input_tokens` and
2248    /// `output_tokens` only from `failed.spent`, and advances the turn and token counters only
2249    /// when `spent` is `Some`, by exactly its counts. Parents like any other `inference`: an
2250    /// agent-loop record becomes the turn node, a hook's hangs off the turn.
2251    pub(crate) async fn write_failed_inference(
2252        &mut self,
2253        failed: FailedInference<'_>,
2254    ) -> std::io::Result<()> {
2255        let error =
2256            crate::agent::truncate_on_char_boundary(failed.error, MAX_TASK_FAILURE_REASON_BYTES)
2257                .to_string();
2258        let (input_tokens, output_tokens) = failed.spent.unwrap_or((None, None));
2259        self.write_inference_event(InferenceLine {
2260            turn: failed.turn,
2261            input_tokens,
2262            output_tokens,
2263            decision: "error".to_string(),
2264            stop_reason: Some("error"),
2265            tool_name: failed.tool_name,
2266            origin: failed.origin,
2267            usage: failed.usage,
2268            message_ids: failed.message_ids,
2269            wire: None,
2270            choice: failed.choice,
2271            failure: Some((failed.error_code, error, failed.provider_status)),
2272            counted: failed.spent.is_some(),
2273        })
2274        .await
2275    }
2276
2277    async fn write_inference_event(&mut self, line: InferenceLine<'_>) -> std::io::Result<()> {
2278        let InferenceLine {
2279            turn,
2280            input_tokens,
2281            output_tokens,
2282            decision,
2283            stop_reason,
2284            tool_name,
2285            origin,
2286            usage,
2287            message_ids,
2288            wire,
2289            choice,
2290            failure,
2291            counted,
2292        } = line;
2293        let (error_code, error, provider_status) = match failure {
2294            Some((code, error, status)) => (Some(code.to_string()), Some(error), status),
2295            None => (None, None, None),
2296        };
2297        let event_id = new_event_id();
2298        // The agent loop's own inference *is* the turn node — there is no separate turn line —
2299        // so it hangs off the task and everything the turn goes on to produce hangs off it. A
2300        // hook's `run-inference` (`origin: Some(_)`) is one of those products, not a new turn.
2301        let parent_id = if origin.is_none() {
2302            let parent = self.task_parent();
2303            self.turn_event_id = Some(event_id.clone());
2304            parent
2305        } else {
2306            self.turn_parent()
2307        };
2308        let (system_sha, tools_sha, response_sha, message_shas) =
2309            match wire.filter(|_| self.capture.captures_hashes()) {
2310                Some(wire) => self.record_wire(wire).await?,
2311                None => (None, None, None, Vec::new()),
2312            };
2313        let event = InferenceEvent {
2314            event_type: "inference",
2315            event_id,
2316            parent_id,
2317            session_id: self.session_id.clone(),
2318            timestamp: timestamp_ms(),
2319            turn,
2320            task_id: self.active_task_id.clone(),
2321            input_tokens,
2322            output_tokens,
2323            decision,
2324            stop_reason: stop_reason.map(str::to_string),
2325            tool_name,
2326            input_tokens_actual: usage.and_then(|u| u.input_tokens),
2327            output_tokens_actual: usage.and_then(|u| u.output_tokens),
2328            cached_tokens: usage.and_then(|u| u.cached_tokens),
2329            cache_write_tokens: usage.and_then(|u| u.cache_write_tokens),
2330            thinking_tokens: usage.and_then(|u| u.thinking_tokens),
2331            origin: origin.map(|o| o.source.clone()),
2332            driver_choice: choice.map(|choice| choice.name.clone()),
2333            model: origin
2334                .map(|o| o.model.clone())
2335                .or_else(|| choice.map(|choice| choice.model.clone())),
2336            message_ids,
2337            system_sha,
2338            tools_sha,
2339            response_sha,
2340            message_shas,
2341            error_code,
2342            error,
2343            provider_status,
2344        };
2345        self.write_event(&event).await?;
2346        if origin.is_none() {
2347            self.task_agent_turns = self.task_agent_turns.saturating_add(1);
2348        }
2349        if !counted {
2350            return Ok(());
2351        }
2352        // A turn nothing counted contributes nothing to the totals. The totals are plain `u64`
2353        // because a session's spend is a sum, and the sum of no measurements is zero; the
2354        // absent/zero distinction lives on the record itself.
2355        let (input_tokens, output_tokens) = (input_tokens.unwrap_or(0), output_tokens.unwrap_or(0));
2356        self.total_turns = self.total_turns.saturating_add(1);
2357        self.total_input_tokens = self.total_input_tokens.saturating_add(input_tokens);
2358        self.total_output_tokens = self.total_output_tokens.saturating_add(output_tokens);
2359        self.task_turns = self.task_turns.saturating_add(1);
2360        if origin.is_some() {
2361            self.task_hook_inferences = self.task_hook_inferences.saturating_add(1);
2362        }
2363        self.task_input_tokens = self.task_input_tokens.saturating_add(input_tokens);
2364        self.task_output_tokens = self.task_output_tokens.saturating_add(output_tokens);
2365        Ok(())
2366    }
2367
2368    /// Hash the four bodies of one request and, under [`TraceCapture::Content`], store each
2369    /// behind its hash.
2370    ///
2371    /// The hash is computed in every capture mode that reaches here; only the blob write is
2372    /// gated, so `meta` and `content` name the same bytes and differ solely in whether those
2373    /// bytes are also on disk.
2374    async fn record_wire(
2375        &mut self,
2376        wire: &WireCapture,
2377    ) -> std::io::Result<(Option<String>, Option<String>, Option<String>, Vec<String>)> {
2378        let system_sha = murmur_artifact::sha256_hex(&wire.system);
2379        let tools_sha = murmur_artifact::sha256_hex(&wire.tools);
2380        let response_sha = murmur_artifact::sha256_hex(&wire.response);
2381        let message_shas: Vec<String> = wire
2382            .messages
2383            .iter()
2384            .map(|message| murmur_artifact::sha256_hex(message))
2385            .collect();
2386
2387        if self.capture.captures_content() {
2388            // Verbatim, unredacted. `write_tool_call` redacts peer handles out of its own summary
2389            // fields; a blob is not a summary, and redacting one would make its hash name bytes
2390            // no file holds — destroying the only property this store exists to provide.
2391            self.blobs.put(&system_sha, &wire.system).await?;
2392            self.blobs.put(&tools_sha, &wire.tools).await?;
2393            self.blobs.put(&response_sha, &wire.response).await?;
2394            for (sha, body) in message_shas.iter().zip(&wire.messages) {
2395                self.blobs.put(sha, body).await?;
2396            }
2397        }
2398
2399        Ok((
2400            Some(system_sha),
2401            Some(tools_sha),
2402            Some(response_sha),
2403            message_shas,
2404        ))
2405    }
2406
2407    #[allow(clippy::too_many_arguments)]
2408    pub(crate) async fn write_tool_call(
2409        &mut self,
2410        turn: u32,
2411        tool_name: String,
2412        tool_call_id: Option<String>,
2413        mut input: Value,
2414        input_bytes: u64,
2415        output: &str,
2416        output_bytes: u64,
2417        duration_ms: u64,
2418        status: String,
2419        state_effect: Option<String>,
2420        resource_id: Option<String>,
2421    ) -> std::io::Result<()> {
2422        // A peer handle reaches this event by an ordinary route — it is an argument the model
2423        // passed to `fetch-peer-file`, and every call's arguments are recorded. It is also a
2424        // credential, and the trace is durable, so the two must not meet: the `handle_id` goes in
2425        // instead, which is what correlates the record with the mint and the redeem anyway.
2426        crate::peer_handoff::redact_handles_in_json(&mut input);
2427        let event = ToolCallEvent {
2428            event_type: "tool_call",
2429            event_id: new_event_id(),
2430            parent_id: self.turn_parent(),
2431            session_id: self.session_id.clone(),
2432            timestamp: timestamp_ms(),
2433            turn,
2434            task_id: self.active_task_id.clone(),
2435            tool_name,
2436            // An absent or empty provider id records as `null`: `""` would read as an id the
2437            // provider actually issued.
2438            tool_call_id: tool_call_id.filter(|id| !id.is_empty()),
2439            input,
2440            input_bytes,
2441            output: self
2442                .capture
2443                .captures_content()
2444                .then(|| crate::peer_handoff::redact_handle_tokens(output).into_owned()),
2445            output_bytes,
2446            duration_ms,
2447            status,
2448            state_effect,
2449            resource_id,
2450        };
2451        self.write_event(&event).await?;
2452        self.total_tool_calls = self.total_tool_calls.saturating_add(1);
2453        self.task_tool_calls = self.task_tool_calls.saturating_add(1);
2454        Ok(())
2455    }
2456
2457    /// Records a skill invocation as a `skill_call` event. Skill calls are NOT counted in
2458    /// `total_tool_calls` — they appear in a separate `── Skill calls ──` section in `mur trace show`.
2459    ///
2460    /// `origin` is the skill's `murmur.lock` origin; the record's `trust` is derived from it
2461    /// through [`crate::origin::artifact_trust`], the same rule that decides the skill's fence.
2462    pub(crate) async fn write_skill_call(
2463        &mut self,
2464        turn: u32,
2465        skill_name: String,
2466        origin: &LockOrigin,
2467        output_bytes: u64,
2468        duration_ms: u64,
2469        status: String,
2470    ) -> std::io::Result<()> {
2471        let event = SkillCallEvent {
2472            event_type: "skill_call",
2473            event_id: new_event_id(),
2474            parent_id: self.turn_parent(),
2475            session_id: self.session_id.clone(),
2476            timestamp: timestamp_ms(),
2477            turn,
2478            task_id: self.active_task_id.clone(),
2479            skill_name,
2480            output_bytes,
2481            duration_ms,
2482            status,
2483            origin: origin.as_str(),
2484            trust: crate::origin::artifact_trust(origin).as_str(),
2485        };
2486        self.write_event(&event).await
2487        // Intentionally does not increment total_tool_calls or task_tool_calls.
2488    }
2489
2490    // One positional parameter per JSONL column, as every other `write_*` here does; the
2491    // sibling `write_tool_call` carries the same allowance.
2492    #[allow(clippy::too_many_arguments)]
2493    pub(crate) async fn write_shell(
2494        &mut self,
2495        turn: u32,
2496        binary: String,
2497        command: String,
2498        exit_code: i32,
2499        stdout_bytes: u64,
2500        stderr_bytes: u64,
2501        duration_ms: u64,
2502        resource_limit: Option<String>,
2503    ) -> std::io::Result<()> {
2504        let event = ShellEvent {
2505            event_type: "shell",
2506            event_id: new_event_id(),
2507            parent_id: self.turn_parent(),
2508            session_id: self.session_id.clone(),
2509            timestamp: timestamp_ms(),
2510            turn,
2511            task_id: self.active_task_id.clone(),
2512            binary,
2513            command,
2514            exit_code,
2515            stdout_bytes,
2516            stderr_bytes,
2517            duration_ms,
2518            resource_limit,
2519        };
2520        self.write_event(&event).await?;
2521        self.total_shell_calls = self.total_shell_calls.saturating_add(1);
2522        self.task_shell_calls = self.task_shell_calls.saturating_add(1);
2523        Ok(())
2524    }
2525
2526    /// A command demoted to the background, written from the agent loop at the moment of
2527    /// demotion.
2528    ///
2529    /// Counts as this session's shell call, exactly as a foreground one does — the pair with
2530    /// [`Self::write_shell_completed`], which deliberately does not count, so `mur trace show`
2531    /// reports each shell command once whichever way it ran.
2532    pub(crate) async fn write_shell_detached(
2533        &mut self,
2534        turn: u32,
2535        work_id: &str,
2536        binary: &str,
2537        command: &str,
2538        grace_ms: u64,
2539    ) -> std::io::Result<()> {
2540        let event = ShellDetachedEvent {
2541            event_type: "shell_detached",
2542            event_id: new_event_id(),
2543            parent_id: self.turn_parent(),
2544            session_id: self.session_id.clone(),
2545            timestamp: timestamp_ms(),
2546            turn,
2547            task_id: self.active_task_id.clone(),
2548            work_id: work_id.to_string(),
2549            binary: binary.to_string(),
2550            command: command.to_string(),
2551            grace_ms,
2552        };
2553        self.write_event(&event).await?;
2554        self.total_shell_calls = self.total_shell_calls.saturating_add(1);
2555        self.task_shell_calls = self.task_shell_calls.saturating_add(1);
2556        Ok(())
2557    }
2558
2559    /// A detached command finishing, written by the task loop as it turns the completion into a
2560    /// task. `completion_task_id` is that task's id, so the two records join.
2561    #[allow(clippy::too_many_arguments)]
2562    pub(crate) async fn write_shell_completed(
2563        &mut self,
2564        work_id: &str,
2565        binary: &str,
2566        command: &str,
2567        exit_code: i32,
2568        duration_ms: u64,
2569        output_path: &str,
2570        output_bytes: u64,
2571        resource_limit: Option<String>,
2572        status: &str,
2573        completion_task_id: &str,
2574    ) -> std::io::Result<()> {
2575        let event = ShellCompletedEvent {
2576            event_type: "shell_completed",
2577            event_id: new_event_id(),
2578            parent_id: self.session_parent(),
2579            session_id: self.session_id.clone(),
2580            timestamp: timestamp_ms(),
2581            work_id: work_id.to_string(),
2582            binary: binary.to_string(),
2583            command: command.to_string(),
2584            exit_code,
2585            duration_ms,
2586            output_path: output_path.to_string(),
2587            output_bytes,
2588            resource_limit,
2589            status: status.to_string(),
2590            completion_task_id: completion_task_id.to_string(),
2591        };
2592        self.write_event(&event).await
2593    }
2594
2595    /// A detached command the session ended without waiting for.
2596    ///
2597    /// `recovered` carries the exit code, the output path and that file's byte count for a command
2598    /// that finished during teardown, and is `None` for one still running. The three travel as one
2599    /// argument because they exist together: a command with an exit code has a written log, and
2600    /// one without has neither.
2601    pub(crate) async fn write_shell_abandoned(
2602        &mut self,
2603        work_id: &str,
2604        binary: &str,
2605        command: &str,
2606        running_ms: u64,
2607        recovered: Option<(i32, &str, u64)>,
2608    ) -> std::io::Result<()> {
2609        let event = ShellAbandonedEvent {
2610            event_type: "shell_abandoned",
2611            event_id: new_event_id(),
2612            parent_id: self.session_parent(),
2613            session_id: self.session_id.clone(),
2614            timestamp: timestamp_ms(),
2615            work_id: work_id.to_string(),
2616            binary: binary.to_string(),
2617            command: command.to_string(),
2618            running_ms,
2619            exit_code: recovered.map(|(exit_code, _, _)| exit_code),
2620            output_path: recovered.map(|(_, path, _)| path.to_string()),
2621            output_bytes: recovered.map(|(_, _, bytes)| bytes),
2622        };
2623        self.write_event(&event).await
2624    }
2625
2626    /// A demotion the trace could not record, written where the failure was seen.
2627    pub(crate) async fn write_shell_detach_unrecorded(
2628        &mut self,
2629        turn: u32,
2630        work_id: &str,
2631        binary: &str,
2632        reason: &str,
2633    ) -> std::io::Result<()> {
2634        let event = ShellDetachUnrecordedEvent {
2635            event_type: "shell_detach_unrecorded",
2636            event_id: new_event_id(),
2637            parent_id: self.turn_parent(),
2638            session_id: self.session_id.clone(),
2639            timestamp: timestamp_ms(),
2640            turn,
2641            task_id: self.active_task_id.clone(),
2642            work_id: work_id.to_string(),
2643            binary: binary.to_string(),
2644            reason: reason.to_string(),
2645        };
2646        self.write_event(&event).await
2647    }
2648
2649    pub(crate) async fn write_a2a_task_received(
2650        &mut self,
2651        task_id: &str,
2652        context_id: &str,
2653        message_id: &str,
2654        traceparent_from_caller: Option<&str>,
2655        caller_member: Option<&str>,
2656    ) -> std::io::Result<()> {
2657        let event = A2aTaskReceivedEvent {
2658            event_type: "a2a_task_received",
2659            event_id: new_event_id(),
2660            parent_id: self.session_parent(),
2661            session_id: self.session_id.clone(),
2662            timestamp: timestamp_ms(),
2663            task_id: task_id.to_string(),
2664            context_id: context_id.to_string(),
2665            message_id: message_id.to_string(),
2666            traceparent_from_caller: traceparent_from_caller.map(str::to_string),
2667            caller_member: caller_member.map(str::to_string),
2668        };
2669        self.write_event(&event).await
2670    }
2671
2672    pub(crate) async fn write_a2a_send(
2673        &mut self,
2674        peer_url: &str,
2675        message_id: &str,
2676        task_id: &str,
2677        context_id: &str,
2678        traceparent: Option<&str>,
2679        trust: TrustClass,
2680    ) -> std::io::Result<()> {
2681        let event = A2aSendEvent {
2682            event_type: "a2a_send",
2683            event_id: new_event_id(),
2684            parent_id: self.session_parent(),
2685            session_id: self.session_id.clone(),
2686            timestamp: timestamp_ms(),
2687            peer_url: peer_url.to_string(),
2688            message_id: message_id.to_string(),
2689            task_id: task_id.to_string(),
2690            context_id: context_id.to_string(),
2691            traceparent: traceparent.map(str::to_string),
2692            trust: trust.as_str().to_string(),
2693        };
2694        self.write_event(&event).await
2695    }
2696
2697    /// Records a successful `manage.pull()` of `artifact`, as the store summarised it after the
2698    /// lock was written. Parented to the session node when `session_start` was written through
2699    /// this writer, and to nothing otherwise — the script path drains these through a writer it
2700    /// opens after the guest returns, as it does its buffered `a2a_send` lines.
2701    pub(crate) async fn write_artifact_pulled(
2702        &mut self,
2703        artifact: &InstalledArtifactSummary,
2704    ) -> std::io::Result<()> {
2705        let session = match &artifact.origin {
2706            LockOrigin::Runtime { session } => Some(session.clone()),
2707            LockOrigin::Operator => None,
2708        };
2709        let event = ArtifactPulledEvent {
2710            event_type: "artifact_pulled",
2711            event_id: new_event_id(),
2712            parent_id: self.session_parent(),
2713            session_id: self.session_id.clone(),
2714            timestamp: timestamp_ms(),
2715            name: artifact.name.clone(),
2716            version: artifact.version.clone(),
2717            runtime: artifact.runtime.as_str(),
2718            origin: artifact.origin.as_str(),
2719            session,
2720            trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
2721        };
2722        self.write_event(&event).await
2723    }
2724
2725    pub(crate) async fn write_task_start(
2726        &mut self,
2727        task_id: &str,
2728        context_id: &str,
2729        source: &str,
2730        provenance: TaskProvenance,
2731        message_parts_bytes: u64,
2732    ) -> std::io::Result<()> {
2733        // Reset per-task counters for this task
2734        self.task_turns = 0;
2735        self.task_input_tokens = 0;
2736        self.task_output_tokens = 0;
2737        self.task_tool_calls = 0;
2738        self.task_shell_calls = 0;
2739        self.task_agent_turns = 0;
2740        self.task_hook_inferences = 0;
2741        self.task_start_instant = Some(Instant::now());
2742        self.active_task_id = Some(task_id.to_string());
2743
2744        let event_id = new_event_id();
2745        self.task_event_id = Some(event_id.clone());
2746        self.turn_event_id = None;
2747
2748        let event = TaskStartEvent {
2749            event_type: "task_start",
2750            event_id: event_id.clone(),
2751            parent_id: self.session_parent(),
2752            session_id: self.session_id.clone(),
2753            timestamp: timestamp_ms(),
2754            task_id: task_id.to_string(),
2755            context_id: context_id.to_string(),
2756            source: source.to_string(),
2757            origin: provenance.origin().as_str().to_string(),
2758            trust: provenance.trust().as_str().to_string(),
2759            // One rule for every call site, so the recorded lane cannot disagree with the lane
2760            // `LaneQueue` filed the task under — both read the same provenance.
2761            lane: TaskLane::for_origin(provenance.origin())
2762                .as_str()
2763                .to_string(),
2764            message_parts_bytes,
2765        };
2766        self.write_event(&event).await
2767    }
2768
2769    pub(crate) async fn write_task_end(
2770        &mut self,
2771        task_id: &str,
2772        exit_status: &str,
2773        reopen_count: u32,
2774    ) -> std::io::Result<()> {
2775        let duration_ms = self
2776            .task_start_instant
2777            .take()
2778            .map(|t| t.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
2779            .unwrap_or(0);
2780
2781        let event = TaskEndEvent {
2782            event_type: "task_end",
2783            event_id: new_event_id(),
2784            parent_id: self.task_parent(),
2785            session_id: self.session_id.clone(),
2786            timestamp: timestamp_ms(),
2787            task_id: task_id.to_string(),
2788            exit_status: exit_status.to_string(),
2789            duration_ms,
2790            turns: self.task_turns,
2791            input_tokens: self.task_input_tokens,
2792            output_tokens: self.task_output_tokens,
2793            tool_calls: self.task_tool_calls,
2794            shell_calls: self.task_shell_calls,
2795            reopen_count,
2796        };
2797        self.active_task_id = None;
2798        self.task_event_id = None;
2799        self.turn_event_id = None;
2800        self.task_agent_turns = 0;
2801        self.write_event(&event).await
2802    }
2803
2804    /// Record that a person stopped this task, at the point the loop stopped.
2805    ///
2806    /// Leaves `active_task_id` and the task's parent id alone: for a running task the terminal
2807    /// `task_end` still follows and closes the frame. A task cancelled while queued was never
2808    /// started, so this record hangs off the session.
2809    pub(crate) async fn write_task_canceled(
2810        &mut self,
2811        task_id: &str,
2812        turn: Option<u32>,
2813        phase: &str,
2814        detached_work_ids: Vec<String>,
2815        delegation_ids: Vec<String>,
2816    ) -> std::io::Result<()> {
2817        let event = TaskCanceledEvent {
2818            event_type: "task_canceled",
2819            event_id: new_event_id(),
2820            parent_id: self.task_parent(),
2821            session_id: self.session_id.clone(),
2822            timestamp: timestamp_ms(),
2823            task_id: task_id.to_string(),
2824            turn,
2825            phase: phase.to_string(),
2826            detached_work_ids,
2827            delegation_ids,
2828        };
2829        self.write_event(&event).await
2830    }
2831
2832    /// Record that a queued task was refused when the session stopped taking work, with `cause`
2833    /// from the `TASK_REJECTED_*` vocabulary. Written whatever `trace.capture` is.
2834    ///
2835    /// Leaves `active_task_id` alone: the refused task never started, so there is no task frame
2836    /// to open or close, and with no task active the record hangs off the session.
2837    pub(crate) async fn write_task_rejected(
2838        &mut self,
2839        task_id: &str,
2840        context_id: &str,
2841        source: &str,
2842        cause: &str,
2843        reason: &str,
2844    ) -> std::io::Result<()> {
2845        let event = TaskRejectedEvent {
2846            event_type: "task_rejected",
2847            event_id: new_event_id(),
2848            parent_id: self.task_parent(),
2849            session_id: self.session_id.clone(),
2850            timestamp: timestamp_ms(),
2851            task_id: task_id.to_string(),
2852            context_id: context_id.to_string(),
2853            source: source.to_string(),
2854            cause: cause.to_string(),
2855            reason: reason.to_string(),
2856        };
2857        self.write_event(&event).await
2858    }
2859
2860    /// Record that the task attempt in progress failed, with `cause` from the `TASK_FAILED_*`
2861    /// vocabulary. Written whatever `trace.capture` is.
2862    ///
2863    /// Leaves the task frame open, as [`Self::write_task_canceled`] does: the `task_end` that
2864    /// follows closes it. The reason, capped, is kept for [`Self::last_task_failure`].
2865    pub(crate) async fn write_task_failed(
2866        &mut self,
2867        turn: Option<u32>,
2868        cause: &str,
2869        reason: &str,
2870    ) -> std::io::Result<()> {
2871        let reason = crate::agent::truncate_on_char_boundary(reason, MAX_TASK_FAILURE_REASON_BYTES)
2872            .to_string();
2873        let event = TaskFailedEvent {
2874            event_type: "task_failed",
2875            event_id: new_event_id(),
2876            parent_id: self.task_parent(),
2877            session_id: self.session_id.clone(),
2878            timestamp: timestamp_ms(),
2879            task_id: self.active_task_id.clone(),
2880            turn,
2881            cause: cause.to_string(),
2882            reason: reason.clone(),
2883        };
2884        self.task_failures_written += 1;
2885        self.last_task_failure = Some(reason);
2886        self.write_event(&event).await
2887    }
2888
2889    /// The `reason` of the last `task_failed` line this writer wrote, or `None` before the first.
2890    pub(crate) fn last_task_failure(&self) -> Option<&str> {
2891        self.last_task_failure.as_deref()
2892    }
2893
2894    /// How many `task_failed` lines this writer has written. Compared across one run to tell
2895    /// whether that run wrote its own.
2896    pub(crate) fn task_failures_written(&self) -> u64 {
2897        self.task_failures_written
2898    }
2899
2900    /// Record that an `on-task-end` hook reopened the task. Written by the runtime's
2901    /// per-task reopen loop between two agent-loop attempts, once per reopen, before
2902    /// the terminal `task_end` record. `reopen_number` is 1-based.
2903    pub(crate) async fn write_task_reopened(
2904        &mut self,
2905        task_id: &str,
2906        hook_name: &str,
2907        reason: &str,
2908        reopen_number: u32,
2909        attempt_context: &'static str,
2910        turns_remaining: u32,
2911    ) -> std::io::Result<()> {
2912        let event = TaskReopenedEvent {
2913            event_type: "task_reopened",
2914            event_id: new_event_id(),
2915            parent_id: self.task_parent(),
2916            session_id: self.session_id.clone(),
2917            timestamp: timestamp_ms(),
2918            task_id: task_id.to_string(),
2919            hook_name: hook_name.to_string(),
2920            reason: reason.to_string(),
2921            reopen_number,
2922            attempt_context,
2923            turns_remaining,
2924        };
2925        self.write_event(&event).await
2926    }
2927
2928    /// Record that the task continues with one round of handed-off work's outcomes. Written by
2929    /// the runtime's per-task loop once per continuation, before the continued attempt's first
2930    /// `inference`. `continuation_number` is 1-based.
2931    pub(crate) async fn write_task_continued(
2932        &mut self,
2933        task_id: &str,
2934        continuation_number: u32,
2935        member_calls: &[String],
2936        delegations: &[String],
2937        waited_ms: u64,
2938        turns_remaining: u32,
2939    ) -> std::io::Result<()> {
2940        let event = TaskContinuedEvent {
2941            event_type: "task_continued",
2942            event_id: new_event_id(),
2943            parent_id: self.task_parent(),
2944            session_id: self.session_id.clone(),
2945            timestamp: timestamp_ms(),
2946            task_id: task_id.to_string(),
2947            continuation_number,
2948            member_calls,
2949            delegations,
2950            waited_ms,
2951            turns_remaining,
2952        };
2953        self.write_event(&event).await
2954    }
2955
2956    /// Cumulative turns consumed by the active task so far, across however many
2957    /// agent-loop attempts have run since the last [`Self::write_task_start`]. Read by
2958    /// the reopen loop to compute the remaining `max_turns` budget for the next attempt
2959    /// so reopening never grants turns past the capsule's ceiling.
2960    ///
2961    /// A hook's counted `run-inference` call is one of them. `inference.max_turns` is the cap on
2962    /// inference calls per task, and `task_end.turns` and `session_end.total_turns` count the
2963    /// same calls; a hook that calls `run-inference` every turn is per-task spend that the cap
2964    /// exists to bound, and the spend ceiling is per capsule, not per task.
2965    pub(crate) fn task_turns(&self) -> u32 {
2966        self.task_turns
2967    }
2968
2969    /// How many of [`Self::task_turns`] were hook `run-inference` calls.
2970    pub(crate) fn task_hook_inferences(&self) -> u32 {
2971        self.task_hook_inferences
2972    }
2973
2974    /// The number the active task's next agent-loop turn takes: how many agent-loop `inference`
2975    /// lines it has written since [`Self::write_task_start`], across every attempt. 0 outside a
2976    /// task frame. Not the `max_turns` budget, which is [`Self::task_turns`].
2977    pub(crate) fn task_agent_turns(&self) -> u32 {
2978        self.task_agent_turns
2979    }
2980
2981    pub(crate) async fn write_compaction(
2982        &mut self,
2983        turn: u32,
2984        tokens_before: u64,
2985        tokens_after: u64,
2986    ) -> std::io::Result<()> {
2987        let event = CompactionEvent {
2988            event_type: "compaction",
2989            event_id: new_event_id(),
2990            parent_id: self.turn_parent(),
2991            session_id: self.session_id.clone(),
2992            timestamp: timestamp_ms(),
2993            turn,
2994            task_id: self.active_task_id.clone(),
2995            tokens_before,
2996            tokens_after,
2997        };
2998        self.write_event(&event).await
2999    }
3000
3001    /// Record that compaction was attempted and declined, leaving the context over budget.
3002    /// `tokens` is the occupancy at the moment of the decline; `reason` is
3003    /// [`COMPACTION_DECLINED_NO_HOOK_REPLACEMENT`] or
3004    /// [`COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL`].
3005    pub(crate) async fn write_compaction_declined(
3006        &mut self,
3007        turn: u32,
3008        tokens: u64,
3009        reason: &str,
3010    ) -> std::io::Result<()> {
3011        let event = CompactionDeclinedEvent {
3012            event_type: "compaction_declined",
3013            event_id: new_event_id(),
3014            parent_id: self.turn_parent(),
3015            session_id: self.session_id.clone(),
3016            timestamp: timestamp_ms(),
3017            turn,
3018            task_id: self.active_task_id.clone(),
3019            tokens,
3020            reason: reason.to_string(),
3021        };
3022        self.write_event(&event).await
3023    }
3024
3025    /// Record what the runtime did with an `on-task-start` hook's `seed-context`.
3026    ///
3027    /// Written for every outcome, committed or not: `tokens` is what reached the context
3028    /// (`0` on a rejection), `proposed_tokens` is what the hook returned, and
3029    /// `budget_tokens` is the ceiling it was measured against. `reason` is `Some` only for
3030    /// [`SEED_OUTCOME_REJECTED`]. Task-scoped, like `task_start`/`task_end`.
3031    #[allow(clippy::too_many_arguments)]
3032    pub(crate) async fn write_context_seed(
3033        &mut self,
3034        hook_name: &str,
3035        tokens: u64,
3036        proposed_tokens: u64,
3037        budget_tokens: u64,
3038        outcome: &str,
3039        reason: Option<&str>,
3040        message_ids: Vec<String>,
3041    ) -> std::io::Result<()> {
3042        let event = ContextSeedEvent {
3043            event_type: "context_seed",
3044            event_id: new_event_id(),
3045            parent_id: self.task_parent(),
3046            session_id: self.session_id.clone(),
3047            timestamp: timestamp_ms(),
3048            task_id: self.active_task_id.clone(),
3049            hook_name: hook_name.to_string(),
3050            tokens,
3051            proposed_tokens,
3052            budget_tokens,
3053            outcome: outcome.to_string(),
3054            reason: reason.map(str::to_string),
3055            message_ids,
3056        };
3057        self.write_event(&event).await
3058    }
3059
3060    /// Record one store's deletion, naming the reason and everything that went.
3061    ///
3062    /// Session-scoped: `parent_id` is the session node, so the deletion hangs off the launch that
3063    /// performed it rather than off whatever task happened to be open. `messages_dropped` is
3064    /// `Some` only for [`RETENTION_REASON_MAX_MESSAGES`].
3065    pub(crate) async fn write_retention(
3066        &mut self,
3067        store: &str,
3068        reason: &str,
3069        targets: Vec<String>,
3070        messages_dropped: Option<u64>,
3071    ) -> std::io::Result<()> {
3072        let event = RetentionEvent {
3073            event_type: "retention",
3074            event_id: new_event_id(),
3075            parent_id: self.session_parent(),
3076            session_id: self.session_id.clone(),
3077            timestamp: timestamp_ms(),
3078            store: store.to_string(),
3079            reason: reason.to_string(),
3080            removed: u32::try_from(targets.len()).unwrap_or(u32::MAX),
3081            targets,
3082            messages_dropped,
3083        };
3084        self.write_event(&event).await
3085    }
3086
3087    /// Record that a policy hook refused a call before it ran. The only account of a call the
3088    /// model asked for and never got: nothing ran, so no `tool_call` or `shell` record
3089    /// accompanies it.
3090    pub(crate) async fn write_call_denied(
3091        &mut self,
3092        turn: u32,
3093        event: &str,
3094        hook_name: &str,
3095        target: &str,
3096        reason: &str,
3097    ) -> std::io::Result<()> {
3098        let event = CallDeniedEvent {
3099            event_type: "call_denied",
3100            event_id: new_event_id(),
3101            parent_id: self.turn_parent(),
3102            session_id: self.session_id.clone(),
3103            timestamp: timestamp_ms(),
3104            turn,
3105            event: event.to_string(),
3106            hook_name: hook_name.to_string(),
3107            target: target.to_string(),
3108            reason: reason.to_string(),
3109        };
3110        self.write_event(&event).await
3111    }
3112
3113    /// Record that a call was refused because its input lacked a field its tool's `input_schema`
3114    /// requires. A `tool_call` record with `status: "error"` follows it on both transports, so
3115    /// unlike [`Self::write_call_denied`] this is not the only line the call leaves. An empty
3116    /// `tool_call_id` records as `null`, as in [`Self::write_tool_call`].
3117    pub(crate) async fn write_tool_input_refused(
3118        &mut self,
3119        turn: u32,
3120        tool_name: &str,
3121        tool_call_id: Option<&str>,
3122        missing: &[String],
3123        reason: &str,
3124    ) -> std::io::Result<()> {
3125        let event = ToolInputRefusedEvent {
3126            event_type: "tool_input_refused",
3127            event_id: new_event_id(),
3128            parent_id: self.turn_parent(),
3129            session_id: self.session_id.clone(),
3130            timestamp: timestamp_ms(),
3131            turn,
3132            tool_name: tool_name.to_string(),
3133            tool_call_id: tool_call_id.filter(|id| !id.is_empty()).map(str::to_string),
3134            missing: missing.to_vec(),
3135            reason: reason.to_string(),
3136        };
3137        self.write_event(&event).await
3138    }
3139
3140    /// Record that a spend ceiling refused a driver call before it was sent. `origin` is the
3141    /// `hook:<name>` of a hook's `run-inference` call, `None` for an agent-loop turn.
3142    pub(crate) async fn write_spend_ceiling_reached(
3143        &mut self,
3144        turn: u32,
3145        refusal: &crate::spend::SpendRefusal,
3146        origin: Option<&str>,
3147    ) -> std::io::Result<()> {
3148        let event = SpendCeilingReachedEvent {
3149            event_type: "spend_ceiling_reached",
3150            event_id: new_event_id(),
3151            parent_id: self.turn_parent(),
3152            session_id: self.session_id.clone(),
3153            timestamp: timestamp_ms(),
3154            turn,
3155            task_id: self.active_task_id.clone(),
3156            refusal,
3157            origin,
3158        };
3159        self.write_event(&event).await
3160    }
3161
3162    /// Record that the manifest's `capabilities.filesystem.read_only` refused a call before it
3163    /// ran. Like [`Self::write_call_denied`] it is the only account of a call the model asked for
3164    /// and never got — nothing ran, so no `tool_call` or `shell` record accompanies it.
3165    #[allow(clippy::too_many_arguments)]
3166    pub(crate) async fn write_protected_path_denied(
3167        &mut self,
3168        turn: u32,
3169        call: &str,
3170        target: &str,
3171        path: &str,
3172        rule: &str,
3173        signal: &str,
3174        reason: &str,
3175    ) -> std::io::Result<()> {
3176        let event = ProtectedPathDeniedEvent {
3177            event_type: "protected_path_denied",
3178            event_id: new_event_id(),
3179            parent_id: self.turn_parent(),
3180            session_id: self.session_id.clone(),
3181            timestamp: timestamp_ms(),
3182            turn,
3183            call: call.to_string(),
3184            target: target.to_string(),
3185            path: path.to_string(),
3186            rule: rule.to_string(),
3187            signal: signal.to_string(),
3188            reason: reason.to_string(),
3189        };
3190        self.write_event(&event).await
3191    }
3192
3193    /// Record that a hook returned a `hook-output` arm the lifecycle event does not
3194    /// honor. Written by the agent loop from a buffered [`crate::hooks::DispatchFault`]
3195    /// drained just before `session_end`. Non-fatal: the session already continued as
3196    /// if the hook had returned `none`; this only makes the discard visible to
3197    /// `mur trace show` and anything reading the session trace.
3198    pub(crate) async fn write_hook_dispatch_error(
3199        &mut self,
3200        hook_name: &str,
3201        event: &str,
3202        arm: &str,
3203    ) -> std::io::Result<()> {
3204        let event = HookDispatchErrorEvent {
3205            event_type: "hook_dispatch_error",
3206            event_id: new_event_id(),
3207            parent_id: self.session_parent(),
3208            session_id: self.session_id.clone(),
3209            timestamp: timestamp_ms(),
3210            hook_name: hook_name.to_string(),
3211            event: event.to_string(),
3212            arm: arm.to_string(),
3213        };
3214        self.write_event(&event).await
3215    }
3216
3217    // ── The harness a `transport: process` session drives ────────────────────────────────
3218    //
3219    // Eight events account for one harness run: what was spawned, what it said about itself,
3220    // what it warned or noted, how it failed, how it was stopped, and how it ended. None of them
3221    // ever carries an argument value, an environment value or a file's contents — the bridge's
3222    // bearer token reaches the harness through exactly those three, and `trace.jsonl` is durable.
3223
3224    /// Record the harness about to be spawned: written after the driver planned the run and
3225    /// before the process exists, so a spawn that goes wrong still has its plan on record.
3226    pub(crate) async fn write_harness_start(&mut self, start: HarnessStart) -> std::io::Result<()> {
3227        let event = HarnessStartEvent {
3228            event_type: "harness_start",
3229            event_id: new_event_id(),
3230            parent_id: self.task_parent(),
3231            session_id: self.session_id.clone(),
3232            timestamp: timestamp_ms(),
3233            task_id: self.active_task_id.clone(),
3234            start,
3235        };
3236        self.write_event(&event).await
3237    }
3238
3239    /// Record a warning the run raised, beside the line it printed to stderr.
3240    pub(crate) async fn write_harness_warning(
3241        &mut self,
3242        code: &str,
3243        message: &str,
3244    ) -> std::io::Result<()> {
3245        let event = HarnessWarningEvent {
3246            event_type: "harness_warning",
3247            event_id: new_event_id(),
3248            parent_id: self.task_parent(),
3249            session_id: self.session_id.clone(),
3250            timestamp: timestamp_ms(),
3251            task_id: self.active_task_id.clone(),
3252            code: code.to_string(),
3253            message: message.to_string(),
3254        };
3255        self.write_event(&event).await
3256    }
3257
3258    /// Record the session the harness reports it opened, and how it is billing for it.
3259    pub(crate) async fn write_harness_session(
3260        &mut self,
3261        harness_session_id: &str,
3262        auth: &str,
3263        model: Option<&str>,
3264    ) -> std::io::Result<()> {
3265        let event = HarnessSessionEvent {
3266            event_type: "harness_session",
3267            event_id: new_event_id(),
3268            parent_id: self.task_parent(),
3269            session_id: self.session_id.clone(),
3270            timestamp: timestamp_ms(),
3271            task_id: self.active_task_id.clone(),
3272            harness_session_id: harness_session_id.to_string(),
3273            auth: auth.to_string(),
3274            model: model.map(str::to_string),
3275        };
3276        self.write_event(&event).await
3277    }
3278
3279    /// Record the harness session a person asked this capsule to forget, and who asked.
3280    pub(crate) async fn write_harness_session_forgotten(
3281        &mut self,
3282        context_id: &str,
3283        harness_session_id: &str,
3284        requested_by: &str,
3285    ) -> std::io::Result<()> {
3286        let event = HarnessSessionForgottenEvent {
3287            event_type: "harness_session_forgotten",
3288            event_id: new_event_id(),
3289            parent_id: self.task_parent(),
3290            session_id: self.session_id.clone(),
3291            timestamp: timestamp_ms(),
3292            task_id: self.active_task_id.clone(),
3293            context_id: context_id.to_string(),
3294            harness_session_id: harness_session_id.to_string(),
3295            requested_by: requested_by.to_string(),
3296        };
3297        self.write_event(&event).await
3298    }
3299
3300    /// Record that the harness is retrying a request to its own provider.
3301    pub(crate) async fn write_harness_retry(
3302        &mut self,
3303        attempt: u32,
3304        reason: &str,
3305    ) -> std::io::Result<()> {
3306        let event = HarnessRetryEvent {
3307            event_type: "harness_retry",
3308            event_id: new_event_id(),
3309            parent_id: self.task_parent(),
3310            session_id: self.session_id.clone(),
3311            timestamp: timestamp_ms(),
3312            task_id: self.active_task_id.clone(),
3313            attempt,
3314            reason: reason.to_string(),
3315        };
3316        self.write_event(&event).await
3317    }
3318
3319    /// Record a diagnostic the driver produced for the trace alone — a line it could not read,
3320    /// or a result the runtime could not pair with a call.
3321    pub(crate) async fn write_harness_note(&mut self, text: &str) -> std::io::Result<()> {
3322        let event = HarnessNoteEvent {
3323            event_type: "harness_note",
3324            event_id: new_event_id(),
3325            parent_id: self.task_parent(),
3326            session_id: self.session_id.clone(),
3327            timestamp: timestamp_ms(),
3328            task_id: self.active_task_id.clone(),
3329            text: text.to_string(),
3330        };
3331        self.write_event(&event).await
3332    }
3333
3334    /// Record a failed turn. `source` is `"harness"` when the harness reported the failure and
3335    /// `"runtime"` when this runtime ended the turn itself.
3336    pub(crate) async fn write_harness_failed(
3337        &mut self,
3338        kind: &str,
3339        message: &str,
3340        source: &str,
3341    ) -> std::io::Result<()> {
3342        let event = HarnessFailedEvent {
3343            event_type: "harness_failed",
3344            event_id: new_event_id(),
3345            parent_id: self.task_parent(),
3346            session_id: self.session_id.clone(),
3347            timestamp: timestamp_ms(),
3348            task_id: self.active_task_id.clone(),
3349            kind: kind.to_string(),
3350            message: message.to_string(),
3351            source: source.to_string(),
3352        };
3353        self.write_event(&event).await
3354    }
3355
3356    /// Record the interrupt a cancelled task sent its harness: the method the driver declared,
3357    /// whether it went out, and how long the harness has before it is killed.
3358    pub(crate) async fn write_harness_interrupt(
3359        &mut self,
3360        method: &str,
3361        delivered: bool,
3362        grace_ms: u64,
3363    ) -> std::io::Result<()> {
3364        let event = HarnessInterruptEvent {
3365            event_type: "harness_interrupt",
3366            event_id: new_event_id(),
3367            parent_id: self.task_parent(),
3368            session_id: self.session_id.clone(),
3369            timestamp: timestamp_ms(),
3370            task_id: self.active_task_id.clone(),
3371            method: method.to_string(),
3372            delivered,
3373            grace_ms,
3374        };
3375        self.write_event(&event).await
3376    }
3377
3378    /// Record how the harness ended: once per spawned process, whatever ended it.
3379    pub(crate) async fn write_harness_exit(&mut self, exit: HarnessExit) -> std::io::Result<()> {
3380        let event = HarnessExitEvent {
3381            event_type: "harness_exit",
3382            event_id: new_event_id(),
3383            parent_id: self.task_parent(),
3384            session_id: self.session_id.clone(),
3385            timestamp: timestamp_ms(),
3386            task_id: self.active_task_id.clone(),
3387            exit,
3388        };
3389        self.write_event(&event).await
3390    }
3391
3392    pub(crate) async fn write_session_end(&mut self, exit_status: &str) -> std::io::Result<()> {
3393        let duration_ms = self
3394            .session_start_time
3395            .elapsed()
3396            .as_millis()
3397            .try_into()
3398            .unwrap_or(u64::MAX);
3399        let event = SessionEndEvent {
3400            event_type: "session_end",
3401            event_id: new_event_id(),
3402            parent_id: self.session_parent(),
3403            session_id: self.session_id.clone(),
3404            timestamp: timestamp_ms(),
3405            total_turns: self.total_turns,
3406            total_input_tokens: self.total_input_tokens,
3407            total_output_tokens: self.total_output_tokens,
3408            total_tool_calls: self.total_tool_calls,
3409            total_shell_calls: self.total_shell_calls,
3410            duration_ms,
3411            exit_status: exit_status.to_string(),
3412        };
3413        self.write_event(&event).await?;
3414        self.session_ended = true;
3415        Ok(())
3416    }
3417
3418    /// Writes session_end with the accumulated totals if session_start was written but
3419    /// session_end was not (error exit paths that bypass the normal Ok() return sites).
3420    pub(crate) async fn write_session_end_if_not_ended(
3421        &mut self,
3422        exit_status: &str,
3423    ) -> std::io::Result<()> {
3424        if self.session_started && !self.session_ended {
3425            self.write_session_end(exit_status).await?;
3426        }
3427        Ok(())
3428    }
3429
3430    pub(crate) async fn flush(&mut self) -> std::io::Result<()> {
3431        self.writer.flush().await
3432    }
3433
3434    /// Writes one event as one line, and flushes.
3435    ///
3436    /// The flush is not an optimisation choice: [`ResourceTraceAppender`] appends to this same
3437    /// `trace.jsonl` while a task runs, so a line left half-buffered here would be split around
3438    /// whatever the appender wrote in between and the file would stop parsing as JSONL. The
3439    /// two writers agree on one rule — a complete line per write, then flush — and that rule is
3440    /// what makes concurrent reads during a running task safe to record.
3441    async fn write_event(&mut self, event: &impl Serialize) -> std::io::Result<()> {
3442        let mut line = serde_json::to_string(event).map_err(std::io::Error::other)?;
3443        line.push('\n');
3444        self.writer.write_all(line.as_bytes()).await?;
3445        self.writer.flush().await
3446    }
3447}
3448
3449/// One element of `session_start.gateways`: an artifact's credential gateway. Never the key.
3450#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3451pub(crate) struct SessionGateway {
3452    /// The artifact whose entry declares the gateway.
3453    pub(crate) artifact: String,
3454    /// The upstream's host, with its port when `gateway.endpoint` wrote one.
3455    pub(crate) host: String,
3456    /// `"config"`, `"environment"`, `"manifest"`, or `"none"` when the entry names no key.
3457    pub(crate) credential_source: &'static str,
3458    /// Whether the gateway is admitted against the spend meter: true only for the configured
3459    /// `transport: http` driver's.
3460    pub(crate) metered: bool,
3461}
3462
3463/// One element of `session_start.runtime_artifacts`: a staged artifact whose `murmur.lock` pin a
3464/// running capsule fetched.
3465#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3466pub(crate) struct RuntimeArtifact {
3467    name: String,
3468    version: String,
3469    /// Always `"runtime"`: only runtime-origin entries are listed.
3470    origin: &'static str,
3471    /// The session the lock entry names as the puller.
3472    session: String,
3473    /// Always `"untrusted"`, derived from `origin`.
3474    trust: &'static str,
3475}
3476
3477/// `session_start.control`: the names a capsule's `control:` block declares, never a value.
3478#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3479pub(crate) struct SessionControl {
3480    /// Controllable setting names, as `control.settings` lists them.
3481    pub(crate) settings: Vec<&'static str>,
3482    /// Controller-supplied secret names, as `control.secrets` lists them.
3483    pub(crate) secrets: Vec<String>,
3484    /// Setting names the agent may change, as `control.agent_settings` lists them. Omitted when
3485    /// the block grants the agent nothing.
3486    #[serde(skip_serializing_if = "Vec::is_empty")]
3487    pub(crate) agent_settings: Vec<&'static str>,
3488}
3489
3490/// One element of `session_start.inference_choices`: a driver choice and whether its credential
3491/// resolved at launch. Never a key.
3492#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3493pub(crate) struct SessionInferenceChoice {
3494    pub(crate) name: String,
3495    pub(crate) driver: String,
3496    pub(crate) model: String,
3497    /// Where the driver's gateway credential came from, as `session_start.gateways` names it, or
3498    /// `"none"` for one found nowhere.
3499    pub(crate) credential_source: &'static str,
3500    /// Whether the credential resolved at launch. An injected credential is `true` here and is
3501    /// checked again when a switch asks for it.
3502    pub(crate) available: bool,
3503}
3504
3505/// One accepted setting or secret change, as [`ResourceTraceAppender::write_control_change`]
3506/// records it. `event_id` is minted by the caller, because the setting it changed carries it
3507/// forward to the `control_applied` that names it.
3508pub(crate) struct ControlChange<'a> {
3509    pub(crate) event_id: String,
3510    /// Who made the change.
3511    pub(crate) principal: crate::control_plane::Principal,
3512    /// The control token's id for a controller's change; `None` for the agent's, which presents
3513    /// no token.
3514    pub(crate) token_id: Option<&'a str>,
3515    /// `"setting"` or `"secret"`.
3516    pub(crate) kind: &'static str,
3517    pub(crate) name: &'a str,
3518    /// `"set"` or `"forget"`.
3519    pub(crate) action: &'static str,
3520    /// A setting's value before and after. Always `None` for a secret.
3521    pub(crate) previous: Option<Value>,
3522    pub(crate) value: Option<Value>,
3523    /// When a setting change takes effect. `None` for a secret.
3524    pub(crate) applies_from: Option<&'static str>,
3525    /// For a secret `set`: whether it replaced a held value.
3526    pub(crate) replaced: Option<bool>,
3527}
3528
3529/// `control_change`: a controller changed a setting or a secret. A secret's line names it and
3530/// carries nothing of its value.
3531#[derive(Serialize)]
3532struct ControlChangeEvent<'a> {
3533    event_type: &'static str,
3534    event_id: String,
3535    parent_id: Option<String>,
3536    session_id: String,
3537    timestamp: u64,
3538    principal: &'static str,
3539    #[serde(skip_serializing_if = "Option::is_none")]
3540    token_id: Option<&'a str>,
3541    kind: &'static str,
3542    name: &'a str,
3543    action: &'static str,
3544    #[serde(skip_serializing_if = "Option::is_none")]
3545    previous: Option<Value>,
3546    #[serde(skip_serializing_if = "Option::is_none")]
3547    value: Option<Value>,
3548    #[serde(skip_serializing_if = "Option::is_none")]
3549    applies_from: Option<&'static str>,
3550    #[serde(skip_serializing_if = "Option::is_none")]
3551    replaced: Option<bool>,
3552}
3553
3554/// `control_refused`: the control plane refused a request, or the runtime refused the agent's
3555/// change. `kind`, `name` and `token_id` are written only once a controller's request
3556/// authenticated, so a `401` names nothing; the agent's refusal names its setting and no token.
3557#[derive(Serialize)]
3558struct ControlRefusedEvent<'a> {
3559    event_type: &'static str,
3560    event_id: String,
3561    parent_id: Option<String>,
3562    session_id: String,
3563    timestamp: u64,
3564    principal: &'static str,
3565    status: u16,
3566    reason: &'static str,
3567    #[serde(skip_serializing_if = "Option::is_none")]
3568    kind: Option<&'static str>,
3569    #[serde(skip_serializing_if = "Option::is_none")]
3570    name: Option<&'a str>,
3571    #[serde(skip_serializing_if = "Option::is_none")]
3572    token_id: Option<&'a str>,
3573}
3574
3575/// `control_applied`: the first inference call to use a setting value a controller set.
3576#[derive(Serialize)]
3577struct ControlAppliedEvent<'a> {
3578    event_type: &'static str,
3579    event_id: String,
3580    parent_id: Option<String>,
3581    session_id: String,
3582    timestamp: u64,
3583    turn: u32,
3584    task_id: Option<String>,
3585    name: &'a str,
3586    value: Value,
3587    change_id: &'a str,
3588}
3589
3590/// `tools_refreshed`: the first inference call to send a tool array rebuilt after an install.
3591#[derive(Serialize)]
3592struct ToolsRefreshedEvent<'a> {
3593    event_type: &'static str,
3594    event_id: String,
3595    parent_id: Option<String>,
3596    session_id: String,
3597    timestamp: u64,
3598    turn: u32,
3599    task_id: Option<String>,
3600    trigger: &'a str,
3601    added: &'a [String],
3602    removed: &'a [String],
3603    tools: &'a [String],
3604}
3605
3606/// `gateway_credential`: the `inference_credential` event for the credential of any artifact other
3607/// than the configured inference driver, with the artifact's name.
3608#[derive(Serialize)]
3609struct GatewayCredentialEvent<'a> {
3610    event_type: &'static str,
3611    event_id: String,
3612    parent_id: Option<String>,
3613    session_id: String,
3614    timestamp: u64,
3615    artifact: &'a str,
3616    source: &'static str,
3617    #[serde(skip_serializing_if = "Option::is_none")]
3618    credential: Option<&'a str>,
3619    change: &'static str,
3620    #[serde(skip_serializing_if = "Option::is_none")]
3621    trigger: Option<&'static str>,
3622    #[serde(skip_serializing_if = "Option::is_none")]
3623    status: Option<u16>,
3624    #[serde(skip_serializing_if = "Option::is_none")]
3625    retried: Option<bool>,
3626    #[serde(skip_serializing_if = "Option::is_none")]
3627    reason: Option<&'static str>,
3628}
3629
3630/// `inference_credential`: a rotation, rejection or unreadable source for the session's inference
3631/// credential. `trigger` is written only with `change: "rotated"`, `status` and `retried` only with
3632/// `"rejected"`, `reason` only with `"unreadable"`, and `credential` whenever the manifest named one.
3633#[derive(Serialize)]
3634struct InferenceCredentialEvent<'a> {
3635    event_type: &'static str,
3636    event_id: String,
3637    parent_id: Option<String>,
3638    session_id: String,
3639    timestamp: u64,
3640    source: &'static str,
3641    #[serde(skip_serializing_if = "Option::is_none")]
3642    credential: Option<&'a str>,
3643    change: &'static str,
3644    #[serde(skip_serializing_if = "Option::is_none")]
3645    trigger: Option<&'static str>,
3646    #[serde(skip_serializing_if = "Option::is_none")]
3647    status: Option<u16>,
3648    #[serde(skip_serializing_if = "Option::is_none")]
3649    retried: Option<bool>,
3650    #[serde(skip_serializing_if = "Option::is_none")]
3651    reason: Option<&'static str>,
3652}
3653
3654/// A credential change as the `change`, `trigger`, `status`, `retried` and `reason` fields of an
3655/// `inference_credential` or `gateway_credential` event.
3656#[allow(clippy::type_complexity)]
3657fn credential_change_fields(
3658    change: CredentialChange,
3659) -> (
3660    &'static str,
3661    Option<&'static str>,
3662    Option<u16>,
3663    Option<bool>,
3664    Option<&'static str>,
3665) {
3666    match change {
3667        CredentialChange::Rotated { trigger } => ("rotated", Some(trigger), None, None, None),
3668        CredentialChange::Rejected { status, retried } => {
3669            ("rejected", None, Some(status), Some(retried), None)
3670        }
3671        CredentialChange::Unreadable { reason } => ("unreadable", None, None, None, Some(reason)),
3672    }
3673}
3674
3675// ── Resource-plane appender ───────────────────────────────────────────────────
3676
3677/// A second, independent `O_APPEND` handle to the session's `trace.jsonl`, used by the resource
3678/// plane, by the peer plane's listener, and by the peer-handoff tools in the agent loop.
3679///
3680/// [`TraceWriter`] is `&mut`-owned by the agent loop and cannot be shared, and the motivating
3681/// case for a resource-plane event — a gateway reading a finished-but-alive capsule — happens
3682/// after `session_end` has already been written. So the plane gets its own handle rather than a
3683/// borrow of the loop's, and every event is written at the moment of the request rather than
3684/// deferred to a task boundary: a denied read that only surfaced at the next task end would be a
3685/// record of the wrong thing at the wrong time.
3686///
3687/// Interleaving is safe because both writers emit exactly one complete line per `write_all` and
3688/// flush it, and `O_APPEND` makes each such write atomic against the other's.
3689pub struct ResourceTraceAppender {
3690    /// Async rather than a `std::sync::Mutex`: this is held across an `await` on the write.
3691    file: tokio::sync::Mutex<File>,
3692    session_id: String,
3693    /// The session node every line this appender writes hangs off — [`TraceWriter`]'s own
3694    /// [`TraceWriter::session_event_id`], handed over at open time. The plane has no turn or
3695    /// task of its own: a read served after `session_end` belongs to the launch, not to
3696    /// whatever task happened to run last.
3697    session_event_id: String,
3698}
3699
3700impl ResourceTraceAppender {
3701    pub async fn open(
3702        workdir: &Path,
3703        session_id: String,
3704        session_event_id: String,
3705    ) -> std::io::Result<Self> {
3706        let file = OpenOptions::new()
3707            .create(true)
3708            .append(true)
3709            .open(workdir.join("trace.jsonl"))
3710            .await?;
3711        Ok(Self {
3712            file: tokio::sync::Mutex::new(file),
3713            session_id,
3714            session_event_id,
3715        })
3716    }
3717
3718    /// Records one `list`. Failures are swallowed: a trace that cannot be written must not turn
3719    /// a served read into an error, and the alternative — refusing the read — would let anyone
3720    /// who can fill the disk take the plane down.
3721    #[allow(clippy::too_many_arguments)]
3722    pub(crate) async fn write_resource_list(
3723        &self,
3724        root: &str,
3725        entry_count: usize,
3726        total_bytes: u64,
3727        generation: u64,
3728        containment_achieved: ContainmentClass,
3729        outcome: &str,
3730        reason: Option<String>,
3731    ) {
3732        let event = ResourceListEvent {
3733            event_type: "resource_list",
3734            event_id: new_event_id(),
3735            parent_id: Some(self.session_event_id.clone()),
3736            session_id: self.session_id.clone(),
3737            timestamp: timestamp_ms(),
3738            root: root.to_string(),
3739            entry_count,
3740            total_bytes,
3741            generation,
3742            containment_achieved,
3743            outcome: outcome.to_string(),
3744            reason,
3745        };
3746        self.append(&event).await;
3747    }
3748
3749    /// Records one `read`, served or refused. `bytes` and `sha256` are `None` on every
3750    /// non-`ok` outcome — a refusal must not carry a hash of something it did not serve.
3751    #[allow(clippy::too_many_arguments)]
3752    pub(crate) async fn write_resource_read(
3753        &self,
3754        path: &str,
3755        outcome: &str,
3756        bytes: Option<u64>,
3757        sha256: Option<String>,
3758        generation: u64,
3759        containment_achieved: ContainmentClass,
3760        reason: Option<String>,
3761    ) {
3762        let event = ResourceReadEvent {
3763            event_type: "resource_read",
3764            event_id: new_event_id(),
3765            parent_id: Some(self.session_event_id.clone()),
3766            session_id: self.session_id.clone(),
3767            timestamp: timestamp_ms(),
3768            path: path.to_string(),
3769            outcome: outcome.to_string(),
3770            bytes,
3771            sha256,
3772            generation,
3773            containment_achieved,
3774            reason,
3775        };
3776        self.append(&event).await;
3777    }
3778
3779    /// Records `formation_ended`, hung off the session node. Failures are swallowed: a member
3780    /// whose trace cannot be written still winds down.
3781    pub(crate) async fn write_formation_ended(&self, formation_id: &crate::FormationId) {
3782        let event = FormationEndedEvent {
3783            event_type: "formation_ended",
3784            event_id: new_event_id(),
3785            parent_id: Some(self.session_event_id.clone()),
3786            session_id: self.session_id.clone(),
3787            timestamp: timestamp_ms(),
3788            formation_id: formation_id.as_str().to_string(),
3789        };
3790        self.append(&event).await;
3791    }
3792
3793    /// Records `spawner_ended`, hung off the session node, naming the session that spawned this
3794    /// one and the delegation it was spawned under. Failures are swallowed: a child whose trace
3795    /// cannot be written still winds down.
3796    pub(crate) async fn write_spawner_ended(
3797        &self,
3798        spawned_by: Option<&str>,
3799        delegation_id: Option<&str>,
3800    ) {
3801        let event = SpawnerEndedEvent {
3802            event_type: "spawner_ended",
3803            event_id: new_event_id(),
3804            parent_id: Some(self.session_event_id.clone()),
3805            session_id: self.session_id.clone(),
3806            timestamp: timestamp_ms(),
3807            spawned_by: spawned_by.map(str::to_string),
3808            delegation_id: delegation_id.map(str::to_string),
3809        };
3810        self.append(&event).await;
3811    }
3812
3813    /// Records one mint. `handle_id` and `expires_at_ms` are `None` on every non-`ok` outcome —
3814    /// a refused mint produced no token, and an audit record must not imply one exists.
3815    #[allow(clippy::too_many_arguments)]
3816    pub(crate) async fn write_peer_handle_mint(
3817        &self,
3818        handle_id: Option<String>,
3819        path: &str,
3820        audience: &str,
3821        expires_at_ms: Option<u64>,
3822        outcome: &str,
3823        reason: Option<String>,
3824    ) {
3825        let event = PeerHandleMintEvent {
3826            event_type: "peer_handle_mint",
3827            event_id: new_event_id(),
3828            parent_id: Some(self.session_event_id.clone()),
3829            session_id: self.session_id.clone(),
3830            timestamp: timestamp_ms(),
3831            handle_id,
3832            path: path.to_string(),
3833            audience: audience.to_string(),
3834            expires_at_ms,
3835            outcome: outcome.to_string(),
3836            reason,
3837        };
3838        self.append(&event).await;
3839    }
3840
3841    /// Records one redeem, served or refused. The token itself is never written — only its
3842    /// `handle_id`.
3843    #[allow(clippy::too_many_arguments)]
3844    pub(crate) async fn write_peer_handle_redeem(
3845        &self,
3846        handle_id: &str,
3847        path: Option<String>,
3848        generation: u64,
3849        audience_asserted: Option<String>,
3850        bytes: Option<u64>,
3851        sha256: Option<String>,
3852        outcome: &str,
3853        reason: Option<String>,
3854    ) {
3855        let event = PeerHandleRedeemEvent {
3856            event_type: "peer_handle_redeem",
3857            event_id: new_event_id(),
3858            parent_id: Some(self.session_event_id.clone()),
3859            session_id: self.session_id.clone(),
3860            timestamp: timestamp_ms(),
3861            handle_id: handle_id.to_string(),
3862            path,
3863            generation,
3864            audience_asserted,
3865            bytes,
3866            sha256,
3867            outcome: outcome.to_string(),
3868            reason,
3869        };
3870        self.append(&event).await;
3871    }
3872
3873    /// Records one fetch on the ingesting side, served or refused.
3874    #[allow(clippy::too_many_arguments)]
3875    pub(crate) async fn write_peer_file_fetch(
3876        &self,
3877        peer: &str,
3878        handle_id: &str,
3879        stored_path: Option<String>,
3880        bytes: Option<u64>,
3881        sha256: Option<String>,
3882        outcome: &str,
3883        reason: Option<String>,
3884    ) {
3885        let event = PeerFileFetchEvent {
3886            event_type: "peer_file_fetch",
3887            event_id: new_event_id(),
3888            parent_id: Some(self.session_event_id.clone()),
3889            session_id: self.session_id.clone(),
3890            timestamp: timestamp_ms(),
3891            peer: peer.to_string(),
3892            handle_id: handle_id.to_string(),
3893            stored_path,
3894            bytes,
3895            sha256,
3896            outcome: outcome.to_string(),
3897            reason,
3898        };
3899        self.append(&event).await;
3900    }
3901
3902    /// Records one launched child, while its delegation is still running.
3903    ///
3904    /// Every field is known by the time the child has reported its session id, so none is
3905    /// optional: a delegation with nothing to say here was never launched and writes no line.
3906    pub(crate) async fn write_delegation_start(
3907        &self,
3908        delegation_id: &str,
3909        capsule: &str,
3910        version: &str,
3911        child_session_id: &str,
3912        child_workdir: &str,
3913    ) {
3914        let event = DelegationStartEvent::new(
3915            &self.session_id,
3916            &self.session_event_id,
3917            delegation_id,
3918            capsule,
3919            version,
3920            child_session_id,
3921            child_workdir,
3922        );
3923        self.append(&event).await;
3924    }
3925
3926    /// Records one delegation ending. `delegation_id` and `child_session_id` are `None` when no
3927    /// child was launched — a delegation that never started names no id, and an audit record must
3928    /// not imply one exists.
3929    #[allow(clippy::too_many_arguments)]
3930    pub(crate) async fn write_delegation(
3931        &self,
3932        capsule: &str,
3933        version: &str,
3934        delegation_id: Option<String>,
3935        child_session_id: Option<String>,
3936        duration_ms: u64,
3937        outcome: &str,
3938        reason: Option<String>,
3939    ) {
3940        let event = DelegationEvent::new(
3941            &self.session_id,
3942            &self.session_event_id,
3943            capsule,
3944            version,
3945            delegation_id,
3946            child_session_id,
3947            duration_ms,
3948            outcome,
3949            reason,
3950        );
3951        self.append(&event).await;
3952    }
3953
3954    /// Records a `call-member` call whose callee now holds the task.
3955    pub(crate) async fn write_member_call_start(
3956        &self,
3957        task_id: &str,
3958        call_id: &str,
3959        member: &str,
3960        member_task_id: &str,
3961    ) {
3962        let event = self.member_call_start(task_id, call_id, member, member_task_id);
3963        self.append(&event).await;
3964    }
3965
3966    /// [`Self::write_member_call_start`] when `admit`, run with the trace held, returns `true`;
3967    /// returns what `admit` did. No other line lands between `admit` and this one.
3968    pub(crate) async fn write_member_call_start_if(
3969        &self,
3970        task_id: &str,
3971        call_id: &str,
3972        member: &str,
3973        member_task_id: &str,
3974        admit: impl FnOnce() -> bool,
3975    ) -> bool {
3976        let event = self.member_call_start(task_id, call_id, member, member_task_id);
3977        self.append_if(&event, admit).await
3978    }
3979
3980    fn member_call_start(
3981        &self,
3982        task_id: &str,
3983        call_id: &str,
3984        member: &str,
3985        member_task_id: &str,
3986    ) -> MemberCallStartEvent {
3987        MemberCallStartEvent {
3988            event_type: "member_call_start",
3989            event_id: new_event_id(),
3990            parent_id: Some(self.session_event_id.clone()),
3991            session_id: self.session_id.clone(),
3992            timestamp: timestamp_ms(),
3993            task_id: task_id.to_string(),
3994            call_id: call_id.to_string(),
3995            member: member.to_string(),
3996            member_task_id: member_task_id.to_string(),
3997        }
3998    }
3999
4000    /// Records the `offer`th offer of a `call-member` call turned away busy, `waited_ms` after the
4001    /// call was made, with the door's status `message`.
4002    pub(crate) async fn write_member_call_busy(
4003        &self,
4004        task_id: &str,
4005        call_id: &str,
4006        member: &str,
4007        offer: u32,
4008        waited_ms: u64,
4009        message: &str,
4010    ) {
4011        let event = MemberCallBusyEvent {
4012            event_type: "member_call_busy",
4013            event_id: new_event_id(),
4014            parent_id: Some(self.session_event_id.clone()),
4015            session_id: self.session_id.clone(),
4016            timestamp: timestamp_ms(),
4017            task_id: task_id.to_string(),
4018            call_id: call_id.to_string(),
4019            member: member.to_string(),
4020            offer,
4021            waited_ms,
4022            message: message.to_string(),
4023        };
4024        self.append(&event).await;
4025    }
4026
4027    /// Records one `call-member` call's ending, and whether its outcome reached the calling task.
4028    pub(crate) async fn write_member_call(
4029        &self,
4030        task_id: &str,
4031        outcome: &crate::member_call::MemberCallOutcome,
4032        delivered: bool,
4033    ) {
4034        let event = MemberCallEvent {
4035            event_type: "member_call",
4036            event_id: new_event_id(),
4037            parent_id: Some(self.session_event_id.clone()),
4038            session_id: self.session_id.clone(),
4039            timestamp: timestamp_ms(),
4040            task_id: task_id.to_string(),
4041            call_id: outcome.call_id.clone(),
4042            member: outcome.member.clone(),
4043            member_task_id: outcome.member_task_id.clone(),
4044            status: outcome.status.as_str().to_string(),
4045            duration_ms: outcome.duration_ms,
4046            output: outcome.output.clone(),
4047            truncated: outcome.truncated,
4048            delivered,
4049            no_answer_below: outcome
4050                .below
4051                .iter()
4052                .map(|(member, status)| NoAnswerBelow {
4053                    member: member.clone(),
4054                    status: status.as_str().to_string(),
4055                })
4056                .collect(),
4057        };
4058        self.append(&event).await;
4059    }
4060
4061    /// Records one change to the session's inference credential. Carries where the credential
4062    /// comes from and what happened to it, never the value.
4063    pub(crate) async fn write_inference_credential(
4064        &self,
4065        source: &'static str,
4066        credential: Option<&str>,
4067        change: CredentialChange,
4068    ) {
4069        let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
4070        let event = InferenceCredentialEvent {
4071            event_type: "inference_credential",
4072            event_id: new_event_id(),
4073            parent_id: Some(self.session_event_id.clone()),
4074            session_id: self.session_id.clone(),
4075            timestamp: timestamp_ms(),
4076            source,
4077            credential,
4078            change: change_name,
4079            trigger,
4080            status,
4081            retried,
4082            reason,
4083        };
4084        self.append(&event).await;
4085    }
4086
4087    /// Writes a `gateway_credential` event for `artifact`'s credential: the fields
4088    /// [`Self::write_inference_credential`] writes, plus `artifact`.
4089    pub(crate) async fn write_gateway_credential(
4090        &self,
4091        artifact: &str,
4092        source: &'static str,
4093        credential: Option<&str>,
4094        change: CredentialChange,
4095    ) {
4096        let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
4097        let event = GatewayCredentialEvent {
4098            event_type: "gateway_credential",
4099            event_id: new_event_id(),
4100            parent_id: Some(self.session_event_id.clone()),
4101            session_id: self.session_id.clone(),
4102            timestamp: timestamp_ms(),
4103            artifact,
4104            source,
4105            credential,
4106            change: change_name,
4107            trigger,
4108            status,
4109            retried,
4110            reason,
4111        };
4112        self.append(&event).await;
4113    }
4114
4115    /// Records one accepted control-plane change, under the `event_id` the caller minted.
4116    pub(crate) async fn write_control_change(&self, change: ControlChange<'_>) {
4117        let event = ControlChangeEvent {
4118            event_type: "control_change",
4119            event_id: change.event_id,
4120            parent_id: Some(self.session_event_id.clone()),
4121            session_id: self.session_id.clone(),
4122            timestamp: timestamp_ms(),
4123            principal: change.principal.as_str(),
4124            token_id: change.token_id,
4125            kind: change.kind,
4126            name: change.name,
4127            action: change.action,
4128            previous: change.previous,
4129            value: change.value,
4130            applies_from: change.applies_from,
4131            replaced: change.replaced,
4132        };
4133        self.append(&event).await;
4134    }
4135
4136    /// Records one refused change. `kind`, `name` and `token_id` are `None` for a controller's
4137    /// request that did not authenticate.
4138    pub(crate) async fn write_control_refused(
4139        &self,
4140        principal: crate::control_plane::Principal,
4141        status: u16,
4142        reason: &'static str,
4143        kind: Option<&'static str>,
4144        name: Option<&str>,
4145        token_id: Option<&str>,
4146    ) {
4147        let event = ControlRefusedEvent {
4148            event_type: "control_refused",
4149            event_id: new_event_id(),
4150            parent_id: Some(self.session_event_id.clone()),
4151            session_id: self.session_id.clone(),
4152            timestamp: timestamp_ms(),
4153            principal: principal.as_str(),
4154            status,
4155            reason,
4156            kind,
4157            name,
4158            token_id,
4159        };
4160        self.append(&event).await;
4161    }
4162
4163    async fn append(&self, event: &impl Serialize) {
4164        self.append_if(event, || true).await;
4165    }
4166
4167    /// [`Self::append`] when `admit`, run with the trace held, returns `true`; returns what
4168    /// `admit` did. No other line lands between `admit` and this one.
4169    async fn append_if(&self, event: &impl Serialize, admit: impl FnOnce() -> bool) -> bool {
4170        let Ok(mut line) = serde_json::to_string(event) else {
4171            return admit();
4172        };
4173        line.push('\n');
4174        let mut file = self.file.lock().await;
4175        if !admit() {
4176            return false;
4177        }
4178        if file.write_all(line.as_bytes()).await.is_ok() {
4179            let _ = file.flush().await;
4180        }
4181        true
4182    }
4183}
4184
4185// -- Prior-session appender ---------------------------------------------------
4186
4187/// An `O_APPEND` handle to a *previous* session's `trace.jsonl`, opened by a resuming launch to
4188/// account for demoted commands that session left unreported.
4189///
4190/// The marker goes in the file that holds the unmatched `shell_detached` line rather than in the
4191/// resuming session's own trace, which keeps clearing O(1) instead of a scan of every sibling
4192/// session, and means [`crate::retention::prune_sessions`] removes a marker and the work it
4193/// accounts for together.
4194///
4195/// Writing to another session's trace is safe because that session's writer is dead — that is the
4196/// precondition for there being anything unaccounted — and because this obeys the same rule
4197/// [`ResourceTraceAppender`] does: one complete line per `write_all`, then flush, which `O_APPEND`
4198/// makes atomic against any other writer.
4199pub(crate) struct PriorSessionTraceAppender {
4200    file: File,
4201    /// The prior session's id, which is what its own lines carry.
4202    session_id: String,
4203    /// That session's `session_start` node, read back from the same file.
4204    session_event_id: Option<String>,
4205    /// Whether the file's last record is unterminated, which a writer killed mid-`write_all`
4206    /// leaves behind. The first appended line opens with a newline when it is, so the marker is
4207    /// a line of its own rather than spliced onto the torn one.
4208    terminate_torn_tail: bool,
4209}
4210
4211impl PriorSessionTraceAppender {
4212    /// `trace_path` is the prior session's `trace.jsonl`. Errors when it cannot be opened for
4213    /// appending, which the caller reports rather than reporting work it could not mark.
4214    pub(crate) async fn open(
4215        trace_path: &Path,
4216        session_id: String,
4217        session_event_id: Option<String>,
4218    ) -> std::io::Result<Self> {
4219        let terminate_torn_tail = !ends_with_newline(trace_path)?;
4220        let file = OpenOptions::new().append(true).open(trace_path).await?;
4221        Ok(Self {
4222            file,
4223            session_id,
4224            session_event_id,
4225            terminate_torn_tail,
4226        })
4227    }
4228
4229    /// Records one demoted command as lost, and reports whether the line landed.
4230    ///
4231    /// The result is not swallowed: a work id whose marker did not land must be dropped from the
4232    /// report rather than reported unmarked, because an unmarked work id is reported again by
4233    /// every later resume of the same session.
4234    pub(crate) async fn write_shell_lost(
4235        &mut self,
4236        work: &crate::detached::LostWork,
4237        reconciled_by_session: &str,
4238        reconciled_task_id: &str,
4239    ) -> std::io::Result<()> {
4240        let event = ShellLostEvent {
4241            event_type: "shell_lost",
4242            event_id: new_event_id(),
4243            parent_id: self.session_event_id.clone(),
4244            session_id: self.session_id.clone(),
4245            timestamp: timestamp_ms(),
4246            work_id: work.work_id.clone(),
4247            binary: work.binary.clone(),
4248            command: work.command.clone(),
4249            detached_at_ms: work.detached_at_ms,
4250            reconciled_by_session: reconciled_by_session.to_string(),
4251            reconciled_task_id: reconciled_task_id.to_string(),
4252        };
4253        let mut line = if self.terminate_torn_tail {
4254            "\n".to_string()
4255        } else {
4256            String::new()
4257        };
4258        line.push_str(&serde_json::to_string(&event).map_err(std::io::Error::other)?);
4259        line.push('\n');
4260        self.file.write_all(line.as_bytes()).await?;
4261        self.file.flush().await?;
4262        self.terminate_torn_tail = false;
4263        Ok(())
4264    }
4265}
4266
4267/// Whether the file's last byte is a newline. An empty file counts as terminated: there is no
4268/// record for an appended line to run into.
4269fn ends_with_newline(path: &Path) -> std::io::Result<bool> {
4270    use std::io::{Read, Seek, SeekFrom};
4271
4272    let mut file = std::fs::File::open(path)?;
4273    let length = file.metadata()?.len();
4274    if length == 0 {
4275        return Ok(true);
4276    }
4277    file.seek(SeekFrom::End(-1))?;
4278    let mut last = [0u8; 1];
4279    file.read_exact(&mut last)?;
4280    Ok(last[0] == b'\n')
4281}
4282
4283pub(crate) fn timestamp_ms() -> u64 {
4284    SystemTime::now()
4285        .duration_since(UNIX_EPOCH)
4286        .unwrap_or_default()
4287        .as_millis() as u64
4288}
4289
4290// ── Plan-scheduler events ────────────────────────────────────────────────────
4291
4292/// One step of a plan's DAG as authored, written into `plan_start`.
4293///
4294/// The shape is recorded once, up front, so the run stays legible for a step that never ran:
4295/// the trace names every step the plan declared whether or not the scheduler reached it.
4296#[derive(Serialize, Clone)]
4297pub(crate) struct PlanStepShape {
4298    pub(crate) step_id: String,
4299    /// `"tool"`, `"shell"` or `"capsule"`; `"unknown"` for a step whose kind cannot be inferred,
4300    /// which is a step the plan validator refuses.
4301    pub(crate) kind: &'static str,
4302    pub(crate) depends_on: Vec<String>,
4303    /// Whether the step carries an `if`, and so may settle without ever being dispatched.
4304    pub(crate) has_condition: bool,
4305}
4306
4307/// The plan's structure, written once before any step runs. Its `event_id` is the plan node
4308/// every other line of the run hangs off.
4309#[derive(Serialize)]
4310struct PlanStartEvent {
4311    event_type: &'static str,
4312    event_id: String,
4313    parent_id: Option<String>,
4314    session_id: String,
4315    timestamp: u64,
4316    plan_id: String,
4317    step_count: usize,
4318    steps: Vec<PlanStepShape>,
4319}
4320
4321/// One dispatched step, written as the scheduler hands it to a worker.
4322///
4323/// A step that settled without ever being dispatched — an `if` that evaluated false, a
4324/// dependency that never resolved — writes no line here, only a terminal `plan_step`. Joins to
4325/// that line on `(plan_id, step_id)`.
4326#[derive(Serialize)]
4327struct PlanStepStartEvent {
4328    event_type: &'static str,
4329    event_id: String,
4330    parent_id: Option<String>,
4331    session_id: String,
4332    timestamp: u64,
4333    plan_id: String,
4334    step_id: String,
4335    kind: &'static str,
4336    depends_on: Vec<String>,
4337}
4338
4339/// A settled step, written by the scheduler loop after the step's `on_error` policy has been
4340/// applied — which is why the worker thread does not write it: a step that failed under
4341/// `on_error: skip` settles as skipped in the returned report and must read as skipped here.
4342#[derive(Serialize)]
4343struct PlanStepEvent {
4344    event_type: &'static str,
4345    event_id: String,
4346    parent_id: Option<String>,
4347    session_id: String,
4348    timestamp: u64,
4349    plan_id: String,
4350    step_id: String,
4351    kind: &'static str,
4352    status: &'static str,
4353    attempts: u32,
4354    duration_ms: u64,
4355    #[serde(skip_serializing_if = "Option::is_none")]
4356    error: Option<String>,
4357    /// The interpolated input the step dispatched, with peer handle tokens redacted. Tool steps
4358    /// only — a shell step's command and a capsule step's task text are not carried here.
4359    #[serde(skip_serializing_if = "Option::is_none")]
4360    input: Option<Value>,
4361    /// The tool's self-declared `state_effect` for this step, lifted verbatim from
4362    /// `tool-result.metadata`. Absent when the tool declared nothing. Feeds the same
4363    /// redundant-call analysis `tool_call.state_effect` does.
4364    #[serde(skip_serializing_if = "Option::is_none")]
4365    state_effect: Option<String>,
4366    /// The resource this step addressed, as the tool declared it. Opaque and never parsed here.
4367    #[serde(skip_serializing_if = "Option::is_none")]
4368    resource_id: Option<String>,
4369}
4370
4371/// The run's summary, written once as `plan::execute` returns, whatever ended it.
4372#[derive(Serialize)]
4373struct PlanEndEvent {
4374    event_type: &'static str,
4375    event_id: String,
4376    parent_id: Option<String>,
4377    session_id: String,
4378    timestamp: u64,
4379    plan_id: String,
4380    outcome: &'static str,
4381    #[serde(skip_serializing_if = "Option::is_none")]
4382    failed_step: Option<String>,
4383    steps_total: usize,
4384    steps_succeeded: usize,
4385    steps_failed: usize,
4386    steps_skipped: usize,
4387    duration_ms: u64,
4388    /// Why the run ended when the reason was not a step's own failure: a plan that would not
4389    /// parse, a cgroup scope the host refused, a DAG with nothing left to run.
4390    #[serde(skip_serializing_if = "Option::is_none")]
4391    reason: Option<String>,
4392}
4393
4394/// Everything the scheduler loop knows about a settled step, as one argument.
4395pub(crate) struct PlanStepRecord {
4396    pub(crate) plan_id: String,
4397    pub(crate) step_id: String,
4398    pub(crate) kind: &'static str,
4399    /// `"success"`, `"failed"` or `"skipped"` — the status the returned `ExecutionReport` carries
4400    /// for this step, so the two can never disagree.
4401    pub(crate) status: &'static str,
4402    /// How many times the step was dispatched. `0` for a step that settled without dispatch.
4403    pub(crate) attempts: u32,
4404    pub(crate) duration_ms: u64,
4405    pub(crate) error: Option<String>,
4406    pub(crate) input: Option<Value>,
4407    pub(crate) state_effect: Option<String>,
4408    pub(crate) resource_id: Option<String>,
4409}
4410
4411/// Everything the scheduler knows about a finished run, as one argument.
4412pub(crate) struct PlanEndRecord {
4413    pub(crate) plan_id: String,
4414    pub(crate) outcome: &'static str,
4415    pub(crate) failed_step: Option<String>,
4416    pub(crate) steps_total: usize,
4417    pub(crate) steps_succeeded: usize,
4418    pub(crate) steps_failed: usize,
4419    pub(crate) steps_skipped: usize,
4420    pub(crate) duration_ms: u64,
4421    pub(crate) reason: Option<String>,
4422}
4423
4424/// A third `O_APPEND` handle to the session's `trace.jsonl`, held by a plan run.
4425///
4426/// Synchronous where [`ResourceTraceAppender`] is async, because [`crate::plan::execute`] is: it
4427/// runs its ready set on plain threads inside a `thread::scope`, so there is no runtime to await
4428/// on and no `await` for a lock to be held across. Interleaving with [`TraceWriter`] and with
4429/// the resource plane is safe on the same terms they interleave with each other — one complete
4430/// line per `write_all`, then flush, which `O_APPEND` makes atomic against the other writers.
4431///
4432/// Handing one of these to a [`crate::plan::SchedulerContext`] is the whole of the opt-in: a
4433/// context carrying `None` opens no file and writes no line.
4434pub struct PlanTraceAppender {
4435    file: std::sync::Mutex<std::fs::File>,
4436    session_id: String,
4437    /// The session node a run's `plan_start` hangs off — [`TraceWriter`]'s own
4438    /// `session_start`, handed over at open time so an appended line names a real parent.
4439    session_event_id: String,
4440}
4441
4442impl PlanTraceAppender {
4443    pub fn open(
4444        workdir: &Path,
4445        session_id: String,
4446        session_event_id: String,
4447    ) -> std::io::Result<Self> {
4448        let file = std::fs::OpenOptions::new()
4449            .create(true)
4450            .append(true)
4451            .open(workdir.join("trace.jsonl"))?;
4452        Ok(Self {
4453            file: std::sync::Mutex::new(file),
4454            session_id,
4455            session_event_id,
4456        })
4457    }
4458
4459    /// Records the plan's DAG and returns the plan node every later line of this run hangs off.
4460    pub(crate) fn write_plan_start(&self, plan_id: &str, steps: Vec<PlanStepShape>) -> String {
4461        let event_id = new_event_id();
4462        let event = PlanStartEvent {
4463            event_type: "plan_start",
4464            event_id: event_id.clone(),
4465            parent_id: Some(self.session_event_id.clone()),
4466            session_id: self.session_id.clone(),
4467            timestamp: timestamp_ms(),
4468            plan_id: plan_id.to_string(),
4469            step_count: steps.len(),
4470            steps,
4471        };
4472        self.append(&event);
4473        event_id
4474    }
4475
4476    pub(crate) fn write_plan_step_start(
4477        &self,
4478        plan_node: &str,
4479        plan_id: &str,
4480        step_id: &str,
4481        kind: &'static str,
4482        depends_on: Vec<String>,
4483    ) {
4484        let event = PlanStepStartEvent {
4485            event_type: "plan_step_start",
4486            event_id: new_event_id(),
4487            parent_id: Some(plan_node.to_string()),
4488            session_id: self.session_id.clone(),
4489            timestamp: timestamp_ms(),
4490            plan_id: plan_id.to_string(),
4491            step_id: step_id.to_string(),
4492            kind,
4493            depends_on,
4494        };
4495        self.append(&event);
4496    }
4497
4498    pub(crate) fn write_plan_step(&self, plan_node: &str, record: PlanStepRecord) {
4499        // On the same terms `TraceWriter::write_tool_call` redacts a tool call's input: a peer
4500        // handle is a credential and this file is durable.
4501        let input = record.input.map(|mut value| {
4502            crate::peer_handoff::redact_handles_in_json(&mut value);
4503            value
4504        });
4505        let event = PlanStepEvent {
4506            event_type: "plan_step",
4507            event_id: new_event_id(),
4508            parent_id: Some(plan_node.to_string()),
4509            session_id: self.session_id.clone(),
4510            timestamp: timestamp_ms(),
4511            plan_id: record.plan_id,
4512            step_id: record.step_id,
4513            kind: record.kind,
4514            status: record.status,
4515            attempts: record.attempts,
4516            duration_ms: record.duration_ms,
4517            error: record.error,
4518            input,
4519            state_effect: record.state_effect,
4520            resource_id: record.resource_id,
4521        };
4522        self.append(&event);
4523    }
4524
4525    /// Records the run's summary. `plan_node` is `None` for a run that never had one — a plan
4526    /// file that would not parse describes no DAG — and the line hangs off the session instead.
4527    pub(crate) fn write_plan_end(&self, plan_node: Option<&str>, record: PlanEndRecord) {
4528        let event = PlanEndEvent {
4529            event_type: "plan_end",
4530            event_id: new_event_id(),
4531            parent_id: Some(
4532                plan_node
4533                    .map(str::to_string)
4534                    .unwrap_or_else(|| self.session_event_id.clone()),
4535            ),
4536            session_id: self.session_id.clone(),
4537            timestamp: timestamp_ms(),
4538            plan_id: record.plan_id,
4539            outcome: record.outcome,
4540            failed_step: record.failed_step,
4541            steps_total: record.steps_total,
4542            steps_succeeded: record.steps_succeeded,
4543            steps_failed: record.steps_failed,
4544            steps_skipped: record.steps_skipped,
4545            duration_ms: record.duration_ms,
4546            reason: record.reason,
4547        };
4548        self.append(&event);
4549    }
4550
4551    /// Records one child a `capsule` step launched, while its delegation is still running.
4552    ///
4553    /// The synchronous twin of [`ResourceTraceAppender::write_delegation_start`]. Both go through
4554    /// [`DelegationStartEvent::new`], so the two lines are identical but for their ids and
4555    /// clocks — held by there being one constructor rather than by a test.
4556    pub(crate) fn write_delegation_start(
4557        &self,
4558        delegation_id: &str,
4559        capsule: &str,
4560        version: &str,
4561        child_session_id: &str,
4562        child_workdir: &str,
4563    ) {
4564        let event = DelegationStartEvent::new(
4565            &self.session_id,
4566            &self.session_event_id,
4567            delegation_id,
4568            capsule,
4569            version,
4570            child_session_id,
4571            child_workdir,
4572        );
4573        self.append(&event);
4574    }
4575
4576    /// Records one `capsule` step's delegation ending. `delegation_id` and `child_session_id` are
4577    /// `None` when no child was launched, on the same terms
4578    /// [`ResourceTraceAppender::write_delegation`] passes them.
4579    ///
4580    /// One pair per *attempt*: a step with retries delegates once per attempt, so it writes one
4581    /// of these per attempt. Two `failed` lines and a `completed` one is what happened, not a
4582    /// duplicate of one thing.
4583    #[allow(clippy::too_many_arguments)]
4584    pub(crate) fn write_delegation(
4585        &self,
4586        capsule: &str,
4587        version: &str,
4588        delegation_id: Option<String>,
4589        child_session_id: Option<String>,
4590        duration_ms: u64,
4591        outcome: &str,
4592        reason: Option<String>,
4593    ) {
4594        let event = DelegationEvent::new(
4595            &self.session_id,
4596            &self.session_event_id,
4597            capsule,
4598            version,
4599            delegation_id,
4600            child_session_id,
4601            duration_ms,
4602            outcome,
4603            reason,
4604        );
4605        self.append(&event);
4606    }
4607
4608    /// Writes one complete line and flushes it. Every failure is swallowed, exactly as
4609    /// [`ResourceTraceAppender::append`] swallows one: a trace that cannot be written must never
4610    /// turn a step that ran into a step that failed.
4611    fn append(&self, event: &impl Serialize) {
4612        use std::io::Write as _;
4613
4614        let Ok(mut line) = serde_json::to_string(event) else {
4615            return;
4616        };
4617        line.push('\n');
4618        let Ok(mut file) = self.file.lock() else {
4619            return;
4620        };
4621        if file.write_all(line.as_bytes()).is_ok() {
4622            let _ = file.flush();
4623        }
4624    }
4625}
4626
4627// ── Unit tests ────────────────────────────────────────────────────────────────
4628
4629#[cfg(test)]
4630mod tests {
4631    use super::*;
4632    use crate::{
4633        containment::scope_report_for_tier, sandbox::EnforcementTier, sealed::UsernsGrant,
4634        trace_blobs::BLOB_DIR_NAME, types::CapabilityPolicy,
4635    };
4636    use murmur_artifact::{ArtifactRuntime, InterpreterRuntimeDir, InterpreterRuntimeGrant};
4637    use serde_json::Value;
4638
4639    use crate::origin::TaskOrigin;
4640
4641    /// The class an A2A task carrying no origin claim resolves to.
4642    fn event_provenance() -> TaskProvenance {
4643        TaskProvenance::derive(TaskOrigin::Event, None)
4644    }
4645
4646    /// A [`ScopeReport`] built the way production builds one — through
4647    /// `containment::scope_report_for_tier` with an *explicit* tier, never a live probe, so these
4648    /// tests assert the same values on a Landlock-capable host and a laptop that has none.
4649    fn report_for(
4650        declared: ContainmentClass,
4651        tier: EnforcementTier,
4652        workdir_exec: bool,
4653    ) -> ScopeReport {
4654        scope_report_for_tier(
4655            &CapabilityPolicy {
4656                workdir_exec_allowed: workdir_exec,
4657                ..CapabilityPolicy::default()
4658            },
4659            declared,
4660            tier,
4661            None,
4662            None,
4663            None,
4664            Vec::new(),
4665            Vec::new(),
4666            Vec::new(),
4667            crate::cgroup::IoMaxReport::default(),
4668        )
4669    }
4670
4671    fn demotion_info() -> crate::detached::DetachedDispatchInfo {
4672        crate::detached::DetachedDispatchInfo {
4673            work_id: "wrk_0a1b2c3d4e5f6a7b".to_string(),
4674            binary: "/usr/bin/bash".to_string(),
4675            command: "make -j8".to_string(),
4676            grace_ms: 1_000,
4677        }
4678    }
4679
4680    /// A demotion whose marker cannot be written stands anyway: the write failure is reported and
4681    /// swallowed, and the session carries on with the command running in the background.
4682    #[tokio::test]
4683    async fn a_demotion_marker_that_cannot_be_written_does_not_fail_the_demotion() {
4684        let dir = tempfile::tempdir().unwrap();
4685        // `/dev/full` opens and fails every write with `ENOSPC`, which is the one way to get a
4686        // trace handle that is valid and unwritable without root.
4687        std::os::unix::fs::symlink("/dev/full", dir.path().join("trace.jsonl")).unwrap();
4688        let mut writer = make_writer(dir.path()).await;
4689        let info = demotion_info();
4690
4691        assert!(
4692            writer
4693                .write_shell_detached(1, &info.work_id, &info.binary, &info.command, info.grace_ms)
4694                .await
4695                .is_err(),
4696            "the sink under this writer must be unwritable for the claim below to mean anything"
4697        );
4698        crate::agent::record_demotion(&mut writer, 1, &info).await;
4699    }
4700
4701    /// The ordinary path writes one `shell_detached` and nothing else.
4702    #[tokio::test]
4703    async fn a_recorded_demotion_writes_only_its_own_marker() {
4704        let dir = tempfile::tempdir().unwrap();
4705        let mut writer = make_writer(dir.path()).await;
4706        crate::agent::record_demotion(&mut writer, 3, &demotion_info()).await;
4707
4708        let events = read_events(dir.path());
4709        assert_eq!(events.len(), 1);
4710        assert_eq!(events[0]["event_type"], "shell_detached");
4711        assert_eq!(events[0]["work_id"], "wrk_0a1b2c3d4e5f6a7b");
4712        assert_eq!(events[0]["turn"], 3);
4713    }
4714
4715    #[tokio::test]
4716    async fn an_unrecorded_demotion_names_the_work_id_and_the_write_error() {
4717        let dir = tempfile::tempdir().unwrap();
4718        let mut writer = make_writer(dir.path()).await;
4719        writer
4720            .write_shell_detach_unrecorded(2, "wrk_0a1b", "/usr/bin/bash", "No space left")
4721            .await
4722            .unwrap();
4723
4724        let events = read_events(dir.path());
4725        assert_eq!(events.len(), 1);
4726        assert_eq!(events[0]["event_type"], "shell_detach_unrecorded");
4727        assert_eq!(events[0]["turn"], 2);
4728        assert_eq!(events[0]["work_id"], "wrk_0a1b");
4729        assert_eq!(events[0]["binary"], "/usr/bin/bash");
4730        assert_eq!(events[0]["reason"], "No space left");
4731    }
4732
4733    /// A command still running when the session ended has no exit code, no output file and no
4734    /// byte count, and its line carries none of the three — omitted rather than `null`, so the
4735    /// shape is identical to a line written before those fields existed.
4736    #[tokio::test]
4737    async fn an_abandoned_command_still_running_omits_the_fields_it_cannot_know() {
4738        let dir = tempfile::tempdir().unwrap();
4739        let mut writer = make_writer(dir.path()).await;
4740        writer
4741            .write_shell_abandoned(
4742                "wrk_0a1b2c3d4e5f6a7b",
4743                "/usr/bin/bash",
4744                "sleep 45; echo done",
4745                45_012,
4746                None,
4747            )
4748            .await
4749            .unwrap();
4750
4751        let events = read_events(dir.path());
4752        assert_eq!(events.len(), 1);
4753        let event = &events[0];
4754        assert_eq!(event["event_type"], "shell_abandoned");
4755        assert_eq!(event["work_id"], "wrk_0a1b2c3d4e5f6a7b");
4756        assert_eq!(event["binary"], "/usr/bin/bash");
4757        assert_eq!(event["command"], "sleep 45; echo done");
4758        assert_eq!(event["running_ms"], 45_012);
4759        for absent in ["exit_code", "output_path", "output_bytes"] {
4760            assert!(
4761                event.get(absent).is_none(),
4762                "{absent} must be omitted, not written as null: {event}"
4763            );
4764        }
4765    }
4766
4767    /// A command that finished during teardown carries the exit code, the output path and the
4768    /// byte count that do exist for it, so a trace reader can tell it from one still running
4769    /// without reading anything else.
4770    #[tokio::test]
4771    async fn an_abandoned_command_that_finished_carries_its_result() {
4772        let dir = tempfile::tempdir().unwrap();
4773        let mut writer = make_writer(dir.path()).await;
4774        writer
4775            .write_shell_abandoned(
4776                "wrk_0a1b2c3d4e5f6a7b",
4777                "/usr/bin/bash",
4778                "sleep 2; echo done",
4779                2_014,
4780                Some((0, "logs/wrk_0a1b2c3d4e5f6a7b.log", 91)),
4781            )
4782            .await
4783            .unwrap();
4784
4785        let event = &read_events(dir.path())[0];
4786        assert_eq!(event["event_type"], "shell_abandoned");
4787        assert_eq!(event["running_ms"], 2_014);
4788        assert_eq!(event["exit_code"], 0);
4789        assert_eq!(event["output_path"], "logs/wrk_0a1b2c3d4e5f6a7b.log");
4790        assert_eq!(event["output_bytes"], 91);
4791    }
4792
4793    async fn make_writer(dir: &std::path::Path) -> TraceWriter {
4794        make_writer_with_opts(dir, TraceCapture::Meta).await
4795    }
4796
4797    async fn make_writer_with_opts(dir: &std::path::Path, capture: TraceCapture) -> TraceWriter {
4798        make_writer_with_prompt(dir, capture, None, false).await
4799    }
4800
4801    async fn make_writer_with_prompt(
4802        dir: &std::path::Path,
4803        capture: TraceCapture,
4804        system_prompt: Option<&str>,
4805        system_prompt_overridden: bool,
4806    ) -> TraceWriter {
4807        TraceWriter::open(
4808            dir,
4809            "test-session-id".to_string(),
4810            "test-capsule".to_string(),
4811            "1.0.0".to_string(),
4812            "claude-test".to_string(),
4813            vec!["shell".to_string()],
4814            report_for(
4815                ContainmentClass::Advisory,
4816                EnforcementTier::EnvironmentOnly,
4817                false,
4818            ),
4819            capture,
4820            system_prompt.map(str::to_string),
4821            system_prompt_overridden,
4822            None,
4823            None,
4824            None,
4825            None,
4826        )
4827        .await
4828        .unwrap()
4829    }
4830
4831    fn read_events(dir: &std::path::Path) -> Vec<Value> {
4832        let content = std::fs::read_to_string(dir.join("trace.jsonl")).unwrap();
4833        content
4834            .lines()
4835            .filter(|l| !l.is_empty())
4836            .map(|l| serde_json::from_str(l).unwrap())
4837            .collect()
4838    }
4839
4840    #[tokio::test]
4841    async fn session_start_fields() {
4842        let dir = tempfile::tempdir().unwrap();
4843        let mut w = make_writer(dir.path()).await;
4844        w.write_session_start(10, vec!["bash".to_string()])
4845            .await
4846            .unwrap();
4847        w.flush().await.unwrap();
4848
4849        let events = read_events(dir.path());
4850        assert_eq!(events.len(), 1);
4851        let e = &events[0];
4852        assert_eq!(e["event_type"], "session_start");
4853        assert_eq!(e["session_id"], "test-session-id");
4854        assert_eq!(e["capsule_name"], "test-capsule");
4855        assert_eq!(e["capsule_version"], "1.0.0");
4856        assert_eq!(e["model"], "claude-test");
4857        assert_eq!(e["max_turns"], 10);
4858        assert_eq!(e["capabilities"], serde_json::json!(["shell"]));
4859        assert_eq!(e["tools_declared"], serde_json::json!(["bash"]));
4860        assert_eq!(e["containment_declared"], "advisory");
4861        assert_eq!(e["containment_achieved"], "advisory");
4862        assert_eq!(e["workdir_exec"], false);
4863        assert!(e["timestamp"].as_u64().unwrap() > 0);
4864    }
4865
4866    /// A member's formation is on the trace's first line, which is all a reader grouping sessions
4867    /// reads; a session in no formation writes no key at all.
4868    #[tokio::test]
4869    async fn session_start_is_the_first_line_and_carries_a_members_formation() {
4870        let formation = FormationId::mint();
4871        for member in [Some(&formation), None] {
4872            let dir = tempfile::tempdir().unwrap();
4873            let mut w = make_writer(dir.path()).await;
4874            w.set_formation_id(member);
4875            w.write_session_start(10, Vec::new()).await.unwrap();
4876            w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
4877                .await
4878                .unwrap();
4879            w.flush().await.unwrap();
4880
4881            let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
4882            let first_line = content.lines().next().unwrap();
4883            let first: Value = serde_json::from_str(first_line).unwrap();
4884            assert_eq!(first["event_type"], "session_start");
4885            match member {
4886                Some(id) => {
4887                    assert_eq!(first["formation_id"], id.as_str());
4888                    assert!(
4889                        first_line.contains(&format!("\"formation_id\":\"{id}\"")),
4890                        "{first_line}"
4891                    );
4892                }
4893                None => assert!(!first_line.contains("formation_id"), "{first_line}"),
4894            }
4895            // Only the session frame carries it.
4896            assert_eq!(
4897                content.matches("formation_id").count(),
4898                usize::from(member.is_some())
4899            );
4900        }
4901    }
4902
4903    /// A member's `session_start` names it and its callees, `[]` for none, and only a member's
4904    /// does; `a2a_task_received.caller_member` is present only for a task a formation token let
4905    /// in.
4906    #[tokio::test]
4907    async fn a_members_trace_names_it_its_callees_and_its_callers() {
4908        let authority = crate::formation_credentials::FormationAuthority::for_test();
4909        let cases = [
4910            (Some(("coder", vec!["reviewer"])), Some("planner")),
4911            (Some(("reviewer", vec![])), None),
4912            (None, None),
4913        ];
4914        for (member, caller) in cases {
4915            let member = member.map(|(name, callees)| {
4916                crate::formation_credentials::FormationMember::from_bundle(
4917                    authority.member_bundle(name, &callees),
4918                )
4919            });
4920            let dir = tempfile::tempdir().unwrap();
4921            let mut w = make_writer(dir.path()).await;
4922            w.set_formation_member(member.as_ref());
4923            w.write_session_start(10, Vec::new()).await.unwrap();
4924            w.write_a2a_task_received("tsk_1", "ctx_1", "msg_1", None, caller)
4925                .await
4926                .unwrap();
4927            w.flush().await.unwrap();
4928            let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
4929            let lines: Vec<Value> = content
4930                .lines()
4931                .map(|line| serde_json::from_str(line).unwrap())
4932                .collect();
4933            let start = &lines[0];
4934            let received = lines
4935                .iter()
4936                .find(|line| line["event_type"] == "a2a_task_received")
4937                .unwrap();
4938            match &member {
4939                Some(member) => {
4940                    assert_eq!(start["formation_member"], member.name());
4941                    assert_eq!(
4942                        start["formation_callees"],
4943                        serde_json::json!(member.callees().collect::<Vec<_>>())
4944                    );
4945                }
4946                None => {
4947                    assert!(start.get("formation_member").is_none(), "{start}");
4948                    assert!(start.get("formation_callees").is_none(), "{start}");
4949                }
4950            }
4951            match caller {
4952                Some(caller) => assert_eq!(received["caller_member"], caller),
4953                None => assert!(received.get("caller_member").is_none(), "{received}"),
4954            }
4955            assert!(!content.contains("mft1."), "{content}");
4956        }
4957    }
4958
4959    /// The three `system_prompt_source` values, each paired with the hash the same call derives.
4960    /// A manifest prompt and a CLI override carrying the *same* text differ only in the source
4961    /// field — which is the whole reason the override flag is threaded through separately rather
4962    /// than inferred from the resolved prompt.
4963    #[tokio::test]
4964    async fn session_start_records_system_prompt_source_and_hash() {
4965        let expected_sha = murmur_artifact::sha256_hex(b"Be terse.");
4966
4967        for (prompt, overridden, source, sha) in [
4968            (Some("Be terse."), false, "manifest", Some(&expected_sha)),
4969            (Some("Be terse."), true, "cli", Some(&expected_sha)),
4970            (None, false, "none", None),
4971            // `--system-prompt ""` cleared the manifest's prompt: no prompt is in effect, but the
4972            // operator made that call, so the source is the CLI and not `"none"`.
4973            (None, true, "cli", None),
4974        ] {
4975            let dir = tempfile::tempdir().unwrap();
4976            let mut w =
4977                make_writer_with_prompt(dir.path(), TraceCapture::Meta, prompt, overridden).await;
4978            w.write_session_start(1, Vec::new()).await.unwrap();
4979            w.flush().await.unwrap();
4980
4981            let e = &read_events(dir.path())[0];
4982            assert_eq!(e["system_prompt_source"], source, "prompt={prompt:?}");
4983            match sha {
4984                Some(sha) => assert_eq!(e["system_prompt_sha256"], sha.as_str()),
4985                None => assert!(
4986                    e["system_prompt_sha256"].is_null(),
4987                    "no prompt in effect must hash to null, got {}",
4988                    e["system_prompt_sha256"]
4989                ),
4990            }
4991        }
4992    }
4993
4994    /// `session_start` never carries the prompt text. The hash is written in every capture
4995    /// mode; under `content` the bytes behind it are a blob named by that same hash, which is
4996    /// how a reader gets from the record to the prompt.
4997    #[tokio::test]
4998    async fn session_start_records_the_prompt_by_hash_and_stores_it_only_under_content() {
4999        let sha = murmur_artifact::sha256_hex(b"Be terse.");
5000
5001        let dir = tempfile::tempdir().unwrap();
5002        let mut w =
5003            make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), false).await;
5004        w.write_session_start(1, Vec::new()).await.unwrap();
5005        w.flush().await.unwrap();
5006
5007        let e = &read_events(dir.path())[0];
5008        assert!(
5009            e.get("system_prompt").is_none(),
5010            "the verbatim prompt is not a session_start field, got {e}"
5011        );
5012        assert_eq!(e["system_prompt_sha256"], sha.as_str());
5013        assert!(
5014            !dir.path().join(BLOB_DIR_NAME).exists(),
5015            "meta stores no bodies"
5016        );
5017
5018        let opted_in = tempfile::tempdir().unwrap();
5019        let mut w = make_writer_with_prompt(
5020            opted_in.path(),
5021            TraceCapture::Content,
5022            Some("Be terse."),
5023            false,
5024        )
5025        .await;
5026        w.write_session_start(1, Vec::new()).await.unwrap();
5027        w.flush().await.unwrap();
5028
5029        let event = &read_events(opted_in.path())[0];
5030        assert!(event.get("system_prompt").is_none());
5031        assert_eq!(event["system_prompt_sha256"], sha.as_str());
5032        let blob = opted_in.path().join(BLOB_DIR_NAME).join(&sha);
5033        assert_eq!(std::fs::read(&blob).unwrap(), b"Be terse.");
5034    }
5035
5036    /// Opting in cannot conjure a prompt that was never in effect — and a session with no
5037    /// prompt and nothing else to store leaves no blob directory at all.
5038    #[tokio::test]
5039    async fn content_capture_stores_no_prompt_blob_when_there_is_no_prompt() {
5040        let dir = tempfile::tempdir().unwrap();
5041        let mut w = make_writer_with_prompt(dir.path(), TraceCapture::Content, None, false).await;
5042        w.write_session_start(1, Vec::new()).await.unwrap();
5043        w.flush().await.unwrap();
5044
5045        let e = &read_events(dir.path())[0];
5046        assert!(e.get("system_prompt").is_none());
5047        assert!(e["system_prompt_sha256"].is_null());
5048        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
5049    }
5050
5051    /// The prompt is a session constant, like `model` — every task's `session_start` repeats it.
5052    #[tokio::test]
5053    async fn every_task_session_start_repeats_the_same_system_prompt_record() {
5054        let dir = tempfile::tempdir().unwrap();
5055        let mut w =
5056            make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), true).await;
5057        w.write_session_start(1, Vec::new()).await.unwrap();
5058        w.write_session_start(1, Vec::new()).await.unwrap();
5059        w.flush().await.unwrap();
5060
5061        let events = read_events(dir.path());
5062        assert_eq!(events.len(), 2);
5063        assert_eq!(
5064            events[0]["system_prompt_source"],
5065            events[1]["system_prompt_source"]
5066        );
5067        assert_eq!(
5068            events[0]["system_prompt_sha256"],
5069            events[1]["system_prompt_sha256"]
5070        );
5071        assert_eq!(events[1]["system_prompt_source"], "cli");
5072    }
5073
5074    /// The pairing an operator reads a trace for: `workdir_exec: true` next to the `advisory` it
5075    /// forces, on a session whose *declared* floor was higher. Without the flag on the record, a
5076    /// trace showing `achieved: advisory` is indistinguishable from one written on a host with no
5077    /// Landlock at all.
5078    #[tokio::test]
5079    async fn session_start_records_workdir_exec_next_to_the_class_it_forced() {
5080        let dir = tempfile::tempdir().unwrap();
5081        let mut w = TraceWriter::open(
5082            dir.path(),
5083            "test-session-id".to_string(),
5084            "test-capsule".to_string(),
5085            "1.0.0".to_string(),
5086            "claude-test".to_string(),
5087            Vec::new(),
5088            // A host that could back `scoped`, capped to `advisory` by the declaration alone.
5089            report_for(
5090                ContainmentClass::Advisory,
5091                EnforcementTier::KernelFull,
5092                true,
5093            ),
5094            TraceCapture::Meta,
5095            None,
5096            false,
5097            None,
5098            None,
5099            None,
5100            None,
5101        )
5102        .await
5103        .unwrap();
5104        w.write_session_start(1, Vec::new()).await.unwrap();
5105        w.flush().await.unwrap();
5106
5107        let events = read_events(dir.path());
5108        assert_eq!(events[0]["workdir_exec"], true);
5109        assert_eq!(events[0]["containment_achieved"], "advisory");
5110    }
5111
5112    /// A declared floor and an achieved class are recorded independently — the trace shows
5113    /// what was asked for next to what the host actually gave, not one derived from the other.
5114    #[tokio::test]
5115    async fn session_start_records_declared_and_achieved_containment_separately() {
5116        let dir = tempfile::tempdir().unwrap();
5117        let mut w = TraceWriter::open(
5118            dir.path(),
5119            "test-session-id".to_string(),
5120            "test-capsule".to_string(),
5121            "1.0.0".to_string(),
5122            "claude-test".to_string(),
5123            Vec::new(),
5124            report_for(ContainmentClass::Sealed, EnforcementTier::KernelFull, false),
5125            TraceCapture::Meta,
5126            None,
5127            false,
5128            None,
5129            None,
5130            None,
5131            None,
5132        )
5133        .await
5134        .unwrap();
5135        w.write_session_start(1, Vec::new()).await.unwrap();
5136        w.flush().await.unwrap();
5137
5138        let events = read_events(dir.path());
5139        assert_eq!(events[0]["containment_declared"], "sealed");
5140        assert_eq!(events[0]["containment_achieved"], "scoped");
5141    }
5142
5143    /// The audit property the containment feature exists to provide: two sessions that reached
5144    /// the *same* achieved class through *different* host permissions must not produce the same
5145    /// record. A `sealed` result obtained through the shipped AppArmor profile and one obtained on
5146    /// a host whose unprivileged-userns hardening is switched off for every binary differ in
5147    /// nothing else the event carries, so `userns_grant` is what keeps the two apart.
5148    ///
5149    /// The tier is a literal on both sides, so the only thing that differs is the grant.
5150    #[tokio::test]
5151    async fn session_start_distinguishes_two_hosts_with_the_same_achieved_class() {
5152        async fn event_for(grant: UsernsGrant) -> Value {
5153            let report = scope_report_for_tier(
5154                &CapabilityPolicy::default(),
5155                ContainmentClass::Sealed,
5156                EnforcementTier::KernelSealed,
5157                None,
5158                Some(grant),
5159                None,
5160                Vec::new(),
5161                Vec::new(),
5162                Vec::new(),
5163                crate::cgroup::IoMaxReport::default(),
5164            );
5165            let dir = tempfile::tempdir().unwrap();
5166            let mut w = TraceWriter::open(
5167                dir.path(),
5168                "test-session-id".to_string(),
5169                "test-capsule".to_string(),
5170                "1.0.0".to_string(),
5171                "claude-test".to_string(),
5172                Vec::new(),
5173                report,
5174                TraceCapture::Meta,
5175                None,
5176                false,
5177                None,
5178                None,
5179                None,
5180                None,
5181            )
5182            .await
5183            .unwrap();
5184            w.write_session_start(1, Vec::new()).await.unwrap();
5185            w.flush().await.unwrap();
5186            let mut event = read_events(dir.path()).remove(0);
5187            // The one field that legitimately differs between two runs, removed so the assertion
5188            // below is about the grant and nothing else.
5189            event.as_object_mut().unwrap().remove("timestamp");
5190            event
5191        }
5192
5193        let through_profile = event_for(UsernsGrant::ProfileConfining).await;
5194        let host_wide = event_for(UsernsGrant::RestrictionDisabledHostWide).await;
5195
5196        assert_eq!(through_profile["containment_achieved"], "sealed");
5197        assert_eq!(host_wide["containment_achieved"], "sealed");
5198        assert_eq!(through_profile["userns_grant"], "profile_confining");
5199        assert_eq!(host_wide["userns_grant"], "restriction_disabled_host_wide");
5200        assert_ne!(
5201            through_profile, host_wide,
5202            "two hosts granting the user namespace by different mechanisms must not write the \
5203             same session_start record"
5204        );
5205
5206        // Written for every Linux host, never skipped — its absence identifies a runtime that
5207        // predates the key rather than a host that was not asked.
5208        let unprobed = scope_report_for_tier(
5209            &CapabilityPolicy::default(),
5210            ContainmentClass::Advisory,
5211            EnforcementTier::EnvironmentOnly,
5212            None,
5213            None,
5214            None,
5215            Vec::new(),
5216            Vec::new(),
5217            Vec::new(),
5218            crate::cgroup::IoMaxReport::default(),
5219        );
5220        assert!(
5221            serde_json::to_value(&unprobed).unwrap()["userns_grant"].is_null(),
5222            "off Linux the key is present and null, not absent"
5223        );
5224    }
5225
5226    /// Central property: `session_start.effective_grants` is the *whole* `ScopeReport`,
5227    /// serialized byte-for-byte as `mur run --explain-scope --json` prints it — not a re-derived
5228    /// summary of it. Asserted by comparing against `serde_json::to_value` of the very report
5229    /// handed to the writer, so any field added to `ScopeReport` later is covered without this
5230    /// test being touched.
5231    ///
5232    /// The tier is a literal, never a probe: this asserts the recording, not the host.
5233    #[tokio::test]
5234    async fn session_start_records_the_whole_scope_report_as_effective_grants() {
5235        let policy = CapabilityPolicy {
5236            network_allow: vec!["https://api.example.com".to_string()],
5237            unix_sockets_allowed: false,
5238            filesystem_scope: Some("workdir".to_string()),
5239            shell_allow: vec!["python3".to_string()],
5240            spawn_allow: vec!["helper".to_string()],
5241            env_allow: vec!["TZ".to_string()],
5242            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
5243                binary: "python3".to_string(),
5244                dirs: vec![InterpreterRuntimeDir {
5245                    path: "/usr/lib/python3.11".to_string(),
5246                    list_dir: true,
5247                }],
5248            }],
5249            ..CapabilityPolicy::default()
5250        };
5251        let expected = scope_report_for_tier(
5252            &policy,
5253            ContainmentClass::Scoped,
5254            EnforcementTier::KernelFull,
5255            None,
5256            None,
5257            None,
5258            // Populated rather than empty so the whole-report assertion below really covers this
5259            // field: an empty vec would pass whether or not it was written at all.
5260            vec![crate::containment::StateStoreReport {
5261                artifact: "notes-tool".to_string(),
5262                store: "shey".to_string(),
5263                host_path: "/home/dev/.murmur/state/shey".to_string(),
5264            }],
5265            // Populated for the same reason as `state_stores` above.
5266            vec!["config-echo".to_string()],
5267            vec![crate::containment::PreopenReport {
5268                artifact: "notes-tool".to_string(),
5269                role: "tool".to_string(),
5270                scope: None,
5271                surface: crate::containment::PreopenSurface::WholeWorkdir,
5272            }],
5273            crate::cgroup::IoMaxReport::default(),
5274        );
5275
5276        let dir = tempfile::tempdir().unwrap();
5277        let mut w = TraceWriter::open(
5278            dir.path(),
5279            "test-session-id".to_string(),
5280            "test-capsule".to_string(),
5281            "1.0.0".to_string(),
5282            "claude-test".to_string(),
5283            vec!["network".to_string(), "shell".to_string()],
5284            expected.clone(),
5285            TraceCapture::Meta,
5286            None,
5287            false,
5288            None,
5289            None,
5290            None,
5291            None,
5292        )
5293        .await
5294        .unwrap();
5295        w.write_session_start(1, Vec::new()).await.unwrap();
5296        w.flush().await.unwrap();
5297
5298        let events = read_events(dir.path());
5299        assert_eq!(
5300            events[0]["effective_grants"],
5301            serde_json::to_value(&expected).unwrap()
5302        );
5303
5304        // The category-name summary that predates this field is unchanged, and the three
5305        // top-level containment fields agree with the report they are now read from.
5306        assert_eq!(
5307            events[0]["capabilities"],
5308            serde_json::json!(["network", "shell"])
5309        );
5310        assert_eq!(
5311            events[0]["effective_grants"]["state_stores"],
5312            serde_json::json!([{
5313                "artifact": "notes-tool",
5314                "store": "shey",
5315                "host_path": "/home/dev/.murmur/state/shey",
5316            }])
5317        );
5318        // Names only: what an artifact is configured *with* is operator plaintext and reaches
5319        // no trace record.
5320        assert_eq!(
5321            events[0]["effective_grants"]["configured_artifacts"],
5322            serde_json::json!(["config-echo"])
5323        );
5324        // The per-artifact preopen set reaches the trace with no trace-side plumbing: the report
5325        // is cloned whole, so `filesystem_scope` above (which narrows no guest) and the surface a
5326        // guest is actually opened into are both on the record.
5327        assert_eq!(
5328            events[0]["effective_grants"]["preopens"],
5329            serde_json::json!([{
5330                "artifact": "notes-tool",
5331                "role": "tool",
5332                "scope": null,
5333                "surface": "whole-workdir",
5334            }])
5335        );
5336        assert_eq!(events[0]["containment_declared"], "scoped");
5337        assert_eq!(events[0]["containment_achieved"], "scoped");
5338        assert_eq!(events[0]["workdir_exec"], false);
5339        assert_eq!(
5340            events[0]["effective_grants"]["network_allow"],
5341            serde_json::json!(["https://api.example.com"])
5342        );
5343        assert_eq!(
5344            events[0]["effective_grants"]["interpreter_runtime_grants"],
5345            serde_json::json!(["python3: /usr/lib/python3.11 (list_dir)"])
5346        );
5347    }
5348
5349    #[tokio::test]
5350    async fn inference_fields_and_snake_case() {
5351        let dir = tempfile::tempdir().unwrap();
5352        let mut w = make_writer(dir.path()).await;
5353        w.write_inference(
5354            0,
5355            Some(100),
5356            Some(50),
5357            "tool_call".to_string(),
5358            Some("tool_call"),
5359            Some("bash".to_string()),
5360            None,
5361            None,
5362            vec!["msg_0000000000000000000000000000000a".to_string()],
5363            None,
5364        )
5365        .await
5366        .unwrap();
5367        w.flush().await.unwrap();
5368
5369        let events = read_events(dir.path());
5370        let e = &events[0];
5371        assert_eq!(e["event_type"], "inference");
5372        assert_eq!(
5373            e["message_ids"],
5374            serde_json::json!(["msg_0000000000000000000000000000000a"]),
5375            "an agent-loop turn names the messages its request embedded"
5376        );
5377        assert_eq!(e["turn"], 0);
5378        assert_eq!(e["input_tokens"], 100);
5379        assert_eq!(e["output_tokens"], 50);
5380        assert_eq!(e["decision"], "tool_call");
5381        assert_eq!(e["tool_name"], "bash");
5382        // Confirm snake_case (not camelCase)
5383        assert!(e.get("inputTokens").is_none(), "must use snake_case");
5384        assert!(e.get("toolName").is_none(), "must use snake_case");
5385        // No driver reported usage, so no key claims one was reported.
5386        for key in [
5387            "input_tokens_actual",
5388            "output_tokens_actual",
5389            "cached_tokens",
5390            "cache_write_tokens",
5391        ] {
5392            assert!(e.get(key).is_none(), "{key} must be omitted, not null or 0");
5393        }
5394    }
5395
5396    /// A partially-populated `usage` writes only the members the driver reported: a member it
5397    /// left out stays off the line entirely rather than being recorded as a zero.
5398    #[tokio::test]
5399    async fn inference_writes_only_the_reported_usage_members() {
5400        let dir = tempfile::tempdir().unwrap();
5401        let mut w = make_writer(dir.path()).await;
5402        let usage = DriverUsage {
5403            input_tokens: Some(12043),
5404            output_tokens: None,
5405            cached_tokens: Some(0),
5406            cache_write_tokens: None,
5407            thinking_tokens: None,
5408        };
5409        w.write_inference(
5410            0,
5411            Some(100),
5412            Some(50),
5413            "end_turn".to_string(),
5414            Some("end_turn"),
5415            None,
5416            None,
5417            Some(&usage),
5418            Vec::new(),
5419            None,
5420        )
5421        .await
5422        .unwrap();
5423        w.flush().await.unwrap();
5424
5425        let events = read_events(dir.path());
5426        let e = &events[0];
5427        assert_eq!(e["input_tokens"], 100, "the estimate is untouched");
5428        assert_eq!(e["input_tokens_actual"], 12043);
5429        assert_eq!(e["cached_tokens"], 0, "a reported zero is written");
5430        assert!(e.get("output_tokens_actual").is_none());
5431        assert!(e.get("cache_write_tokens").is_none());
5432    }
5433
5434    #[tokio::test]
5435    async fn failed_inference_writes_its_keys_and_counts_only_what_it_spent() {
5436        let dir = tempfile::tempdir().unwrap();
5437        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
5438        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
5439            .await
5440            .unwrap();
5441        let usage = DriverUsage {
5442            input_tokens: Some(7),
5443            ..DriverUsage::default()
5444        };
5445        let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
5446        w.write_failed_inference(FailedInference {
5447            turn: 0,
5448            error_code: INFERENCE_ERROR_CREDENTIAL_REJECTED,
5449            error: &long,
5450            provider_status: Some(401),
5451            usage: Some(&usage),
5452            origin: None,
5453            choice: None,
5454            message_ids: vec!["msg_1".to_string()],
5455            tool_name: None,
5456            spent: None,
5457        })
5458        .await
5459        .unwrap();
5460        assert_eq!(w.task_turns(), 0, "an uncounted record moves no counter");
5461        assert_eq!((w.total_turns, w.total_input_tokens), (0, 0));
5462        assert_eq!((w.task_input_tokens, w.task_output_tokens), (0, 0));
5463
5464        w.write_failed_inference(FailedInference {
5465            turn: 1,
5466            error_code: INFERENCE_ERROR_HARNESS_AUTH,
5467            error: "bad login",
5468            provider_status: None,
5469            usage: None,
5470            origin: None,
5471            choice: None,
5472            message_ids: Vec::new(),
5473            tool_name: None,
5474            spent: Some((Some(10), Some(5))),
5475        })
5476        .await
5477        .unwrap();
5478        assert_eq!(w.task_turns(), 1);
5479        assert_eq!(w.total_turns, 1);
5480        assert_eq!((w.total_input_tokens, w.total_output_tokens), (10, 5));
5481        assert_eq!((w.task_input_tokens, w.task_output_tokens), (10, 5));
5482        w.flush().await.unwrap();
5483
5484        let events = read_events(dir.path());
5485        let failed: Vec<&Value> = events
5486            .iter()
5487            .filter(|e| e["event_type"] == "inference")
5488            .collect();
5489        let keys = |e: &Value| {
5490            let mut keys: Vec<String> = e.as_object().unwrap().keys().cloned().collect();
5491            keys.sort();
5492            keys
5493        };
5494        assert_eq!(
5495            keys(failed[0]),
5496            [
5497                "decision",
5498                "error",
5499                "error_code",
5500                "event_id",
5501                "event_type",
5502                "input_tokens_actual",
5503                "message_ids",
5504                "parent_id",
5505                "provider_status",
5506                "session_id",
5507                "stop_reason",
5508                "task_id",
5509                "timestamp",
5510                "tool_name",
5511                "turn",
5512            ]
5513        );
5514        assert_eq!(failed[0]["decision"], "error");
5515        assert_eq!(failed[0]["stop_reason"], "error");
5516        assert_eq!(failed[0]["error_code"], "credential_rejected");
5517        assert_eq!(failed[0]["provider_status"], 401);
5518        assert_eq!(failed[0]["input_tokens_actual"], 7);
5519        let error = failed[0]["error"].as_str().unwrap();
5520        assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
5521        assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
5522
5523        assert_eq!(failed[1]["error_code"], "harness_auth");
5524        assert_eq!(failed[1]["error"], "bad login");
5525        assert_eq!(failed[1]["input_tokens"], 10);
5526        assert_eq!(failed[1]["output_tokens"], 5);
5527        assert!(failed[1].get("provider_status").is_none());
5528        assert!(
5529            !dir.path().join("blobs").exists(),
5530            "a failed record stores no blob, even under trace.capture: content"
5531        );
5532        // The agent-loop failed record is the turn node, parented to the task.
5533        let task_start = events
5534            .iter()
5535            .find(|e| e["event_type"] == "task_start")
5536            .unwrap();
5537        assert_eq!(failed[0]["parent_id"], task_start["event_id"]);
5538    }
5539
5540    #[tokio::test]
5541    async fn a_failed_hook_record_hangs_off_the_turn_and_keeps_hook_accounting() {
5542        let dir = tempfile::tempdir().unwrap();
5543        let mut w = make_writer(dir.path()).await;
5544        w.write_inference(
5545            0,
5546            Some(10),
5547            Some(5),
5548            "end_turn".to_string(),
5549            Some("end_turn"),
5550            None,
5551            None,
5552            None,
5553            Vec::new(),
5554            None,
5555        )
5556        .await
5557        .unwrap();
5558        let origin = InferenceOrigin {
5559            source: "hook:compactor".to_string(),
5560            model: "m".to_string(),
5561        };
5562        w.write_failed_inference(FailedInference {
5563            turn: 0,
5564            error_code: INFERENCE_ERROR_DRIVER_ERROR,
5565            error: "HTTP 500: boom",
5566            provider_status: Some(500),
5567            usage: None,
5568            origin: Some(&origin),
5569            choice: None,
5570            message_ids: Vec::new(),
5571            tool_name: None,
5572            spent: Some((Some(40), None)),
5573        })
5574        .await
5575        .unwrap();
5576        assert_eq!(w.total_turns, 2);
5577        assert_eq!((w.total_input_tokens, w.total_output_tokens), (50, 5));
5578        w.flush().await.unwrap();
5579
5580        let events = read_events(dir.path());
5581        let turn = events.iter().find(|e| e["decision"] == "end_turn").unwrap();
5582        let hook = events.iter().find(|e| e["decision"] == "error").unwrap();
5583        assert_eq!(hook["parent_id"], turn["event_id"]);
5584        assert_eq!(hook["origin"], "hook:compactor");
5585        assert_eq!(hook["model"], "m");
5586        assert_eq!(hook["input_tokens"], 40);
5587        assert!(hook.get("output_tokens").is_none());
5588    }
5589
5590    #[tokio::test]
5591    async fn tool_call_fields() {
5592        let dir = tempfile::tempdir().unwrap();
5593        let mut w = make_writer(dir.path()).await;
5594        w.write_tool_call(
5595            1,
5596            "bash".to_string(),
5597            None,
5598            serde_json::json!({"command": "echo hi"}),
5599            42,
5600            "hi\n",
5601            100,
5602            55,
5603            "ok".to_string(),
5604            None,
5605            None,
5606        )
5607        .await
5608        .unwrap();
5609        w.flush().await.unwrap();
5610
5611        let events = read_events(dir.path());
5612        let e = &events[0];
5613        assert_eq!(e["event_type"], "tool_call");
5614        assert_eq!(e["turn"], 1);
5615        assert_eq!(e["tool_name"], "bash");
5616        assert_eq!(e["input_bytes"], 42);
5617        assert_eq!(e["output_bytes"], 100);
5618        assert_eq!(e["duration_ms"], 55);
5619        assert_eq!(e["status"], "ok");
5620        assert!(
5621            e.get("state_effect").is_none(),
5622            "state_effect must be omitted when the tool declared nothing"
5623        );
5624    }
5625
5626    #[tokio::test]
5627    async fn tool_call_records_declared_state_effect() {
5628        let dir = tempfile::tempdir().unwrap();
5629        let mut w = make_writer(dir.path()).await;
5630        w.write_tool_call(
5631            0,
5632            "murmur-tool-editor".to_string(),
5633            None,
5634            serde_json::json!({"operation": "read_file", "path": "a.txt"}),
5635            10,
5636            "hi\n",
5637            3,
5638            5,
5639            "ok".to_string(),
5640            Some("read".to_string()),
5641            None,
5642        )
5643        .await
5644        .unwrap();
5645        w.flush().await.unwrap();
5646
5647        let events = read_events(dir.path());
5648        assert_eq!(
5649            events[0]["state_effect"], "read",
5650            "declared state_effect must be recorded verbatim on the tool_call event"
5651        );
5652    }
5653
5654    #[tokio::test]
5655    async fn tool_call_records_declared_resource_id() {
5656        let dir = tempfile::tempdir().unwrap();
5657        let mut w = make_writer(dir.path()).await;
5658        w.write_tool_call(
5659            0,
5660            "murmur-tool-code-graph".to_string(),
5661            None,
5662            serde_json::json!({"symbol": "Foo::bar"}),
5663            10,
5664            "hi\n",
5665            3,
5666            5,
5667            "ok".to_string(),
5668            Some("read".to_string()),
5669            Some("sym:Foo".to_string()),
5670        )
5671        .await
5672        .unwrap();
5673        w.flush().await.unwrap();
5674
5675        let events = read_events(dir.path());
5676        assert_eq!(
5677            events[0]["resource_id"], "sym:Foo",
5678            "declared resource_id must reach trace.jsonl verbatim, unparsed"
5679        );
5680    }
5681
5682    #[tokio::test]
5683    async fn tool_call_omits_undeclared_resource_id() {
5684        let dir = tempfile::tempdir().unwrap();
5685        let mut w = make_writer(dir.path()).await;
5686        w.write_tool_call(
5687            0,
5688            "bash".to_string(),
5689            None,
5690            serde_json::json!({"command": "echo hi"}),
5691            10,
5692            "hi\n",
5693            3,
5694            5,
5695            "ok".to_string(),
5696            None,
5697            None,
5698        )
5699        .await
5700        .unwrap();
5701        w.flush().await.unwrap();
5702
5703        let events = read_events(dir.path());
5704        assert!(
5705            events[0].get("resource_id").is_none(),
5706            "resource_id must be omitted entirely (not null) when the tool declared nothing"
5707        );
5708    }
5709
5710    #[tokio::test]
5711    async fn shell_fields() {
5712        let dir = tempfile::tempdir().unwrap();
5713        let mut w = make_writer(dir.path()).await;
5714        w.write_shell(
5715            0,
5716            "/usr/bin/echo".to_string(),
5717            "echo hi".to_string(),
5718            0,
5719            7,
5720            0,
5721            10,
5722            None,
5723        )
5724        .await
5725        .unwrap();
5726        w.flush().await.unwrap();
5727
5728        let events = read_events(dir.path());
5729        let e = &events[0];
5730        assert_eq!(e["event_type"], "shell");
5731        // `binary` names what ran; `command` still carries only the argument list, so
5732        // neither can be derived from the other.
5733        assert_eq!(e["binary"], "/usr/bin/echo");
5734        assert_eq!(e["command"], "echo hi");
5735        assert_eq!(e["exit_code"], 0);
5736        assert_eq!(e["stdout_bytes"], 7);
5737        assert_eq!(e["stderr_bytes"], 0);
5738        assert_eq!(e["duration_ms"], 10);
5739    }
5740
5741    #[tokio::test]
5742    async fn compaction_fields() {
5743        let dir = tempfile::tempdir().unwrap();
5744        let mut w = make_writer(dir.path()).await;
5745        w.write_compaction(3, 80000, 20000).await.unwrap();
5746        w.flush().await.unwrap();
5747
5748        let events = read_events(dir.path());
5749        let e = &events[0];
5750        assert_eq!(e["event_type"], "compaction");
5751        assert_eq!(e["turn"], 3);
5752        assert_eq!(e["tokens_before"], 80000);
5753        assert_eq!(e["tokens_after"], 20000);
5754    }
5755
5756    #[tokio::test]
5757    async fn hook_dispatch_error_fields() {
5758        let dir = tempfile::tempdir().unwrap();
5759        let mut w = make_writer(dir.path()).await;
5760        w.write_hook_dispatch_error("my-hook", "on-tool-call", "write-manifests")
5761            .await
5762            .unwrap();
5763        w.flush().await.unwrap();
5764
5765        let events = read_events(dir.path());
5766        let e = &events[0];
5767        assert_eq!(e["event_type"], "hook_dispatch_error");
5768        assert_eq!(e["session_id"], "test-session-id");
5769        assert_eq!(e["hook_name"], "my-hook");
5770        assert_eq!(e["event"], "on-tool-call");
5771        assert_eq!(e["arm"], "write-manifests");
5772        assert!(e["timestamp"].as_u64().unwrap() > 0);
5773        // snake_case checks
5774        assert!(e.get("hookName").is_none(), "must use snake_case");
5775    }
5776
5777    #[tokio::test]
5778    async fn session_end_fields_and_snake_case() {
5779        let dir = tempfile::tempdir().unwrap();
5780        let mut w = make_writer(dir.path()).await;
5781        w.write_inference(
5782            0,
5783            Some(100),
5784            Some(50),
5785            "tool_call".to_string(),
5786            Some("tool_call"),
5787            None,
5788            None,
5789            None,
5790            Vec::new(),
5791            None,
5792        )
5793        .await
5794        .unwrap();
5795        w.write_tool_call(
5796            0,
5797            "bash".to_string(),
5798            None,
5799            serde_json::json!({"command": "ls"}),
5800            10,
5801            "file.txt\n",
5802            20,
5803            5,
5804            "ok".to_string(),
5805            None,
5806            None,
5807        )
5808        .await
5809        .unwrap();
5810        w.write_shell(0, "/bin/ls".to_string(), "ls".to_string(), 0, 3, 0, 2, None)
5811            .await
5812            .unwrap();
5813        w.write_session_end("ok").await.unwrap();
5814        w.flush().await.unwrap();
5815
5816        let events = read_events(dir.path());
5817        let se = events.last().unwrap();
5818        assert_eq!(se["event_type"], "session_end");
5819        assert_eq!(se["total_turns"], 1);
5820        assert_eq!(se["total_input_tokens"], 100);
5821        assert_eq!(se["total_output_tokens"], 50);
5822        assert_eq!(se["total_tool_calls"], 1);
5823        assert_eq!(se["total_shell_calls"], 1);
5824        assert_eq!(se["exit_status"], "ok");
5825        assert!(se["duration_ms"].as_u64().is_some());
5826        // snake_case checks
5827        assert!(se.get("exitStatus").is_none(), "must use snake_case");
5828        assert!(se.get("totalTurns").is_none(), "must use snake_case");
5829    }
5830
5831    #[tokio::test]
5832    async fn write_session_end_if_not_ended_is_noop_when_already_ended() {
5833        let dir = tempfile::tempdir().unwrap();
5834        let mut w = make_writer(dir.path()).await;
5835        w.write_session_start(10, vec![]).await.unwrap();
5836        w.write_session_end("ok").await.unwrap();
5837        w.write_session_end_if_not_ended("failed").await.unwrap();
5838        w.flush().await.unwrap();
5839
5840        let events = read_events(dir.path());
5841        assert_eq!(
5842            events
5843                .iter()
5844                .filter(|e| e["event_type"] == "session_end")
5845                .count(),
5846            1,
5847            "session_end should appear exactly once"
5848        );
5849    }
5850
5851    #[tokio::test]
5852    async fn write_session_end_if_not_ended_writes_when_started_not_ended() {
5853        let dir = tempfile::tempdir().unwrap();
5854        let mut w = make_writer(dir.path()).await;
5855        w.write_session_start(10, vec![]).await.unwrap();
5856        // Simulate error path — session_end_if_not_ended in launch_session
5857        w.write_session_end_if_not_ended("failed").await.unwrap();
5858        w.flush().await.unwrap();
5859
5860        let events = read_events(dir.path());
5861        let se = events
5862            .iter()
5863            .find(|e| e["event_type"] == "session_end")
5864            .unwrap();
5865        assert_eq!(se["exit_status"], "failed");
5866    }
5867
5868    #[tokio::test]
5869    async fn no_session_end_written_when_session_never_started() {
5870        let dir = tempfile::tempdir().unwrap();
5871        let mut w = make_writer(dir.path()).await;
5872        // session_start never written — fallback should be no-op
5873        w.write_session_end_if_not_ended("failed").await.unwrap();
5874        w.flush().await.unwrap();
5875
5876        let events = read_events(dir.path());
5877        assert!(
5878            events.iter().all(|e| e["event_type"] != "session_end"),
5879            "session_end must not appear when session_start was never written"
5880        );
5881    }
5882
5883    #[tokio::test]
5884    async fn session_id_on_every_event() {
5885        let dir = tempfile::tempdir().unwrap();
5886        let mut w = make_writer(dir.path()).await;
5887        w.write_session_start(10, vec!["bash".to_string()])
5888            .await
5889            .unwrap();
5890        w.write_inference(
5891            0,
5892            Some(10),
5893            Some(5),
5894            "end_turn".to_string(),
5895            Some("end_turn"),
5896            None,
5897            None,
5898            None,
5899            Vec::new(),
5900            None,
5901        )
5902        .await
5903        .unwrap();
5904        w.write_session_end("ok").await.unwrap();
5905        w.flush().await.unwrap();
5906
5907        let events = read_events(dir.path());
5908        for e in &events {
5909            assert_eq!(e["session_id"], "test-session-id");
5910        }
5911    }
5912
5913    #[tokio::test]
5914    async fn counts_match_per_event_sum() {
5915        let dir = tempfile::tempdir().unwrap();
5916        let mut w = make_writer(dir.path()).await;
5917        w.write_session_start(10, vec![]).await.unwrap();
5918        w.write_inference(
5919            0,
5920            Some(50),
5921            Some(25),
5922            "tool_call".to_string(),
5923            Some("tool_call"),
5924            Some("bash".to_string()),
5925            None,
5926            None,
5927            vec!["msg_0000000000000000000000000000000a".to_string()],
5928            None,
5929        )
5930        .await
5931        .unwrap();
5932        w.write_tool_call(
5933            0,
5934            "bash".to_string(),
5935            None,
5936            serde_json::json!({"command": "echo"}),
5937            5,
5938            "ok",
5939            10,
5940            3,
5941            "ok".to_string(),
5942            None,
5943            None,
5944        )
5945        .await
5946        .unwrap();
5947        w.write_shell(
5948            0,
5949            "/bin/echo".to_string(),
5950            "echo".to_string(),
5951            0,
5952            4,
5953            0,
5954            1,
5955            None,
5956        )
5957        .await
5958        .unwrap();
5959        w.write_inference(
5960            1,
5961            Some(60),
5962            Some(30),
5963            "end_turn".to_string(),
5964            Some("end_turn"),
5965            None,
5966            None,
5967            None,
5968            Vec::new(),
5969            None,
5970        )
5971        .await
5972        .unwrap();
5973        w.write_session_end("ok").await.unwrap();
5974        w.flush().await.unwrap();
5975
5976        let events = read_events(dir.path());
5977        let inference_count = events
5978            .iter()
5979            .filter(|e| e["event_type"] == "inference")
5980            .count();
5981        let tool_count = events
5982            .iter()
5983            .filter(|e| e["event_type"] == "tool_call")
5984            .count();
5985        let shell_count = events.iter().filter(|e| e["event_type"] == "shell").count();
5986
5987        let se = events
5988            .iter()
5989            .find(|e| e["event_type"] == "session_end")
5990            .unwrap();
5991        assert_eq!(
5992            se["total_turns"].as_u64().unwrap() as usize,
5993            inference_count
5994        );
5995        assert_eq!(
5996            se["total_tool_calls"].as_u64().unwrap() as usize,
5997            tool_count
5998        );
5999        assert_eq!(
6000            se["total_shell_calls"].as_u64().unwrap() as usize,
6001            shell_count
6002        );
6003    }
6004
6005    #[tokio::test]
6006    async fn tool_call_input_always_present() {
6007        let dir = tempfile::tempdir().unwrap();
6008        let mut w = make_writer(dir.path()).await; // TraceCapture::Meta
6009        w.write_tool_call(
6010            0,
6011            "bash".to_string(),
6012            None,
6013            serde_json::json!({"command": "ls -la"}),
6014            20,
6015            "total 8\n",
6016            80,
6017            12,
6018            "ok".to_string(),
6019            None,
6020            None,
6021        )
6022        .await
6023        .unwrap();
6024        w.flush().await.unwrap();
6025
6026        let events = read_events(dir.path());
6027        let e = &events[0];
6028        assert_eq!(e["event_type"], "tool_call");
6029        assert_eq!(e["input"]["command"], "ls -la");
6030        assert!(e.get("input").is_some(), "input must always be present");
6031    }
6032
6033    #[tokio::test]
6034    async fn tool_call_output_absent_by_default() {
6035        let dir = tempfile::tempdir().unwrap();
6036        let mut w = make_writer(dir.path()).await; // TraceCapture::Meta
6037        w.write_tool_call(
6038            0,
6039            "bash".to_string(),
6040            None,
6041            serde_json::json!({"command": "ls"}),
6042            10,
6043            "file.txt\n",
6044            90,
6045            8,
6046            "ok".to_string(),
6047            None,
6048            None,
6049        )
6050        .await
6051        .unwrap();
6052        w.flush().await.unwrap();
6053
6054        let events = read_events(dir.path());
6055        let e = &events[0];
6056        assert!(
6057            e.get("output").is_none(),
6058            "output must be absent under a capture mode that stores no bodies"
6059        );
6060        assert_eq!(e["output_bytes"], 90, "output_bytes must still be recorded");
6061    }
6062
6063    #[tokio::test]
6064    async fn skill_call_writes_skill_call_event() {
6065        let dir = tempfile::tempdir().unwrap();
6066        let mut w = make_writer(dir.path()).await;
6067        w.write_skill_call(
6068            2,
6069            "my-skill".to_string(),
6070            &LockOrigin::Operator,
6071            1024,
6072            8,
6073            "ok".to_string(),
6074        )
6075        .await
6076        .unwrap();
6077        w.flush().await.unwrap();
6078
6079        let events = read_events(dir.path());
6080        let e = &events[0];
6081        assert_eq!(e["event_type"], "skill_call");
6082        assert_eq!(e["turn"], 2);
6083        assert_eq!(e["skill_name"], "my-skill");
6084        assert_eq!(e["output_bytes"], 1024);
6085        assert_eq!(e["duration_ms"], 8);
6086        assert_eq!(e["status"], "ok");
6087    }
6088
6089    #[tokio::test]
6090    async fn skill_call_does_not_increment_total_tool_calls() {
6091        let dir = tempfile::tempdir().unwrap();
6092        let mut w = make_writer(dir.path()).await;
6093        w.write_session_start(10, vec![]).await.unwrap();
6094        w.write_tool_call(
6095            0,
6096            "real-tool".to_string(),
6097            None,
6098            serde_json::json!({}),
6099            2,
6100            "data",
6101            4,
6102            5,
6103            "ok".to_string(),
6104            None,
6105            None,
6106        )
6107        .await
6108        .unwrap();
6109        w.write_skill_call(
6110            1,
6111            "my-skill".to_string(),
6112            &LockOrigin::Operator,
6113            512,
6114            3,
6115            "ok".to_string(),
6116        )
6117        .await
6118        .unwrap();
6119        w.write_session_end("ok").await.unwrap();
6120        w.flush().await.unwrap();
6121
6122        let events = read_events(dir.path());
6123        let se = events
6124            .iter()
6125            .find(|e| e["event_type"] == "session_end")
6126            .unwrap();
6127        assert_eq!(
6128            se["total_tool_calls"], 1,
6129            "skill_call must not inflate total_tool_calls"
6130        );
6131    }
6132
6133    #[tokio::test]
6134    async fn tool_call_output_present_when_opted_in() {
6135        let dir = tempfile::tempdir().unwrap();
6136        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
6137        w.write_tool_call(
6138            0,
6139            "bash".to_string(),
6140            None,
6141            serde_json::json!({"command": "echo hello"}),
6142            22,
6143            "hello\n",
6144            60,
6145            5,
6146            "ok".to_string(),
6147            None,
6148            None,
6149        )
6150        .await
6151        .unwrap();
6152        w.flush().await.unwrap();
6153
6154        let events = read_events(dir.path());
6155        let e = &events[0];
6156        assert_eq!(
6157            e["output"].as_str().unwrap(),
6158            "hello\n",
6159            "output must be present under capture: content"
6160        );
6161        assert_eq!(e["output_bytes"], 60);
6162    }
6163
6164    #[tokio::test]
6165    async fn task_end_carries_reopen_count() {
6166        let dir = tempfile::tempdir().unwrap();
6167        let mut w = make_writer(dir.path()).await;
6168        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6169            .await
6170            .unwrap();
6171        w.write_task_end("tsk_1", "ok", 2).await.unwrap();
6172        w.flush().await.unwrap();
6173
6174        let events = read_events(dir.path());
6175        let end = events
6176            .iter()
6177            .find(|e| e["event_type"] == "task_end")
6178            .unwrap();
6179        assert_eq!(end["task_id"], "tsk_1");
6180        assert_eq!(end["exit_status"], "ok");
6181        assert_eq!(end["reopen_count"], 2);
6182    }
6183
6184    #[tokio::test]
6185    async fn task_reopened_names_hook_reason_and_ordinal() {
6186        let dir = tempfile::tempdir().unwrap();
6187        let mut w = make_writer(dir.path()).await;
6188        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6189            .await
6190            .unwrap();
6191        w.write_task_reopened(
6192            "tsk_1",
6193            "gatekeeper",
6194            "tests still fail",
6195            1,
6196            REOPEN_CONTEXT_CONTINUED,
6197            7,
6198        )
6199        .await
6200        .unwrap();
6201        w.flush().await.unwrap();
6202
6203        let events = read_events(dir.path());
6204        let re = events
6205            .iter()
6206            .find(|e| e["event_type"] == "task_reopened")
6207            .unwrap();
6208        assert_eq!(re["task_id"], "tsk_1");
6209        assert_eq!(re["hook_name"], "gatekeeper");
6210        assert_eq!(re["reason"], "tests still fail");
6211        assert_eq!(re["reopen_number"], 1);
6212        assert_eq!(re["attempt_context"], "continued");
6213        assert_eq!(re["turns_remaining"], 7);
6214        assert!(re["timestamp"].as_u64().unwrap() > 0);
6215    }
6216
6217    #[tokio::test]
6218    async fn task_continued_names_the_round_under_the_task_node() {
6219        let dir = tempfile::tempdir().unwrap();
6220        let mut w = make_writer(dir.path()).await;
6221        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6222            .await
6223            .unwrap();
6224        w.write_task_continued(
6225            "tsk_1",
6226            1,
6227            &["mcl_b".to_string(), "mcl_a".to_string()],
6228            &["dlg_1".to_string()],
6229            1234,
6230            8,
6231        )
6232        .await
6233        .unwrap();
6234        w.flush().await.unwrap();
6235
6236        let events = read_events(dir.path());
6237        let start = events
6238            .iter()
6239            .find(|e| e["event_type"] == "task_start")
6240            .unwrap();
6241        let continued = events
6242            .iter()
6243            .find(|e| e["event_type"] == "task_continued")
6244            .unwrap();
6245        assert_eq!(continued["parent_id"], start["event_id"]);
6246        assert_eq!(continued["task_id"], "tsk_1");
6247        assert_eq!(continued["continuation_number"], 1);
6248        assert_eq!(
6249            continued["member_calls"],
6250            serde_json::json!(["mcl_b", "mcl_a"])
6251        );
6252        assert_eq!(continued["delegations"], serde_json::json!(["dlg_1"]));
6253        assert_eq!(continued["waited_ms"], 1234);
6254        assert_eq!(continued["turns_remaining"], 8);
6255    }
6256
6257    /// `task_turns()` reports the cumulative per-task turn count the reopen loop reads to
6258    /// compute the remaining `max_turns` budget: it advances with each `write_inference`
6259    /// and resets on `write_task_start`.
6260    #[tokio::test]
6261    async fn task_turns_accessor_tracks_and_resets() {
6262        let dir = tempfile::tempdir().unwrap();
6263        let mut w = make_writer(dir.path()).await;
6264        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6265            .await
6266            .unwrap();
6267        assert_eq!(w.task_turns(), 0);
6268        w.write_inference(
6269            0,
6270            Some(10),
6271            Some(5),
6272            "end_turn".to_string(),
6273            Some("end_turn"),
6274            None,
6275            None,
6276            None,
6277            Vec::new(),
6278            None,
6279        )
6280        .await
6281        .unwrap();
6282        w.write_inference(
6283            1,
6284            Some(10),
6285            Some(5),
6286            "end_turn".to_string(),
6287            Some("end_turn"),
6288            None,
6289            None,
6290            None,
6291            Vec::new(),
6292            None,
6293        )
6294        .await
6295        .unwrap();
6296        assert_eq!(w.task_turns(), 2);
6297        // A new task resets the per-task counter.
6298        w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3)
6299            .await
6300            .unwrap();
6301        assert_eq!(w.task_turns(), 0);
6302    }
6303
6304    /// `task_agent_turns()` counts the agent loop's own `inference` lines and not a hook's
6305    /// `run-inference` record, which `task_turns()` does count; both task boundaries reset it.
6306    #[tokio::test]
6307    async fn task_agent_turns_counts_agent_loop_inference_only() {
6308        let dir = tempfile::tempdir().unwrap();
6309        let mut w = make_writer(dir.path()).await;
6310        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6311            .await
6312            .unwrap();
6313        assert_eq!(w.task_agent_turns(), 0);
6314        let hook = InferenceOrigin {
6315            source: "hook:x".into(),
6316            model: "m".into(),
6317        };
6318        for (turn, origin) in [(0, None), (0, Some(&hook)), (1, None)] {
6319            w.write_inference(
6320                turn,
6321                Some(10),
6322                Some(5),
6323                "end_turn".to_string(),
6324                Some("end_turn"),
6325                None,
6326                origin,
6327                None,
6328                Vec::new(),
6329                None,
6330            )
6331            .await
6332            .unwrap();
6333        }
6334        assert_eq!(w.task_agent_turns(), 2);
6335        assert_eq!(w.task_turns(), 3);
6336        assert_eq!(w.task_hook_inferences(), 1);
6337        w.write_task_end("tsk_1", "completed", 0).await.unwrap();
6338        assert_eq!(
6339            w.task_agent_turns(),
6340            0,
6341            "a run outside a task frame starts at 0"
6342        );
6343        w.write_inference(
6344            0,
6345            Some(10),
6346            Some(5),
6347            "end_turn".to_string(),
6348            Some("end_turn"),
6349            None,
6350            None,
6351            None,
6352            Vec::new(),
6353            None,
6354        )
6355        .await
6356        .unwrap();
6357        w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3)
6358            .await
6359            .unwrap();
6360        assert_eq!(w.task_agent_turns(), 0);
6361    }
6362
6363    // ── Event identity and parenting ──────────────────────────────────────────
6364
6365    /// The full tree one task produces, walked the way a reader walks it: every line is
6366    /// identified, every non-null `parent_id` names a line already written, and the shape is
6367    /// session → task → turn → {tool_call, shell}.
6368    #[tokio::test]
6369    async fn every_event_carries_a_unique_id_and_parents_to_an_earlier_event() {
6370        let dir = tempfile::tempdir().unwrap();
6371        let mut w = make_writer(dir.path()).await;
6372        w.write_session_start(10, vec!["bash".to_string()])
6373            .await
6374            .unwrap();
6375        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6376            .await
6377            .unwrap();
6378        w.write_inference(
6379            0,
6380            Some(10),
6381            Some(5),
6382            "tool_call".to_string(),
6383            Some("tool_call"),
6384            None,
6385            None,
6386            None,
6387            Vec::new(),
6388            None,
6389        )
6390        .await
6391        .unwrap();
6392        w.write_tool_call(
6393            0,
6394            "bash".to_string(),
6395            Some("toolu_1".to_string()),
6396            serde_json::json!({"command": "echo hi"}),
6397            10,
6398            "hi\n",
6399            3,
6400            5,
6401            "ok".to_string(),
6402            None,
6403            None,
6404        )
6405        .await
6406        .unwrap();
6407        w.write_shell(
6408            0,
6409            "/bin/bash".to_string(),
6410            "echo hi".to_string(),
6411            0,
6412            3,
6413            0,
6414            2,
6415            None,
6416        )
6417        .await
6418        .unwrap();
6419        w.write_artifact_pulled(&runtime_summary("pulled-style", ArtifactRuntime::Skill))
6420            .await
6421            .unwrap();
6422        w.write_task_end("tsk_1", "ok", 0).await.unwrap();
6423        w.write_session_end("ok").await.unwrap();
6424        w.flush().await.unwrap();
6425
6426        let events = read_events(dir.path());
6427        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
6428        for (i, event) in events.iter().enumerate() {
6429            let id = event["event_id"].as_str().unwrap();
6430            assert!(id.starts_with("evt_") && id.len() == 36, "event {i}: {id}");
6431            assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()), "event {i}");
6432            assert!(seen.insert(id.to_string()), "event {i} reuses {id}");
6433            assert!(
6434                event.get("parent_id").is_some(),
6435                "event {i} has no parent_id"
6436            );
6437            if let Some(parent) = event["parent_id"].as_str() {
6438                assert!(seen.contains(parent), "event {i} names dangling {parent}");
6439            }
6440        }
6441
6442        let by_type = |ty: &str| {
6443            events
6444                .iter()
6445                .find(|e| e["event_type"] == ty)
6446                .unwrap_or_else(|| panic!("no {ty}"))
6447                .clone()
6448        };
6449        let session = by_type("session_start");
6450        assert!(session["parent_id"].is_null());
6451        assert_eq!(session["event_id"], w.session_event_id());
6452
6453        let task = by_type("task_start");
6454        assert_eq!(task["parent_id"], session["event_id"]);
6455
6456        let turn = by_type("inference");
6457        assert_eq!(turn["parent_id"], task["event_id"]);
6458
6459        assert_eq!(by_type("tool_call")["parent_id"], turn["event_id"]);
6460        assert_eq!(by_type("shell")["parent_id"], turn["event_id"]);
6461        assert_eq!(by_type("task_end")["parent_id"], task["event_id"]);
6462        assert_eq!(by_type("session_end")["parent_id"], session["event_id"]);
6463        assert_eq!(by_type("artifact_pulled")["parent_id"], session["event_id"]);
6464    }
6465
6466    fn runtime_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
6467        InstalledArtifactSummary {
6468            name: name.to_string(),
6469            version: "1.0.0".to_string(),
6470            runtime,
6471            implementation: None,
6472            origin: LockOrigin::Runtime {
6473                session: "ses_puller".to_string(),
6474            },
6475        }
6476    }
6477
6478    fn operator_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
6479        InstalledArtifactSummary {
6480            origin: LockOrigin::Operator,
6481            ..runtime_summary(name, runtime)
6482        }
6483    }
6484
6485    /// `runtime_artifacts` is always written: `[]` for a session whose every artifact the operator
6486    /// pinned, including one that never called the setter.
6487    #[tokio::test]
6488    async fn runtime_origin_session_start_lists_nothing_for_an_operator_session() {
6489        for set in [false, true] {
6490            let dir = tempfile::tempdir().unwrap();
6491            let mut w = make_writer(dir.path()).await;
6492            if set {
6493                w.set_runtime_artifacts(&[
6494                    operator_summary("house-style", ArtifactRuntime::Skill),
6495                    operator_summary("fetcher", ArtifactRuntime::Tool),
6496                ]);
6497            }
6498            w.write_session_start(10, Vec::new()).await.unwrap();
6499            w.flush().await.unwrap();
6500            let events = read_events(dir.path());
6501            assert_eq!(events[0]["runtime_artifacts"], serde_json::json!([]));
6502        }
6503    }
6504
6505    /// Only runtime-origin entries are listed, in the order staging recorded them, each naming
6506    /// its puller.
6507    #[tokio::test]
6508    async fn runtime_origin_session_start_lists_runtime_artifacts_in_order() {
6509        let dir = tempfile::tempdir().unwrap();
6510        let mut w = make_writer(dir.path()).await;
6511        w.set_runtime_artifacts(&[
6512            runtime_summary("zeta-tool", ArtifactRuntime::Tool),
6513            operator_summary("house-style", ArtifactRuntime::Skill),
6514            InstalledArtifactSummary {
6515                origin: LockOrigin::Runtime {
6516                    session: "ses_other".to_string(),
6517                },
6518                ..runtime_summary("alpha-style", ArtifactRuntime::Skill)
6519            },
6520        ]);
6521        w.write_session_start(10, Vec::new()).await.unwrap();
6522        w.flush().await.unwrap();
6523
6524        let events = read_events(dir.path());
6525        assert_eq!(
6526            events[0]["runtime_artifacts"],
6527            serde_json::json!([
6528                {"name": "zeta-tool", "version": "1.0.0", "origin": "runtime",
6529                 "session": "ses_puller", "trust": "untrusted"},
6530                {"name": "alpha-style", "version": "1.0.0", "origin": "runtime",
6531                 "session": "ses_other", "trust": "untrusted"},
6532            ])
6533        );
6534    }
6535
6536    #[tokio::test]
6537    async fn runtime_origin_skill_call_carries_origin_and_trust() {
6538        let dir = tempfile::tempdir().unwrap();
6539        let mut w = make_writer(dir.path()).await;
6540        for origin in [
6541            LockOrigin::Operator,
6542            LockOrigin::Runtime {
6543                session: "ses_puller".to_string(),
6544            },
6545        ] {
6546            w.write_skill_call(0, "a-skill".to_string(), &origin, 1, 1, "ok".to_string())
6547                .await
6548                .unwrap();
6549        }
6550        w.flush().await.unwrap();
6551
6552        let events = read_events(dir.path());
6553        assert_eq!(events[0]["origin"], "operator");
6554        assert_eq!(events[0]["trust"], "trusted");
6555        assert_eq!(events[1]["origin"], "runtime");
6556        assert_eq!(events[1]["trust"], "untrusted");
6557    }
6558
6559    /// Every key is written, `session` included: `null` when the lock entry is the operator's.
6560    #[tokio::test]
6561    async fn runtime_origin_artifact_pulled_writes_every_key() {
6562        let dir = tempfile::tempdir().unwrap();
6563        let mut w = make_writer(dir.path()).await;
6564        w.write_artifact_pulled(&runtime_summary("pulled-style", ArtifactRuntime::Skill))
6565            .await
6566            .unwrap();
6567        w.write_artifact_pulled(&operator_summary("fetcher", ArtifactRuntime::Tool))
6568            .await
6569            .unwrap();
6570        w.flush().await.unwrap();
6571
6572        let events = read_events(dir.path());
6573        let runtime = &events[0];
6574        assert_eq!(runtime["event_type"], "artifact_pulled");
6575        assert_eq!(runtime["name"], "pulled-style");
6576        assert_eq!(runtime["version"], "1.0.0");
6577        assert_eq!(runtime["runtime"], "skill");
6578        assert_eq!(runtime["origin"], "runtime");
6579        assert_eq!(runtime["session"], "ses_puller");
6580        assert_eq!(runtime["trust"], "untrusted");
6581        assert!(
6582            runtime["parent_id"].is_null(),
6583            "no session_start was written"
6584        );
6585
6586        let operator = &events[1];
6587        assert_eq!(operator["runtime"], "tool");
6588        assert_eq!(operator["origin"], "operator");
6589        assert!(operator.get("session").is_some_and(Value::is_null));
6590        assert_eq!(operator["trust"], "trusted");
6591    }
6592
6593    /// A hook's `run-inference` record is a product of the turn it ran inside, not a turn of
6594    /// its own: it parents to the agent loop's inference and never replaces it as the node the
6595    /// turn's other events hang off.
6596    #[tokio::test]
6597    async fn hook_origin_inference_hangs_off_the_turn_rather_than_becoming_one() {
6598        let dir = tempfile::tempdir().unwrap();
6599        let mut w = make_writer(dir.path()).await;
6600        w.write_session_start(10, Vec::new()).await.unwrap();
6601        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6602            .await
6603            .unwrap();
6604        w.write_inference(
6605            0,
6606            Some(10),
6607            Some(5),
6608            "tool_call".to_string(),
6609            Some("tool_call"),
6610            None,
6611            None,
6612            None,
6613            Vec::new(),
6614            None,
6615        )
6616        .await
6617        .unwrap();
6618        let origin = InferenceOrigin {
6619            source: "hook:gatekeeper".to_string(),
6620            model: "claude-test".to_string(),
6621        };
6622        w.write_inference(
6623            0,
6624            Some(1),
6625            Some(1),
6626            "end_turn".to_string(),
6627            // A hook's `run-inference` saw no provider stop reason, so it records none.
6628            None,
6629            None,
6630            Some(&origin),
6631            None,
6632            Vec::new(),
6633            None,
6634        )
6635        .await
6636        .unwrap();
6637        w.flush().await.unwrap();
6638
6639        let events = read_events(dir.path());
6640        let turn = &events[2];
6641        let hook = &events[3];
6642        assert!(turn["origin"].is_null());
6643        assert_eq!(hook["origin"], "hook:gatekeeper");
6644        assert_eq!(hook["parent_id"], turn["event_id"]);
6645        // The hook sent a message list the runtime never minted, so the key is absent rather
6646        // than empty.
6647        assert!(hook.get("message_ids").is_none(), "{hook}");
6648    }
6649
6650    /// A writer with no session frame behind it names no parent. This is the script-capsule
6651    /// `a2a_send` drain, which opens a writer purely to flush buffered sends into a file that
6652    /// has no `session_start` line — naming one would dangle.
6653    #[tokio::test]
6654    async fn a2a_send_parents_to_null_without_a_session_frame_and_to_it_with_one() {
6655        let bare = tempfile::tempdir().unwrap();
6656        let mut w = make_writer(bare.path()).await;
6657        w.write_a2a_send(
6658            "http://peer",
6659            "msg_1",
6660            "tsk_1",
6661            "ctx_1",
6662            None,
6663            TrustClass::Untrusted,
6664        )
6665        .await
6666        .unwrap();
6667        w.flush().await.unwrap();
6668        assert!(read_events(bare.path())[0]["parent_id"].is_null());
6669
6670        let framed = tempfile::tempdir().unwrap();
6671        let mut w = make_writer(framed.path()).await;
6672        w.write_session_start(1, Vec::new()).await.unwrap();
6673        w.write_a2a_send(
6674            "http://peer",
6675            "msg_1",
6676            "tsk_1",
6677            "ctx_1",
6678            None,
6679            TrustClass::Untrusted,
6680        )
6681        .await
6682        .unwrap();
6683        w.flush().await.unwrap();
6684        let events = read_events(framed.path());
6685        assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
6686    }
6687
6688    /// `task_rejected` carries every field and, with no task active, hangs off `session_start`.
6689    #[tokio::test]
6690    async fn task_rejected_names_the_task_source_cause_and_reason_under_the_session() {
6691        let dir = tempfile::tempdir().unwrap();
6692        let mut w = make_writer(dir.path()).await;
6693        w.write_session_start(10, Vec::new()).await.unwrap();
6694        w.write_task_start("tsk_ran", "ctx_ran", "a2a", event_provenance(), 3)
6695            .await
6696            .unwrap();
6697        w.write_task_end("tsk_ran", "ok", 0).await.unwrap();
6698        w.write_task_rejected(
6699            "tsk_1",
6700            "ctx_1",
6701            "detached_shell",
6702            TASK_REJECTED_SESSION_ENDED,
6703            "task rejected: the session ended before this task started",
6704        )
6705        .await
6706        .unwrap();
6707        w.flush().await.unwrap();
6708
6709        let events = read_events(dir.path());
6710        let session = events
6711            .iter()
6712            .find(|e| e["event_type"] == "session_start")
6713            .unwrap();
6714        let rejected: Vec<_> = events
6715            .iter()
6716            .filter(|e| e["event_type"] == "task_rejected")
6717            .collect();
6718        assert_eq!(rejected.len(), 1);
6719        let rejected = rejected[0];
6720        assert_eq!(rejected["parent_id"], session["event_id"], "{rejected}");
6721        assert_eq!(rejected["session_id"], session["session_id"]);
6722        assert_eq!(rejected["task_id"], "tsk_1");
6723        assert_eq!(rejected["context_id"], "ctx_1");
6724        assert_eq!(rejected["source"], "detached_shell");
6725        assert_eq!(rejected["cause"], "session_ended");
6726        assert_eq!(
6727            rejected["reason"],
6728            "task rejected: the session ended before this task started"
6729        );
6730        assert!(rejected["event_id"].is_string());
6731        assert!(rejected["timestamp"].is_u64());
6732        assert_eq!(rejected.as_object().unwrap().len(), 10, "{rejected}");
6733    }
6734
6735    /// `task_failed` hangs off the task it failed, and a reason past the cap is cut on a character
6736    /// boundary. Both optional fields are absent, not null, when there is nothing to say.
6737    #[tokio::test]
6738    async fn task_failed_names_the_task_turn_cause_and_capped_reason() {
6739        let dir = tempfile::tempdir().unwrap();
6740        let mut w = make_writer(dir.path()).await;
6741        w.write_session_start(10, Vec::new()).await.unwrap();
6742        assert_eq!(w.last_task_failure(), None);
6743        w.write_task_failed(None, TASK_FAILED_RUNTIME_ERROR, "before any task")
6744            .await
6745            .unwrap();
6746        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6747            .await
6748            .unwrap();
6749        let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
6750        w.write_task_failed(Some(2), TASK_FAILED_DRIVER_ERROR, &long)
6751            .await
6752            .unwrap();
6753        w.write_task_end("tsk_1", "failed", 0).await.unwrap();
6754        w.flush().await.unwrap();
6755
6756        assert_eq!(w.task_failures_written(), 2);
6757        let events = read_events(dir.path());
6758        let failed: Vec<_> = events
6759            .iter()
6760            .filter(|e| e["event_type"] == "task_failed")
6761            .collect();
6762        assert_eq!(failed.len(), 2);
6763
6764        assert!(failed[0].get("task_id").is_none(), "{}", failed[0]);
6765        assert!(failed[0].get("turn").is_none(), "{}", failed[0]);
6766        assert_eq!(failed[0]["cause"], "runtime_error");
6767        assert_eq!(failed[0]["reason"], "before any task");
6768
6769        let start = events
6770            .iter()
6771            .find(|e| e["event_type"] == "task_start")
6772            .unwrap();
6773        assert_eq!(failed[1]["task_id"], "tsk_1");
6774        assert_eq!(failed[1]["turn"], 2);
6775        assert_eq!(failed[1]["cause"], "driver_error");
6776        assert_eq!(failed[1]["parent_id"], start["event_id"]);
6777        assert_eq!(failed[1]["session_id"], start["session_id"]);
6778        assert!(failed[1]["event_id"].is_string());
6779        assert!(failed[1]["timestamp"].is_u64());
6780        let reason = failed[1]["reason"].as_str().unwrap();
6781        assert!(reason.len() <= MAX_TASK_FAILURE_REASON_BYTES);
6782        assert!(reason.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
6783        assert_eq!(w.last_task_failure(), Some(reason));
6784    }
6785
6786    /// Turn-level events carry the enclosing task's id, and `null` once the task has ended —
6787    /// `task_id` is a record of scope, not a value the writer holds on to.
6788    #[tokio::test]
6789    async fn turn_level_events_carry_the_active_task_id() {
6790        let dir = tempfile::tempdir().unwrap();
6791        let mut w = make_writer(dir.path()).await;
6792        w.write_session_start(10, Vec::new()).await.unwrap();
6793        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6794            .await
6795            .unwrap();
6796        w.write_inference(
6797            0,
6798            Some(10),
6799            Some(5),
6800            "tool_call".to_string(),
6801            Some("tool_call"),
6802            None,
6803            None,
6804            None,
6805            Vec::new(),
6806            None,
6807        )
6808        .await
6809        .unwrap();
6810        w.write_skill_call(
6811            0,
6812            "house-style".to_string(),
6813            &LockOrigin::Operator,
6814            12,
6815            1,
6816            "ok".to_string(),
6817        )
6818        .await
6819        .unwrap();
6820        w.write_compaction(0, 100, 40).await.unwrap();
6821        w.write_task_end("tsk_1", "ok", 0).await.unwrap();
6822        w.write_inference(
6823            1,
6824            Some(1),
6825            Some(1),
6826            "end_turn".to_string(),
6827            Some("end_turn"),
6828            None,
6829            None,
6830            None,
6831            Vec::new(),
6832            None,
6833        )
6834        .await
6835        .unwrap();
6836        w.flush().await.unwrap();
6837
6838        let events = read_events(dir.path());
6839        for event in events
6840            .iter()
6841            .filter(|e| {
6842                matches!(
6843                    e["event_type"].as_str(),
6844                    Some("inference" | "skill_call" | "compaction")
6845                )
6846            })
6847            .take(3)
6848        {
6849            assert_eq!(event["task_id"], "tsk_1", "{event}");
6850        }
6851        let after = events.last().unwrap();
6852        assert_eq!(after["event_type"], "inference");
6853        assert!(
6854            after["task_id"].is_null(),
6855            "an inference outside any task carries a null task_id, not the last task's"
6856        );
6857        // Once the task has ended, its events fall back to the session node.
6858        assert_eq!(after["parent_id"], events[0]["event_id"]);
6859    }
6860
6861    /// The provider's id is recorded verbatim; an absent or empty one records as `null`, never
6862    /// as an id the provider never issued.
6863    #[tokio::test]
6864    async fn tool_call_records_the_provider_id_verbatim_and_empty_as_null() {
6865        for (given, expected) in [
6866            (Some("toolu_1".to_string()), Some("toolu_1")),
6867            (Some(String::new()), None),
6868            (None, None),
6869        ] {
6870            let dir = tempfile::tempdir().unwrap();
6871            let mut w = make_writer(dir.path()).await;
6872            w.write_tool_call(
6873                0,
6874                "bash".to_string(),
6875                given.clone(),
6876                serde_json::json!({}),
6877                2,
6878                "",
6879                0,
6880                1,
6881                "ok".to_string(),
6882                None,
6883                None,
6884            )
6885            .await
6886            .unwrap();
6887            w.flush().await.unwrap();
6888            let e = &read_events(dir.path())[0];
6889            match expected {
6890                Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
6891                None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
6892            }
6893        }
6894    }
6895
6896    /// `tool_input_refused` carries the tool, its call id (`null` when absent or empty), the
6897    /// missing names and the reason, and is written under every capture mode alike.
6898    #[tokio::test]
6899    async fn tool_input_refused_records_its_fields_in_every_capture_mode() {
6900        for capture in [
6901            TraceCapture::None,
6902            TraceCapture::Meta,
6903            TraceCapture::Content,
6904        ] {
6905            for (given, expected) in [
6906                (Some("toolu_1"), Some("toolu_1")),
6907                (Some(""), None),
6908                (None, None),
6909            ] {
6910                let dir = tempfile::tempdir().unwrap();
6911                let mut w = make_writer_with_opts(dir.path(), capture).await;
6912                w.write_tool_input_refused(
6913                    2,
6914                    "murmur-tool-editor",
6915                    given,
6916                    &["operation".to_string(), "dest_path".to_string()],
6917                    "murmur-tool-editor: missing required fields",
6918                )
6919                .await
6920                .unwrap();
6921                w.flush().await.unwrap();
6922                let e = &read_events(dir.path())[0];
6923                assert_eq!(e["event_type"], "tool_input_refused", "{capture:?}");
6924                assert!(e["event_id"].is_string());
6925                assert!(e["session_id"].is_string());
6926                assert!(e["timestamp"].is_u64());
6927                assert_eq!(e["turn"], 2);
6928                assert_eq!(e["tool_name"], "murmur-tool-editor");
6929                match expected {
6930                    Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
6931                    None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
6932                }
6933                assert_eq!(e["missing"], serde_json::json!(["operation", "dest_path"]));
6934                assert_eq!(e["reason"], "murmur-tool-editor: missing required fields");
6935            }
6936        }
6937    }
6938
6939    /// `tools_refreshed` carries the turn, the task, the trigger and three sorted name lists,
6940    /// hangs off the task node rather than the previous turn, and `session_start` records the
6941    /// declared trigger — `null` under `transport: process`.
6942    #[tokio::test]
6943    async fn tools_refreshed_event_records_trigger_names_and_task_parent() {
6944        let dir = tempfile::tempdir().unwrap();
6945        let mut w = make_writer(dir.path()).await;
6946        w.set_tool_refresh(Some("immediate"));
6947        w.write_session_start(10, Vec::new()).await.unwrap();
6948        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6949            .await
6950            .unwrap();
6951        w.write_inference(
6952            0,
6953            Some(10),
6954            Some(5),
6955            "tool_call".to_string(),
6956            Some("tool_call"),
6957            None,
6958            None,
6959            None,
6960            Vec::new(),
6961            None,
6962        )
6963        .await
6964        .unwrap();
6965        w.write_tools_refreshed(
6966            1,
6967            "immediate",
6968            &["aaa-late-skill".to_string()],
6969            &[],
6970            &[
6971                "aaa-late-skill".to_string(),
6972                "zzz-existing-tool".to_string(),
6973            ],
6974        )
6975        .await
6976        .unwrap();
6977        w.flush().await.unwrap();
6978
6979        let events = read_events(dir.path());
6980        assert_eq!(events[0]["tool_refresh"], "immediate");
6981        let task = events
6982            .iter()
6983            .find(|e| e["event_type"] == "task_start")
6984            .unwrap();
6985        let refreshed: Vec<&Value> = events
6986            .iter()
6987            .filter(|e| e["event_type"] == "tools_refreshed")
6988            .collect();
6989        assert_eq!(refreshed.len(), 1);
6990        let r = refreshed[0];
6991        assert!(r["event_id"].as_str().unwrap().starts_with("evt_"));
6992        assert_eq!(r["parent_id"], task["event_id"]);
6993        assert_eq!(r["session_id"], "test-session-id");
6994        assert!(r["timestamp"].as_u64().is_some());
6995        assert_eq!(r["turn"], 1);
6996        assert_eq!(r["task_id"], "tsk_1");
6997        assert_eq!(r["trigger"], "immediate");
6998        assert_eq!(r["added"], serde_json::json!(["aaa-late-skill"]));
6999        assert_eq!(r["removed"], serde_json::json!([]));
7000        assert_eq!(
7001            r["tools"],
7002            serde_json::json!(["aaa-late-skill", "zzz-existing-tool"])
7003        );
7004
7005        let process_dir = tempfile::tempdir().unwrap();
7006        let mut process = make_writer(process_dir.path()).await;
7007        process.set_tool_refresh(None);
7008        process.write_session_start(10, Vec::new()).await.unwrap();
7009        process.flush().await.unwrap();
7010        let start = &read_events(process_dir.path())[0];
7011        assert!(start.as_object().unwrap().contains_key("tool_refresh"));
7012        assert!(start["tool_refresh"].is_null());
7013    }
7014
7015    /// A declined compaction is a full record of the decline, not a bare marker: it names the
7016    /// turn, the task, the occupancy the session went on running over, and the reason.
7017    #[tokio::test]
7018    async fn compaction_declined_records_turn_task_tokens_and_reason() {
7019        let dir = tempfile::tempdir().unwrap();
7020        let mut w = make_writer(dir.path()).await;
7021        w.write_session_start(10, Vec::new()).await.unwrap();
7022        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
7023            .await
7024            .unwrap();
7025        w.write_inference(
7026            2,
7027            Some(10),
7028            Some(5),
7029            "tool_call".to_string(),
7030            Some("tool_call"),
7031            None,
7032            None,
7033            None,
7034            Vec::new(),
7035            None,
7036        )
7037        .await
7038        .unwrap();
7039        w.write_compaction_declined(2, 4321, COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL)
7040            .await
7041            .unwrap();
7042        w.flush().await.unwrap();
7043
7044        let events = read_events(dir.path());
7045        let declined: Vec<&Value> = events
7046            .iter()
7047            .filter(|e| e["event_type"] == "compaction_declined")
7048            .collect();
7049        assert_eq!(declined.len(), 1);
7050        let d = declined[0];
7051        assert_eq!(d["reason"], "unresolved_tool_call");
7052        assert_eq!(d["turn"], 2);
7053        assert_eq!(d["task_id"], "tsk_1");
7054        assert_eq!(d["tokens"], 4321);
7055        assert!(d["event_id"].as_str().unwrap().starts_with("evt_"));
7056        assert_eq!(
7057            d["parent_id"],
7058            events
7059                .iter()
7060                .find(|e| e["event_type"] == "inference")
7061                .unwrap()["event_id"]
7062        );
7063    }
7064
7065    /// `evt_` ids sort by mint time and carry their own millisecond timestamp, the same
7066    /// property `mur trace list --since` reads off a `ses_` id.
7067    #[tokio::test]
7068    async fn event_ids_sort_by_mint_time() {
7069        let dir = tempfile::tempdir().unwrap();
7070        let mut w = make_writer(dir.path()).await;
7071        for turn in 0..5 {
7072            w.write_inference(
7073                turn,
7074                Some(1),
7075                Some(1),
7076                "end_turn".to_string(),
7077                Some("end_turn"),
7078                None,
7079                None,
7080                None,
7081                Vec::new(),
7082                None,
7083            )
7084            .await
7085            .unwrap();
7086        }
7087        w.flush().await.unwrap();
7088
7089        let ids: Vec<String> = read_events(dir.path())
7090            .iter()
7091            .map(|e| e["event_id"].as_str().unwrap().to_string())
7092            .collect();
7093        let mut sorted = ids.clone();
7094        sorted.sort();
7095        assert_eq!(ids, sorted, "evt_ ids must sort into mint order");
7096    }
7097
7098    // ── Wire capture: hashes, blobs and the capture gate ─────────────────────
7099
7100    /// A payload in the shape `build_driver_payload` produces, with `n` messages.
7101    fn wire_payload(system: &str, messages: usize) -> Value {
7102        let messages: Vec<Value> = (0..messages)
7103            .map(|i| serde_json::json!({"role": "user", "content": format!("m{i}")}))
7104            .collect();
7105        serde_json::json!({
7106            "model": "claude-test",
7107            "max_tokens": 1024,
7108            "messages": messages,
7109            "tools": [{"name": "bash"}],
7110            "params": {},
7111            "system": system,
7112        })
7113    }
7114
7115    async fn write_turn(w: &mut TraceWriter, turn: u32, payload: &Value, response: &str) {
7116        let wire = WireCapture::from_driver_payload(payload, response);
7117        w.write_inference(
7118            turn,
7119            Some(10),
7120            Some(5),
7121            "end_turn".to_string(),
7122            Some("end_turn"),
7123            None,
7124            None,
7125            None,
7126            vec!["msg_0000000000000000000000000000000a".to_string()],
7127            Some(&wire),
7128        )
7129        .await
7130        .unwrap();
7131    }
7132
7133    fn blob_path(dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
7134        dir.join(BLOB_DIR_NAME).join(sha)
7135    }
7136
7137    /// Every hash an `inference` event names under `content` is the filename of a real file whose
7138    /// own digest is that filename — the whole point of the store.
7139    #[tokio::test]
7140    async fn content_capture_names_blobs_that_exist_and_rehash_to_their_names() {
7141        let dir = tempfile::tempdir().unwrap();
7142        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7143        let payload = wire_payload("You are a capsule.", 3);
7144        write_turn(&mut w, 0, &payload, r#"{"stop_reason":"end_turn"}"#).await;
7145        w.flush().await.unwrap();
7146
7147        let e = &read_events(dir.path())[0];
7148        let mut named: Vec<String> = ["system_sha", "tools_sha", "response_sha"]
7149            .iter()
7150            .map(|key| {
7151                e[*key]
7152                    .as_str()
7153                    .unwrap_or_else(|| panic!("{key} missing from {e}"))
7154                    .to_string()
7155            })
7156            .collect();
7157        let shas = e["message_shas"].as_array().expect("message_shas");
7158        assert_eq!(shas.len(), 3);
7159        named.extend(shas.iter().map(|s| s.as_str().unwrap().to_string()));
7160
7161        for sha in &named {
7162            let path = blob_path(dir.path(), sha);
7163            let bytes =
7164                std::fs::read(&path).unwrap_or_else(|err| panic!("blob {sha} must exist: {err}"));
7165            assert_eq!(murmur_artifact::sha256_hex(&bytes), *sha);
7166            // A bare sha256 names content; `evt_`/`msg_`/`ses_` name entities. Neither a prefix
7167            // nor an extension may creep onto a blob name.
7168            assert_eq!(sha.len(), 64, "{sha}");
7169            assert!(
7170                sha.chars()
7171                    .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
7172                "{sha}"
7173            );
7174        }
7175    }
7176
7177    /// The hashed system body is the payload's `system` string, as text — so the blob reads as a
7178    /// prompt rather than as a JSON-quoted one.
7179    #[tokio::test]
7180    async fn the_system_blob_holds_the_prompt_as_readable_text() {
7181        let dir = tempfile::tempdir().unwrap();
7182        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7183        let payload = wire_payload("[Capsule] you are cap.\nBe terse.", 1);
7184        write_turn(&mut w, 0, &payload, "{}").await;
7185        w.flush().await.unwrap();
7186
7187        let sha = read_events(dir.path())[0]["system_sha"]
7188            .as_str()
7189            .unwrap()
7190            .to_string();
7191        assert_eq!(
7192            std::fs::read_to_string(blob_path(dir.path(), &sha)).unwrap(),
7193            "[Capsule] you are cap.\nBe terse."
7194        );
7195    }
7196
7197    /// `meta` is the default and hashes exactly what `content` hashes; the difference is only
7198    /// whether the bodies are on disk.
7199    #[tokio::test]
7200    async fn meta_writes_the_same_hashes_and_no_bodies() {
7201        let payload = wire_payload("You are a capsule.", 2);
7202        let response = r#"{"stop_reason":"end_turn"}"#;
7203
7204        let meta = tempfile::tempdir().unwrap();
7205        let mut w = make_writer_with_opts(meta.path(), TraceCapture::Meta).await;
7206        write_turn(&mut w, 0, &payload, response).await;
7207        w.flush().await.unwrap();
7208
7209        let content = tempfile::tempdir().unwrap();
7210        let mut w = make_writer_with_opts(content.path(), TraceCapture::Content).await;
7211        write_turn(&mut w, 0, &payload, response).await;
7212        w.flush().await.unwrap();
7213
7214        let meta_event = read_events(meta.path()).remove(0);
7215        let content_event = read_events(content.path()).remove(0);
7216        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
7217            assert_eq!(meta_event[key], content_event[key], "{key}");
7218        }
7219        assert!(
7220            !meta.path().join(BLOB_DIR_NAME).exists(),
7221            "meta must not create the blob directory"
7222        );
7223        assert!(content.path().join(BLOB_DIR_NAME).is_dir());
7224    }
7225
7226    /// The default a writer gets when the manifest declares no `trace:` block.
7227    #[tokio::test]
7228    async fn the_default_capture_mode_is_meta() {
7229        assert_eq!(TraceCapture::default(), TraceCapture::Meta);
7230        let dir = tempfile::tempdir().unwrap();
7231        let mut w = make_writer_with_prompt(dir.path(), TraceCapture::default(), None, false).await;
7232        write_turn(&mut w, 0, &wire_payload("s", 1), "{}").await;
7233        w.flush().await.unwrap();
7234
7235        let e = &read_events(dir.path())[0];
7236        assert!(e.get("system_sha").is_some());
7237        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
7238    }
7239
7240    /// Under `none` not one of the four hash keys is present, and every other field is.
7241    #[tokio::test]
7242    async fn none_omits_every_hash_and_leaves_the_rest_of_the_event_intact() {
7243        let payload = wire_payload("You are a capsule.", 2);
7244
7245        let off = tempfile::tempdir().unwrap();
7246        let mut w = make_writer_with_opts(off.path(), TraceCapture::None).await;
7247        write_turn(&mut w, 3, &payload, "{}").await;
7248        w.flush().await.unwrap();
7249
7250        let e = read_events(off.path()).remove(0);
7251        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
7252            assert!(
7253                e.get(key).is_none(),
7254                "{key} must be absent under none, got {e}"
7255            );
7256        }
7257        assert!(!off.path().join(BLOB_DIR_NAME).exists());
7258        for key in [
7259            "event_type",
7260            "event_id",
7261            "parent_id",
7262            "session_id",
7263            "timestamp",
7264            "turn",
7265            "task_id",
7266            "input_tokens",
7267            "output_tokens",
7268            "decision",
7269            "tool_name",
7270            "message_ids",
7271        ] {
7272            assert!(
7273                e.get(key).is_some(),
7274                "{key} must survive under none, got {e}"
7275            );
7276        }
7277        assert_eq!(e["turn"], 3);
7278        assert_eq!(e["message_ids"].as_array().unwrap().len(), 1);
7279    }
7280
7281    /// A prompt that does not change across ten turns is one blob, written once and never
7282    /// rewritten — the file's bytes are replaced by hand after the first turn and must survive.
7283    #[tokio::test]
7284    async fn an_unchanged_system_prompt_is_stored_once() {
7285        let dir = tempfile::tempdir().unwrap();
7286        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7287        let payload = wire_payload("You are a capsule.", 1);
7288        write_turn(&mut w, 0, &payload, "{}").await;
7289
7290        let sha = {
7291            w.flush().await.unwrap();
7292            read_events(dir.path())[0]["system_sha"]
7293                .as_str()
7294                .unwrap()
7295                .to_string()
7296        };
7297        let path = blob_path(dir.path(), &sha);
7298        std::fs::write(&path, b"sentinel").unwrap();
7299
7300        for turn in 1..10 {
7301            write_turn(&mut w, turn, &payload, "{}").await;
7302        }
7303        w.flush().await.unwrap();
7304
7305        let events = read_events(dir.path());
7306        assert_eq!(events.len(), 10);
7307        for e in &events {
7308            assert_eq!(e["system_sha"], sha.as_str());
7309        }
7310        assert_eq!(
7311            std::fs::read(&path).unwrap(),
7312            b"sentinel",
7313            "an existing blob is never rewritten"
7314        );
7315        let matching = std::fs::read_dir(dir.path().join(BLOB_DIR_NAME))
7316            .unwrap()
7317            .filter(|entry| entry.as_ref().unwrap().file_name() == sha.as_str())
7318            .count();
7319        assert_eq!(matching, 1, "exactly one file bears that name");
7320    }
7321
7322    /// A record written for a completion the runtime parsed no driver response for names no stop
7323    /// reason: the key is absent, never an empty string. Absence means "no driver response was
7324    /// parsed here", so a reader can tell it apart from a driver that reported nothing — which is
7325    /// recorded as `""`.
7326    #[tokio::test]
7327    async fn stop_reason_is_absent_when_no_driver_response_was_parsed() {
7328        let dir = tempfile::tempdir().unwrap();
7329        let mut w = make_writer(dir.path()).await;
7330        w.write_inference(
7331            0,
7332            Some(10),
7333            Some(5),
7334            "end_turn".to_string(),
7335            None,
7336            None,
7337            None,
7338            None,
7339            Vec::new(),
7340            None,
7341        )
7342        .await
7343        .unwrap();
7344        w.flush().await.unwrap();
7345
7346        let event = read_events(dir.path()).remove(0);
7347        assert!(
7348            event.get("stop_reason").is_none(),
7349            "stop_reason must be absent, got {event}"
7350        );
7351    }
7352
7353    /// A hook's `run-inference` sends a payload the runtime never built, so its record names no
7354    /// hashes — absent from the JSON, not empty strings.
7355    #[tokio::test]
7356    async fn a_hook_origin_record_carries_no_hashes() {
7357        let dir = tempfile::tempdir().unwrap();
7358        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7359        let origin = InferenceOrigin {
7360            source: "hook:gatekeeper".to_string(),
7361            model: "claude-test".to_string(),
7362        };
7363        w.write_inference(
7364            0,
7365            Some(1),
7366            Some(1),
7367            "end_turn".to_string(),
7368            // A hook's `run-inference` saw no provider stop reason, so it records none.
7369            None,
7370            None,
7371            Some(&origin),
7372            None,
7373            Vec::new(),
7374            None,
7375        )
7376        .await
7377        .unwrap();
7378        w.flush().await.unwrap();
7379
7380        let e = read_events(dir.path()).remove(0);
7381        assert_eq!(e["origin"], "hook:gatekeeper");
7382        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
7383            assert!(e.get(key).is_none(), "{key} must be absent, got {e}");
7384        }
7385        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
7386    }
7387
7388    /// Two runs sharing a prefix agree on that prefix's `message_shas` and disagree from the
7389    /// changed message onwards — the divergence index is the first unequal position.
7390    #[tokio::test]
7391    async fn message_shas_locate_the_index_two_runs_diverge_at() {
7392        async fn shas_for(payload: &Value) -> Vec<String> {
7393            let dir = tempfile::tempdir().unwrap();
7394            let mut w = make_writer_with_opts(dir.path(), TraceCapture::Meta).await;
7395            write_turn(&mut w, 0, payload, "{}").await;
7396            w.flush().await.unwrap();
7397            read_events(dir.path())[0]["message_shas"]
7398                .as_array()
7399                .unwrap()
7400                .iter()
7401                .map(|s| s.as_str().unwrap().to_string())
7402                .collect()
7403        }
7404
7405        let first = wire_payload("You are a capsule.", 4);
7406        let mut second = first.clone();
7407        second["messages"][2]["content"] = serde_json::json!("diverged");
7408
7409        let a = shas_for(&first).await;
7410        let b = shas_for(&second).await;
7411        assert_eq!(a.len(), 4);
7412        let divergence = a.iter().zip(&b).position(|(x, y)| x != y);
7413        assert_eq!(divergence, Some(2));
7414        assert_eq!(a[3], b[3], "only the changed message changes its own sha");
7415    }
7416
7417    /// A message repeated inside one request hashes to one name and one file. Ids never repeat;
7418    /// shas repeat exactly when content does, and that is what makes them comparable.
7419    #[tokio::test]
7420    async fn identical_messages_share_one_sha_and_one_blob() {
7421        let dir = tempfile::tempdir().unwrap();
7422        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7423        let payload = serde_json::json!({
7424            "messages": [{"role": "user", "content": "same"}, {"role": "user", "content": "same"}],
7425            "tools": [],
7426            "system": "s",
7427        });
7428        write_turn(&mut w, 0, &payload, "{}").await;
7429        w.flush().await.unwrap();
7430
7431        let shas = read_events(dir.path())[0]["message_shas"]
7432            .as_array()
7433            .unwrap()
7434            .iter()
7435            .map(|s| s.as_str().unwrap().to_string())
7436            .collect::<Vec<_>>();
7437        assert_eq!(shas[0], shas[1]);
7438        assert!(blob_path(dir.path(), &shas[0]).is_file());
7439    }
7440
7441    /// Blobs are the bytes as sent, so a peer handle a tool call would have had redacted out of
7442    /// its summary stays in the body a `content` capture stores.
7443    #[tokio::test]
7444    async fn a_message_blob_is_the_body_verbatim() {
7445        let dir = tempfile::tempdir().unwrap();
7446        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7447        let message = serde_json::json!({"role": "user", "content": "hello"});
7448        let payload = serde_json::json!({
7449            "messages": [message.clone()],
7450            "tools": [],
7451            "system": "s",
7452        });
7453        write_turn(&mut w, 0, &payload, "{}").await;
7454        w.flush().await.unwrap();
7455
7456        let sha = read_events(dir.path())[0]["message_shas"][0]
7457            .as_str()
7458            .unwrap()
7459            .to_string();
7460        assert_eq!(
7461            std::fs::read(blob_path(dir.path(), &sha)).unwrap(),
7462            serde_json::to_vec(&message).unwrap()
7463        );
7464    }
7465
7466    /// A delegation record a plan's `capsule` step wrote and one `delegate-task` wrote are the
7467    /// same record.
7468    ///
7469    /// Identical key sets and identical values on everything but the two fields that are
7470    /// per-line by construction — the event id and the clock. That is what makes a trace
7471    /// unable to say which surface launched a child, and it holds because both appenders
7472    /// serialize the same struct rather than because this test watches them.
7473    #[tokio::test]
7474    async fn a_plan_written_delegation_record_matches_a_delegate_task_one() {
7475        let plan_dir = tempfile::tempdir().unwrap();
7476        let tool_dir = tempfile::tempdir().unwrap();
7477        let session = "ses_0000000000004000800000000000plan".to_string();
7478        let node = "evt_0000000000004000800000000000node".to_string();
7479
7480        let plan = PlanTraceAppender::open(plan_dir.path(), session.clone(), node.clone()).unwrap();
7481        let tool = ResourceTraceAppender::open(tool_dir.path(), session.clone(), node.clone())
7482            .await
7483            .unwrap();
7484
7485        plan.write_delegation_start("dlg_abc123", "worker", "0.1.0", "ses_child", ".murmur/c");
7486        tool.write_delegation_start("dlg_abc123", "worker", "0.1.0", "ses_child", ".murmur/c")
7487            .await;
7488        plan.write_delegation(
7489            "worker",
7490            "0.1.0",
7491            Some("dlg_abc123".to_string()),
7492            Some("ses_child".to_string()),
7493            42,
7494            "completed",
7495            None,
7496        );
7497        tool.write_delegation(
7498            "worker",
7499            "0.1.0",
7500            Some("dlg_abc123".to_string()),
7501            Some("ses_child".to_string()),
7502            42,
7503            "completed",
7504            None,
7505        )
7506        .await;
7507
7508        let read = |dir: &Path| -> Vec<serde_json::Map<String, Value>> {
7509            std::fs::read_to_string(dir.join("trace.jsonl"))
7510                .unwrap()
7511                .lines()
7512                .map(|line| serde_json::from_str::<serde_json::Map<_, _>>(line).unwrap())
7513                .collect()
7514        };
7515        let from_plan = read(plan_dir.path());
7516        let from_tool = read(tool_dir.path());
7517        assert_eq!(from_plan.len(), 2);
7518        assert_eq!(from_tool.len(), 2);
7519
7520        for (planned, called, event_type) in [
7521            (&from_plan[0], &from_tool[0], "delegation_start"),
7522            (&from_plan[1], &from_tool[1], "delegation"),
7523        ] {
7524            assert_eq!(planned["event_type"], event_type);
7525            assert_eq!(called["event_type"], event_type);
7526            assert_eq!(
7527                planned.keys().collect::<Vec<_>>(),
7528                called.keys().collect::<Vec<_>>(),
7529                "{planned:?} vs {called:?}"
7530            );
7531            assert_eq!(planned["parent_id"], node.as_str());
7532            assert_eq!(called["parent_id"], node.as_str());
7533            for (key, value) in planned {
7534                if key == "event_id" || key == "timestamp" {
7535                    continue;
7536                }
7537                assert_eq!(value, &called[key], "{key}: {planned:?} vs {called:?}");
7538            }
7539            for key in ["plan_id", "step_id", "surface", "kind"] {
7540                assert!(!planned.contains_key(key), "{key} is on {planned:?}");
7541                assert!(!called.contains_key(key), "{key} is on {called:?}");
7542            }
7543        }
7544    }
7545
7546    /// `spawner_ended` carries the lineage `session_start` does, on the same terms: both keys
7547    /// present for a delegated child, both absent — not null — for a session launched with a
7548    /// lifeline but no spawner handle.
7549    #[tokio::test]
7550    async fn spawner_ended_names_the_lineage_or_omits_it() {
7551        let dir = tempfile::tempdir().unwrap();
7552        let session = "ses_0000000000004000800000000child".to_string();
7553        let node = "evt_0000000000004000800000000000node".to_string();
7554        let appender = ResourceTraceAppender::open(dir.path(), session.clone(), node.clone())
7555            .await
7556            .unwrap();
7557
7558        appender
7559            .write_spawner_ended(Some("ses_parent"), Some("dlg_abc123"))
7560            .await;
7561        appender.write_spawner_ended(None, None).await;
7562
7563        let lines: Vec<serde_json::Map<String, Value>> =
7564            std::fs::read_to_string(dir.path().join("trace.jsonl"))
7565                .unwrap()
7566                .lines()
7567                .map(|line| serde_json::from_str(line).unwrap())
7568                .collect();
7569        assert_eq!(lines.len(), 2);
7570        let named = &lines[0];
7571        assert_eq!(named["event_type"], "spawner_ended");
7572        assert_eq!(named["parent_id"], node.as_str());
7573        assert_eq!(named["session_id"], session.as_str());
7574        assert_eq!(named["spawned_by"], "ses_parent");
7575        assert_eq!(named["delegation_id"], "dlg_abc123");
7576        assert_eq!(
7577            named.keys().collect::<Vec<_>>(),
7578            [
7579                "delegation_id",
7580                "event_id",
7581                "event_type",
7582                "parent_id",
7583                "session_id",
7584                "spawned_by",
7585                "timestamp"
7586            ]
7587        );
7588        let bare = &lines[1];
7589        assert_eq!(bare["event_type"], "spawner_ended");
7590        assert!(!bare.contains_key("spawned_by"), "{bare:?}");
7591        assert!(!bare.contains_key("delegation_id"), "{bare:?}");
7592    }
7593}