1use std::time::{Instant, SystemTime, UNIX_EPOCH};
2
3use murmur_artifact::{ContainmentClass, LockOrigin, TraceCapture};
4use serde::Serialize;
5use serde_json::Value;
6use tokio::{
7 fs::File,
8 io::{AsyncWriteExt, BufWriter},
9};
10
11use crate::{
12 agent::{DriverUsage, OccupancyBasis},
13 containment::ScopeReport,
14 credential_gateway::GatewayOffsets,
15 formation::FormationId,
16 gateway_credential::CredentialChange,
17 inference_retry::{InferenceRetryRecord, RetryNote, RetryStop},
18 lanes::TaskLane,
19 origin::{TaskProvenance, TrustClass},
20 trace_blobs::BlobStore,
21 types::InstalledArtifactSummary,
22 workdir_fs::{WorkdirRoot, DEFAULT_FILE_MODE},
23};
24
25pub(crate) const TRACE_FILE_NAME: &str = "trace.jsonl";
27
28pub(crate) struct TraceWriter {
29 writer: BufWriter<File>,
30 session_id: String,
31 capsule_name: String,
32 capsule_version: String,
33 model: String,
34 capabilities: Vec<String>,
35 effective_grants: ScopeReport,
41 capture: TraceCapture,
45 blobs: BlobStore,
48 system_prompt_source: &'static str,
53 system_prompt_sha256: Option<String>,
57 credential_source: &'static str,
60 gateways: Vec<SessionGateway>,
63 control: Option<SessionControl>,
66 inference_choices: Vec<SessionInferenceChoice>,
69 tool_refresh: Option<&'static str>,
72 runtime_artifacts: Vec<RuntimeArtifact>,
75 host_facts: Vec<HostFactsRecord>,
78 instruction_files: Option<Vec<crate::instruction_files::InstructionFileRecord>>,
82 resumed_from: Option<String>,
86 context_id: Option<String>,
89 spawned_by: Option<String>,
93 spawn_delegation_id: Option<String>,
94 formation_id: Option<FormationId>,
97 formation_member: Option<(String, Vec<String>)>,
100 session_start_time: Instant,
101 session_event_id: String,
106 session_started: bool,
107 session_ended: bool,
108 task_event_id: Option<String>,
111 turn_event_id: Option<String>,
115 total_turns: u32,
117 total_input_tokens: u64,
118 total_output_tokens: u64,
119 total_tool_calls: u32,
120 total_shell_calls: u32,
121 task_turns: u32,
123 task_input_tokens: u64,
124 task_output_tokens: u64,
125 task_tool_calls: u32,
126 task_shell_calls: u32,
127 task_agent_turns: u32,
132 task_hook_inferences: u32,
135 task_capped_turns: u32,
138 task_start_instant: Option<Instant>,
139 pub(crate) active_task_id: Option<String>,
140 task_failures_written: u64,
143 last_task_failure: Option<String>,
145 max_session_tokens: Option<u64>,
147 machine_tokens_per_day: Option<u64>,
148 session_status: Option<std::sync::Arc<crate::session_status::SessionStatus>>,
152 call_deadlines: Option<crate::limits::CallDeadlines>,
156}
157
158#[derive(Debug, Clone)]
163pub(crate) struct InferenceOrigin {
164 pub(crate) source: String,
166 pub(crate) model: String,
168}
169
170#[derive(Debug, Clone, PartialEq)]
172pub(crate) struct InferenceCallSettings {
173 pub(crate) max_output_tokens: u32,
176 pub(crate) input_budget: Option<u32>,
179 pub(crate) driver_config: Option<Value>,
182}
183
184pub(crate) fn recorded_driver_config(
192 env: &[(String, String)],
193 redact: impl Fn(&str) -> String,
194) -> Option<Value> {
195 fn redact_value(value: Value, redact: &impl Fn(&str) -> String) -> Value {
196 match value {
197 Value::String(text) => Value::String(redact(&text)),
198 Value::Number(number) => {
199 let text = number.to_string();
200 if redact(&text) == text {
201 Value::Number(number)
202 } else {
203 Value::String(crate::gateway_credential::REDACTED.to_string())
204 }
205 }
206 Value::Array(items) => Value::Array(
207 items
208 .into_iter()
209 .map(|item| redact_value(item, redact))
210 .collect(),
211 ),
212 Value::Object(entries) => Value::Object(
213 entries
214 .into_iter()
215 .map(|(key, item)| (redact(&key), redact_value(item, redact)))
216 .collect(),
217 ),
218 other @ (Value::Bool(_) | Value::Null) => other,
219 }
220 }
221 let (_, raw) = env
222 .iter()
223 .find(|(key, _)| key == "MURMUR_INFERENCE_DRIVER_CONFIG")?;
224 Some(match serde_json::from_str::<Value>(raw) {
225 Ok(value) => redact_value(value, &redact),
226 Err(_) => Value::String(redact(raw)),
227 })
228}
229
230pub(crate) const COMPACTION_DECLINED_NO_HOOK_REPLACEMENT: &str = "no_hook_replacement";
233
234pub(crate) const COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL: &str = "unresolved_tool_call";
237
238pub(crate) const SEED_OUTCOME_SEEDED: &str = "seeded";
240
241pub(crate) const SEED_OUTCOME_TRIMMED: &str = "trimmed";
244
245pub(crate) const SEED_OUTCOME_COMPACTED: &str = "compacted";
248
249pub(crate) const SEED_OUTCOME_REJECTED: &str = "rejected";
251
252pub(crate) const SEED_REJECTED_MESSAGE_OVER_BUDGET: &str = "message_over_budget";
255
256pub(crate) const SEED_REJECTED_OVERFLOW_OVER_LIMIT: &str = "overflow_over_limit";
259
260pub(crate) const SEED_REJECTED_NO_BUDGET: &str = "no_budget";
263
264pub(crate) const SEED_REJECTED_UNSUPPORTED_TRANSPORT: &str = "unsupported_transport";
267
268pub(crate) const RETENTION_STORE_SESSIONS: &str = "sessions";
270
271pub(crate) const RETENTION_STORE_RECORDS: &str = "records";
273
274pub(crate) const RETENTION_REASON_MAX_SESSIONS: &str = "max_sessions";
276
277pub(crate) const RETENTION_REASON_MAX_AGE: &str = "max_age";
280
281pub(crate) const RETENTION_REASON_MAX_MESSAGES: &str = "max_messages";
283
284pub(crate) fn new_event_id() -> String {
292 format!("evt_{}", uuid::Uuid::now_v7().simple())
293}
294
295#[derive(Serialize)]
309struct SessionStartEvent {
310 event_type: &'static str,
311 event_id: String,
312 parent_id: Option<String>,
313 session_id: String,
314 timestamp: u64,
315 capsule_name: String,
316 capsule_version: String,
317 model: String,
318 max_turns: Option<u32>,
320 model_limits: Option<crate::model_limits::ResolvedModelLimits>,
323 max_session_tokens: Option<u64>,
326 machine_tokens_per_day: Option<u64>,
329 deadline: Option<SessionStartDeadline>,
332 capabilities: Vec<String>,
333 tools_declared: Vec<String>,
334 containment_declared: ContainmentClass,
336 containment_achieved: ContainmentClass,
341 workdir_exec: bool,
349 userns_grant: Option<crate::sealed::UsernsGrant>,
361 effective_grants: ScopeReport,
370 system_prompt_source: &'static str,
378 system_prompt_sha256: Option<String>,
387 credential_source: &'static str,
390 gateways: Vec<SessionGateway>,
394 #[serde(skip_serializing_if = "Option::is_none")]
397 control: Option<SessionControl>,
398 #[serde(skip_serializing_if = "Vec::is_empty")]
402 inference_choices: Vec<SessionInferenceChoice>,
403 tool_refresh: Option<&'static str>,
406 runtime_artifacts: Vec<RuntimeArtifact>,
410 host_facts: Vec<HostFactsRecord>,
414 instruction_files: Option<Vec<crate::instruction_files::InstructionFileRecord>>,
420 resumed_from: Option<String>,
427 context_id: Option<String>,
433 #[serde(skip_serializing_if = "Option::is_none")]
437 spawned_by: Option<String>,
438 #[serde(skip_serializing_if = "Option::is_none")]
441 delegation_id: Option<String>,
442 #[serde(skip_serializing_if = "Option::is_none")]
446 formation_id: Option<FormationId>,
447 #[serde(skip_serializing_if = "Option::is_none")]
450 formation_member: Option<String>,
451 #[serde(skip_serializing_if = "Option::is_none")]
454 formation_callees: Option<Vec<String>>,
455}
456
457#[derive(Serialize)]
459struct SessionStartDeadline {
460 at: String,
462 secs: u64,
465 notice_secs: u64,
468 source: &'static str,
470}
471
472#[derive(Serialize)]
473struct InferenceEvent {
474 event_type: &'static str,
475 event_id: String,
476 parent_id: Option<String>,
477 session_id: String,
478 timestamp: u64,
479 turn: u32,
480 task_id: Option<String>,
481 #[serde(skip_serializing_if = "Option::is_none")]
494 input_tokens: Option<u64>,
495 #[serde(skip_serializing_if = "Option::is_none")]
499 output_tokens: Option<u64>,
500 decision: String,
501 #[serde(skip_serializing_if = "Option::is_none")]
508 stop_reason: Option<String>,
509 tool_name: Option<String>,
510 #[serde(skip_serializing_if = "Option::is_none")]
513 input_tokens_actual: Option<u64>,
514 #[serde(skip_serializing_if = "Option::is_none")]
515 output_tokens_actual: Option<u64>,
516 #[serde(skip_serializing_if = "Option::is_none")]
517 cached_tokens: Option<u64>,
518 #[serde(skip_serializing_if = "Option::is_none")]
519 cache_write_tokens: Option<u64>,
520 #[serde(skip_serializing_if = "Option::is_none")]
523 thinking_tokens: Option<u64>,
524 #[serde(skip_serializing_if = "Option::is_none")]
529 origin: Option<String>,
530 #[serde(skip_serializing_if = "Option::is_none")]
533 driver_choice: Option<String>,
534 #[serde(skip_serializing_if = "Option::is_none")]
539 model: Option<String>,
540 #[serde(skip_serializing_if = "Option::is_none")]
545 max_output_tokens: Option<u32>,
546 #[serde(skip_serializing_if = "Option::is_none")]
550 input_budget: Option<Option<u32>>,
551 #[serde(skip_serializing_if = "Option::is_none")]
556 driver_config: Option<Option<Value>>,
557 #[serde(skip_serializing_if = "Vec::is_empty")]
562 message_ids: Vec<String>,
563 #[serde(skip_serializing_if = "Option::is_none")]
571 system_sha: Option<String>,
572 #[serde(skip_serializing_if = "Option::is_none")]
575 tools_sha: Option<String>,
576 #[serde(skip_serializing_if = "Option::is_none")]
579 response_sha: Option<String>,
580 #[serde(skip_serializing_if = "Vec::is_empty")]
588 message_shas: Vec<String>,
589 #[serde(skip_serializing_if = "Option::is_none")]
593 error_code: Option<String>,
594 #[serde(skip_serializing_if = "Option::is_none")]
598 error: Option<String>,
599 #[serde(skip_serializing_if = "Option::is_none")]
603 provider_status: Option<u16>,
604 #[serde(skip_serializing_if = "Option::is_none")]
609 gateway_reached_ms: Option<Option<u64>>,
610 #[serde(skip_serializing_if = "Option::is_none")]
614 upstream_responded_ms: Option<Option<u64>>,
615 #[serde(skip_serializing_if = "Option::is_none")]
618 attempts: Option<u32>,
619 #[serde(skip_serializing_if = "Option::is_none")]
622 retry_stopped: Option<&'static str>,
623}
624
625pub(crate) struct WireCapture {
635 system: Vec<u8>,
638 tools: Vec<u8>,
640 messages: Vec<Vec<u8>>,
644 response: Vec<u8>,
646}
647
648impl WireCapture {
649 pub(crate) fn from_driver_payload(payload: &Value, response: &str) -> Self {
655 Self {
656 system: payload
657 .get("system")
658 .and_then(Value::as_str)
659 .unwrap_or_default()
660 .as_bytes()
661 .to_vec(),
662 tools: payload
663 .get("tools")
664 .map(|tools| serde_json::to_vec(tools).unwrap_or_default())
665 .unwrap_or_default(),
666 messages: payload
667 .get("messages")
668 .and_then(Value::as_array)
669 .map(|messages| {
670 messages
671 .iter()
672 .map(|message| serde_json::to_vec(message).unwrap_or_default())
673 .collect()
674 })
675 .unwrap_or_default(),
676 response: response.as_bytes().to_vec(),
677 }
678 }
679}
680
681#[derive(Serialize)]
682struct ToolCallEvent {
683 event_type: &'static str,
684 event_id: String,
685 parent_id: Option<String>,
686 session_id: String,
687 timestamp: u64,
688 turn: u32,
689 task_id: Option<String>,
690 tool_name: String,
691 tool_call_id: Option<String>,
695 input: Value,
696 input_bytes: u64,
697 #[serde(skip_serializing_if = "Option::is_none")]
698 output: Option<String>,
699 output_bytes: u64,
700 duration_ms: u64,
701 status: String,
702 #[serde(skip_serializing_if = "Option::is_none")]
708 error: Option<String>,
709 #[serde(skip_serializing_if = "Option::is_none")]
713 state_effect: Option<String>,
714 #[serde(skip_serializing_if = "Option::is_none")]
719 resource_id: Option<String>,
720 #[serde(skip_serializing_if = "Vec::is_empty")]
723 attachments: Vec<AttachmentRecord>,
724}
725
726#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
732pub(crate) struct AttachmentRecord {
733 pub(crate) path: String,
734 pub(crate) media_type: String,
735 #[serde(skip_serializing_if = "Option::is_none")]
736 pub(crate) bytes: Option<u64>,
737 #[serde(skip_serializing_if = "Option::is_none")]
738 pub(crate) sha256: Option<String>,
739 #[serde(skip_serializing_if = "Option::is_none")]
740 pub(crate) refused: Option<&'static str>,
741}
742
743#[derive(Serialize)]
744struct SkillCallEvent {
745 event_type: &'static str,
746 event_id: String,
747 parent_id: Option<String>,
748 session_id: String,
749 timestamp: u64,
750 turn: u32,
751 task_id: Option<String>,
752 skill_name: String,
753 output_bytes: u64,
754 duration_ms: u64,
755 status: String,
756 origin: &'static str,
758 trust: &'static str,
761}
762
763#[derive(Serialize)]
764struct ShellEvent {
765 event_type: &'static str,
766 event_id: String,
767 parent_id: Option<String>,
768 session_id: String,
769 timestamp: u64,
770 turn: u32,
771 task_id: Option<String>,
772 binary: String,
776 command: String,
777 exit_code: i32,
778 stdout_bytes: u64,
779 stderr_bytes: u64,
780 duration_ms: u64,
781 #[serde(skip_serializing_if = "Option::is_none")]
787 resource_limit: Option<String>,
788}
789
790#[derive(Serialize)]
793struct ShellDetachedEvent {
794 event_type: &'static str,
795 event_id: String,
796 parent_id: Option<String>,
797 session_id: String,
798 timestamp: u64,
799 turn: u32,
800 task_id: Option<String>,
801 work_id: String,
802 binary: String,
803 command: String,
804 #[serde(skip_serializing_if = "Option::is_none")]
808 grace_ms: Option<u64>,
809}
810
811#[derive(Serialize)]
817struct ShellCompletedEvent {
818 event_type: &'static str,
819 event_id: String,
820 parent_id: Option<String>,
821 session_id: String,
822 timestamp: u64,
823 work_id: String,
824 binary: String,
825 command: String,
826 exit_code: i32,
827 duration_ms: u64,
828 exited_at_ms: u64,
831 output_path: String,
834 output_bytes: u64,
835 #[serde(skip_serializing_if = "Option::is_none")]
837 resource_limit: Option<String>,
838 status: String,
841 delivered_via: &'static str,
843 #[serde(skip_serializing_if = "Option::is_none")]
847 completion_task_id: Option<String>,
848}
849
850impl ShellCompletedEvent {
851 fn new(
852 session_id: String,
853 parent_id: Option<String>,
854 completion: &crate::detached::DetachedCompletion,
855 delivery: crate::detached::Delivery,
856 completion_task_id: Option<&str>,
857 ) -> Self {
858 Self {
859 event_type: "shell_completed",
860 event_id: new_event_id(),
861 parent_id,
862 session_id,
863 timestamp: timestamp_ms(),
864 work_id: completion.work_id.clone(),
865 binary: completion.binary.clone(),
866 command: completion.command.clone(),
867 exit_code: completion.exit_code,
868 duration_ms: completion.duration_ms,
869 exited_at_ms: completion.exited_at_ms,
870 output_path: completion.output_path.clone(),
871 output_bytes: completion.output_bytes,
872 resource_limit: completion.resource_limit.clone(),
873 status: completion.status().to_string(),
874 delivered_via: delivery.as_str(),
875 completion_task_id: completion_task_id.map(str::to_string),
876 }
877 }
878}
879
880#[derive(Serialize)]
882struct BackgroundCommandEvent<'a> {
883 event_type: &'static str,
884 event_id: String,
885 parent_id: Option<String>,
886 session_id: String,
887 timestamp: u64,
888 #[serde(skip_serializing_if = "Option::is_none")]
890 task_id: Option<String>,
891 work_id: &'a str,
892 operation: &'static str,
894 state: &'static str,
896 #[serde(skip_serializing_if = "Option::is_none")]
898 exit_code: Option<i32>,
899 #[serde(skip_serializing_if = "Option::is_none")]
901 waited_ms: Option<u64>,
902 output_bytes: u64,
904}
905
906#[derive(Serialize)]
908pub(crate) struct ProcessStartLine<'a> {
909 #[serde(skip_serializing_if = "Option::is_none")]
911 pub(crate) task_id: Option<String>,
912 pub(crate) tool: &'a str,
914 pub(crate) process_id: &'a str,
916 pub(crate) kind: &'static str,
918 pub(crate) name: Option<&'a str>,
920 pub(crate) binary: &'a str,
922 pub(crate) argv: &'a [String],
924 pub(crate) pid: i32,
926}
927
928#[derive(Serialize)]
929struct ProcessStartEvent<'a> {
930 event_type: &'static str,
931 event_id: String,
932 parent_id: Option<String>,
933 session_id: String,
934 timestamp: u64,
935 #[serde(flatten)]
936 line: ProcessStartLine<'a>,
937}
938
939#[derive(Serialize)]
941pub(crate) struct ProcessExitLine<'a> {
942 #[serde(skip_serializing_if = "Option::is_none")]
943 pub(crate) task_id: Option<String>,
944 pub(crate) tool: &'a str,
945 pub(crate) process_id: &'a str,
946 pub(crate) kind: &'static str,
947 pub(crate) name: Option<&'a str>,
948 pub(crate) binary: &'a str,
949 pub(crate) exit_code: i32,
951 pub(crate) reason: &'static str,
953 pub(crate) duration_ms: u64,
954}
955
956#[derive(Serialize)]
957struct ProcessExitEvent<'a> {
958 event_type: &'static str,
959 event_id: String,
960 parent_id: Option<String>,
961 session_id: String,
962 timestamp: u64,
963 #[serde(flatten)]
964 line: ProcessExitLine<'a>,
965}
966
967#[derive(Serialize)]
969pub(crate) struct ProcessRefusedLine<'a> {
970 #[serde(skip_serializing_if = "Option::is_none")]
971 pub(crate) task_id: Option<String>,
972 pub(crate) tool: &'a str,
973 pub(crate) kind: &'static str,
975 pub(crate) name: Option<&'a str>,
977 #[serde(skip_serializing_if = "Option::is_none")]
979 pub(crate) binary: Option<&'a str>,
980 #[serde(skip_serializing_if = "Option::is_none")]
982 pub(crate) port: Option<u16>,
983 pub(crate) rule: &'static str,
984 pub(crate) reason: &'a str,
985}
986
987#[derive(Serialize)]
988struct ProcessRefusedEvent<'a> {
989 event_type: &'static str,
990 event_id: String,
991 parent_id: Option<String>,
992 session_id: String,
993 timestamp: u64,
994 #[serde(flatten)]
995 line: ProcessRefusedLine<'a>,
996}
997
998#[derive(Serialize)]
1007struct ShellAbandonedEvent {
1008 event_type: &'static str,
1009 event_id: String,
1010 parent_id: Option<String>,
1011 session_id: String,
1012 timestamp: u64,
1013 work_id: String,
1014 binary: String,
1015 command: String,
1016 running_ms: u64,
1019 #[serde(skip_serializing_if = "Option::is_none")]
1022 exit_code: Option<i32>,
1023 #[serde(skip_serializing_if = "Option::is_none")]
1027 output_path: Option<String>,
1028 #[serde(skip_serializing_if = "Option::is_none")]
1030 output_bytes: Option<u64>,
1031}
1032
1033#[derive(Serialize)]
1045struct ShellLostEvent {
1046 event_type: &'static str,
1047 event_id: String,
1048 #[serde(skip_serializing_if = "Option::is_none")]
1051 parent_id: Option<String>,
1052 session_id: String,
1054 timestamp: u64,
1055 work_id: String,
1056 binary: String,
1057 command: String,
1058 detached_at_ms: u64,
1060 reconciled_by_session: String,
1062 reconciled_task_id: String,
1065}
1066
1067#[derive(Serialize)]
1073struct ShellDetachUnrecordedEvent {
1074 event_type: &'static str,
1075 event_id: String,
1076 parent_id: Option<String>,
1077 session_id: String,
1078 timestamp: u64,
1079 turn: u32,
1080 task_id: Option<String>,
1081 work_id: String,
1082 binary: String,
1083 reason: String,
1085}
1086
1087#[derive(Serialize)]
1091struct SafetyCheckEvent {
1092 event_type: &'static str,
1093 event_id: String,
1094 parent_id: Option<String>,
1095 session_id: String,
1096 timestamp: u64,
1097 turn: u32,
1098 task_id: Option<String>,
1099 tool_call_id: String,
1100 tool: String,
1101 checks: serde_json::Value,
1103 outcome: &'static str,
1105}
1106
1107#[derive(Serialize)]
1108struct CompactionEvent {
1109 event_type: &'static str,
1110 event_id: String,
1111 parent_id: Option<String>,
1112 session_id: String,
1113 timestamp: u64,
1114 turn: u32,
1115 task_id: Option<String>,
1116 tokens_before: u64,
1119 tokens_before_source: OccupancyBasis,
1121 tokens_after: u64,
1124}
1125
1126#[derive(Serialize)]
1130struct CompactionDeclinedEvent {
1131 event_type: &'static str,
1132 event_id: String,
1133 parent_id: Option<String>,
1134 session_id: String,
1135 timestamp: u64,
1136 turn: u32,
1137 task_id: Option<String>,
1138 tokens: u64,
1141 tokens_source: OccupancyBasis,
1143 reason: String,
1146}
1147
1148#[derive(Serialize)]
1155struct ContextSeedEvent {
1156 event_type: &'static str,
1157 event_id: String,
1158 parent_id: Option<String>,
1159 session_id: String,
1160 timestamp: u64,
1161 task_id: Option<String>,
1162 hook_name: String,
1164 tokens: u64,
1167 proposed_tokens: u64,
1170 budget_tokens: u64,
1173 outcome: String,
1176 #[serde(skip_serializing_if = "Option::is_none")]
1179 reason: Option<String>,
1180 message_ids: Vec<String>,
1184}
1185
1186#[derive(Serialize)]
1194struct RetentionEvent {
1195 event_type: &'static str,
1196 event_id: String,
1197 parent_id: Option<String>,
1198 session_id: String,
1199 timestamp: u64,
1200 store: String,
1202 reason: String,
1205 removed: u32,
1209 targets: Vec<String>,
1211 #[serde(skip_serializing_if = "Option::is_none")]
1214 messages_dropped: Option<u64>,
1215}
1216
1217#[derive(Serialize)]
1218struct A2aTaskReceivedEvent {
1219 event_type: &'static str,
1220 event_id: String,
1221 parent_id: Option<String>,
1222 session_id: String,
1223 timestamp: u64,
1224 task_id: String,
1225 context_id: String,
1226 message_id: String,
1227 traceparent_from_caller: Option<String>,
1228 caller: &'static str,
1231 #[serde(skip_serializing_if = "Option::is_none")]
1233 caller_member: Option<String>,
1234 #[serde(skip_serializing_if = "Option::is_none")]
1237 caller_credential: Option<String>,
1238 #[serde(skip_serializing_if = "Option::is_none")]
1241 message_sha256: Option<String>,
1242}
1243
1244pub(crate) const RECEIVED_FROM_OPERATOR: &str = "operator";
1246pub(crate) const RECEIVED_FROM_MEMBER: &str = "member";
1248pub(crate) const RECEIVED_FROM_CREDENTIAL: &str = "credential";
1250pub(crate) const RECEIVED_FROM_PUBLIC: &str = "public";
1252
1253#[derive(Serialize)]
1254struct A2aSendEvent {
1255 event_type: &'static str,
1256 event_id: String,
1257 parent_id: Option<String>,
1258 session_id: String,
1259 timestamp: u64,
1260 peer_url: String,
1261 message_id: String,
1262 task_id: String,
1263 context_id: String,
1264 traceparent: Option<String>,
1265 trust: String,
1268}
1269
1270#[derive(Serialize)]
1273struct ArtifactPulledEvent {
1274 event_type: &'static str,
1275 event_id: String,
1276 parent_id: Option<String>,
1277 session_id: String,
1278 timestamp: u64,
1279 name: String,
1280 version: String,
1281 runtime: &'static str,
1283 origin: &'static str,
1286 session: Option<String>,
1289 trust: &'static str,
1290 fetched_from: String,
1293 #[serde(skip_serializing_if = "Option::is_none")]
1296 owner_id: Option<u64>,
1297}
1298
1299#[derive(Serialize)]
1300struct SessionEndEvent {
1301 event_type: &'static str,
1302 event_id: String,
1303 parent_id: Option<String>,
1304 session_id: String,
1305 timestamp: u64,
1306 total_turns: u32,
1307 total_input_tokens: u64,
1308 total_output_tokens: u64,
1309 total_tool_calls: u32,
1310 total_shell_calls: u32,
1311 duration_ms: u64,
1312 exit_status: String,
1313}
1314
1315#[derive(Serialize)]
1316struct TaskStartEvent {
1317 event_type: &'static str,
1318 event_id: String,
1319 parent_id: Option<String>,
1320 session_id: String,
1321 timestamp: u64,
1322 task_id: String,
1323 context_id: String,
1324 source: String,
1327 origin: String,
1328 trust: String,
1329 lane: String,
1332 message_parts_bytes: u64,
1333 #[serde(skip_serializing_if = "Vec::is_empty")]
1336 part_kinds: Vec<&'static str>,
1337 #[serde(skip_serializing_if = "Vec::is_empty")]
1339 reference_task_ids: Vec<String>,
1340 #[serde(skip_serializing_if = "Vec::is_empty")]
1343 attachments: Vec<AttachmentRecord>,
1344}
1345
1346#[derive(Serialize)]
1347struct TaskEndEvent {
1348 event_type: &'static str,
1349 event_id: String,
1350 parent_id: Option<String>,
1351 session_id: String,
1352 timestamp: u64,
1353 task_id: String,
1354 exit_status: String,
1355 duration_ms: u64,
1356 turns: u32,
1357 input_tokens: u64,
1358 output_tokens: u64,
1359 tool_calls: u32,
1360 shell_calls: u32,
1361 reopen_count: u32,
1364 capped_turns: u32,
1367 #[serde(skip_serializing_if = "Vec::is_empty")]
1370 running_commands: Vec<String>,
1371 #[serde(skip_serializing_if = "Option::is_none")]
1373 keep_working: Option<KeepWorkingSummary>,
1374 #[serde(skip_serializing_if = "Option::is_none")]
1376 done_check: Option<DoneCheckSummary>,
1377}
1378
1379#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1382pub(crate) struct DoneCheckSummary {
1383 pub(crate) runs: u32,
1385 pub(crate) reopens: u32,
1387 pub(crate) limit: u32,
1389 pub(crate) met: Option<bool>,
1392 pub(crate) stopped_by: Option<&'static str>,
1395}
1396
1397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1400pub(crate) struct KeepWorkingSummary {
1401 pub(crate) used: u32,
1403 pub(crate) limit: u32,
1405 pub(crate) stopped_by: Option<&'static str>,
1409}
1410
1411#[derive(Serialize)]
1418struct TaskCanceledEvent {
1419 event_type: &'static str,
1420 event_id: String,
1421 parent_id: Option<String>,
1422 session_id: String,
1423 timestamp: u64,
1424 task_id: String,
1425 #[serde(skip_serializing_if = "Option::is_none")]
1427 turn: Option<u32>,
1428 phase: String,
1431 detached_work_ids: Vec<String>,
1434 delegation_ids: Vec<String>,
1436}
1437
1438pub(crate) const TASK_REJECTED_SESSION_ENDED: &str = "session_ended";
1441
1442pub(crate) const TASK_REJECTED_SESSION_STOPPED: &str = "session_stopped";
1444
1445#[derive(Serialize)]
1452struct TaskRejectedEvent {
1453 event_type: &'static str,
1454 event_id: String,
1455 parent_id: Option<String>,
1456 session_id: String,
1457 timestamp: u64,
1458 task_id: String,
1459 context_id: String,
1460 source: String,
1463 cause: String,
1465 reason: String,
1467}
1468
1469pub(crate) const TASK_FAILED_DRIVER_ERROR: &str = "driver_error";
1472
1473pub(crate) const TASK_FAILED_CREDENTIAL_REJECTED: &str = "credential_rejected";
1476
1477pub(crate) const TASK_FAILED_MALFORMED_RESPONSE: &str = "malformed_response";
1480
1481pub(crate) const TASK_FAILED_COMPACTION_HOOK: &str = "compaction_hook";
1483
1484pub(crate) const TASK_FAILED_INPUT_TIMEOUT: &str = "input_timeout";
1486
1487pub(crate) const TASK_FAILED_REOPEN_BUDGET_EXHAUSTED: &str = "reopen_budget_exhausted";
1490
1491pub(crate) const TASK_FAILED_RUNTIME_ERROR: &str = "runtime_error";
1494
1495pub(crate) const TASK_FAILED_OUTPUT_CAP: &str = "output_cap";
1498
1499pub(crate) const TASK_FAILED_WORKDIR_PATH_REFUSED: &str = "workdir_path_refused";
1502
1503pub(crate) const INFERENCE_ERROR_CREDENTIAL_REJECTED: &str = TASK_FAILED_CREDENTIAL_REJECTED;
1507
1508pub(crate) const INFERENCE_ERROR_DRIVER_ERROR: &str = TASK_FAILED_DRIVER_ERROR;
1512
1513pub(crate) const INFERENCE_ERROR_DRIVER_FAILED: &str = "driver_failed";
1516
1517pub(crate) const INFERENCE_ERROR_MALFORMED_RESPONSE: &str = TASK_FAILED_MALFORMED_RESPONSE;
1520
1521pub(crate) const INFERENCE_ERROR_HARNESS_AUTH: &str = "harness_auth";
1523
1524pub(crate) const INFERENCE_ERROR_HARNESS_QUOTA: &str = "harness_quota";
1526
1527pub(crate) const INFERENCE_ERROR_HARNESS_ERROR: &str = "harness_error";
1530
1531pub(crate) const INFERENCE_ERROR_HARNESS_OTHER: &str = "harness_other";
1533
1534pub(crate) const INFERENCE_ERROR_HARNESS_INACTIVE: &str = "harness_inactive";
1536
1537pub(crate) struct FailedInference<'a> {
1539 pub(crate) turn: u32,
1540 pub(crate) error_code: &'static str,
1542 pub(crate) error: &'a str,
1544 pub(crate) provider_status: Option<u16>,
1546 pub(crate) usage: Option<&'a DriverUsage>,
1548 pub(crate) origin: Option<&'a InferenceOrigin>,
1550 pub(crate) choice: Option<&'a crate::driver_choice::DriverChoice>,
1552 pub(crate) message_ids: Vec<String>,
1554 pub(crate) tool_name: Option<String>,
1555 pub(crate) spent: Option<(Option<u64>, Option<u64>)>,
1558 pub(crate) settings: Option<&'a InferenceCallSettings>,
1560 pub(crate) retry: RetryNote,
1562 pub(crate) gateway: Option<GatewayOffsets>,
1564}
1565
1566struct InferenceLine<'a> {
1569 turn: u32,
1570 input_tokens: Option<u64>,
1571 output_tokens: Option<u64>,
1572 decision: String,
1573 stop_reason: Option<&'a str>,
1574 tool_name: Option<String>,
1575 origin: Option<&'a InferenceOrigin>,
1576 usage: Option<&'a DriverUsage>,
1577 message_ids: Vec<String>,
1578 wire: Option<&'a WireCapture>,
1579 choice: Option<&'a crate::driver_choice::DriverChoice>,
1580 failure: Option<(&'static str, String, Option<u16>)>,
1582 counted: bool,
1584 settings: Option<&'a InferenceCallSettings>,
1586 retry: RetryNote,
1587 gateway: Option<GatewayOffsets>,
1590}
1591
1592pub(crate) const MAX_TASK_FAILURE_REASON_BYTES: usize = 2_000;
1595
1596#[derive(Serialize)]
1600struct WorkdirPathRefusedEvent<'a> {
1601 event_type: &'static str,
1602 event_id: String,
1603 parent_id: Option<String>,
1604 session_id: String,
1605 timestamp: u64,
1606 #[serde(skip_serializing_if = "Option::is_none")]
1607 task_id: Option<String>,
1608 path: &'a str,
1610 operation: crate::workdir_fs::WorkdirOperation,
1611 refused_at: &'a str,
1614 reason: crate::workdir_fs::RefusalReason,
1615}
1616
1617#[derive(Serialize)]
1625struct TaskFailedEvent {
1626 event_type: &'static str,
1627 event_id: String,
1628 parent_id: Option<String>,
1629 session_id: String,
1630 timestamp: u64,
1631 #[serde(skip_serializing_if = "Option::is_none")]
1633 task_id: Option<String>,
1634 #[serde(skip_serializing_if = "Option::is_none")]
1637 turn: Option<u32>,
1638 cause: String,
1639 reason: String,
1641}
1642
1643pub(crate) const REOPEN_CONTEXT_CONTINUED: &str = "continued";
1646
1647pub(crate) const REOPEN_CONTEXT_RESTARTED: &str = "restarted";
1650
1651#[derive(Serialize)]
1656struct TaskReopenedEvent {
1657 event_type: &'static str,
1658 event_id: String,
1659 parent_id: Option<String>,
1660 session_id: String,
1661 timestamp: u64,
1662 task_id: String,
1663 hook_name: String,
1665 reason: String,
1667 reopen_number: u32,
1669 attempt_context: &'static str,
1671 turns_remaining: Option<u32>,
1675}
1676
1677#[derive(Serialize)]
1682struct TaskContinuedEvent<'a> {
1683 event_type: &'static str,
1684 event_id: String,
1685 parent_id: Option<String>,
1686 session_id: String,
1687 timestamp: u64,
1688 task_id: String,
1689 continuation_number: u32,
1691 member_calls: &'a [String],
1693 delegations: &'a [String],
1696 waited_ms: u64,
1698 turns_remaining: Option<u32>,
1702}
1703
1704#[derive(Serialize)]
1707struct KeepWorkingEvent {
1708 event_type: &'static str,
1709 event_id: String,
1710 parent_id: Option<String>,
1711 session_id: String,
1712 timestamp: u64,
1713 task_id: String,
1714 number: u32,
1716 limit: u32,
1718 turns_remaining: Option<u32>,
1721 message_bytes: u64,
1723}
1724
1725#[derive(Serialize)]
1729struct DoneCheckEvent<'a> {
1730 event_type: &'static str,
1731 event_id: String,
1732 parent_id: Option<String>,
1733 session_id: String,
1734 timestamp: u64,
1735 #[serde(flatten)]
1736 run: &'a DoneCheckRun<'a>,
1737}
1738
1739#[derive(Debug, Serialize)]
1741pub(crate) struct DoneCheckRun<'a> {
1742 pub(crate) task_id: &'a str,
1743 pub(crate) number: u32,
1745 pub(crate) command: &'a str,
1747 pub(crate) outcome: &'static str,
1749 pub(crate) exit_code: Option<i32>,
1751 pub(crate) duration_ms: u64,
1752 pub(crate) stdout_bytes: u64,
1754 pub(crate) stderr_bytes: u64,
1755 pub(crate) truncated: bool,
1756 pub(crate) resource_limit: Option<&'a str>,
1758 pub(crate) reopened: bool,
1760 pub(crate) reopens_left: u32,
1762 pub(crate) turns_remaining: Option<u32>,
1764}
1765
1766#[derive(Serialize)]
1770struct CallDeadlineEvent {
1771 event_type: &'static str,
1772 event_id: String,
1773 parent_id: Option<String>,
1774 session_id: String,
1775 timestamp: u64,
1776 #[serde(skip_serializing_if = "Option::is_none")]
1777 task_id: Option<String>,
1778 component: String,
1780 role: &'static str,
1782 call: String,
1784 deadline_seconds: u64,
1785 elapsed_ms: u64,
1787}
1788
1789#[derive(Serialize)]
1792struct InferenceRetryEvent<'a> {
1793 event_type: &'static str,
1794 event_id: String,
1795 parent_id: Option<String>,
1796 session_id: String,
1797 timestamp: u64,
1798 turn: u32,
1799 task_id: Option<String>,
1800 attempt: u32,
1801 max_retries: u32,
1802 reason: &'static str,
1803 #[serde(skip_serializing_if = "Option::is_none")]
1804 provider_status: Option<u16>,
1805 error: &'a str,
1806 delay_ms: u64,
1807 #[serde(skip_serializing_if = "Option::is_none")]
1808 retry_after_ms: Option<u64>,
1809 charged_tokens: u64,
1810 #[serde(skip_serializing_if = "Option::is_none")]
1811 origin: Option<&'a str>,
1812}
1813
1814#[derive(Serialize)]
1817struct SpendCeilingReachedEvent<'a> {
1818 event_type: &'static str,
1819 event_id: String,
1820 parent_id: Option<String>,
1821 session_id: String,
1822 timestamp: u64,
1823 turn: u32,
1824 task_id: Option<String>,
1825 #[serde(flatten)]
1826 refusal: &'a crate::spend::SpendRefusal,
1827 #[serde(skip_serializing_if = "Option::is_none")]
1829 origin: Option<&'a str>,
1830}
1831
1832#[derive(Serialize)]
1837pub(crate) struct HarnessStart {
1838 pub driver: String,
1840 pub driver_version: String,
1841 pub harness: String,
1843 pub binary: String,
1845 pub binary_source: String,
1847 pub harness_version: Option<String>,
1849 pub version_tested: bool,
1851 pub harness_session_id: String,
1853 pub session_mode: String,
1854 pub args_count: usize,
1857 pub env_names: Vec<String>,
1859 pub account_env_unset: Vec<String>,
1862 pub files: Vec<String>,
1864 pub bridge_tools: Vec<String>,
1866 pub stdin_bytes: usize,
1868 pub keep_stdin_open: bool,
1869 pub system_prompt_sha256: String,
1873 #[serde(skip)]
1876 pub system_prompt: String,
1877}
1878
1879#[derive(Serialize)]
1880struct HarnessStartEvent {
1881 event_type: &'static str,
1882 event_id: String,
1883 parent_id: Option<String>,
1884 session_id: String,
1885 timestamp: u64,
1886 task_id: Option<String>,
1887 #[serde(flatten)]
1888 start: HarnessStart,
1889}
1890
1891#[derive(Serialize)]
1894struct HarnessWarningEvent {
1895 event_type: &'static str,
1896 event_id: String,
1897 parent_id: Option<String>,
1898 session_id: String,
1899 timestamp: u64,
1900 task_id: Option<String>,
1901 code: String,
1902 message: String,
1903}
1904
1905#[derive(Serialize)]
1908struct HarnessSessionEvent {
1909 event_type: &'static str,
1910 event_id: String,
1911 parent_id: Option<String>,
1912 session_id: String,
1913 timestamp: u64,
1914 task_id: Option<String>,
1915 harness_session_id: String,
1916 auth: String,
1918 model: Option<String>,
1920}
1921
1922#[derive(Serialize)]
1926struct HarnessSessionForgottenEvent {
1927 event_type: &'static str,
1928 event_id: String,
1929 parent_id: Option<String>,
1930 session_id: String,
1931 timestamp: u64,
1932 task_id: Option<String>,
1933 context_id: String,
1935 harness_session_id: String,
1937 requested_by: String,
1939}
1940
1941#[derive(Serialize)]
1942struct HarnessRetryEvent {
1943 event_type: &'static str,
1944 event_id: String,
1945 parent_id: Option<String>,
1946 session_id: String,
1947 timestamp: u64,
1948 task_id: Option<String>,
1949 attempt: u32,
1950 reason: String,
1951}
1952
1953#[derive(Serialize)]
1956struct HarnessTextEvent {
1957 event_type: &'static str,
1958 event_id: String,
1959 parent_id: Option<String>,
1960 session_id: String,
1961 timestamp: u64,
1962 task_id: Option<String>,
1963 text: String,
1964}
1965
1966#[derive(Serialize)]
1969struct HarnessQuotaWindow<'a> {
1970 name: &'a str,
1971 #[serde(skip_serializing_if = "Option::is_none")]
1972 used: Option<f64>,
1973 #[serde(skip_serializing_if = "Option::is_none")]
1974 resets_at_ms: Option<u64>,
1975 #[serde(skip_serializing_if = "Option::is_none")]
1976 window_minutes: Option<u64>,
1977 #[serde(skip_serializing_if = "Option::is_none")]
1978 state: Option<&'static str>,
1979}
1980
1981#[derive(Serialize)]
1982struct HarnessQuotaEvent<'a> {
1983 event_type: &'static str,
1984 event_id: String,
1985 parent_id: Option<String>,
1986 session_id: String,
1987 timestamp: u64,
1988 task_id: Option<String>,
1989 windows: Vec<HarnessQuotaWindow<'a>>,
1990}
1991
1992#[derive(Serialize)]
1993struct HarnessFailedEvent {
1994 event_type: &'static str,
1995 event_id: String,
1996 parent_id: Option<String>,
1997 session_id: String,
1998 timestamp: u64,
1999 task_id: Option<String>,
2000 kind: String,
2002 message: String,
2003 source: String,
2005}
2006
2007#[derive(Serialize)]
2009struct HarnessInterruptEvent {
2010 event_type: &'static str,
2011 event_id: String,
2012 parent_id: Option<String>,
2013 session_id: String,
2014 timestamp: u64,
2015 task_id: Option<String>,
2016 method: String,
2018 delivered: bool,
2020 grace_ms: u64,
2023}
2024
2025#[derive(Serialize)]
2027pub(crate) struct HarnessExit {
2028 pub code: Option<i32>,
2030 pub signal: Option<i32>,
2032 pub cause: String,
2035 pub killed: bool,
2037 pub duration_ms: u64,
2039}
2040
2041#[derive(Serialize)]
2042struct HarnessExitEvent {
2043 event_type: &'static str,
2044 event_id: String,
2045 parent_id: Option<String>,
2046 session_id: String,
2047 timestamp: u64,
2048 task_id: Option<String>,
2049 #[serde(flatten)]
2050 exit: HarnessExit,
2051}
2052
2053#[derive(Serialize)]
2057struct CallDeniedEvent {
2058 event_type: &'static str,
2059 event_id: String,
2060 parent_id: Option<String>,
2061 session_id: String,
2062 timestamp: u64,
2063 turn: u32,
2064 event: String,
2067 hook_name: String,
2069 target: String,
2072 reason: String,
2074}
2075
2076#[derive(Serialize)]
2080struct ToolInputRefusedEvent {
2081 event_type: &'static str,
2082 event_id: String,
2083 parent_id: Option<String>,
2084 session_id: String,
2085 timestamp: u64,
2086 turn: u32,
2087 tool_name: String,
2088 tool_call_id: Option<String>,
2091 missing: Vec<String>,
2093 reason: String,
2095}
2096
2097#[derive(Serialize)]
2102struct ProtectedPathDeniedEvent {
2103 event_type: &'static str,
2104 event_id: String,
2105 parent_id: Option<String>,
2106 session_id: String,
2107 timestamp: u64,
2108 turn: u32,
2109 call: String,
2111 target: String,
2113 path: String,
2116 rule: String,
2118 signal: String,
2121 reason: String,
2123}
2124
2125#[derive(Serialize)]
2126struct HookDispatchErrorEvent {
2127 event_type: &'static str,
2128 event_id: String,
2129 parent_id: Option<String>,
2130 session_id: String,
2131 timestamp: u64,
2132 hook_name: String,
2133 event: String,
2134 arm: String,
2135}
2136
2137#[derive(Serialize)]
2141struct FormationEndedEvent {
2142 event_type: &'static str,
2143 event_id: String,
2144 parent_id: Option<String>,
2145 session_id: String,
2146 timestamp: u64,
2147 formation_id: String,
2148}
2149
2150#[derive(Serialize)]
2152struct SessionStatusEvent<'a> {
2153 event_type: &'static str,
2154 event_id: String,
2155 parent_id: Option<String>,
2156 session_id: String,
2157 timestamp: u64,
2158 #[serde(skip_serializing_if = "Option::is_none")]
2160 task_id: Option<String>,
2161 status: &'a crate::session_status::StatusReport,
2162}
2163
2164#[derive(Serialize)]
2166struct DeadlineNoticeEvent<'a> {
2167 event_type: &'static str,
2168 event_id: String,
2169 parent_id: Option<String>,
2170 session_id: String,
2171 timestamp: u64,
2172 #[serde(skip_serializing_if = "Option::is_none")]
2174 task_id: Option<String>,
2175 tool_name: &'a str,
2177 deadline_at: &'a str,
2179 left_secs: u64,
2181 notice_secs: u64,
2183}
2184
2185#[derive(Serialize)]
2188struct DeadlineReachedEvent {
2189 event_type: &'static str,
2190 event_id: String,
2191 parent_id: Option<String>,
2192 session_id: String,
2193 timestamp: u64,
2194 deadline_at: String,
2196 deadline_secs: u64,
2198 source: &'static str,
2200}
2201
2202#[derive(Serialize)]
2209struct SpawnerEndedEvent {
2210 event_type: &'static str,
2211 event_id: String,
2212 parent_id: Option<String>,
2213 session_id: String,
2214 timestamp: u64,
2215 #[serde(skip_serializing_if = "Option::is_none")]
2216 spawned_by: Option<String>,
2217 #[serde(skip_serializing_if = "Option::is_none")]
2218 delegation_id: Option<String>,
2219}
2220
2221#[derive(Serialize)]
2222struct ResourceListEvent {
2223 event_type: &'static str,
2224 event_id: String,
2225 parent_id: Option<String>,
2226 session_id: String,
2227 timestamp: u64,
2228 root: String,
2231 entry_count: usize,
2232 total_bytes: u64,
2233 generation: u64,
2235 containment_achieved: ContainmentClass,
2236 outcome: String,
2238 reason: Option<String>,
2240}
2241
2242#[derive(Serialize)]
2243struct ResourceReadEvent {
2244 event_type: &'static str,
2245 event_id: String,
2246 parent_id: Option<String>,
2247 session_id: String,
2248 timestamp: u64,
2249 path: String,
2252 outcome: String,
2253 bytes: Option<u64>,
2255 sha256: Option<String>,
2257 generation: u64,
2258 containment_achieved: ContainmentClass,
2259 reason: Option<String>,
2260}
2261
2262#[derive(Serialize)]
2264struct PeerHandleMintEvent {
2265 event_type: &'static str,
2266 event_id: String,
2267 parent_id: Option<String>,
2268 session_id: String,
2269 timestamp: u64,
2270 handle_id: Option<String>,
2273 path: String,
2276 audience: String,
2277 expires_at_ms: Option<u64>,
2279 outcome: String,
2280 reason: Option<String>,
2281}
2282
2283#[derive(Serialize)]
2286struct PeerHandleRedeemEvent {
2287 event_type: &'static str,
2288 event_id: String,
2289 parent_id: Option<String>,
2290 session_id: String,
2291 timestamp: u64,
2292 handle_id: String,
2293 path: Option<String>,
2296 generation: u64,
2299 audience_asserted: Option<String>,
2301 bytes: Option<u64>,
2303 sha256: Option<String>,
2305 outcome: String,
2306 reason: Option<String>,
2307}
2308
2309#[derive(Serialize)]
2311struct PeerFileFetchEvent {
2312 event_type: &'static str,
2313 event_id: String,
2314 parent_id: Option<String>,
2315 session_id: String,
2316 timestamp: u64,
2317 peer: String,
2318 handle_id: String,
2319 stored_path: Option<String>,
2322 bytes: Option<u64>,
2323 sha256: Option<String>,
2324 outcome: String,
2325 reason: Option<String>,
2326}
2327
2328#[derive(Serialize)]
2339struct DelegationStartEvent {
2340 event_type: &'static str,
2341 event_id: String,
2342 parent_id: Option<String>,
2343 session_id: String,
2344 timestamp: u64,
2345 delegation_id: String,
2348 capsule: String,
2349 version: String,
2350 child_session_id: String,
2352 child_workdir: String,
2354 child_records_dir: String,
2357 workdir_snapshot: Option<crate::workdir_snapshot::WorkdirSnapshot>,
2361}
2362
2363impl DelegationStartEvent {
2364 #[allow(clippy::too_many_arguments)]
2367 fn new(
2368 session_id: &str,
2369 session_event_id: &str,
2370 delegation_id: &str,
2371 capsule: &str,
2372 version: &str,
2373 child_session_id: &str,
2374 child_workdir: &str,
2375 child_records_dir: &str,
2376 workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
2377 ) -> Self {
2378 Self {
2379 event_type: "delegation_start",
2380 event_id: new_event_id(),
2381 parent_id: Some(session_event_id.to_string()),
2382 session_id: session_id.to_string(),
2383 timestamp: timestamp_ms(),
2384 delegation_id: delegation_id.to_string(),
2385 capsule: capsule.to_string(),
2386 version: version.to_string(),
2387 child_session_id: child_session_id.to_string(),
2388 child_workdir: child_workdir.to_string(),
2389 child_records_dir: child_records_dir.to_string(),
2390 workdir_snapshot: workdir_snapshot.cloned(),
2391 }
2392 }
2393}
2394
2395#[derive(Serialize)]
2401struct MemberCallStartEvent {
2402 event_type: &'static str,
2403 event_id: String,
2404 parent_id: Option<String>,
2405 session_id: String,
2406 timestamp: u64,
2407 task_id: String,
2409 call_id: String,
2411 member: String,
2413 member_task_id: String,
2415}
2416
2417#[derive(Serialize)]
2423struct MemberCallBusyEvent {
2424 event_type: &'static str,
2425 event_id: String,
2426 parent_id: Option<String>,
2427 session_id: String,
2428 timestamp: u64,
2429 task_id: String,
2431 call_id: String,
2433 member: String,
2435 offer: u32,
2437 waited_ms: u64,
2439 message: String,
2441}
2442
2443#[derive(Serialize)]
2449struct MemberCallEvent {
2450 event_type: &'static str,
2451 event_id: String,
2452 parent_id: Option<String>,
2453 session_id: String,
2454 timestamp: u64,
2455 task_id: String,
2456 call_id: String,
2457 member: String,
2458 #[serde(skip_serializing_if = "Option::is_none")]
2460 member_task_id: Option<String>,
2461 status: String,
2464 duration_ms: u64,
2465 output: String,
2467 truncated: bool,
2468 delivered: bool,
2471 #[serde(skip_serializing_if = "Vec::is_empty")]
2474 no_answer_below: Vec<NoAnswerBelow>,
2475}
2476
2477#[derive(Serialize)]
2479struct NoAnswerBelow {
2480 member: String,
2481 status: String,
2483}
2484
2485#[derive(Serialize)]
2496struct DelegationEvent {
2497 event_type: &'static str,
2498 event_id: String,
2499 parent_id: Option<String>,
2500 session_id: String,
2501 timestamp: u64,
2502 capsule: String,
2504 version: String,
2505 delegation_id: Option<String>,
2507 child_session_id: Option<String>,
2509 duration_ms: u64,
2512 outcome: String,
2522 reason: Option<String>,
2525}
2526
2527impl DelegationEvent {
2528 #[allow(clippy::too_many_arguments)]
2531 fn new(
2532 session_id: &str,
2533 session_event_id: &str,
2534 capsule: &str,
2535 version: &str,
2536 delegation_id: Option<String>,
2537 child_session_id: Option<String>,
2538 duration_ms: u64,
2539 outcome: &str,
2540 reason: Option<String>,
2541 ) -> Self {
2542 Self {
2543 event_type: "delegation",
2544 event_id: new_event_id(),
2545 parent_id: Some(session_event_id.to_string()),
2546 session_id: session_id.to_string(),
2547 timestamp: timestamp_ms(),
2548 capsule: capsule.to_string(),
2549 version: version.to_string(),
2550 delegation_id,
2551 child_session_id,
2552 duration_ms,
2553 outcome: outcome.to_string(),
2554 reason,
2555 }
2556 }
2557}
2558
2559impl TraceWriter {
2562 #[allow(clippy::too_many_arguments)]
2563 pub(crate) async fn open(
2564 records_root: &WorkdirRoot,
2565 session_id: String,
2566 capsule_name: String,
2567 capsule_version: String,
2568 model: String,
2569 capabilities: Vec<String>,
2570 effective_grants: ScopeReport,
2571 capture: TraceCapture,
2572 system_prompt: Option<String>,
2573 system_prompt_overridden: bool,
2574 resumed_from: Option<String>,
2575 context_id: Option<String>,
2576 spawned_by: Option<String>,
2577 spawn_delegation_id: Option<String>,
2578 ) -> std::io::Result<Self> {
2579 let file = File::from_std(records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?);
2580 let system_prompt_source = match (system_prompt_overridden, system_prompt.is_some()) {
2583 (true, _) => "cli",
2584 (false, true) => "manifest",
2585 (false, false) => "none",
2586 };
2587 let system_prompt_sha256 = system_prompt
2588 .as_ref()
2589 .map(|prompt| murmur_artifact::sha256_hex(prompt.as_bytes()));
2590 let blobs = BlobStore::new(records_root);
2591 if let (true, Some(prompt), Some(sha)) = (
2595 capture.captures_content(),
2596 system_prompt.as_ref(),
2597 system_prompt_sha256.as_deref(),
2598 ) {
2599 blobs.put(sha, prompt.as_bytes()).await?;
2600 }
2601 Ok(Self {
2602 writer: BufWriter::new(file),
2603 session_id,
2604 capsule_name,
2605 capsule_version,
2606 model,
2607 capabilities,
2608 effective_grants,
2609 capture,
2610 blobs,
2611 system_prompt_source,
2612 system_prompt_sha256,
2613 credential_source: "none",
2614 gateways: Vec::new(),
2615 control: None,
2616 inference_choices: Vec::new(),
2617 tool_refresh: None,
2618 runtime_artifacts: Vec::new(),
2619 host_facts: Vec::new(),
2620 instruction_files: None,
2621 resumed_from,
2622 context_id,
2623 spawned_by,
2624 spawn_delegation_id,
2625 formation_id: None,
2626 formation_member: None,
2627 session_start_time: Instant::now(),
2628 session_event_id: new_event_id(),
2629 session_started: false,
2630 session_ended: false,
2631 task_event_id: None,
2632 turn_event_id: None,
2633 total_turns: 0,
2634 total_input_tokens: 0,
2635 total_output_tokens: 0,
2636 total_tool_calls: 0,
2637 total_shell_calls: 0,
2638 task_turns: 0,
2639 task_input_tokens: 0,
2640 task_output_tokens: 0,
2641 task_tool_calls: 0,
2642 task_shell_calls: 0,
2643 task_agent_turns: 0,
2644 task_hook_inferences: 0,
2645 task_capped_turns: 0,
2646 task_start_instant: None,
2647 active_task_id: None,
2648 task_failures_written: 0,
2649 last_task_failure: None,
2650 max_session_tokens: None,
2651 machine_tokens_per_day: None,
2652 session_status: None,
2653 call_deadlines: None,
2654 })
2655 }
2656
2657 pub(crate) fn set_call_deadlines(&mut self, deadlines: crate::limits::CallDeadlines) {
2660 self.call_deadlines = Some(deadlines);
2661 }
2662
2663 pub(crate) fn set_session_status(
2666 &mut self,
2667 status: std::sync::Arc<crate::session_status::SessionStatus>,
2668 ) {
2669 self.session_status = Some(status);
2670 }
2671
2672 pub(crate) fn set_spend_ceilings(
2674 &mut self,
2675 max_session_tokens: Option<u64>,
2676 machine_tokens_per_day: Option<u64>,
2677 ) {
2678 self.max_session_tokens = max_session_tokens;
2679 self.machine_tokens_per_day = machine_tokens_per_day;
2680 }
2681
2682 pub(crate) fn session_event_id(&self) -> &str {
2686 &self.session_event_id
2687 }
2688
2689 pub(crate) fn captures_wire(&self) -> bool {
2693 self.capture.captures_hashes()
2694 }
2695
2696 fn session_parent(&self) -> Option<String> {
2700 self.session_started.then(|| self.session_event_id.clone())
2701 }
2702
2703 fn task_parent(&self) -> Option<String> {
2706 self.task_event_id.clone().or_else(|| self.session_parent())
2707 }
2708
2709 fn turn_parent(&self) -> Option<String> {
2713 self.turn_event_id.clone().or_else(|| self.task_parent())
2714 }
2715
2716 pub(crate) fn set_credential_source(&mut self, source: &'static str) {
2719 self.credential_source = source;
2720 }
2721
2722 pub(crate) fn set_gateways(&mut self, gateways: Vec<SessionGateway>) {
2724 self.gateways = gateways;
2725 }
2726
2727 pub(crate) fn set_control(&mut self, control: Option<SessionControl>) {
2729 self.control = control;
2730 }
2731
2732 pub(crate) fn set_inference_choices(&mut self, choices: Vec<SessionInferenceChoice>) {
2735 self.inference_choices = choices;
2736 }
2737
2738 pub(crate) fn set_runtime_artifacts(&mut self, installed: &[InstalledArtifactSummary]) {
2741 self.runtime_artifacts = installed
2742 .iter()
2743 .filter_map(|artifact| match &artifact.origin {
2744 LockOrigin::Runtime { session } => Some(RuntimeArtifact {
2745 name: artifact.name.clone(),
2746 version: artifact.version.clone(),
2747 origin: artifact.origin.as_str(),
2748 session: session.clone(),
2749 trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
2750 }),
2751 LockOrigin::Operator => None,
2752 })
2753 .collect();
2754 }
2755
2756 pub(crate) fn set_host_facts(&mut self, uses: &[crate::host_facts::HostFactsUse]) {
2758 self.host_facts = uses.iter().map(HostFactsRecord::of).collect();
2759 }
2760
2761 pub(crate) async fn write_control_applied(
2768 &mut self,
2769 turn: u32,
2770 name: &str,
2771 value: Value,
2772 change_id: &str,
2773 ) -> std::io::Result<()> {
2774 let event = ControlAppliedEvent {
2775 event_type: "control_applied",
2776 event_id: new_event_id(),
2777 parent_id: self.task_parent(),
2778 session_id: self.session_id.clone(),
2779 timestamp: timestamp_ms(),
2780 turn,
2781 task_id: self.active_task_id.clone(),
2782 name,
2783 value,
2784 change_id,
2785 };
2786 self.write_event(&event).await
2787 }
2788
2789 pub(crate) fn set_formation_id(&mut self, formation_id: Option<&FormationId>) {
2791 self.formation_id = formation_id.cloned();
2792 }
2793
2794 pub(crate) fn set_formation_member(
2797 &mut self,
2798 member: Option<&crate::formation_credentials::FormationMember>,
2799 ) {
2800 self.formation_member = member.map(|member| {
2801 (
2802 member.name().to_string(),
2803 member.callees().map(str::to_string).collect(),
2804 )
2805 });
2806 }
2807
2808 pub(crate) fn set_instruction_files(
2811 &mut self,
2812 files: Option<&crate::instruction_files::InstructionFiles>,
2813 ) {
2814 self.instruction_files =
2815 files.map(crate::instruction_files::InstructionFiles::trace_records);
2816 }
2817
2818 pub(crate) fn set_tool_refresh(&mut self, tool_refresh: Option<&'static str>) {
2821 self.tool_refresh = tool_refresh;
2822 }
2823
2824 pub(crate) async fn write_tools_refreshed(
2832 &mut self,
2833 turn: u32,
2834 trigger: &str,
2835 added: &[String],
2836 removed: &[String],
2837 tools: &[String],
2838 ) -> std::io::Result<()> {
2839 let event = ToolsRefreshedEvent {
2840 event_type: "tools_refreshed",
2841 event_id: new_event_id(),
2842 parent_id: self.task_parent(),
2843 session_id: self.session_id.clone(),
2844 timestamp: timestamp_ms(),
2845 turn,
2846 task_id: self.active_task_id.clone(),
2847 trigger,
2848 added,
2849 removed,
2850 tools,
2851 };
2852 self.write_event(&event).await
2853 }
2854
2855 pub(crate) async fn write_session_start(
2856 &mut self,
2857 max_turns: Option<u32>,
2858 model_limits: Option<crate::model_limits::ResolvedModelLimits>,
2859 tools_declared: Vec<String>,
2860 ) -> std::io::Result<()> {
2861 let event = SessionStartEvent {
2862 event_type: "session_start",
2863 event_id: self.session_event_id.clone(),
2864 parent_id: None,
2865 session_id: self.session_id.clone(),
2866 timestamp: timestamp_ms(),
2867 capsule_name: self.capsule_name.clone(),
2868 capsule_version: self.capsule_version.clone(),
2869 model: self.model.clone(),
2870 max_turns,
2871 model_limits,
2872 max_session_tokens: self.max_session_tokens,
2873 machine_tokens_per_day: self.machine_tokens_per_day,
2874 deadline: self.session_status.as_ref().and_then(|status| {
2875 status.deadline().map(|deadline| SessionStartDeadline {
2876 at: deadline.at_rfc3339(),
2877 secs: deadline.secs(),
2878 notice_secs: status.notice_secs(),
2879 source: deadline.source().as_str(),
2880 })
2881 }),
2882 capabilities: self.capabilities.clone(),
2883 tools_declared,
2884 containment_declared: self.effective_grants.declared_containment,
2885 containment_achieved: self.effective_grants.achieved_containment,
2886 workdir_exec: self.effective_grants.workdir_exec,
2887 userns_grant: self.effective_grants.userns_grant,
2888 effective_grants: self.effective_grants.clone(),
2889 system_prompt_source: self.system_prompt_source,
2890 system_prompt_sha256: self.system_prompt_sha256.clone(),
2891 credential_source: self.credential_source,
2892 gateways: self.gateways.clone(),
2893 control: self.control.clone(),
2894 inference_choices: self.inference_choices.clone(),
2895 tool_refresh: self.tool_refresh,
2896 runtime_artifacts: self.runtime_artifacts.clone(),
2897 host_facts: self.host_facts.clone(),
2898 instruction_files: self.instruction_files.clone(),
2899 resumed_from: self.resumed_from.clone(),
2900 context_id: self.context_id.clone(),
2901 spawned_by: self.spawned_by.clone(),
2902 delegation_id: self.spawn_delegation_id.clone(),
2903 formation_id: self.formation_id.clone(),
2904 formation_member: self.formation_member.as_ref().map(|(name, _)| name.clone()),
2905 formation_callees: self
2906 .formation_member
2907 .as_ref()
2908 .map(|(_, callees)| callees.clone()),
2909 };
2910 self.write_event(&event).await?;
2911 self.session_started = true;
2912 self.write_call_deadlines().await
2914 }
2915
2916 #[allow(clippy::too_many_arguments)]
2929 pub(crate) async fn write_inference(
2930 &mut self,
2931 turn: u32,
2932 input_tokens: Option<u64>,
2933 output_tokens: Option<u64>,
2934 decision: String,
2935 stop_reason: Option<&str>,
2936 tool_name: Option<String>,
2937 origin: Option<&InferenceOrigin>,
2938 usage: Option<&DriverUsage>,
2939 message_ids: Vec<String>,
2940 wire: Option<&WireCapture>,
2941 settings: Option<&InferenceCallSettings>,
2942 ) -> std::io::Result<()> {
2943 self.write_inference_event(InferenceLine {
2944 turn,
2945 input_tokens,
2946 output_tokens,
2947 decision,
2948 stop_reason,
2949 tool_name,
2950 origin,
2951 usage,
2952 message_ids,
2953 wire,
2954 choice: None,
2955 failure: None,
2956 counted: true,
2957 settings,
2958 retry: RetryNote::default(),
2959 gateway: None,
2960 })
2961 .await
2962 }
2963
2964 #[allow(clippy::too_many_arguments)]
2967 pub(crate) async fn write_hook_inference(
2968 &mut self,
2969 turn: u32,
2970 input_tokens: Option<u64>,
2971 output_tokens: Option<u64>,
2972 decision: String,
2973 origin: &InferenceOrigin,
2974 usage: Option<&DriverUsage>,
2975 settings: Option<&InferenceCallSettings>,
2976 retry: RetryNote,
2977 gateway: Option<GatewayOffsets>,
2978 ) -> std::io::Result<()> {
2979 self.write_inference_event(InferenceLine {
2980 turn,
2981 input_tokens,
2982 output_tokens,
2983 decision,
2984 stop_reason: None,
2985 tool_name: None,
2986 origin: Some(origin),
2987 usage,
2988 message_ids: Vec::new(),
2989 wire: None,
2990 choice: None,
2991 failure: None,
2992 counted: true,
2993 settings,
2994 retry,
2995 gateway,
2996 })
2997 .await
2998 }
2999
3000 #[allow(clippy::too_many_arguments)]
3004 pub(crate) async fn write_turn_inference(
3005 &mut self,
3006 turn: u32,
3007 input_tokens: Option<u64>,
3008 output_tokens: Option<u64>,
3009 decision: String,
3010 stop_reason: Option<&str>,
3011 tool_name: Option<String>,
3012 usage: Option<&DriverUsage>,
3013 message_ids: Vec<String>,
3014 wire: Option<&WireCapture>,
3015 choice: Option<&crate::driver_choice::DriverChoice>,
3016 settings: Option<&InferenceCallSettings>,
3017 retry: RetryNote,
3018 gateway: Option<GatewayOffsets>,
3019 ) -> std::io::Result<()> {
3020 if stop_reason == Some("max_tokens") {
3021 self.task_capped_turns = self.task_capped_turns.saturating_add(1);
3022 }
3023 self.write_inference_event(InferenceLine {
3024 turn,
3025 input_tokens,
3026 output_tokens,
3027 decision,
3028 stop_reason,
3029 tool_name,
3030 origin: None,
3031 usage,
3032 message_ids,
3033 wire,
3034 choice,
3035 failure: None,
3036 counted: true,
3037 settings,
3038 retry,
3039 gateway,
3040 })
3041 .await
3042 }
3043
3044 pub(crate) async fn write_failed_inference(
3052 &mut self,
3053 failed: FailedInference<'_>,
3054 ) -> std::io::Result<()> {
3055 let error =
3056 crate::agent::truncate_on_char_boundary(failed.error, MAX_TASK_FAILURE_REASON_BYTES)
3057 .to_string();
3058 let (input_tokens, output_tokens) = failed.spent.unwrap_or((None, None));
3059 self.write_inference_event(InferenceLine {
3060 turn: failed.turn,
3061 input_tokens,
3062 output_tokens,
3063 decision: "error".to_string(),
3064 stop_reason: Some("error"),
3065 tool_name: failed.tool_name,
3066 origin: failed.origin,
3067 usage: failed.usage,
3068 message_ids: failed.message_ids,
3069 wire: None,
3070 choice: failed.choice,
3071 failure: Some((failed.error_code, error, failed.provider_status)),
3072 counted: failed.spent.is_some(),
3073 settings: failed.settings,
3074 retry: failed.retry,
3075 gateway: failed.gateway,
3076 })
3077 .await
3078 }
3079
3080 async fn write_inference_event(&mut self, line: InferenceLine<'_>) -> std::io::Result<()> {
3081 let InferenceLine {
3082 turn,
3083 input_tokens,
3084 output_tokens,
3085 decision,
3086 stop_reason,
3087 tool_name,
3088 origin,
3089 usage,
3090 message_ids,
3091 wire,
3092 choice,
3093 failure,
3094 counted,
3095 settings,
3096 retry,
3097 gateway,
3098 } = line;
3099 let (error_code, error, provider_status) = match failure {
3100 Some((code, error, status)) => (Some(code.to_string()), Some(error), status),
3101 None => (None, None, None),
3102 };
3103 self.write_call_deadlines().await?;
3105 let event_id = new_event_id();
3106 let parent_id = if origin.is_none() {
3110 let parent = self.task_parent();
3111 self.turn_event_id = Some(event_id.clone());
3112 parent
3113 } else {
3114 self.turn_parent()
3115 };
3116 let (system_sha, tools_sha, response_sha, message_shas) =
3117 match wire.filter(|_| self.capture.captures_hashes()) {
3118 Some(wire) => self.record_wire(wire).await?,
3119 None => (None, None, None, Vec::new()),
3120 };
3121 let event = InferenceEvent {
3122 event_type: "inference",
3123 event_id,
3124 parent_id,
3125 session_id: self.session_id.clone(),
3126 timestamp: timestamp_ms(),
3127 turn,
3128 task_id: self.active_task_id.clone(),
3129 input_tokens,
3130 output_tokens,
3131 decision,
3132 stop_reason: stop_reason.map(str::to_string),
3133 tool_name,
3134 input_tokens_actual: usage.and_then(|u| u.input_tokens),
3135 output_tokens_actual: usage.and_then(|u| u.output_tokens),
3136 cached_tokens: usage.and_then(|u| u.cached_tokens),
3137 cache_write_tokens: usage.and_then(|u| u.cache_write_tokens),
3138 thinking_tokens: usage.and_then(|u| u.thinking_tokens),
3139 origin: origin.map(|o| o.source.clone()),
3140 driver_choice: choice.map(|choice| choice.name.clone()),
3141 model: origin
3142 .map(|o| o.model.clone())
3143 .or_else(|| choice.map(|choice| choice.model.clone())),
3144 max_output_tokens: settings.map(|settings| settings.max_output_tokens),
3145 input_budget: settings.map(|settings| settings.input_budget),
3146 driver_config: settings.map(|settings| settings.driver_config.clone()),
3147 message_ids,
3148 system_sha,
3149 tools_sha,
3150 response_sha,
3151 message_shas,
3152 error_code,
3153 error,
3154 provider_status,
3155 gateway_reached_ms: gateway.map(|gateway| gateway.reached_ms),
3156 upstream_responded_ms: gateway.map(|gateway| gateway.responded_ms),
3157 attempts: retry.attempts,
3158 retry_stopped: retry.stopped.map(RetryStop::wire_name),
3159 };
3160 self.write_event(&event).await?;
3161 if origin.is_none() {
3162 self.task_agent_turns = self.task_agent_turns.saturating_add(1);
3163 }
3164 if counted {
3165 let (input, output) = (input_tokens.unwrap_or(0), output_tokens.unwrap_or(0));
3169 self.total_turns = self.total_turns.saturating_add(1);
3170 self.total_input_tokens = self.total_input_tokens.saturating_add(input);
3171 self.total_output_tokens = self.total_output_tokens.saturating_add(output);
3172 self.task_turns = self.task_turns.saturating_add(1);
3173 if origin.is_some() {
3174 self.task_hook_inferences = self.task_hook_inferences.saturating_add(1);
3175 }
3176 self.task_input_tokens = self.task_input_tokens.saturating_add(input);
3177 self.task_output_tokens = self.task_output_tokens.saturating_add(output);
3178 }
3179 if let Some(status) = &self.session_status {
3182 status.record_inference(self.task_turns, input_tokens, output_tokens, usage);
3183 }
3184 Ok(())
3185 }
3186
3187 async fn record_wire(
3194 &mut self,
3195 wire: &WireCapture,
3196 ) -> std::io::Result<(Option<String>, Option<String>, Option<String>, Vec<String>)> {
3197 let system_sha = murmur_artifact::sha256_hex(&wire.system);
3198 let tools_sha = murmur_artifact::sha256_hex(&wire.tools);
3199 let response_sha = murmur_artifact::sha256_hex(&wire.response);
3200 let message_shas: Vec<String> = wire
3201 .messages
3202 .iter()
3203 .map(|message| murmur_artifact::sha256_hex(message))
3204 .collect();
3205
3206 if self.capture.captures_content() {
3207 self.blobs.put(&system_sha, &wire.system).await?;
3211 self.blobs.put(&tools_sha, &wire.tools).await?;
3212 self.blobs.put(&response_sha, &wire.response).await?;
3213 for (sha, body) in message_shas.iter().zip(&wire.messages) {
3214 self.blobs.put(sha, body).await?;
3215 }
3216 }
3217
3218 Ok((
3219 Some(system_sha),
3220 Some(tools_sha),
3221 Some(response_sha),
3222 message_shas,
3223 ))
3224 }
3225
3226 #[allow(clippy::too_many_arguments)]
3227 pub(crate) async fn write_tool_call(
3228 &mut self,
3229 turn: u32,
3230 tool_name: String,
3231 tool_call_id: Option<String>,
3232 input: Value,
3233 input_bytes: u64,
3234 output: &str,
3235 output_bytes: u64,
3236 duration_ms: u64,
3237 status: String,
3238 state_effect: Option<String>,
3239 resource_id: Option<String>,
3240 error: Option<&str>,
3241 ) -> std::io::Result<()> {
3242 self.write_tool_call_with_attachments(
3243 turn,
3244 tool_name,
3245 tool_call_id,
3246 input,
3247 input_bytes,
3248 output,
3249 output_bytes,
3250 duration_ms,
3251 status,
3252 state_effect,
3253 resource_id,
3254 error,
3255 Vec::new(),
3256 )
3257 .await
3258 }
3259
3260 #[allow(clippy::too_many_arguments)]
3263 pub(crate) async fn write_tool_call_with_attachments(
3264 &mut self,
3265 turn: u32,
3266 tool_name: String,
3267 tool_call_id: Option<String>,
3268 mut input: Value,
3269 input_bytes: u64,
3270 output: &str,
3271 output_bytes: u64,
3272 duration_ms: u64,
3273 status: String,
3274 state_effect: Option<String>,
3275 resource_id: Option<String>,
3276 error: Option<&str>,
3277 attachments: Vec<AttachmentRecord>,
3278 ) -> std::io::Result<()> {
3279 crate::peer_handoff::redact_handles_in_json(&mut input);
3284 let event = ToolCallEvent {
3285 event_type: "tool_call",
3286 event_id: new_event_id(),
3287 parent_id: self.turn_parent(),
3288 session_id: self.session_id.clone(),
3289 timestamp: timestamp_ms(),
3290 turn,
3291 task_id: self.active_task_id.clone(),
3292 tool_name,
3293 tool_call_id: tool_call_id.filter(|id| !id.is_empty()),
3296 input,
3297 input_bytes,
3298 output: self
3299 .capture
3300 .captures_content()
3301 .then(|| crate::peer_handoff::redact_handle_tokens(output).into_owned()),
3302 output_bytes,
3303 duration_ms,
3304 status,
3305 error: error.map(|error| {
3306 let redacted = crate::peer_handoff::redact_handle_tokens(error);
3307 crate::agent::truncate_on_char_boundary(&redacted, MAX_TASK_FAILURE_REASON_BYTES)
3308 .to_string()
3309 }),
3310 state_effect,
3311 resource_id,
3312 attachments,
3313 };
3314 self.write_event(&event).await?;
3315 self.total_tool_calls = self.total_tool_calls.saturating_add(1);
3316 self.task_tool_calls = self.task_tool_calls.saturating_add(1);
3317 Ok(())
3318 }
3319
3320 pub(crate) async fn write_skill_call(
3326 &mut self,
3327 turn: u32,
3328 skill_name: String,
3329 origin: &LockOrigin,
3330 output_bytes: u64,
3331 duration_ms: u64,
3332 status: String,
3333 ) -> std::io::Result<()> {
3334 let event = SkillCallEvent {
3335 event_type: "skill_call",
3336 event_id: new_event_id(),
3337 parent_id: self.turn_parent(),
3338 session_id: self.session_id.clone(),
3339 timestamp: timestamp_ms(),
3340 turn,
3341 task_id: self.active_task_id.clone(),
3342 skill_name,
3343 output_bytes,
3344 duration_ms,
3345 status,
3346 origin: origin.as_str(),
3347 trust: crate::origin::artifact_trust(origin).as_str(),
3348 };
3349 self.write_event(&event).await
3350 }
3352
3353 #[allow(clippy::too_many_arguments)]
3356 pub(crate) async fn write_shell(
3357 &mut self,
3358 turn: u32,
3359 binary: String,
3360 command: String,
3361 exit_code: i32,
3362 stdout_bytes: u64,
3363 stderr_bytes: u64,
3364 duration_ms: u64,
3365 resource_limit: Option<String>,
3366 ) -> std::io::Result<()> {
3367 let event = ShellEvent {
3368 event_type: "shell",
3369 event_id: new_event_id(),
3370 parent_id: self.turn_parent(),
3371 session_id: self.session_id.clone(),
3372 timestamp: timestamp_ms(),
3373 turn,
3374 task_id: self.active_task_id.clone(),
3375 binary,
3376 command,
3377 exit_code,
3378 stdout_bytes,
3379 stderr_bytes,
3380 duration_ms,
3381 resource_limit,
3382 };
3383 self.write_event(&event).await?;
3384 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
3385 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
3386 Ok(())
3387 }
3388
3389 pub(crate) async fn write_shell_detached(
3396 &mut self,
3397 turn: u32,
3398 work_id: &str,
3399 binary: &str,
3400 command: &str,
3401 grace_ms: Option<u64>,
3402 ) -> std::io::Result<()> {
3403 let event = ShellDetachedEvent {
3404 event_type: "shell_detached",
3405 event_id: new_event_id(),
3406 parent_id: self.turn_parent(),
3407 session_id: self.session_id.clone(),
3408 timestamp: timestamp_ms(),
3409 turn,
3410 task_id: self.active_task_id.clone(),
3411 work_id: work_id.to_string(),
3412 binary: binary.to_string(),
3413 command: command.to_string(),
3414 grace_ms,
3415 };
3416 self.write_event(&event).await?;
3417 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
3418 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
3419 Ok(())
3420 }
3421
3422 pub(crate) async fn write_shell_completed(
3425 &mut self,
3426 completion: &crate::detached::DetachedCompletion,
3427 completion_task_id: &str,
3428 ) -> std::io::Result<()> {
3429 let event = ShellCompletedEvent::new(
3430 self.session_id.clone(),
3431 self.session_parent(),
3432 completion,
3433 crate::detached::Delivery::Task,
3434 Some(completion_task_id),
3435 );
3436 self.write_event(&event).await
3437 }
3438
3439 pub(crate) async fn write_shell_abandoned(
3446 &mut self,
3447 work_id: &str,
3448 binary: &str,
3449 command: &str,
3450 running_ms: u64,
3451 recovered: Option<(i32, &str, u64)>,
3452 ) -> std::io::Result<()> {
3453 let event = ShellAbandonedEvent {
3454 event_type: "shell_abandoned",
3455 event_id: new_event_id(),
3456 parent_id: self.session_parent(),
3457 session_id: self.session_id.clone(),
3458 timestamp: timestamp_ms(),
3459 work_id: work_id.to_string(),
3460 binary: binary.to_string(),
3461 command: command.to_string(),
3462 running_ms,
3463 exit_code: recovered.map(|(exit_code, _, _)| exit_code),
3464 output_path: recovered.map(|(_, path, _)| path.to_string()),
3465 output_bytes: recovered.map(|(_, _, bytes)| bytes),
3466 };
3467 self.write_event(&event).await
3468 }
3469
3470 pub(crate) async fn write_shell_detach_unrecorded(
3472 &mut self,
3473 turn: u32,
3474 work_id: &str,
3475 binary: &str,
3476 reason: &str,
3477 ) -> std::io::Result<()> {
3478 let event = ShellDetachUnrecordedEvent {
3479 event_type: "shell_detach_unrecorded",
3480 event_id: new_event_id(),
3481 parent_id: self.turn_parent(),
3482 session_id: self.session_id.clone(),
3483 timestamp: timestamp_ms(),
3484 turn,
3485 task_id: self.active_task_id.clone(),
3486 work_id: work_id.to_string(),
3487 binary: binary.to_string(),
3488 reason: reason.to_string(),
3489 };
3490 self.write_event(&event).await
3491 }
3492
3493 pub(crate) async fn write_safety_check(
3495 &mut self,
3496 turn: u32,
3497 tool_call_id: &str,
3498 tool: &str,
3499 checks: &serde_json::Value,
3500 outcome: &'static str,
3501 ) -> std::io::Result<()> {
3502 let event = SafetyCheckEvent {
3503 event_type: "safety_check",
3504 event_id: new_event_id(),
3505 parent_id: self.turn_parent(),
3506 session_id: self.session_id.clone(),
3507 timestamp: timestamp_ms(),
3508 turn,
3509 task_id: self.active_task_id.clone(),
3510 tool_call_id: tool_call_id.to_string(),
3511 tool: tool.to_string(),
3512 checks: checks.clone(),
3513 outcome,
3514 };
3515 self.write_event(&event).await
3516 }
3517
3518 #[allow(clippy::too_many_arguments)]
3523 pub(crate) async fn write_a2a_task_received(
3524 &mut self,
3525 task_id: &str,
3526 context_id: &str,
3527 message_id: &str,
3528 traceparent_from_caller: Option<&str>,
3529 caller: &crate::identity::TaskCaller,
3530 caller_member: Option<&str>,
3531 message_text: &str,
3532 ) -> std::io::Result<()> {
3533 use crate::identity::TaskCaller;
3534 let (received_from, caller_credential) = match (caller_member, caller) {
3535 (Some(_), _) => (RECEIVED_FROM_MEMBER, None),
3536 (None, TaskCaller::Operator) => (RECEIVED_FROM_OPERATOR, None),
3537 (None, TaskCaller::Public) => (RECEIVED_FROM_PUBLIC, None),
3538 (None, TaskCaller::Credential(name)) => {
3539 (RECEIVED_FROM_CREDENTIAL, Some(name.to_string()))
3540 }
3541 };
3542 let message_sha256 = if self.capture.captures_hashes() {
3543 let sha = murmur_artifact::sha256_hex(message_text.as_bytes());
3544 if self.capture.captures_content() {
3545 self.blobs.put(&sha, message_text.as_bytes()).await?;
3546 }
3547 Some(sha)
3548 } else {
3549 None
3550 };
3551 let event = A2aTaskReceivedEvent {
3552 event_type: "a2a_task_received",
3553 event_id: new_event_id(),
3554 parent_id: self.session_parent(),
3555 session_id: self.session_id.clone(),
3556 timestamp: timestamp_ms(),
3557 task_id: task_id.to_string(),
3558 context_id: context_id.to_string(),
3559 message_id: message_id.to_string(),
3560 traceparent_from_caller: traceparent_from_caller.map(str::to_string),
3561 caller: received_from,
3562 caller_member: caller_member.map(str::to_string),
3563 caller_credential,
3564 message_sha256,
3565 };
3566 self.write_event(&event).await
3567 }
3568
3569 pub(crate) async fn write_a2a_send(
3570 &mut self,
3571 peer_url: &str,
3572 message_id: &str,
3573 task_id: &str,
3574 context_id: &str,
3575 traceparent: Option<&str>,
3576 trust: TrustClass,
3577 ) -> std::io::Result<()> {
3578 let event = A2aSendEvent {
3579 event_type: "a2a_send",
3580 event_id: new_event_id(),
3581 parent_id: self.session_parent(),
3582 session_id: self.session_id.clone(),
3583 timestamp: timestamp_ms(),
3584 peer_url: peer_url.to_string(),
3585 message_id: message_id.to_string(),
3586 task_id: task_id.to_string(),
3587 context_id: context_id.to_string(),
3588 traceparent: traceparent.map(str::to_string),
3589 trust: trust.as_str().to_string(),
3590 };
3591 self.write_event(&event).await
3592 }
3593
3594 pub(crate) async fn write_artifact_pulled(
3597 &mut self,
3598 pulled: &crate::runtime::PulledArtifact,
3599 ) -> std::io::Result<()> {
3600 let artifact = &pulled.summary;
3601 let session = match &artifact.origin {
3602 LockOrigin::Runtime { session } => Some(session.clone()),
3603 LockOrigin::Operator => None,
3604 };
3605 let event = ArtifactPulledEvent {
3606 event_type: "artifact_pulled",
3607 event_id: new_event_id(),
3608 parent_id: self.session_parent(),
3609 session_id: self.session_id.clone(),
3610 timestamp: timestamp_ms(),
3611 name: artifact.name.clone(),
3612 version: artifact.version.clone(),
3613 runtime: artifact.runtime.as_str(),
3614 origin: artifact.origin.as_str(),
3615 session,
3616 trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
3617 fetched_from: pulled.fetched_from.clone(),
3618 owner_id: pulled.owner_id,
3619 };
3620 self.write_event(&event).await
3621 }
3622
3623 #[cfg(test)]
3626 #[allow(clippy::too_many_arguments)]
3627 pub(crate) async fn write_task_start(
3628 &mut self,
3629 task_id: &str,
3630 context_id: &str,
3631 source: &str,
3632 provenance: TaskProvenance,
3633 message_parts_bytes: u64,
3634 part_kinds: &[&'static str],
3635 reference_task_ids: &[String],
3636 ) -> std::io::Result<()> {
3637 self.write_task_start_with_attachments(
3638 task_id,
3639 context_id,
3640 source,
3641 provenance,
3642 message_parts_bytes,
3643 part_kinds,
3644 reference_task_ids,
3645 Vec::new(),
3646 )
3647 .await
3648 }
3649
3650 #[allow(clippy::too_many_arguments)]
3653 pub(crate) async fn write_task_start_with_attachments(
3654 &mut self,
3655 task_id: &str,
3656 context_id: &str,
3657 source: &str,
3658 provenance: TaskProvenance,
3659 message_parts_bytes: u64,
3660 part_kinds: &[&'static str],
3661 reference_task_ids: &[String],
3662 attachments: Vec<AttachmentRecord>,
3663 ) -> std::io::Result<()> {
3664 self.write_call_deadlines().await?;
3666 self.task_turns = 0;
3668 self.task_input_tokens = 0;
3669 self.task_output_tokens = 0;
3670 self.task_tool_calls = 0;
3671 self.task_shell_calls = 0;
3672 self.task_agent_turns = 0;
3673 self.task_hook_inferences = 0;
3674 self.task_capped_turns = 0;
3675 let started = Instant::now();
3676 self.task_start_instant = Some(started);
3677 self.active_task_id = Some(task_id.to_string());
3678 if let Some(status) = &self.session_status {
3679 status.task_started(task_id, started);
3680 }
3681
3682 let event_id = new_event_id();
3683 self.task_event_id = Some(event_id.clone());
3684 self.turn_event_id = None;
3685
3686 let event = TaskStartEvent {
3687 event_type: "task_start",
3688 event_id: event_id.clone(),
3689 parent_id: self.session_parent(),
3690 session_id: self.session_id.clone(),
3691 timestamp: timestamp_ms(),
3692 task_id: task_id.to_string(),
3693 context_id: context_id.to_string(),
3694 source: source.to_string(),
3695 origin: provenance.origin().as_str().to_string(),
3696 trust: provenance.trust().as_str().to_string(),
3697 lane: TaskLane::for_origin(provenance.origin())
3700 .as_str()
3701 .to_string(),
3702 message_parts_bytes,
3703 part_kinds: part_kinds.to_vec(),
3704 reference_task_ids: reference_task_ids.to_vec(),
3705 attachments,
3706 };
3707 self.write_event(&event).await
3708 }
3709
3710 pub(crate) async fn write_task_end(
3711 &mut self,
3712 task_id: &str,
3713 exit_status: &str,
3714 reopen_count: u32,
3715 running_commands: Vec<String>,
3716 keep_working: Option<KeepWorkingSummary>,
3717 done_check: Option<DoneCheckSummary>,
3718 ) -> std::io::Result<()> {
3719 self.write_call_deadlines().await?;
3721 let duration_ms = self
3722 .task_start_instant
3723 .take()
3724 .map(|t| t.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
3725 .unwrap_or(0);
3726
3727 let event = TaskEndEvent {
3728 event_type: "task_end",
3729 event_id: new_event_id(),
3730 parent_id: self.task_parent(),
3731 session_id: self.session_id.clone(),
3732 timestamp: timestamp_ms(),
3733 task_id: task_id.to_string(),
3734 exit_status: exit_status.to_string(),
3735 duration_ms,
3736 turns: self.task_turns,
3737 input_tokens: self.task_input_tokens,
3738 output_tokens: self.task_output_tokens,
3739 tool_calls: self.task_tool_calls,
3740 shell_calls: self.task_shell_calls,
3741 reopen_count,
3742 capped_turns: self.task_capped_turns,
3743 running_commands,
3744 keep_working,
3745 done_check,
3746 };
3747 self.active_task_id = None;
3748 self.task_event_id = None;
3749 self.turn_event_id = None;
3750 self.task_agent_turns = 0;
3751 if let Some(status) = &self.session_status {
3752 status.task_ended();
3753 }
3754 self.write_event(&event).await
3755 }
3756
3757 pub(crate) async fn write_task_canceled(
3763 &mut self,
3764 task_id: &str,
3765 turn: Option<u32>,
3766 phase: &str,
3767 detached_work_ids: Vec<String>,
3768 delegation_ids: Vec<String>,
3769 ) -> std::io::Result<()> {
3770 let event = TaskCanceledEvent {
3771 event_type: "task_canceled",
3772 event_id: new_event_id(),
3773 parent_id: self.task_parent(),
3774 session_id: self.session_id.clone(),
3775 timestamp: timestamp_ms(),
3776 task_id: task_id.to_string(),
3777 turn,
3778 phase: phase.to_string(),
3779 detached_work_ids,
3780 delegation_ids,
3781 };
3782 self.write_event(&event).await
3783 }
3784
3785 pub(crate) async fn write_task_rejected(
3791 &mut self,
3792 task_id: &str,
3793 context_id: &str,
3794 source: &str,
3795 cause: &str,
3796 reason: &str,
3797 ) -> std::io::Result<()> {
3798 let event = TaskRejectedEvent {
3799 event_type: "task_rejected",
3800 event_id: new_event_id(),
3801 parent_id: self.task_parent(),
3802 session_id: self.session_id.clone(),
3803 timestamp: timestamp_ms(),
3804 task_id: task_id.to_string(),
3805 context_id: context_id.to_string(),
3806 source: source.to_string(),
3807 cause: cause.to_string(),
3808 reason: reason.to_string(),
3809 };
3810 self.write_event(&event).await
3811 }
3812
3813 pub(crate) async fn write_task_failed(
3819 &mut self,
3820 turn: Option<u32>,
3821 cause: &str,
3822 reason: &str,
3823 ) -> std::io::Result<()> {
3824 let reason = crate::agent::truncate_on_char_boundary(reason, MAX_TASK_FAILURE_REASON_BYTES)
3825 .to_string();
3826 let event = TaskFailedEvent {
3827 event_type: "task_failed",
3828 event_id: new_event_id(),
3829 parent_id: self.task_parent(),
3830 session_id: self.session_id.clone(),
3831 timestamp: timestamp_ms(),
3832 task_id: self.active_task_id.clone(),
3833 turn,
3834 cause: cause.to_string(),
3835 reason: reason.clone(),
3836 };
3837 self.task_failures_written += 1;
3838 self.last_task_failure = Some(reason);
3839 self.write_event(&event).await
3840 }
3841
3842 pub(crate) async fn write_workdir_path_refused(
3844 &mut self,
3845 refusal: &crate::workdir_fs::WorkdirRefusal,
3846 ) -> std::io::Result<()> {
3847 let event = WorkdirPathRefusedEvent {
3848 event_type: "workdir_path_refused",
3849 event_id: new_event_id(),
3850 parent_id: self.task_parent(),
3851 session_id: self.session_id.clone(),
3852 timestamp: timestamp_ms(),
3853 task_id: self.active_task_id.clone(),
3854 path: &refusal.path,
3855 operation: refusal.operation,
3856 refused_at: &refusal.refused_at,
3857 reason: refusal.reason,
3858 };
3859 self.write_event(&event).await
3860 }
3861
3862 pub(crate) fn last_task_failure(&self) -> Option<&str> {
3864 self.last_task_failure.as_deref()
3865 }
3866
3867 pub(crate) fn task_failures_written(&self) -> u64 {
3870 self.task_failures_written
3871 }
3872
3873 pub(crate) async fn write_task_reopened(
3877 &mut self,
3878 task_id: &str,
3879 hook_name: &str,
3880 reason: &str,
3881 reopen_number: u32,
3882 attempt_context: &'static str,
3883 turns_remaining: Option<u32>,
3884 ) -> std::io::Result<()> {
3885 let event = TaskReopenedEvent {
3886 event_type: "task_reopened",
3887 event_id: new_event_id(),
3888 parent_id: self.task_parent(),
3889 session_id: self.session_id.clone(),
3890 timestamp: timestamp_ms(),
3891 task_id: task_id.to_string(),
3892 hook_name: hook_name.to_string(),
3893 reason: reason.to_string(),
3894 reopen_number,
3895 attempt_context,
3896 turns_remaining,
3897 };
3898 self.write_event(&event).await
3899 }
3900
3901 pub(crate) async fn write_task_continued(
3905 &mut self,
3906 task_id: &str,
3907 continuation_number: u32,
3908 member_calls: &[String],
3909 delegations: &[String],
3910 waited_ms: u64,
3911 turns_remaining: Option<u32>,
3912 ) -> std::io::Result<()> {
3913 let event = TaskContinuedEvent {
3914 event_type: "task_continued",
3915 event_id: new_event_id(),
3916 parent_id: self.task_parent(),
3917 session_id: self.session_id.clone(),
3918 timestamp: timestamp_ms(),
3919 task_id: task_id.to_string(),
3920 continuation_number,
3921 member_calls,
3922 delegations,
3923 waited_ms,
3924 turns_remaining,
3925 };
3926 self.write_event(&event).await
3927 }
3928
3929 pub(crate) async fn write_keep_working(
3932 &mut self,
3933 task_id: &str,
3934 number: u32,
3935 limit: u32,
3936 turns_remaining: Option<u32>,
3937 message_bytes: u64,
3938 ) -> std::io::Result<()> {
3939 let event = KeepWorkingEvent {
3940 event_type: "keep_working",
3941 event_id: new_event_id(),
3942 parent_id: self.task_parent(),
3943 session_id: self.session_id.clone(),
3944 timestamp: timestamp_ms(),
3945 task_id: task_id.to_string(),
3946 number,
3947 limit,
3948 turns_remaining,
3949 message_bytes,
3950 };
3951 if let Some(status) = &self.session_status {
3952 status.keep_working_sent(number);
3953 }
3954 self.write_event(&event).await
3955 }
3956
3957 pub(crate) async fn write_done_check(
3960 &mut self,
3961 run: &DoneCheckRun<'_>,
3962 reopens: u32,
3963 ) -> std::io::Result<()> {
3964 let event = DoneCheckEvent {
3965 event_type: "done_check",
3966 event_id: new_event_id(),
3967 parent_id: self.task_parent(),
3968 session_id: self.session_id.clone(),
3969 timestamp: timestamp_ms(),
3970 run,
3971 };
3972 if run.reopened {
3973 if let Some(status) = &self.session_status {
3974 status.done_check_reopened(reopens);
3975 }
3976 }
3977 self.write_event(&event).await
3978 }
3979
3980 pub(crate) fn task_turns(&self) -> u32 {
3990 self.task_turns
3991 }
3992
3993 pub(crate) fn task_hook_inferences(&self) -> u32 {
3995 self.task_hook_inferences
3996 }
3997
3998 pub(crate) fn task_agent_turns(&self) -> u32 {
4002 self.task_agent_turns
4003 }
4004
4005 pub(crate) async fn write_compaction(
4006 &mut self,
4007 turn: u32,
4008 tokens_before: u64,
4009 tokens_before_source: OccupancyBasis,
4010 tokens_after: u64,
4011 ) -> std::io::Result<()> {
4012 let event = CompactionEvent {
4013 event_type: "compaction",
4014 event_id: new_event_id(),
4015 parent_id: self.turn_parent(),
4016 session_id: self.session_id.clone(),
4017 timestamp: timestamp_ms(),
4018 turn,
4019 task_id: self.active_task_id.clone(),
4020 tokens_before,
4021 tokens_before_source,
4022 tokens_after,
4023 };
4024 self.write_event(&event).await
4025 }
4026
4027 pub(crate) async fn write_compaction_declined(
4033 &mut self,
4034 turn: u32,
4035 tokens: u64,
4036 tokens_source: OccupancyBasis,
4037 reason: &str,
4038 ) -> std::io::Result<()> {
4039 let event = CompactionDeclinedEvent {
4040 event_type: "compaction_declined",
4041 event_id: new_event_id(),
4042 parent_id: self.turn_parent(),
4043 session_id: self.session_id.clone(),
4044 timestamp: timestamp_ms(),
4045 turn,
4046 task_id: self.active_task_id.clone(),
4047 tokens,
4048 tokens_source,
4049 reason: reason.to_string(),
4050 };
4051 self.write_event(&event).await
4052 }
4053
4054 #[allow(clippy::too_many_arguments)]
4061 pub(crate) async fn write_context_seed(
4062 &mut self,
4063 hook_name: &str,
4064 tokens: u64,
4065 proposed_tokens: u64,
4066 budget_tokens: u64,
4067 outcome: &str,
4068 reason: Option<&str>,
4069 message_ids: Vec<String>,
4070 ) -> std::io::Result<()> {
4071 let event = ContextSeedEvent {
4072 event_type: "context_seed",
4073 event_id: new_event_id(),
4074 parent_id: self.task_parent(),
4075 session_id: self.session_id.clone(),
4076 timestamp: timestamp_ms(),
4077 task_id: self.active_task_id.clone(),
4078 hook_name: hook_name.to_string(),
4079 tokens,
4080 proposed_tokens,
4081 budget_tokens,
4082 outcome: outcome.to_string(),
4083 reason: reason.map(str::to_string),
4084 message_ids,
4085 };
4086 self.write_event(&event).await
4087 }
4088
4089 pub(crate) async fn write_retention(
4095 &mut self,
4096 store: &str,
4097 reason: &str,
4098 targets: Vec<String>,
4099 messages_dropped: Option<u64>,
4100 ) -> std::io::Result<()> {
4101 let event = RetentionEvent {
4102 event_type: "retention",
4103 event_id: new_event_id(),
4104 parent_id: self.session_parent(),
4105 session_id: self.session_id.clone(),
4106 timestamp: timestamp_ms(),
4107 store: store.to_string(),
4108 reason: reason.to_string(),
4109 removed: u32::try_from(targets.len()).unwrap_or(u32::MAX),
4110 targets,
4111 messages_dropped,
4112 };
4113 self.write_event(&event).await
4114 }
4115
4116 pub(crate) async fn write_call_denied(
4120 &mut self,
4121 turn: u32,
4122 event: &str,
4123 hook_name: &str,
4124 target: &str,
4125 reason: &str,
4126 ) -> std::io::Result<()> {
4127 let event = CallDeniedEvent {
4128 event_type: "call_denied",
4129 event_id: new_event_id(),
4130 parent_id: self.turn_parent(),
4131 session_id: self.session_id.clone(),
4132 timestamp: timestamp_ms(),
4133 turn,
4134 event: event.to_string(),
4135 hook_name: hook_name.to_string(),
4136 target: target.to_string(),
4137 reason: reason.to_string(),
4138 };
4139 self.write_event(&event).await
4140 }
4141
4142 pub(crate) async fn write_tool_input_refused(
4147 &mut self,
4148 turn: u32,
4149 tool_name: &str,
4150 tool_call_id: Option<&str>,
4151 missing: &[String],
4152 reason: &str,
4153 ) -> std::io::Result<()> {
4154 let event = ToolInputRefusedEvent {
4155 event_type: "tool_input_refused",
4156 event_id: new_event_id(),
4157 parent_id: self.turn_parent(),
4158 session_id: self.session_id.clone(),
4159 timestamp: timestamp_ms(),
4160 turn,
4161 tool_name: tool_name.to_string(),
4162 tool_call_id: tool_call_id.filter(|id| !id.is_empty()).map(str::to_string),
4163 missing: missing.to_vec(),
4164 reason: reason.to_string(),
4165 };
4166 self.write_event(&event).await
4167 }
4168
4169 pub(crate) async fn write_inference_retry(
4174 &mut self,
4175 turn: u32,
4176 retry: &InferenceRetryRecord,
4177 ) -> std::io::Result<()> {
4178 let event = InferenceRetryEvent {
4179 event_type: "inference_retry",
4180 event_id: new_event_id(),
4181 parent_id: self.turn_parent(),
4182 session_id: self.session_id.clone(),
4183 timestamp: timestamp_ms(),
4184 turn,
4185 task_id: self.active_task_id.clone(),
4186 attempt: retry.attempt,
4187 max_retries: retry.max_retries,
4188 reason: retry.reason.wire_name(),
4189 provider_status: retry.provider_status,
4190 error: crate::agent::truncate_on_char_boundary(
4191 &retry.error,
4192 MAX_TASK_FAILURE_REASON_BYTES,
4193 ),
4194 delay_ms: retry.delay_ms,
4195 retry_after_ms: retry.retry_after_ms,
4196 charged_tokens: retry.charged_tokens,
4197 origin: retry.origin.as_deref(),
4198 };
4199 self.write_event(&event).await
4200 }
4201
4202 pub(crate) async fn write_spend_ceiling_reached(
4205 &mut self,
4206 turn: u32,
4207 refusal: &crate::spend::SpendRefusal,
4208 origin: Option<&str>,
4209 ) -> std::io::Result<()> {
4210 let event = SpendCeilingReachedEvent {
4211 event_type: "spend_ceiling_reached",
4212 event_id: new_event_id(),
4213 parent_id: self.turn_parent(),
4214 session_id: self.session_id.clone(),
4215 timestamp: timestamp_ms(),
4216 turn,
4217 task_id: self.active_task_id.clone(),
4218 refusal,
4219 origin,
4220 };
4221 self.write_event(&event).await
4222 }
4223
4224 #[allow(clippy::too_many_arguments)]
4228 pub(crate) async fn write_protected_path_denied(
4229 &mut self,
4230 turn: u32,
4231 call: &str,
4232 target: &str,
4233 path: &str,
4234 rule: &str,
4235 signal: &str,
4236 reason: &str,
4237 ) -> std::io::Result<()> {
4238 let event = ProtectedPathDeniedEvent {
4239 event_type: "protected_path_denied",
4240 event_id: new_event_id(),
4241 parent_id: self.turn_parent(),
4242 session_id: self.session_id.clone(),
4243 timestamp: timestamp_ms(),
4244 turn,
4245 call: call.to_string(),
4246 target: target.to_string(),
4247 path: path.to_string(),
4248 rule: rule.to_string(),
4249 signal: signal.to_string(),
4250 reason: reason.to_string(),
4251 };
4252 self.write_event(&event).await
4253 }
4254
4255 pub(crate) async fn write_hook_dispatch_error(
4261 &mut self,
4262 hook_name: &str,
4263 event: &str,
4264 arm: &str,
4265 ) -> std::io::Result<()> {
4266 let event = HookDispatchErrorEvent {
4267 event_type: "hook_dispatch_error",
4268 event_id: new_event_id(),
4269 parent_id: self.session_parent(),
4270 session_id: self.session_id.clone(),
4271 timestamp: timestamp_ms(),
4272 hook_name: hook_name.to_string(),
4273 event: event.to_string(),
4274 arm: arm.to_string(),
4275 };
4276 self.write_event(&event).await
4277 }
4278
4279 pub(crate) async fn write_harness_start(&mut self, start: HarnessStart) -> std::io::Result<()> {
4289 if self.capture.captures_content() {
4290 self.blobs
4291 .put(&start.system_prompt_sha256, start.system_prompt.as_bytes())
4292 .await?;
4293 }
4294 let event = HarnessStartEvent {
4295 event_type: "harness_start",
4296 event_id: new_event_id(),
4297 parent_id: self.task_parent(),
4298 session_id: self.session_id.clone(),
4299 timestamp: timestamp_ms(),
4300 task_id: self.active_task_id.clone(),
4301 start,
4302 };
4303 self.write_event(&event).await
4304 }
4305
4306 pub(crate) async fn write_harness_warning(
4308 &mut self,
4309 code: &str,
4310 message: &str,
4311 ) -> std::io::Result<()> {
4312 let event = HarnessWarningEvent {
4313 event_type: "harness_warning",
4314 event_id: new_event_id(),
4315 parent_id: self.task_parent(),
4316 session_id: self.session_id.clone(),
4317 timestamp: timestamp_ms(),
4318 task_id: self.active_task_id.clone(),
4319 code: code.to_string(),
4320 message: message.to_string(),
4321 };
4322 self.write_event(&event).await
4323 }
4324
4325 pub(crate) async fn write_harness_session(
4327 &mut self,
4328 harness_session_id: &str,
4329 auth: &str,
4330 model: Option<&str>,
4331 ) -> std::io::Result<()> {
4332 let event = HarnessSessionEvent {
4333 event_type: "harness_session",
4334 event_id: new_event_id(),
4335 parent_id: self.task_parent(),
4336 session_id: self.session_id.clone(),
4337 timestamp: timestamp_ms(),
4338 task_id: self.active_task_id.clone(),
4339 harness_session_id: harness_session_id.to_string(),
4340 auth: auth.to_string(),
4341 model: model.map(str::to_string),
4342 };
4343 self.write_event(&event).await
4344 }
4345
4346 pub(crate) async fn write_harness_session_forgotten(
4348 &mut self,
4349 context_id: &str,
4350 harness_session_id: &str,
4351 requested_by: &str,
4352 ) -> std::io::Result<()> {
4353 let event = HarnessSessionForgottenEvent {
4354 event_type: "harness_session_forgotten",
4355 event_id: new_event_id(),
4356 parent_id: self.task_parent(),
4357 session_id: self.session_id.clone(),
4358 timestamp: timestamp_ms(),
4359 task_id: self.active_task_id.clone(),
4360 context_id: context_id.to_string(),
4361 harness_session_id: harness_session_id.to_string(),
4362 requested_by: requested_by.to_string(),
4363 };
4364 self.write_event(&event).await
4365 }
4366
4367 pub(crate) async fn write_harness_retry(
4369 &mut self,
4370 attempt: u32,
4371 reason: &str,
4372 ) -> std::io::Result<()> {
4373 let event = HarnessRetryEvent {
4374 event_type: "harness_retry",
4375 event_id: new_event_id(),
4376 parent_id: self.task_parent(),
4377 session_id: self.session_id.clone(),
4378 timestamp: timestamp_ms(),
4379 task_id: self.active_task_id.clone(),
4380 attempt,
4381 reason: reason.to_string(),
4382 };
4383 self.write_event(&event).await
4384 }
4385
4386 pub(crate) async fn write_harness_note(&mut self, text: &str) -> std::io::Result<()> {
4389 let event = HarnessTextEvent {
4390 event_type: "harness_note",
4391 event_id: new_event_id(),
4392 parent_id: self.task_parent(),
4393 session_id: self.session_id.clone(),
4394 timestamp: timestamp_ms(),
4395 task_id: self.active_task_id.clone(),
4396 text: text.to_string(),
4397 };
4398 self.write_event(&event).await
4399 }
4400
4401 pub(crate) async fn write_harness_quota(
4404 &mut self,
4405 windows: &[crate::process_driver::QuotaWindow],
4406 ) -> std::io::Result<()> {
4407 if let Some(status) = &self.session_status {
4408 status.record_quota(windows);
4409 }
4410 let event = HarnessQuotaEvent {
4411 event_type: "harness_quota",
4412 event_id: new_event_id(),
4413 parent_id: self.task_parent(),
4414 session_id: self.session_id.clone(),
4415 timestamp: timestamp_ms(),
4416 task_id: self.active_task_id.clone(),
4417 windows: windows
4418 .iter()
4419 .map(|window| HarnessQuotaWindow {
4420 name: &window.name,
4421 used: window.used,
4422 resets_at_ms: window.resets_at,
4423 window_minutes: window.window_minutes,
4424 state: window
4425 .state
4426 .map(crate::process_driver::QuotaState::wire_name),
4427 })
4428 .collect(),
4429 };
4430 self.write_event(&event).await
4431 }
4432
4433 pub(crate) async fn write_harness_suggestion(&mut self, text: &str) -> std::io::Result<()> {
4435 let event = HarnessTextEvent {
4436 event_type: "harness_suggestion",
4437 event_id: new_event_id(),
4438 parent_id: self.task_parent(),
4439 session_id: self.session_id.clone(),
4440 timestamp: timestamp_ms(),
4441 task_id: self.active_task_id.clone(),
4442 text: text.to_string(),
4443 };
4444 self.write_event(&event).await
4445 }
4446
4447 pub(crate) async fn write_harness_failed(
4450 &mut self,
4451 kind: &str,
4452 message: &str,
4453 source: &str,
4454 ) -> std::io::Result<()> {
4455 let event = HarnessFailedEvent {
4456 event_type: "harness_failed",
4457 event_id: new_event_id(),
4458 parent_id: self.task_parent(),
4459 session_id: self.session_id.clone(),
4460 timestamp: timestamp_ms(),
4461 task_id: self.active_task_id.clone(),
4462 kind: kind.to_string(),
4463 message: message.to_string(),
4464 source: source.to_string(),
4465 };
4466 self.write_event(&event).await
4467 }
4468
4469 pub(crate) async fn write_harness_interrupt(
4472 &mut self,
4473 method: &str,
4474 delivered: bool,
4475 grace_ms: u64,
4476 ) -> std::io::Result<()> {
4477 let event = HarnessInterruptEvent {
4478 event_type: "harness_interrupt",
4479 event_id: new_event_id(),
4480 parent_id: self.task_parent(),
4481 session_id: self.session_id.clone(),
4482 timestamp: timestamp_ms(),
4483 task_id: self.active_task_id.clone(),
4484 method: method.to_string(),
4485 delivered,
4486 grace_ms,
4487 };
4488 self.write_event(&event).await
4489 }
4490
4491 pub(crate) async fn write_harness_exit(&mut self, exit: HarnessExit) -> std::io::Result<()> {
4493 let event = HarnessExitEvent {
4494 event_type: "harness_exit",
4495 event_id: new_event_id(),
4496 parent_id: self.task_parent(),
4497 session_id: self.session_id.clone(),
4498 timestamp: timestamp_ms(),
4499 task_id: self.active_task_id.clone(),
4500 exit,
4501 };
4502 self.write_event(&event).await
4503 }
4504
4505 pub(crate) async fn write_session_end(&mut self, exit_status: &str) -> std::io::Result<()> {
4506 let duration_ms = self
4507 .session_start_time
4508 .elapsed()
4509 .as_millis()
4510 .try_into()
4511 .unwrap_or(u64::MAX);
4512 let event = SessionEndEvent {
4513 event_type: "session_end",
4514 event_id: new_event_id(),
4515 parent_id: self.session_parent(),
4516 session_id: self.session_id.clone(),
4517 timestamp: timestamp_ms(),
4518 total_turns: self.total_turns,
4519 total_input_tokens: self.total_input_tokens,
4520 total_output_tokens: self.total_output_tokens,
4521 total_tool_calls: self.total_tool_calls,
4522 total_shell_calls: self.total_shell_calls,
4523 duration_ms,
4524 exit_status: exit_status.to_string(),
4525 };
4526 self.write_event(&event).await?;
4527 self.session_ended = true;
4528 Ok(())
4529 }
4530
4531 pub(crate) async fn write_session_end_if_not_ended(
4534 &mut self,
4535 exit_status: &str,
4536 ) -> std::io::Result<()> {
4537 if self.session_started && !self.session_ended {
4538 self.write_session_end(exit_status).await?;
4539 }
4540 Ok(())
4541 }
4542
4543 pub(crate) async fn flush(&mut self) -> std::io::Result<()> {
4544 self.writer.flush().await
4545 }
4546
4547 async fn write_event(&mut self, event: &impl Serialize) -> std::io::Result<()> {
4551 self.write_call_deadlines().await?;
4552 self.write_line(event).await
4553 }
4554
4555 async fn write_call_deadlines(&mut self) -> std::io::Result<()> {
4558 let Some(deadlines) = self
4559 .call_deadlines
4560 .as_ref()
4561 .filter(|_| self.session_started)
4562 else {
4563 return Ok(());
4564 };
4565 for cut in deadlines.take() {
4566 let event = CallDeadlineEvent {
4567 event_type: "call_deadline",
4568 event_id: new_event_id(),
4569 parent_id: self.turn_parent(),
4570 session_id: self.session_id.clone(),
4571 timestamp: timestamp_ms(),
4572 task_id: self.active_task_id.clone(),
4573 component: cut.component,
4574 role: cut.role.wire_name(),
4575 call: cut.call,
4576 deadline_seconds: cut.deadline_seconds,
4577 elapsed_ms: cut.elapsed_ms,
4578 };
4579 self.write_line(&event).await?;
4580 }
4581 Ok(())
4582 }
4583
4584 async fn write_line(&mut self, event: &impl Serialize) -> std::io::Result<()> {
4592 let mut line = serde_json::to_string(event).map_err(std::io::Error::other)?;
4593 line.push('\n');
4594 self.writer.write_all(line.as_bytes()).await?;
4595 self.writer.flush().await
4596 }
4597}
4598
4599#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4601pub(crate) struct SessionGateway {
4602 pub(crate) artifact: String,
4604 pub(crate) host: String,
4606 pub(crate) credential_source: &'static str,
4608 pub(crate) metered: bool,
4611}
4612
4613#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4616pub(crate) struct RuntimeArtifact {
4617 name: String,
4618 version: String,
4619 origin: &'static str,
4621 session: String,
4623 trust: &'static str,
4625}
4626
4627#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4629pub(crate) struct HostFactsRecord {
4630 binary: String,
4632 source: &'static str,
4634 #[serde(skip_serializing_if = "Option::is_none")]
4637 reason: Option<&'static str>,
4638 #[serde(skip_serializing_if = "Option::is_none")]
4640 changed_path: Option<String>,
4641}
4642
4643impl HostFactsRecord {
4644 fn of(used: &crate::host_facts::HostFactsUse) -> Self {
4645 use crate::host_facts::HostFactsSource;
4646
4647 let binary = used.binary.display().to_string();
4648 match &used.source {
4649 HostFactsSource::Kept => Self {
4650 binary,
4651 source: "kept",
4652 reason: None,
4653 changed_path: None,
4654 },
4655 HostFactsSource::Derived {
4656 reason,
4657 changed_path,
4658 } => Self {
4659 binary,
4660 source: "derived",
4661 reason: Some(reason.wire_name()),
4662 changed_path: changed_path.as_ref().map(|path| path.display().to_string()),
4663 },
4664 }
4665 }
4666}
4667
4668#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4670pub(crate) struct SessionControl {
4671 pub(crate) settings: Vec<&'static str>,
4673 pub(crate) secrets: Vec<String>,
4675 #[serde(skip_serializing_if = "Vec::is_empty")]
4678 pub(crate) agent_settings: Vec<&'static str>,
4679}
4680
4681#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4684pub(crate) struct SessionInferenceChoice {
4685 pub(crate) name: String,
4686 pub(crate) driver: String,
4687 pub(crate) model: String,
4688 pub(crate) credential_source: &'static str,
4691 pub(crate) available: bool,
4694 pub(crate) model_limits: Option<crate::model_limits::ResolvedModelLimits>,
4696}
4697
4698pub(crate) struct ControlChange<'a> {
4702 pub(crate) event_id: String,
4703 pub(crate) principal: crate::control_plane::Principal,
4705 pub(crate) token_id: Option<&'a str>,
4708 pub(crate) kind: &'static str,
4710 pub(crate) name: &'a str,
4711 pub(crate) action: &'static str,
4713 pub(crate) previous: Option<Value>,
4715 pub(crate) value: Option<Value>,
4716 pub(crate) applies_from: Option<&'static str>,
4718 pub(crate) replaced: Option<bool>,
4720}
4721
4722#[derive(Serialize)]
4725struct ControlChangeEvent<'a> {
4726 event_type: &'static str,
4727 event_id: String,
4728 parent_id: Option<String>,
4729 session_id: String,
4730 timestamp: u64,
4731 principal: &'static str,
4732 #[serde(skip_serializing_if = "Option::is_none")]
4733 token_id: Option<&'a str>,
4734 kind: &'static str,
4735 name: &'a str,
4736 action: &'static str,
4737 #[serde(skip_serializing_if = "Option::is_none")]
4738 previous: Option<Value>,
4739 #[serde(skip_serializing_if = "Option::is_none")]
4740 value: Option<Value>,
4741 #[serde(skip_serializing_if = "Option::is_none")]
4742 applies_from: Option<&'static str>,
4743 #[serde(skip_serializing_if = "Option::is_none")]
4744 replaced: Option<bool>,
4745}
4746
4747#[derive(Serialize)]
4751struct ControlRefusedEvent<'a> {
4752 event_type: &'static str,
4753 event_id: String,
4754 parent_id: Option<String>,
4755 session_id: String,
4756 timestamp: u64,
4757 principal: &'static str,
4758 status: u16,
4759 reason: &'static str,
4760 #[serde(skip_serializing_if = "Option::is_none")]
4761 kind: Option<&'static str>,
4762 #[serde(skip_serializing_if = "Option::is_none")]
4763 name: Option<&'a str>,
4764 #[serde(skip_serializing_if = "Option::is_none")]
4765 token_id: Option<&'a str>,
4766}
4767
4768#[derive(Serialize)]
4770struct ControlAppliedEvent<'a> {
4771 event_type: &'static str,
4772 event_id: String,
4773 parent_id: Option<String>,
4774 session_id: String,
4775 timestamp: u64,
4776 turn: u32,
4777 task_id: Option<String>,
4778 name: &'a str,
4779 value: Value,
4780 change_id: &'a str,
4781}
4782
4783#[derive(Serialize)]
4785struct ToolsRefreshedEvent<'a> {
4786 event_type: &'static str,
4787 event_id: String,
4788 parent_id: Option<String>,
4789 session_id: String,
4790 timestamp: u64,
4791 turn: u32,
4792 task_id: Option<String>,
4793 trigger: &'a str,
4794 added: &'a [String],
4795 removed: &'a [String],
4796 tools: &'a [String],
4797}
4798
4799#[derive(Serialize)]
4802struct GatewayCredentialEvent<'a> {
4803 event_type: &'static str,
4804 event_id: String,
4805 parent_id: Option<String>,
4806 session_id: String,
4807 timestamp: u64,
4808 artifact: &'a str,
4809 source: &'static str,
4810 #[serde(skip_serializing_if = "Option::is_none")]
4811 credential: Option<&'a str>,
4812 change: &'static str,
4813 #[serde(skip_serializing_if = "Option::is_none")]
4814 trigger: Option<&'static str>,
4815 #[serde(skip_serializing_if = "Option::is_none")]
4816 status: Option<u16>,
4817 #[serde(skip_serializing_if = "Option::is_none")]
4818 retried: Option<bool>,
4819 #[serde(skip_serializing_if = "Option::is_none")]
4820 reason: Option<&'static str>,
4821}
4822
4823#[derive(Serialize)]
4827struct InferenceCredentialEvent<'a> {
4828 event_type: &'static str,
4829 event_id: String,
4830 parent_id: Option<String>,
4831 session_id: String,
4832 timestamp: u64,
4833 source: &'static str,
4834 #[serde(skip_serializing_if = "Option::is_none")]
4835 credential: Option<&'a str>,
4836 change: &'static str,
4837 #[serde(skip_serializing_if = "Option::is_none")]
4838 trigger: Option<&'static str>,
4839 #[serde(skip_serializing_if = "Option::is_none")]
4840 status: Option<u16>,
4841 #[serde(skip_serializing_if = "Option::is_none")]
4842 retried: Option<bool>,
4843 #[serde(skip_serializing_if = "Option::is_none")]
4844 reason: Option<&'static str>,
4845}
4846
4847#[allow(clippy::type_complexity)]
4850fn credential_change_fields(
4851 change: CredentialChange,
4852) -> (
4853 &'static str,
4854 Option<&'static str>,
4855 Option<u16>,
4856 Option<bool>,
4857 Option<&'static str>,
4858) {
4859 match change {
4860 CredentialChange::Rotated { trigger } => ("rotated", Some(trigger), None, None, None),
4861 CredentialChange::Rejected { status, retried } => {
4862 ("rejected", None, Some(status), Some(retried), None)
4863 }
4864 CredentialChange::Unreadable { reason } => ("unreadable", None, None, None, Some(reason)),
4865 }
4866}
4867
4868pub struct ResourceTraceAppender {
4883 file: tokio::sync::Mutex<File>,
4885 session_id: String,
4886 session_event_id: String,
4891}
4892
4893impl ResourceTraceAppender {
4894 pub async fn open(
4895 records_root: &WorkdirRoot,
4896 session_id: String,
4897 session_event_id: String,
4898 ) -> std::io::Result<Self> {
4899 let file = File::from_std(records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?);
4900 Ok(Self {
4901 file: tokio::sync::Mutex::new(file),
4902 session_id,
4903 session_event_id,
4904 })
4905 }
4906
4907 #[allow(clippy::too_many_arguments)]
4911 pub(crate) async fn write_resource_list(
4912 &self,
4913 root: &str,
4914 entry_count: usize,
4915 total_bytes: u64,
4916 generation: u64,
4917 containment_achieved: ContainmentClass,
4918 outcome: &str,
4919 reason: Option<String>,
4920 ) {
4921 let event = ResourceListEvent {
4922 event_type: "resource_list",
4923 event_id: new_event_id(),
4924 parent_id: Some(self.session_event_id.clone()),
4925 session_id: self.session_id.clone(),
4926 timestamp: timestamp_ms(),
4927 root: root.to_string(),
4928 entry_count,
4929 total_bytes,
4930 generation,
4931 containment_achieved,
4932 outcome: outcome.to_string(),
4933 reason,
4934 };
4935 self.append(&event).await;
4936 }
4937
4938 #[allow(clippy::too_many_arguments)]
4941 pub(crate) async fn write_resource_read(
4942 &self,
4943 path: &str,
4944 outcome: &str,
4945 bytes: Option<u64>,
4946 sha256: Option<String>,
4947 generation: u64,
4948 containment_achieved: ContainmentClass,
4949 reason: Option<String>,
4950 ) {
4951 let event = ResourceReadEvent {
4952 event_type: "resource_read",
4953 event_id: new_event_id(),
4954 parent_id: Some(self.session_event_id.clone()),
4955 session_id: self.session_id.clone(),
4956 timestamp: timestamp_ms(),
4957 path: path.to_string(),
4958 outcome: outcome.to_string(),
4959 bytes,
4960 sha256,
4961 generation,
4962 containment_achieved,
4963 reason,
4964 };
4965 self.append(&event).await;
4966 }
4967
4968 pub(crate) async fn write_formation_ended(&self, formation_id: &crate::FormationId) {
4971 let event = FormationEndedEvent {
4972 event_type: "formation_ended",
4973 event_id: new_event_id(),
4974 parent_id: Some(self.session_event_id.clone()),
4975 session_id: self.session_id.clone(),
4976 timestamp: timestamp_ms(),
4977 formation_id: formation_id.as_str().to_string(),
4978 };
4979 self.append(&event).await;
4980 }
4981
4982 pub(crate) async fn write_session_status(
4985 &self,
4986 task_id: Option<String>,
4987 status: &crate::session_status::StatusReport,
4988 ) {
4989 let event = SessionStatusEvent {
4990 event_type: "session_status",
4991 event_id: new_event_id(),
4992 parent_id: Some(self.session_event_id.clone()),
4993 session_id: self.session_id.clone(),
4994 timestamp: timestamp_ms(),
4995 task_id,
4996 status,
4997 };
4998 self.append(&event).await;
4999 }
5000
5001 #[allow(clippy::too_many_arguments)]
5004 pub(crate) async fn write_background_command(
5005 &self,
5006 task_id: Option<String>,
5007 work_id: &str,
5008 operation: &'static str,
5009 state: &'static str,
5010 exit_code: Option<i32>,
5011 waited_ms: Option<u64>,
5012 output_bytes: u64,
5013 ) {
5014 let event = BackgroundCommandEvent {
5015 event_type: "background_command",
5016 event_id: new_event_id(),
5017 parent_id: Some(self.session_event_id.clone()),
5018 session_id: self.session_id.clone(),
5019 timestamp: timestamp_ms(),
5020 task_id,
5021 work_id,
5022 operation,
5023 state,
5024 exit_code,
5025 waited_ms,
5026 output_bytes,
5027 };
5028 self.append(&event).await;
5029 }
5030
5031 pub(crate) async fn write_process_start(&self, line: ProcessStartLine<'_>) {
5034 let event = ProcessStartEvent {
5035 event_type: "process_start",
5036 event_id: new_event_id(),
5037 parent_id: Some(self.session_event_id.clone()),
5038 session_id: self.session_id.clone(),
5039 timestamp: timestamp_ms(),
5040 line,
5041 };
5042 self.append(&event).await;
5043 }
5044
5045 pub(crate) async fn write_process_exit(&self, line: ProcessExitLine<'_>) {
5048 let event = ProcessExitEvent {
5049 event_type: "process_exit",
5050 event_id: new_event_id(),
5051 parent_id: Some(self.session_event_id.clone()),
5052 session_id: self.session_id.clone(),
5053 timestamp: timestamp_ms(),
5054 line,
5055 };
5056 self.append(&event).await;
5057 }
5058
5059 pub(crate) async fn write_process_refused(&self, line: ProcessRefusedLine<'_>) {
5062 let event = ProcessRefusedEvent {
5063 event_type: "process_refused",
5064 event_id: new_event_id(),
5065 parent_id: Some(self.session_event_id.clone()),
5066 session_id: self.session_id.clone(),
5067 timestamp: timestamp_ms(),
5068 line,
5069 };
5070 self.append(&event).await;
5071 }
5072
5073 pub(crate) async fn write_shell_completed(
5077 &self,
5078 completion: &crate::detached::DetachedCompletion,
5079 ) {
5080 let event = ShellCompletedEvent::new(
5081 self.session_id.clone(),
5082 Some(self.session_event_id.clone()),
5083 completion,
5084 crate::detached::Delivery::BackgroundCommand,
5085 None,
5086 );
5087 self.append(&event).await;
5088 }
5089
5090 pub(crate) async fn write_deadline_notice(
5092 &self,
5093 task_id: Option<String>,
5094 tool_name: &str,
5095 notice: &crate::session_status::DeadlineNotice,
5096 ) {
5097 let event = DeadlineNoticeEvent {
5098 event_type: "deadline_notice",
5099 event_id: new_event_id(),
5100 parent_id: Some(self.session_event_id.clone()),
5101 session_id: self.session_id.clone(),
5102 timestamp: timestamp_ms(),
5103 task_id,
5104 tool_name,
5105 deadline_at: ¬ice.at,
5106 left_secs: notice.left_secs,
5107 notice_secs: notice.notice_secs,
5108 };
5109 self.append(&event).await;
5110 }
5111
5112 pub(crate) async fn write_deadline_reached(
5115 &self,
5116 deadline: &crate::session_status::SessionDeadline,
5117 ) {
5118 let event = DeadlineReachedEvent {
5119 event_type: "deadline_reached",
5120 event_id: new_event_id(),
5121 parent_id: Some(self.session_event_id.clone()),
5122 session_id: self.session_id.clone(),
5123 timestamp: timestamp_ms(),
5124 deadline_at: deadline.at_rfc3339(),
5125 deadline_secs: deadline.secs(),
5126 source: deadline.source().as_str(),
5127 };
5128 self.append(&event).await;
5129 }
5130
5131 pub(crate) async fn write_spawner_ended(
5135 &self,
5136 spawned_by: Option<&str>,
5137 delegation_id: Option<&str>,
5138 ) {
5139 let event = SpawnerEndedEvent {
5140 event_type: "spawner_ended",
5141 event_id: new_event_id(),
5142 parent_id: Some(self.session_event_id.clone()),
5143 session_id: self.session_id.clone(),
5144 timestamp: timestamp_ms(),
5145 spawned_by: spawned_by.map(str::to_string),
5146 delegation_id: delegation_id.map(str::to_string),
5147 };
5148 self.append(&event).await;
5149 }
5150
5151 #[allow(clippy::too_many_arguments)]
5154 pub(crate) async fn write_peer_handle_mint(
5155 &self,
5156 handle_id: Option<String>,
5157 path: &str,
5158 audience: &str,
5159 expires_at_ms: Option<u64>,
5160 outcome: &str,
5161 reason: Option<String>,
5162 ) {
5163 let event = PeerHandleMintEvent {
5164 event_type: "peer_handle_mint",
5165 event_id: new_event_id(),
5166 parent_id: Some(self.session_event_id.clone()),
5167 session_id: self.session_id.clone(),
5168 timestamp: timestamp_ms(),
5169 handle_id,
5170 path: path.to_string(),
5171 audience: audience.to_string(),
5172 expires_at_ms,
5173 outcome: outcome.to_string(),
5174 reason,
5175 };
5176 self.append(&event).await;
5177 }
5178
5179 #[allow(clippy::too_many_arguments)]
5182 pub(crate) async fn write_peer_handle_redeem(
5183 &self,
5184 handle_id: &str,
5185 path: Option<String>,
5186 generation: u64,
5187 audience_asserted: Option<String>,
5188 bytes: Option<u64>,
5189 sha256: Option<String>,
5190 outcome: &str,
5191 reason: Option<String>,
5192 ) {
5193 let event = PeerHandleRedeemEvent {
5194 event_type: "peer_handle_redeem",
5195 event_id: new_event_id(),
5196 parent_id: Some(self.session_event_id.clone()),
5197 session_id: self.session_id.clone(),
5198 timestamp: timestamp_ms(),
5199 handle_id: handle_id.to_string(),
5200 path,
5201 generation,
5202 audience_asserted,
5203 bytes,
5204 sha256,
5205 outcome: outcome.to_string(),
5206 reason,
5207 };
5208 self.append(&event).await;
5209 }
5210
5211 #[allow(clippy::too_many_arguments)]
5213 pub(crate) async fn write_peer_file_fetch(
5214 &self,
5215 peer: &str,
5216 handle_id: &str,
5217 stored_path: Option<String>,
5218 bytes: Option<u64>,
5219 sha256: Option<String>,
5220 outcome: &str,
5221 reason: Option<String>,
5222 ) {
5223 let event = PeerFileFetchEvent {
5224 event_type: "peer_file_fetch",
5225 event_id: new_event_id(),
5226 parent_id: Some(self.session_event_id.clone()),
5227 session_id: self.session_id.clone(),
5228 timestamp: timestamp_ms(),
5229 peer: peer.to_string(),
5230 handle_id: handle_id.to_string(),
5231 stored_path,
5232 bytes,
5233 sha256,
5234 outcome: outcome.to_string(),
5235 reason,
5236 };
5237 self.append(&event).await;
5238 }
5239
5240 #[allow(clippy::too_many_arguments)]
5245 pub(crate) async fn write_delegation_start(
5246 &self,
5247 delegation_id: &str,
5248 capsule: &str,
5249 version: &str,
5250 child_session_id: &str,
5251 child_workdir: &str,
5252 child_records_dir: &str,
5253 workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
5254 ) {
5255 let event = DelegationStartEvent::new(
5256 &self.session_id,
5257 &self.session_event_id,
5258 delegation_id,
5259 capsule,
5260 version,
5261 child_session_id,
5262 child_workdir,
5263 child_records_dir,
5264 workdir_snapshot,
5265 );
5266 self.append(&event).await;
5267 }
5268
5269 #[allow(clippy::too_many_arguments)]
5273 pub(crate) async fn write_delegation(
5274 &self,
5275 capsule: &str,
5276 version: &str,
5277 delegation_id: Option<String>,
5278 child_session_id: Option<String>,
5279 duration_ms: u64,
5280 outcome: &str,
5281 reason: Option<String>,
5282 ) {
5283 let event = DelegationEvent::new(
5284 &self.session_id,
5285 &self.session_event_id,
5286 capsule,
5287 version,
5288 delegation_id,
5289 child_session_id,
5290 duration_ms,
5291 outcome,
5292 reason,
5293 );
5294 self.append(&event).await;
5295 }
5296
5297 pub(crate) async fn write_member_call_start(
5299 &self,
5300 task_id: &str,
5301 call_id: &str,
5302 member: &str,
5303 member_task_id: &str,
5304 ) {
5305 let event = self.member_call_start(task_id, call_id, member, member_task_id);
5306 self.append(&event).await;
5307 }
5308
5309 pub(crate) async fn write_member_call_start_if(
5312 &self,
5313 task_id: &str,
5314 call_id: &str,
5315 member: &str,
5316 member_task_id: &str,
5317 admit: impl FnOnce() -> bool,
5318 ) -> bool {
5319 let event = self.member_call_start(task_id, call_id, member, member_task_id);
5320 self.append_if(&event, admit).await
5321 }
5322
5323 fn member_call_start(
5324 &self,
5325 task_id: &str,
5326 call_id: &str,
5327 member: &str,
5328 member_task_id: &str,
5329 ) -> MemberCallStartEvent {
5330 MemberCallStartEvent {
5331 event_type: "member_call_start",
5332 event_id: new_event_id(),
5333 parent_id: Some(self.session_event_id.clone()),
5334 session_id: self.session_id.clone(),
5335 timestamp: timestamp_ms(),
5336 task_id: task_id.to_string(),
5337 call_id: call_id.to_string(),
5338 member: member.to_string(),
5339 member_task_id: member_task_id.to_string(),
5340 }
5341 }
5342
5343 pub(crate) async fn write_member_call_busy(
5346 &self,
5347 task_id: &str,
5348 call_id: &str,
5349 member: &str,
5350 offer: u32,
5351 waited_ms: u64,
5352 message: &str,
5353 ) {
5354 let event = MemberCallBusyEvent {
5355 event_type: "member_call_busy",
5356 event_id: new_event_id(),
5357 parent_id: Some(self.session_event_id.clone()),
5358 session_id: self.session_id.clone(),
5359 timestamp: timestamp_ms(),
5360 task_id: task_id.to_string(),
5361 call_id: call_id.to_string(),
5362 member: member.to_string(),
5363 offer,
5364 waited_ms,
5365 message: message.to_string(),
5366 };
5367 self.append(&event).await;
5368 }
5369
5370 pub(crate) async fn write_member_call(
5372 &self,
5373 task_id: &str,
5374 outcome: &crate::member_call::MemberCallOutcome,
5375 delivered: bool,
5376 ) {
5377 let event = MemberCallEvent {
5378 event_type: "member_call",
5379 event_id: new_event_id(),
5380 parent_id: Some(self.session_event_id.clone()),
5381 session_id: self.session_id.clone(),
5382 timestamp: timestamp_ms(),
5383 task_id: task_id.to_string(),
5384 call_id: outcome.call_id.clone(),
5385 member: outcome.member.clone(),
5386 member_task_id: outcome.member_task_id.clone(),
5387 status: outcome.status.as_str().to_string(),
5388 duration_ms: outcome.duration_ms,
5389 output: outcome.output.clone(),
5390 truncated: outcome.truncated,
5391 delivered,
5392 no_answer_below: outcome
5393 .below
5394 .iter()
5395 .map(|(member, status)| NoAnswerBelow {
5396 member: member.clone(),
5397 status: status.as_str().to_string(),
5398 })
5399 .collect(),
5400 };
5401 self.append(&event).await;
5402 }
5403
5404 pub(crate) async fn write_inference_credential(
5407 &self,
5408 source: &'static str,
5409 credential: Option<&str>,
5410 change: CredentialChange,
5411 ) {
5412 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
5413 let event = InferenceCredentialEvent {
5414 event_type: "inference_credential",
5415 event_id: new_event_id(),
5416 parent_id: Some(self.session_event_id.clone()),
5417 session_id: self.session_id.clone(),
5418 timestamp: timestamp_ms(),
5419 source,
5420 credential,
5421 change: change_name,
5422 trigger,
5423 status,
5424 retried,
5425 reason,
5426 };
5427 self.append(&event).await;
5428 }
5429
5430 pub(crate) async fn write_gateway_credential(
5433 &self,
5434 artifact: &str,
5435 source: &'static str,
5436 credential: Option<&str>,
5437 change: CredentialChange,
5438 ) {
5439 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
5440 let event = GatewayCredentialEvent {
5441 event_type: "gateway_credential",
5442 event_id: new_event_id(),
5443 parent_id: Some(self.session_event_id.clone()),
5444 session_id: self.session_id.clone(),
5445 timestamp: timestamp_ms(),
5446 artifact,
5447 source,
5448 credential,
5449 change: change_name,
5450 trigger,
5451 status,
5452 retried,
5453 reason,
5454 };
5455 self.append(&event).await;
5456 }
5457
5458 pub(crate) async fn write_control_change(&self, change: ControlChange<'_>) {
5460 let event = ControlChangeEvent {
5461 event_type: "control_change",
5462 event_id: change.event_id,
5463 parent_id: Some(self.session_event_id.clone()),
5464 session_id: self.session_id.clone(),
5465 timestamp: timestamp_ms(),
5466 principal: change.principal.as_str(),
5467 token_id: change.token_id,
5468 kind: change.kind,
5469 name: change.name,
5470 action: change.action,
5471 previous: change.previous,
5472 value: change.value,
5473 applies_from: change.applies_from,
5474 replaced: change.replaced,
5475 };
5476 self.append(&event).await;
5477 }
5478
5479 pub(crate) async fn write_control_refused(
5482 &self,
5483 principal: crate::control_plane::Principal,
5484 status: u16,
5485 reason: &'static str,
5486 kind: Option<&'static str>,
5487 name: Option<&str>,
5488 token_id: Option<&str>,
5489 ) {
5490 let event = ControlRefusedEvent {
5491 event_type: "control_refused",
5492 event_id: new_event_id(),
5493 parent_id: Some(self.session_event_id.clone()),
5494 session_id: self.session_id.clone(),
5495 timestamp: timestamp_ms(),
5496 principal: principal.as_str(),
5497 status,
5498 reason,
5499 kind,
5500 name,
5501 token_id,
5502 };
5503 self.append(&event).await;
5504 }
5505
5506 async fn append(&self, event: &impl Serialize) {
5507 self.append_if(event, || true).await;
5508 }
5509
5510 async fn append_if(&self, event: &impl Serialize, admit: impl FnOnce() -> bool) -> bool {
5513 let Ok(mut line) = serde_json::to_string(event) else {
5514 return admit();
5515 };
5516 line.push('\n');
5517 let mut file = self.file.lock().await;
5518 if !admit() {
5519 return false;
5520 }
5521 if file.write_all(line.as_bytes()).await.is_ok() {
5522 let _ = file.flush().await;
5523 }
5524 true
5525 }
5526}
5527
5528pub(crate) struct PriorSessionTraceAppender {
5543 file: File,
5544 session_id: String,
5546 session_event_id: Option<String>,
5548 terminate_torn_tail: bool,
5552}
5553
5554impl PriorSessionTraceAppender {
5555 pub(crate) async fn open(
5559 trace_dir: &WorkdirRoot,
5560 trace: &str,
5561 session_id: String,
5562 session_event_id: Option<String>,
5563 ) -> std::io::Result<Self> {
5564 let contents = trace_dir
5565 .read_file(trace, u64::MAX)?
5566 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))?;
5567 let terminate_torn_tail = contents.last().is_some_and(|last| *last != b'\n');
5569 let file = File::from_std(trace_dir.open_append(trace, DEFAULT_FILE_MODE)?);
5570 Ok(Self {
5571 file,
5572 session_id,
5573 session_event_id,
5574 terminate_torn_tail,
5575 })
5576 }
5577
5578 pub(crate) async fn write_shell_lost(
5584 &mut self,
5585 work: &crate::detached::LostWork,
5586 reconciled_by_session: &str,
5587 reconciled_task_id: &str,
5588 ) -> std::io::Result<()> {
5589 let event = ShellLostEvent {
5590 event_type: "shell_lost",
5591 event_id: new_event_id(),
5592 parent_id: self.session_event_id.clone(),
5593 session_id: self.session_id.clone(),
5594 timestamp: timestamp_ms(),
5595 work_id: work.work_id.clone(),
5596 binary: work.binary.clone(),
5597 command: work.command.clone(),
5598 detached_at_ms: work.detached_at_ms,
5599 reconciled_by_session: reconciled_by_session.to_string(),
5600 reconciled_task_id: reconciled_task_id.to_string(),
5601 };
5602 let mut line = if self.terminate_torn_tail {
5603 "\n".to_string()
5604 } else {
5605 String::new()
5606 };
5607 line.push_str(&serde_json::to_string(&event).map_err(std::io::Error::other)?);
5608 line.push('\n');
5609 self.file.write_all(line.as_bytes()).await?;
5610 self.file.flush().await?;
5611 self.terminate_torn_tail = false;
5612 Ok(())
5613 }
5614}
5615
5616pub(crate) fn timestamp_ms() -> u64 {
5617 SystemTime::now()
5618 .duration_since(UNIX_EPOCH)
5619 .unwrap_or_default()
5620 .as_millis() as u64
5621}
5622
5623#[derive(Serialize, Clone)]
5630pub(crate) struct PlanStepShape {
5631 pub(crate) step_id: String,
5632 pub(crate) kind: &'static str,
5635 pub(crate) depends_on: Vec<String>,
5636 pub(crate) has_condition: bool,
5638}
5639
5640#[derive(Serialize)]
5643struct PlanStartEvent {
5644 event_type: &'static str,
5645 event_id: String,
5646 parent_id: Option<String>,
5647 session_id: String,
5648 timestamp: u64,
5649 plan_id: String,
5650 step_count: usize,
5651 steps: Vec<PlanStepShape>,
5652}
5653
5654#[derive(Serialize)]
5660struct PlanStepStartEvent {
5661 event_type: &'static str,
5662 event_id: String,
5663 parent_id: Option<String>,
5664 session_id: String,
5665 timestamp: u64,
5666 plan_id: String,
5667 step_id: String,
5668 kind: &'static str,
5669 depends_on: Vec<String>,
5670}
5671
5672#[derive(Serialize)]
5676struct PlanStepEvent {
5677 event_type: &'static str,
5678 event_id: String,
5679 parent_id: Option<String>,
5680 session_id: String,
5681 timestamp: u64,
5682 plan_id: String,
5683 step_id: String,
5684 kind: &'static str,
5685 status: &'static str,
5686 attempts: u32,
5687 duration_ms: u64,
5688 #[serde(skip_serializing_if = "Option::is_none")]
5689 error: Option<String>,
5690 #[serde(skip_serializing_if = "Option::is_none")]
5693 input: Option<Value>,
5694 #[serde(skip_serializing_if = "Option::is_none")]
5698 state_effect: Option<String>,
5699 #[serde(skip_serializing_if = "Option::is_none")]
5701 resource_id: Option<String>,
5702}
5703
5704#[derive(Serialize)]
5706struct PlanEndEvent {
5707 event_type: &'static str,
5708 event_id: String,
5709 parent_id: Option<String>,
5710 session_id: String,
5711 timestamp: u64,
5712 plan_id: String,
5713 outcome: &'static str,
5714 #[serde(skip_serializing_if = "Option::is_none")]
5715 failed_step: Option<String>,
5716 steps_total: usize,
5717 steps_succeeded: usize,
5718 steps_failed: usize,
5719 steps_skipped: usize,
5720 duration_ms: u64,
5721 #[serde(skip_serializing_if = "Option::is_none")]
5724 reason: Option<String>,
5725}
5726
5727pub(crate) struct PlanStepRecord {
5729 pub(crate) plan_id: String,
5730 pub(crate) step_id: String,
5731 pub(crate) kind: &'static str,
5732 pub(crate) status: &'static str,
5735 pub(crate) attempts: u32,
5737 pub(crate) duration_ms: u64,
5738 pub(crate) error: Option<String>,
5739 pub(crate) input: Option<Value>,
5740 pub(crate) state_effect: Option<String>,
5741 pub(crate) resource_id: Option<String>,
5742}
5743
5744pub(crate) struct PlanEndRecord {
5746 pub(crate) plan_id: String,
5747 pub(crate) outcome: &'static str,
5748 pub(crate) failed_step: Option<String>,
5749 pub(crate) steps_total: usize,
5750 pub(crate) steps_succeeded: usize,
5751 pub(crate) steps_failed: usize,
5752 pub(crate) steps_skipped: usize,
5753 pub(crate) duration_ms: u64,
5754 pub(crate) reason: Option<String>,
5755}
5756
5757pub struct PlanTraceAppender {
5768 file: std::sync::Mutex<std::fs::File>,
5769 session_id: String,
5770 session_event_id: String,
5773}
5774
5775impl PlanTraceAppender {
5776 pub fn open(
5777 records_root: &WorkdirRoot,
5778 session_id: String,
5779 session_event_id: String,
5780 ) -> std::io::Result<Self> {
5781 let file = records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?;
5782 Ok(Self {
5783 file: std::sync::Mutex::new(file),
5784 session_id,
5785 session_event_id,
5786 })
5787 }
5788
5789 pub(crate) fn write_plan_start(&self, plan_id: &str, steps: Vec<PlanStepShape>) -> String {
5791 let event_id = new_event_id();
5792 let event = PlanStartEvent {
5793 event_type: "plan_start",
5794 event_id: event_id.clone(),
5795 parent_id: Some(self.session_event_id.clone()),
5796 session_id: self.session_id.clone(),
5797 timestamp: timestamp_ms(),
5798 plan_id: plan_id.to_string(),
5799 step_count: steps.len(),
5800 steps,
5801 };
5802 self.append(&event);
5803 event_id
5804 }
5805
5806 pub(crate) fn write_plan_step_start(
5807 &self,
5808 plan_node: &str,
5809 plan_id: &str,
5810 step_id: &str,
5811 kind: &'static str,
5812 depends_on: Vec<String>,
5813 ) {
5814 let event = PlanStepStartEvent {
5815 event_type: "plan_step_start",
5816 event_id: new_event_id(),
5817 parent_id: Some(plan_node.to_string()),
5818 session_id: self.session_id.clone(),
5819 timestamp: timestamp_ms(),
5820 plan_id: plan_id.to_string(),
5821 step_id: step_id.to_string(),
5822 kind,
5823 depends_on,
5824 };
5825 self.append(&event);
5826 }
5827
5828 pub(crate) fn write_plan_step(&self, plan_node: &str, record: PlanStepRecord) {
5829 let input = record.input.map(|mut value| {
5832 crate::peer_handoff::redact_handles_in_json(&mut value);
5833 value
5834 });
5835 let event = PlanStepEvent {
5836 event_type: "plan_step",
5837 event_id: new_event_id(),
5838 parent_id: Some(plan_node.to_string()),
5839 session_id: self.session_id.clone(),
5840 timestamp: timestamp_ms(),
5841 plan_id: record.plan_id,
5842 step_id: record.step_id,
5843 kind: record.kind,
5844 status: record.status,
5845 attempts: record.attempts,
5846 duration_ms: record.duration_ms,
5847 error: record.error,
5848 input,
5849 state_effect: record.state_effect,
5850 resource_id: record.resource_id,
5851 };
5852 self.append(&event);
5853 }
5854
5855 pub(crate) fn write_plan_end(&self, plan_node: Option<&str>, record: PlanEndRecord) {
5858 let event = PlanEndEvent {
5859 event_type: "plan_end",
5860 event_id: new_event_id(),
5861 parent_id: Some(
5862 plan_node
5863 .map(str::to_string)
5864 .unwrap_or_else(|| self.session_event_id.clone()),
5865 ),
5866 session_id: self.session_id.clone(),
5867 timestamp: timestamp_ms(),
5868 plan_id: record.plan_id,
5869 outcome: record.outcome,
5870 failed_step: record.failed_step,
5871 steps_total: record.steps_total,
5872 steps_succeeded: record.steps_succeeded,
5873 steps_failed: record.steps_failed,
5874 steps_skipped: record.steps_skipped,
5875 duration_ms: record.duration_ms,
5876 reason: record.reason,
5877 };
5878 self.append(&event);
5879 }
5880
5881 #[allow(clippy::too_many_arguments)]
5887 pub(crate) fn write_delegation_start(
5888 &self,
5889 delegation_id: &str,
5890 capsule: &str,
5891 version: &str,
5892 child_session_id: &str,
5893 child_workdir: &str,
5894 child_records_dir: &str,
5895 workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
5896 ) {
5897 let event = DelegationStartEvent::new(
5898 &self.session_id,
5899 &self.session_event_id,
5900 delegation_id,
5901 capsule,
5902 version,
5903 child_session_id,
5904 child_workdir,
5905 child_records_dir,
5906 workdir_snapshot,
5907 );
5908 self.append(&event);
5909 }
5910
5911 #[allow(clippy::too_many_arguments)]
5919 pub(crate) fn write_delegation(
5920 &self,
5921 capsule: &str,
5922 version: &str,
5923 delegation_id: Option<String>,
5924 child_session_id: Option<String>,
5925 duration_ms: u64,
5926 outcome: &str,
5927 reason: Option<String>,
5928 ) {
5929 let event = DelegationEvent::new(
5930 &self.session_id,
5931 &self.session_event_id,
5932 capsule,
5933 version,
5934 delegation_id,
5935 child_session_id,
5936 duration_ms,
5937 outcome,
5938 reason,
5939 );
5940 self.append(&event);
5941 }
5942
5943 fn append(&self, event: &impl Serialize) {
5947 use std::io::Write as _;
5948
5949 let Ok(mut line) = serde_json::to_string(event) else {
5950 return;
5951 };
5952 line.push('\n');
5953 let Ok(mut file) = self.file.lock() else {
5954 return;
5955 };
5956 if file.write_all(line.as_bytes()).is_ok() {
5957 let _ = file.flush();
5958 }
5959 }
5960}
5961
5962#[cfg(test)]
5965mod tests {
5966 use super::*;
5967 use crate::{
5968 sandbox::EnforcementTier, sealed::UsernsGrant, trace_blobs::BLOB_DIR_NAME,
5969 types::CapabilityPolicy,
5970 };
5971 use murmur_artifact::{ArtifactRuntime, InterpreterRuntimeDir, InterpreterRuntimeGrant};
5972 use serde_json::Value;
5973 use std::path::Path;
5974
5975 use crate::origin::TaskOrigin;
5976
5977 fn event_provenance() -> TaskProvenance {
5979 TaskProvenance::derive(TaskOrigin::Event, None)
5980 }
5981
5982 fn report_for(
5986 declared: ContainmentClass,
5987 tier: EnforcementTier,
5988 workdir_exec: bool,
5989 ) -> ScopeReport {
5990 crate::containment::report_for(
5991 &CapabilityPolicy {
5992 workdir_exec_allowed: workdir_exec,
5993 ..CapabilityPolicy::default()
5994 },
5995 declared,
5996 tier,
5997 None,
5998 None,
5999 None,
6000 None,
6001 Vec::new(),
6002 Vec::new(),
6003 Vec::new(),
6004 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6005 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(tier, declared)),
6006 crate::cgroup::IoMaxReport::default(),
6007 )
6008 }
6009
6010 fn demotion_info() -> crate::detached::DetachedDispatchInfo {
6011 crate::detached::DetachedDispatchInfo {
6012 work_id: "wrk_0a1b2c3d4e5f6a7b".to_string(),
6013 binary: "/usr/bin/bash".to_string(),
6014 command: "make -j8".to_string(),
6015 grace_ms: Some(1_000),
6016 }
6017 }
6018
6019 #[tokio::test]
6022 async fn a_demotion_marker_that_cannot_be_written_does_not_fail_the_demotion() {
6023 let dir = tempfile::tempdir().unwrap();
6024 let mut writer = make_writer(dir.path()).await;
6028 let full = std::fs::OpenOptions::new()
6029 .write(true)
6030 .open("/dev/full")
6031 .unwrap();
6032 writer.writer = BufWriter::new(File::from_std(full));
6033 let info = demotion_info();
6034
6035 assert!(
6036 writer
6037 .write_shell_detached(1, &info.work_id, &info.binary, &info.command, info.grace_ms)
6038 .await
6039 .is_err(),
6040 "the sink under this writer must be unwritable for the claim below to mean anything"
6041 );
6042 crate::agent::record_demotion(&mut writer, 1, &info).await;
6043 }
6044
6045 #[tokio::test]
6047 async fn a_recorded_demotion_writes_only_its_own_marker() {
6048 let dir = tempfile::tempdir().unwrap();
6049 let mut writer = make_writer(dir.path()).await;
6050 crate::agent::record_demotion(&mut writer, 3, &demotion_info()).await;
6051
6052 let events = read_events(dir.path());
6053 assert_eq!(events.len(), 1);
6054 assert_eq!(events[0]["event_type"], "shell_detached");
6055 assert_eq!(events[0]["work_id"], "wrk_0a1b2c3d4e5f6a7b");
6056 assert_eq!(events[0]["turn"], 3);
6057 assert_eq!(events[0]["grace_ms"], 1_000);
6058 }
6059
6060 #[tokio::test]
6062 async fn a_demotion_with_no_grace_writes_no_grace_ms() {
6063 let dir = tempfile::tempdir().unwrap();
6064 let mut writer = make_writer(dir.path()).await;
6065 let info = crate::detached::DetachedDispatchInfo {
6066 grace_ms: None,
6067 ..demotion_info()
6068 };
6069 crate::agent::record_demotion(&mut writer, 3, &info).await;
6070
6071 let events = read_events(dir.path());
6072 assert_eq!(events.len(), 1);
6073 assert_eq!(events[0]["event_type"], "shell_detached");
6074 assert!(events[0].get("grace_ms").is_none(), "{}", events[0]);
6075 }
6076
6077 #[tokio::test]
6078 async fn a_safety_check_event_carries_the_checks_verbatim_and_its_outcome() {
6079 let dir = tempfile::tempdir().unwrap();
6080 let mut writer = make_writer(dir.path()).await;
6081 let checks = serde_json::json!([{"id": "sc_1", "code": "c", "message": "m", "x": 1}]);
6082 writer
6083 .write_safety_check(2, "call_1_0", "computer", &checks, "declined")
6084 .await
6085 .unwrap();
6086
6087 let events = read_events(dir.path());
6088 assert_eq!(events.len(), 1);
6089 assert_eq!(events[0]["event_type"], "safety_check");
6090 assert_eq!(events[0]["turn"], 2);
6091 assert_eq!(events[0]["tool_call_id"], "call_1_0");
6092 assert_eq!(events[0]["tool"], "computer");
6093 assert_eq!(events[0]["checks"], checks);
6094 assert_eq!(events[0]["outcome"], "declined");
6095 assert!(events[0]["event_id"].is_string() && events[0]["session_id"].is_string());
6096 }
6097
6098 #[tokio::test]
6099 async fn an_unrecorded_demotion_names_the_work_id_and_the_write_error() {
6100 let dir = tempfile::tempdir().unwrap();
6101 let mut writer = make_writer(dir.path()).await;
6102 writer
6103 .write_shell_detach_unrecorded(2, "wrk_0a1b", "/usr/bin/bash", "No space left")
6104 .await
6105 .unwrap();
6106
6107 let events = read_events(dir.path());
6108 assert_eq!(events.len(), 1);
6109 assert_eq!(events[0]["event_type"], "shell_detach_unrecorded");
6110 assert_eq!(events[0]["turn"], 2);
6111 assert_eq!(events[0]["work_id"], "wrk_0a1b");
6112 assert_eq!(events[0]["binary"], "/usr/bin/bash");
6113 assert_eq!(events[0]["reason"], "No space left");
6114 }
6115
6116 #[tokio::test]
6120 async fn an_abandoned_command_still_running_omits_the_fields_it_cannot_know() {
6121 let dir = tempfile::tempdir().unwrap();
6122 let mut writer = make_writer(dir.path()).await;
6123 writer
6124 .write_shell_abandoned(
6125 "wrk_0a1b2c3d4e5f6a7b",
6126 "/usr/bin/bash",
6127 "sleep 45; echo done",
6128 45_012,
6129 None,
6130 )
6131 .await
6132 .unwrap();
6133
6134 let events = read_events(dir.path());
6135 assert_eq!(events.len(), 1);
6136 let event = &events[0];
6137 assert_eq!(event["event_type"], "shell_abandoned");
6138 assert_eq!(event["work_id"], "wrk_0a1b2c3d4e5f6a7b");
6139 assert_eq!(event["binary"], "/usr/bin/bash");
6140 assert_eq!(event["command"], "sleep 45; echo done");
6141 assert_eq!(event["running_ms"], 45_012);
6142 for absent in ["exit_code", "output_path", "output_bytes"] {
6143 assert!(
6144 event.get(absent).is_none(),
6145 "{absent} must be omitted, not written as null: {event}"
6146 );
6147 }
6148 }
6149
6150 #[tokio::test]
6154 async fn an_abandoned_command_that_finished_carries_its_result() {
6155 let dir = tempfile::tempdir().unwrap();
6156 let mut writer = make_writer(dir.path()).await;
6157 writer
6158 .write_shell_abandoned(
6159 "wrk_0a1b2c3d4e5f6a7b",
6160 "/usr/bin/bash",
6161 "sleep 2; echo done",
6162 2_014,
6163 Some((
6164 0,
6165 "/home/test/.murmur/records/work/ses_1/logs/wrk_0a1b2c3d4e5f6a7b.log",
6166 91,
6167 )),
6168 )
6169 .await
6170 .unwrap();
6171
6172 let event = &read_events(dir.path())[0];
6173 assert_eq!(event["event_type"], "shell_abandoned");
6174 assert_eq!(event["running_ms"], 2_014);
6175 assert_eq!(event["exit_code"], 0);
6176 assert_eq!(
6177 event["output_path"],
6178 "/home/test/.murmur/records/work/ses_1/logs/wrk_0a1b2c3d4e5f6a7b.log"
6179 );
6180 assert_eq!(event["output_bytes"], 91);
6181 }
6182
6183 async fn make_writer(dir: &std::path::Path) -> TraceWriter {
6184 make_writer_with_opts(dir, TraceCapture::Meta).await
6185 }
6186
6187 async fn make_writer_with_opts(dir: &std::path::Path, capture: TraceCapture) -> TraceWriter {
6188 make_writer_with_prompt(dir, capture, None, false).await
6189 }
6190
6191 async fn make_writer_with_prompt(
6192 dir: &std::path::Path,
6193 capture: TraceCapture,
6194 system_prompt: Option<&str>,
6195 system_prompt_overridden: bool,
6196 ) -> TraceWriter {
6197 TraceWriter::open(
6198 &crate::workdir_fs::WorkdirRoot::open(dir).unwrap(),
6199 "test-session-id".to_string(),
6200 "test-capsule".to_string(),
6201 "1.0.0".to_string(),
6202 "claude-test".to_string(),
6203 vec!["shell".to_string()],
6204 report_for(
6205 ContainmentClass::Advisory,
6206 EnforcementTier::EnvironmentOnly,
6207 false,
6208 ),
6209 capture,
6210 system_prompt.map(str::to_string),
6211 system_prompt_overridden,
6212 None,
6213 None,
6214 None,
6215 None,
6216 )
6217 .await
6218 .unwrap()
6219 }
6220
6221 fn read_events(dir: &std::path::Path) -> Vec<Value> {
6222 let content = std::fs::read_to_string(dir.join("trace.jsonl")).unwrap();
6223 content
6224 .lines()
6225 .filter(|l| !l.is_empty())
6226 .map(|l| serde_json::from_str(l).unwrap())
6227 .collect()
6228 }
6229
6230 #[tokio::test]
6231 async fn session_start_fields() {
6232 let dir = tempfile::tempdir().unwrap();
6233 let mut w = make_writer(dir.path()).await;
6234 w.write_session_start(Some(10), None, vec!["bash".to_string()])
6235 .await
6236 .unwrap();
6237 w.flush().await.unwrap();
6238
6239 let events = read_events(dir.path());
6240 assert_eq!(events.len(), 1);
6241 let e = &events[0];
6242 assert_eq!(e["event_type"], "session_start");
6243 assert_eq!(e["session_id"], "test-session-id");
6244 assert_eq!(e["capsule_name"], "test-capsule");
6245 assert_eq!(e["capsule_version"], "1.0.0");
6246 assert_eq!(e["model"], "claude-test");
6247 assert_eq!(e["max_turns"], 10);
6248 assert_eq!(e["capabilities"], serde_json::json!(["shell"]));
6249 assert_eq!(e["tools_declared"], serde_json::json!(["bash"]));
6250 assert_eq!(e["containment_declared"], "advisory");
6251 assert_eq!(e["containment_achieved"], "advisory");
6252 assert_eq!(e["workdir_exec"], false);
6253 assert!(e["timestamp"].as_u64().unwrap() > 0);
6254 assert_eq!(e["model_limits"], serde_json::Value::Null);
6255 }
6256
6257 #[tokio::test]
6259 async fn session_start_writes_no_turn_cap_and_the_model_limits() {
6260 let dir = tempfile::tempdir().unwrap();
6261 let mut w = make_writer(dir.path()).await;
6262 let limits = crate::model_limits::ResolvedModelLimits {
6263 max_output_tokens: 65536,
6264 max_output_tokens_source: crate::model_limits::LimitSource::Driver,
6265 context_window: None,
6266 context_window_source: crate::model_limits::LimitSource::None,
6267 max_input_tokens: None,
6268 max_input_tokens_source: crate::model_limits::LimitSource::None,
6269 input_budget: None,
6270 input_budget_basis: crate::model_limits::BudgetBasis::None,
6271 };
6272 w.write_session_start(None, Some(limits), Vec::new())
6273 .await
6274 .unwrap();
6275 w.flush().await.unwrap();
6276
6277 let e = &read_events(dir.path())[0];
6278 assert!(e.get("max_turns").is_some_and(serde_json::Value::is_null));
6279 assert_eq!(
6280 e["model_limits"],
6281 serde_json::json!({
6282 "max_output_tokens": 65536,
6283 "max_output_tokens_source": "driver",
6284 "context_window": null,
6285 "context_window_source": "none",
6286 "max_input_tokens": null,
6287 "max_input_tokens_source": "none",
6288 "input_budget": null,
6289 "input_budget_basis": "none",
6290 })
6291 );
6292 }
6293
6294 #[tokio::test]
6297 async fn session_start_is_the_first_line_and_carries_a_members_formation() {
6298 let formation = FormationId::mint();
6299 for member in [Some(&formation), None] {
6300 let dir = tempfile::tempdir().unwrap();
6301 let mut w = make_writer(dir.path()).await;
6302 w.set_formation_id(member);
6303 w.write_session_start(Some(10), None, Vec::new())
6304 .await
6305 .unwrap();
6306 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6307 .await
6308 .unwrap();
6309 w.flush().await.unwrap();
6310
6311 let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6312 let first_line = content.lines().next().unwrap();
6313 let first: Value = serde_json::from_str(first_line).unwrap();
6314 assert_eq!(first["event_type"], "session_start");
6315 match member {
6316 Some(id) => {
6317 assert_eq!(first["formation_id"], id.as_str());
6318 assert!(
6319 first_line.contains(&format!("\"formation_id\":\"{id}\"")),
6320 "{first_line}"
6321 );
6322 }
6323 None => assert!(!first_line.contains("formation_id"), "{first_line}"),
6324 }
6325 assert_eq!(
6327 content.matches("formation_id").count(),
6328 usize::from(member.is_some())
6329 );
6330 }
6331 }
6332
6333 #[tokio::test]
6337 async fn a_members_trace_names_it_its_callees_and_its_callers() {
6338 let authority = crate::formation_credentials::FormationAuthority::for_test();
6339 let cases = [
6340 (Some(("coder", vec!["reviewer"])), Some("planner")),
6341 (Some(("reviewer", vec![])), None),
6342 (None, None),
6343 ];
6344 for (member, caller) in cases {
6345 let member = member.map(|(name, callees)| {
6346 crate::formation_credentials::FormationMember::from_bundle(
6347 authority.member_bundle(name, &callees),
6348 )
6349 });
6350 let dir = tempfile::tempdir().unwrap();
6351 let mut w = make_writer(dir.path()).await;
6352 w.set_formation_member(member.as_ref());
6353 w.write_session_start(Some(10), None, Vec::new())
6354 .await
6355 .unwrap();
6356 let door_caller = match caller {
6357 Some(caller) => crate::identity::TaskCaller::Credential(format!("member:{caller}")),
6358 None => crate::identity::TaskCaller::Operator,
6359 };
6360 w.write_a2a_task_received("tsk_1", "ctx_1", "msg_1", None, &door_caller, caller, "hi")
6361 .await
6362 .unwrap();
6363 w.flush().await.unwrap();
6364 let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6365 let lines: Vec<Value> = content
6366 .lines()
6367 .map(|line| serde_json::from_str(line).unwrap())
6368 .collect();
6369 let start = &lines[0];
6370 let received = lines
6371 .iter()
6372 .find(|line| line["event_type"] == "a2a_task_received")
6373 .unwrap();
6374 match &member {
6375 Some(member) => {
6376 assert_eq!(start["formation_member"], member.name());
6377 assert_eq!(
6378 start["formation_callees"],
6379 serde_json::json!(member.callees().collect::<Vec<_>>())
6380 );
6381 }
6382 None => {
6383 assert!(start.get("formation_member").is_none(), "{start}");
6384 assert!(start.get("formation_callees").is_none(), "{start}");
6385 }
6386 }
6387 match caller {
6388 Some(caller) => {
6389 assert_eq!(received["caller"], "member");
6390 assert_eq!(received["caller_member"], caller);
6391 }
6392 None => {
6393 assert_eq!(received["caller"], "operator");
6394 assert!(received.get("caller_member").is_none(), "{received}");
6395 }
6396 }
6397 assert!(received.get("caller_credential").is_none(), "{received}");
6398 assert!(!content.contains("mft1."), "{content}");
6399 }
6400 }
6401
6402 #[tokio::test]
6406 async fn a_received_task_names_who_the_door_let_it_in_as() {
6407 use crate::identity::TaskCaller;
6408 let cases = [
6409 (TaskCaller::Operator, None, "operator", None),
6410 (TaskCaller::Public, None, "public", None),
6411 (
6412 TaskCaller::Credential("alice".to_string()),
6413 None,
6414 "credential",
6415 Some("alice"),
6416 ),
6417 (
6418 TaskCaller::Credential("member:planner".to_string()),
6419 Some("planner"),
6420 "member",
6421 None,
6422 ),
6423 ];
6424 for (door_caller, member, caller, credential) in cases {
6425 let dir = tempfile::tempdir().unwrap();
6426 let mut w = make_writer(dir.path()).await;
6427 w.write_session_start(Some(10), None, Vec::new())
6428 .await
6429 .unwrap();
6430 w.write_a2a_task_received("tsk_1", "ctx_1", "msg_1", None, &door_caller, member, "hi")
6431 .await
6432 .unwrap();
6433 w.flush().await.unwrap();
6434 let received = read_events(dir.path())
6435 .into_iter()
6436 .find(|line| line["event_type"] == "a2a_task_received")
6437 .unwrap();
6438 assert_eq!(received["caller"], caller, "{received}");
6439 match member {
6440 Some(member) => assert_eq!(received["caller_member"], member, "{received}"),
6441 None => assert!(received.get("caller_member").is_none(), "{received}"),
6442 }
6443 match credential {
6444 Some(name) => assert_eq!(received["caller_credential"], name, "{received}"),
6445 None => assert!(received.get("caller_credential").is_none(), "{received}"),
6446 }
6447 }
6448 }
6449
6450 #[tokio::test]
6453 async fn a_received_message_is_hashed_and_stored_by_capture_mode() {
6454 let text = "Name three colours, please.";
6455 let sha = murmur_artifact::sha256_hex(text.as_bytes());
6456 for capture in [
6457 TraceCapture::None,
6458 TraceCapture::Meta,
6459 TraceCapture::Content,
6460 ] {
6461 let dir = tempfile::tempdir().unwrap();
6462 let mut w = make_writer_with_opts(dir.path(), capture).await;
6463 w.write_a2a_task_received(
6464 "tsk_1",
6465 "ctx_1",
6466 "msg_1",
6467 None,
6468 &crate::identity::TaskCaller::Operator,
6469 None,
6470 text,
6471 )
6472 .await
6473 .unwrap();
6474 w.flush().await.unwrap();
6475 let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6476 assert!(!content.contains(text), "{content}");
6477 let received = read_events(dir.path())
6478 .into_iter()
6479 .find(|line| line["event_type"] == "a2a_task_received")
6480 .unwrap();
6481 let blob = std::fs::read(blob_path(dir.path(), &sha)).ok();
6482 match capture {
6483 TraceCapture::None => {
6484 assert!(received.get("message_sha256").is_none(), "{received}");
6485 assert_eq!(blob, None);
6486 }
6487 TraceCapture::Meta => {
6488 assert_eq!(received["message_sha256"], sha.as_str(), "{received}");
6489 assert_eq!(blob, None);
6490 }
6491 TraceCapture::Content => {
6492 assert_eq!(received["message_sha256"], sha.as_str(), "{received}");
6493 assert_eq!(blob.as_deref(), Some(text.as_bytes()));
6494 }
6495 }
6496 }
6497 }
6498
6499 #[tokio::test]
6504 async fn session_start_records_system_prompt_source_and_hash() {
6505 let expected_sha = murmur_artifact::sha256_hex(b"Be terse.");
6506
6507 for (prompt, overridden, source, sha) in [
6508 (Some("Be terse."), false, "manifest", Some(&expected_sha)),
6509 (Some("Be terse."), true, "cli", Some(&expected_sha)),
6510 (None, false, "none", None),
6511 (None, true, "cli", None),
6514 ] {
6515 let dir = tempfile::tempdir().unwrap();
6516 let mut w =
6517 make_writer_with_prompt(dir.path(), TraceCapture::Meta, prompt, overridden).await;
6518 w.write_session_start(Some(1), None, Vec::new())
6519 .await
6520 .unwrap();
6521 w.flush().await.unwrap();
6522
6523 let e = &read_events(dir.path())[0];
6524 assert_eq!(e["system_prompt_source"], source, "prompt={prompt:?}");
6525 match sha {
6526 Some(sha) => assert_eq!(e["system_prompt_sha256"], sha.as_str()),
6527 None => assert!(
6528 e["system_prompt_sha256"].is_null(),
6529 "no prompt in effect must hash to null, got {}",
6530 e["system_prompt_sha256"]
6531 ),
6532 }
6533 }
6534 }
6535
6536 #[tokio::test]
6540 async fn session_start_records_the_prompt_by_hash_and_stores_it_only_under_content() {
6541 let sha = murmur_artifact::sha256_hex(b"Be terse.");
6542
6543 let dir = tempfile::tempdir().unwrap();
6544 let mut w =
6545 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), false).await;
6546 w.write_session_start(Some(1), None, Vec::new())
6547 .await
6548 .unwrap();
6549 w.flush().await.unwrap();
6550
6551 let e = &read_events(dir.path())[0];
6552 assert!(
6553 e.get("system_prompt").is_none(),
6554 "the verbatim prompt is not a session_start field, got {e}"
6555 );
6556 assert_eq!(e["system_prompt_sha256"], sha.as_str());
6557 assert!(
6558 !dir.path().join(BLOB_DIR_NAME).exists(),
6559 "meta stores no bodies"
6560 );
6561
6562 let opted_in = tempfile::tempdir().unwrap();
6563 let mut w = make_writer_with_prompt(
6564 opted_in.path(),
6565 TraceCapture::Content,
6566 Some("Be terse."),
6567 false,
6568 )
6569 .await;
6570 w.write_session_start(Some(1), None, Vec::new())
6571 .await
6572 .unwrap();
6573 w.flush().await.unwrap();
6574
6575 let event = &read_events(opted_in.path())[0];
6576 assert!(event.get("system_prompt").is_none());
6577 assert_eq!(event["system_prompt_sha256"], sha.as_str());
6578 let blob = opted_in.path().join(BLOB_DIR_NAME).join(&sha);
6579 assert_eq!(std::fs::read(&blob).unwrap(), b"Be terse.");
6580 }
6581
6582 #[tokio::test]
6585 async fn content_capture_stores_no_prompt_blob_when_there_is_no_prompt() {
6586 let dir = tempfile::tempdir().unwrap();
6587 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::Content, None, false).await;
6588 w.write_session_start(Some(1), None, Vec::new())
6589 .await
6590 .unwrap();
6591 w.flush().await.unwrap();
6592
6593 let e = &read_events(dir.path())[0];
6594 assert!(e.get("system_prompt").is_none());
6595 assert!(e["system_prompt_sha256"].is_null());
6596 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
6597 }
6598
6599 #[tokio::test]
6601 async fn every_task_session_start_repeats_the_same_system_prompt_record() {
6602 let dir = tempfile::tempdir().unwrap();
6603 let mut w =
6604 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), true).await;
6605 w.write_session_start(Some(1), None, Vec::new())
6606 .await
6607 .unwrap();
6608 w.write_session_start(Some(1), None, Vec::new())
6609 .await
6610 .unwrap();
6611 w.flush().await.unwrap();
6612
6613 let events = read_events(dir.path());
6614 assert_eq!(events.len(), 2);
6615 assert_eq!(
6616 events[0]["system_prompt_source"],
6617 events[1]["system_prompt_source"]
6618 );
6619 assert_eq!(
6620 events[0]["system_prompt_sha256"],
6621 events[1]["system_prompt_sha256"]
6622 );
6623 assert_eq!(events[1]["system_prompt_source"], "cli");
6624 }
6625
6626 #[tokio::test]
6631 async fn session_start_records_workdir_exec_next_to_the_class_it_forced() {
6632 let dir = tempfile::tempdir().unwrap();
6633 let mut w = TraceWriter::open(
6634 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6635 "test-session-id".to_string(),
6636 "test-capsule".to_string(),
6637 "1.0.0".to_string(),
6638 "claude-test".to_string(),
6639 Vec::new(),
6640 report_for(
6642 ContainmentClass::Advisory,
6643 EnforcementTier::KernelFull,
6644 true,
6645 ),
6646 TraceCapture::Meta,
6647 None,
6648 false,
6649 None,
6650 None,
6651 None,
6652 None,
6653 )
6654 .await
6655 .unwrap();
6656 w.write_session_start(Some(1), None, Vec::new())
6657 .await
6658 .unwrap();
6659 w.flush().await.unwrap();
6660
6661 let events = read_events(dir.path());
6662 assert_eq!(events[0]["workdir_exec"], true);
6663 assert_eq!(events[0]["containment_achieved"], "advisory");
6664 }
6665
6666 #[tokio::test]
6669 async fn session_start_records_declared_and_achieved_containment_separately() {
6670 let dir = tempfile::tempdir().unwrap();
6671 let mut w = TraceWriter::open(
6672 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6673 "test-session-id".to_string(),
6674 "test-capsule".to_string(),
6675 "1.0.0".to_string(),
6676 "claude-test".to_string(),
6677 Vec::new(),
6678 report_for(ContainmentClass::Sealed, EnforcementTier::KernelFull, false),
6679 TraceCapture::Meta,
6680 None,
6681 false,
6682 None,
6683 None,
6684 None,
6685 None,
6686 )
6687 .await
6688 .unwrap();
6689 w.write_session_start(Some(1), None, Vec::new())
6690 .await
6691 .unwrap();
6692 w.flush().await.unwrap();
6693
6694 let events = read_events(dir.path());
6695 assert_eq!(events[0]["containment_declared"], "sealed");
6696 assert_eq!(events[0]["containment_achieved"], "scoped");
6697 }
6698
6699 #[tokio::test]
6707 async fn session_start_distinguishes_two_hosts_with_the_same_achieved_class() {
6708 async fn event_for(grant: UsernsGrant) -> Value {
6709 let report = crate::containment::report_for(
6710 &CapabilityPolicy::default(),
6711 ContainmentClass::Sealed,
6712 EnforcementTier::KernelSealed,
6713 None,
6714 Some(grant),
6715 None,
6716 None,
6717 Vec::new(),
6718 Vec::new(),
6719 Vec::new(),
6720 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6721 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6722 EnforcementTier::KernelSealed,
6723 ContainmentClass::Sealed,
6724 )),
6725 crate::cgroup::IoMaxReport::default(),
6726 );
6727 let dir = tempfile::tempdir().unwrap();
6728 let mut w = TraceWriter::open(
6729 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6730 "test-session-id".to_string(),
6731 "test-capsule".to_string(),
6732 "1.0.0".to_string(),
6733 "claude-test".to_string(),
6734 Vec::new(),
6735 report,
6736 TraceCapture::Meta,
6737 None,
6738 false,
6739 None,
6740 None,
6741 None,
6742 None,
6743 )
6744 .await
6745 .unwrap();
6746 w.write_session_start(Some(1), None, Vec::new())
6747 .await
6748 .unwrap();
6749 w.flush().await.unwrap();
6750 let mut event = read_events(dir.path()).remove(0);
6751 event.as_object_mut().unwrap().remove("timestamp");
6754 event
6755 }
6756
6757 let through_profile = event_for(UsernsGrant::ProfileConfining).await;
6758 let host_wide = event_for(UsernsGrant::RestrictionDisabledHostWide).await;
6759
6760 assert_eq!(through_profile["containment_achieved"], "sealed");
6761 assert_eq!(host_wide["containment_achieved"], "sealed");
6762 assert_eq!(through_profile["userns_grant"], "profile_confining");
6763 assert_eq!(host_wide["userns_grant"], "restriction_disabled_host_wide");
6764 assert_ne!(
6765 through_profile, host_wide,
6766 "two hosts granting the user namespace by different mechanisms must not write the \
6767 same session_start record"
6768 );
6769
6770 let unprobed = crate::containment::report_for(
6773 &CapabilityPolicy::default(),
6774 ContainmentClass::Advisory,
6775 EnforcementTier::EnvironmentOnly,
6776 None,
6777 None,
6778 None,
6779 None,
6780 Vec::new(),
6781 Vec::new(),
6782 Vec::new(),
6783 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6784 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6785 EnforcementTier::EnvironmentOnly,
6786 ContainmentClass::Advisory,
6787 )),
6788 crate::cgroup::IoMaxReport::default(),
6789 );
6790 assert!(
6791 serde_json::to_value(&unprobed).unwrap()["userns_grant"].is_null(),
6792 "off Linux the key is present and null, not absent"
6793 );
6794 }
6795
6796 #[tokio::test]
6804 async fn session_start_records_the_whole_scope_report_as_effective_grants() {
6805 let policy = CapabilityPolicy {
6806 network_allow: vec!["https://api.example.com".to_string()],
6807 unix_sockets_allowed: false,
6808 filesystem_scope: Some("workdir".to_string()),
6809 shell_allow: vec!["python3".to_string()],
6810 spawn_allow: vec!["helper".to_string()],
6811 env_allow: vec!["TZ".to_string()],
6812 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6813 binary: "python3".to_string(),
6814 dirs: vec![InterpreterRuntimeDir {
6815 path: "/usr/lib/python3.11".to_string(),
6816 list_dir: true,
6817 }],
6818 }],
6819 ..CapabilityPolicy::default()
6820 };
6821 let expected = crate::containment::report_for(
6822 &policy,
6823 ContainmentClass::Scoped,
6824 EnforcementTier::KernelFull,
6825 None,
6826 None,
6827 Some(8),
6829 None,
6830 vec![crate::containment::StateStoreReport {
6833 artifact: "notes-tool".to_string(),
6834 store: "shey".to_string(),
6835 host_path: "/home/dev/.murmur/state/shey".to_string(),
6836 }],
6837 vec!["config-echo".to_string()],
6839 vec![crate::containment::PreopenReport {
6840 artifact: "notes-tool".to_string(),
6841 role: "tool".to_string(),
6842 scope: None,
6843 surface: crate::containment::PreopenSurface::WholeWorkdir,
6844 runtime_dir_reach: Some(crate::containment::RuntimeDirReach::MurmurRuntime),
6845 extra_roots: vec![crate::containment::ExtraRootReport {
6847 guest_path: "murmur-host/home/dev/.cargo".to_string(),
6848 path: "/home/dev/.cargo".to_string(),
6849 access: crate::host_paths::HostPathAccess::Read,
6850 source: crate::guest_roots::GuestRootSource::HostPaths,
6851 }],
6852 }],
6853 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6854 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6855 EnforcementTier::KernelFull,
6856 ContainmentClass::Scoped,
6857 )),
6858 crate::cgroup::IoMaxReport::default(),
6859 );
6860
6861 let dir = tempfile::tempdir().unwrap();
6862 let mut w = TraceWriter::open(
6863 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6864 "test-session-id".to_string(),
6865 "test-capsule".to_string(),
6866 "1.0.0".to_string(),
6867 "claude-test".to_string(),
6868 vec!["network".to_string(), "shell".to_string()],
6869 expected.clone(),
6870 TraceCapture::Meta,
6871 None,
6872 false,
6873 None,
6874 None,
6875 None,
6876 None,
6877 )
6878 .await
6879 .unwrap();
6880 w.write_session_start(Some(1), None, Vec::new())
6881 .await
6882 .unwrap();
6883 w.flush().await.unwrap();
6884
6885 let events = read_events(dir.path());
6886 assert_eq!(
6887 events[0]["effective_grants"],
6888 serde_json::to_value(&expected).unwrap()
6889 );
6890
6891 assert_eq!(
6894 events[0]["capabilities"],
6895 serde_json::json!(["network", "shell"])
6896 );
6897 assert_eq!(
6898 events[0]["effective_grants"]["state_stores"],
6899 serde_json::json!([{
6900 "artifact": "notes-tool",
6901 "store": "shey",
6902 "host_path": "/home/dev/.murmur/state/shey",
6903 }])
6904 );
6905 assert_eq!(
6908 events[0]["effective_grants"]["configured_artifacts"],
6909 serde_json::json!(["config-echo"])
6910 );
6911 assert_eq!(
6915 events[0]["effective_grants"]["preopens"],
6916 serde_json::json!([{
6917 "artifact": "notes-tool",
6918 "role": "tool",
6919 "scope": null,
6920 "surface": "whole-workdir",
6921 "runtime_dir_reach": "murmur-runtime",
6922 "extra_roots": [{
6923 "guest_path": "murmur-host/home/dev/.cargo",
6924 "path": "/home/dev/.cargo",
6925 "access": "read",
6926 "source": "host_paths",
6927 }],
6928 }])
6929 );
6930 assert_eq!(events[0]["containment_declared"], "scoped");
6931 assert_eq!(events[0]["containment_achieved"], "scoped");
6932 assert_eq!(events[0]["workdir_exec"], false);
6933 assert_eq!(
6934 events[0]["effective_grants"]["network_allow"],
6935 serde_json::json!(["https://api.example.com"])
6936 );
6937 assert_eq!(
6938 events[0]["effective_grants"]["interpreter_runtime_grants"],
6939 serde_json::json!(["python3: /usr/lib/python3.11 (list_dir)"])
6940 );
6941 }
6942
6943 #[tokio::test]
6944 async fn inference_fields_and_snake_case() {
6945 let dir = tempfile::tempdir().unwrap();
6946 let mut w = make_writer(dir.path()).await;
6947 w.write_inference(
6948 0,
6949 Some(100),
6950 Some(50),
6951 "tool_call".to_string(),
6952 Some("tool_call"),
6953 Some("bash".to_string()),
6954 None,
6955 None,
6956 vec!["msg_0000000000000000000000000000000a".to_string()],
6957 None,
6958 None,
6959 )
6960 .await
6961 .unwrap();
6962 w.flush().await.unwrap();
6963
6964 let events = read_events(dir.path());
6965 let e = &events[0];
6966 assert_eq!(e["event_type"], "inference");
6967 assert_eq!(
6968 e["message_ids"],
6969 serde_json::json!(["msg_0000000000000000000000000000000a"]),
6970 "an agent-loop turn names the messages its request embedded"
6971 );
6972 assert_eq!(e["turn"], 0);
6973 assert_eq!(e["input_tokens"], 100);
6974 assert_eq!(e["output_tokens"], 50);
6975 assert_eq!(e["decision"], "tool_call");
6976 assert_eq!(e["tool_name"], "bash");
6977 assert!(e.get("inputTokens").is_none(), "must use snake_case");
6979 assert!(e.get("toolName").is_none(), "must use snake_case");
6980 for key in [
6982 "input_tokens_actual",
6983 "output_tokens_actual",
6984 "cached_tokens",
6985 "cache_write_tokens",
6986 ] {
6987 assert!(e.get(key).is_none(), "{key} must be omitted, not null or 0");
6988 }
6989 }
6990
6991 #[tokio::test]
6994 async fn inference_writes_only_the_reported_usage_members() {
6995 let dir = tempfile::tempdir().unwrap();
6996 let mut w = make_writer(dir.path()).await;
6997 let usage = DriverUsage {
6998 input_tokens: Some(12043),
6999 output_tokens: None,
7000 cached_tokens: Some(0),
7001 cache_write_tokens: None,
7002 thinking_tokens: None,
7003 };
7004 w.write_inference(
7005 0,
7006 Some(100),
7007 Some(50),
7008 "end_turn".to_string(),
7009 Some("end_turn"),
7010 None,
7011 None,
7012 Some(&usage),
7013 Vec::new(),
7014 None,
7015 None,
7016 )
7017 .await
7018 .unwrap();
7019 w.flush().await.unwrap();
7020
7021 let events = read_events(dir.path());
7022 let e = &events[0];
7023 assert_eq!(e["input_tokens"], 100, "the estimate is untouched");
7024 assert_eq!(e["input_tokens_actual"], 12043);
7025 assert_eq!(e["cached_tokens"], 0, "a reported zero is written");
7026 assert!(e.get("output_tokens_actual").is_none());
7027 assert!(e.get("cache_write_tokens").is_none());
7028 }
7029
7030 #[tokio::test]
7031 async fn failed_inference_writes_its_keys_and_counts_only_what_it_spent() {
7032 let dir = tempfile::tempdir().unwrap();
7033 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7034 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
7035 .await
7036 .unwrap();
7037 let usage = DriverUsage {
7038 input_tokens: Some(7),
7039 ..DriverUsage::default()
7040 };
7041 let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
7042 w.write_failed_inference(FailedInference {
7043 turn: 0,
7044 error_code: INFERENCE_ERROR_CREDENTIAL_REJECTED,
7045 error: &long,
7046 provider_status: Some(401),
7047 usage: Some(&usage),
7048 origin: None,
7049 choice: None,
7050 message_ids: vec!["msg_1".to_string()],
7051 tool_name: None,
7052 spent: None,
7053 settings: None,
7054 retry: RetryNote::default(),
7055 gateway: None,
7056 })
7057 .await
7058 .unwrap();
7059 assert_eq!(w.task_turns(), 0, "an uncounted record moves no counter");
7060 assert_eq!((w.total_turns, w.total_input_tokens), (0, 0));
7061 assert_eq!((w.task_input_tokens, w.task_output_tokens), (0, 0));
7062
7063 w.write_failed_inference(FailedInference {
7064 turn: 1,
7065 error_code: INFERENCE_ERROR_HARNESS_AUTH,
7066 error: "bad login",
7067 provider_status: None,
7068 usage: None,
7069 origin: None,
7070 choice: None,
7071 message_ids: Vec::new(),
7072 tool_name: None,
7073 spent: Some((Some(10), Some(5))),
7074 settings: None,
7075 retry: RetryNote::default(),
7076 gateway: None,
7077 })
7078 .await
7079 .unwrap();
7080 assert_eq!(w.task_turns(), 1);
7081 assert_eq!(w.total_turns, 1);
7082 assert_eq!((w.total_input_tokens, w.total_output_tokens), (10, 5));
7083 assert_eq!((w.task_input_tokens, w.task_output_tokens), (10, 5));
7084 w.flush().await.unwrap();
7085
7086 let events = read_events(dir.path());
7087 let failed: Vec<&Value> = events
7088 .iter()
7089 .filter(|e| e["event_type"] == "inference")
7090 .collect();
7091 let keys = |e: &Value| {
7092 let mut keys: Vec<String> = e.as_object().unwrap().keys().cloned().collect();
7093 keys.sort();
7094 keys
7095 };
7096 assert_eq!(
7097 keys(failed[0]),
7098 [
7099 "decision",
7100 "error",
7101 "error_code",
7102 "event_id",
7103 "event_type",
7104 "input_tokens_actual",
7105 "message_ids",
7106 "parent_id",
7107 "provider_status",
7108 "session_id",
7109 "stop_reason",
7110 "task_id",
7111 "timestamp",
7112 "tool_name",
7113 "turn",
7114 ]
7115 );
7116 assert_eq!(failed[0]["decision"], "error");
7117 assert_eq!(failed[0]["stop_reason"], "error");
7118 assert_eq!(failed[0]["error_code"], "credential_rejected");
7119 assert_eq!(failed[0]["provider_status"], 401);
7120 assert_eq!(failed[0]["input_tokens_actual"], 7);
7121 let error = failed[0]["error"].as_str().unwrap();
7122 assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
7123 assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
7124
7125 assert_eq!(failed[1]["error_code"], "harness_auth");
7126 assert_eq!(failed[1]["error"], "bad login");
7127 assert_eq!(failed[1]["input_tokens"], 10);
7128 assert_eq!(failed[1]["output_tokens"], 5);
7129 assert!(failed[1].get("provider_status").is_none());
7130 assert!(
7131 !dir.path().join("blobs").exists(),
7132 "a failed record stores no blob, even under trace.capture: content"
7133 );
7134 let task_start = events
7136 .iter()
7137 .find(|e| e["event_type"] == "task_start")
7138 .unwrap();
7139 assert_eq!(failed[0]["parent_id"], task_start["event_id"]);
7140 }
7141
7142 #[tokio::test]
7145 async fn gateway_timing_keys_follow_the_gateway() {
7146 let dir = tempfile::tempdir().unwrap();
7147 let mut w = make_writer(dir.path()).await;
7148 let failed = |turn, gateway| FailedInference {
7149 turn,
7150 error_code: INFERENCE_ERROR_DRIVER_FAILED,
7151 error: "stopped",
7152 provider_status: None,
7153 usage: None,
7154 origin: None,
7155 choice: None,
7156 message_ids: Vec::new(),
7157 tool_name: None,
7158 spent: None,
7159 settings: None,
7160 retry: RetryNote::default(),
7161 gateway,
7162 };
7163 w.write_failed_inference(failed(0, None)).await.unwrap();
7164 w.write_failed_inference(failed(1, Some(GatewayOffsets::default())))
7165 .await
7166 .unwrap();
7167 let answered = GatewayOffsets {
7168 reached_ms: Some(4),
7169 responded_ms: Some(90),
7170 };
7171 w.write_failed_inference(failed(2, Some(answered)))
7172 .await
7173 .unwrap();
7174 w.flush().await.unwrap();
7175
7176 let events = read_events(dir.path());
7177 assert!(events[0].get("gateway_reached_ms").is_none());
7178 assert!(events[0].get("upstream_responded_ms").is_none());
7179 assert_eq!(events[1]["gateway_reached_ms"], Value::Null);
7180 assert_eq!(events[1]["upstream_responded_ms"], Value::Null);
7181 assert!(events[1]
7182 .as_object()
7183 .unwrap()
7184 .contains_key("gateway_reached_ms"));
7185 assert!(events[1]
7186 .as_object()
7187 .unwrap()
7188 .contains_key("upstream_responded_ms"));
7189 assert_eq!(events[2]["gateway_reached_ms"], 4);
7190 assert_eq!(events[2]["upstream_responded_ms"], 90);
7191 }
7192
7193 #[tokio::test]
7197 async fn call_deadlines_are_drained_ahead_of_the_next_line() {
7198 let dir = tempfile::tempdir().unwrap();
7199 let mut w = make_writer(dir.path()).await;
7200 let deadlines = crate::limits::CallDeadlines::default();
7201 w.set_call_deadlines(deadlines.clone());
7202 let staged = |component: &str, call: &str| crate::limits::CallDeadline {
7203 component: component.to_string(),
7204 role: crate::limits::ComponentRole::Hook,
7205 call: call.to_string(),
7206 deadline_seconds: 2,
7207 elapsed_ms: 4_000,
7208 };
7209 deadlines.push_for_test(staged("stager", "on-stage"));
7210 w.write_session_start(None, None, Vec::new()).await.unwrap();
7211 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
7212 .await
7213 .unwrap();
7214 deadlines.push_for_test(crate::limits::CallDeadline {
7215 component: "stall-driver".to_string(),
7216 role: crate::limits::ComponentRole::Driver,
7217 call: "run".to_string(),
7218 deadline_seconds: 3,
7219 elapsed_ms: 5_001,
7220 });
7221 w.write_failed_inference(FailedInference {
7222 turn: 0,
7223 error_code: INFERENCE_ERROR_DRIVER_FAILED,
7224 error: "stopped",
7225 provider_status: None,
7226 usage: None,
7227 origin: None,
7228 choice: None,
7229 message_ids: Vec::new(),
7230 tool_name: None,
7231 spent: None,
7232 settings: None,
7233 retry: RetryNote::default(),
7234 gateway: Some(GatewayOffsets::default()),
7235 })
7236 .await
7237 .unwrap();
7238 w.write_session_end("ok").await.unwrap();
7239 w.flush().await.unwrap();
7240
7241 let events = read_events(dir.path());
7242 let types: Vec<&str> = events
7243 .iter()
7244 .map(|e| e["event_type"].as_str().unwrap())
7245 .collect();
7246 assert_eq!(
7247 types,
7248 [
7249 "session_start",
7250 "call_deadline",
7251 "task_start",
7252 "call_deadline",
7253 "inference",
7254 "session_end"
7255 ]
7256 );
7257 assert_eq!(events[1]["call"], "on-stage");
7258 assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
7259 assert!(events[1].get("task_id").is_none(), "outside a task");
7260 let cut = &events[3];
7261 assert_eq!(cut["component"], "stall-driver");
7262 assert_eq!(cut["role"], "driver");
7263 assert_eq!(cut["call"], "run");
7264 assert_eq!(cut["deadline_seconds"], 3);
7265 assert_eq!(cut["elapsed_ms"], 5_001);
7266 assert_eq!(cut["task_id"], "tsk_1");
7267 assert_eq!(cut["parent_id"], events[2]["event_id"]);
7268 assert!(cut["event_id"].as_str().is_some());
7269 assert!(cut["timestamp"].as_u64().is_some());
7270 }
7271
7272 #[tokio::test]
7273 async fn a_failed_hook_record_hangs_off_the_turn_and_keeps_hook_accounting() {
7274 let dir = tempfile::tempdir().unwrap();
7275 let mut w = make_writer(dir.path()).await;
7276 w.write_inference(
7277 0,
7278 Some(10),
7279 Some(5),
7280 "end_turn".to_string(),
7281 Some("end_turn"),
7282 None,
7283 None,
7284 None,
7285 Vec::new(),
7286 None,
7287 None,
7288 )
7289 .await
7290 .unwrap();
7291 let origin = InferenceOrigin {
7292 source: "hook:compactor".to_string(),
7293 model: "m".to_string(),
7294 };
7295 w.write_failed_inference(FailedInference {
7296 turn: 0,
7297 error_code: INFERENCE_ERROR_DRIVER_ERROR,
7298 error: "HTTP 500: boom",
7299 provider_status: Some(500),
7300 usage: None,
7301 origin: Some(&origin),
7302 choice: None,
7303 message_ids: Vec::new(),
7304 tool_name: None,
7305 spent: Some((Some(40), None)),
7306 settings: None,
7307 retry: RetryNote::default(),
7308 gateway: None,
7309 })
7310 .await
7311 .unwrap();
7312 assert_eq!(w.total_turns, 2);
7313 assert_eq!((w.total_input_tokens, w.total_output_tokens), (50, 5));
7314 w.flush().await.unwrap();
7315
7316 let events = read_events(dir.path());
7317 let turn = events.iter().find(|e| e["decision"] == "end_turn").unwrap();
7318 let hook = events.iter().find(|e| e["decision"] == "error").unwrap();
7319 assert_eq!(hook["parent_id"], turn["event_id"]);
7320 assert_eq!(hook["origin"], "hook:compactor");
7321 assert_eq!(hook["model"], "m");
7322 assert_eq!(hook["input_tokens"], 40);
7323 assert!(hook.get("output_tokens").is_none());
7324 }
7325
7326 #[tokio::test]
7330 async fn inference_call_settings_are_written_on_every_kind_of_http_line() {
7331 let dir = tempfile::tempdir().unwrap();
7332 let mut w = make_writer(dir.path()).await;
7333 let turn = InferenceCallSettings {
7334 max_output_tokens: 4096,
7335 input_budget: Some(195_904),
7336 driver_config: Some(serde_json::json!({"reasoning_effort": "high"})),
7337 };
7338 let unconfigured = InferenceCallSettings {
7339 max_output_tokens: 1024,
7340 input_budget: None,
7341 driver_config: None,
7342 };
7343 let hook = InferenceCallSettings {
7344 max_output_tokens: 2000,
7345 input_budget: Some(198_000),
7346 driver_config: Some(serde_json::json!({"reasoning_effort": "low"})),
7347 };
7348 let origin = InferenceOrigin {
7349 source: "hook:compactor".to_string(),
7350 model: "m".to_string(),
7351 };
7352 w.write_turn_inference(
7353 0,
7354 Some(10),
7355 Some(5),
7356 "end_turn".to_string(),
7357 Some("end_turn"),
7358 None,
7359 None,
7360 Vec::new(),
7361 None,
7362 None,
7363 Some(&turn),
7364 RetryNote::default(),
7365 None,
7366 )
7367 .await
7368 .unwrap();
7369 w.write_turn_inference(
7370 1,
7371 Some(10),
7372 Some(5),
7373 "end_turn".to_string(),
7374 Some("end_turn"),
7375 None,
7376 None,
7377 Vec::new(),
7378 None,
7379 None,
7380 Some(&unconfigured),
7381 RetryNote::default(),
7382 None,
7383 )
7384 .await
7385 .unwrap();
7386 w.write_inference(
7387 1,
7388 Some(30),
7389 Some(8),
7390 "end_turn".to_string(),
7391 None,
7392 None,
7393 Some(&origin),
7394 None,
7395 Vec::new(),
7396 None,
7397 Some(&hook),
7398 )
7399 .await
7400 .unwrap();
7401 w.write_failed_inference(FailedInference {
7402 turn: 2,
7403 error_code: INFERENCE_ERROR_DRIVER_ERROR,
7404 error: "HTTP 500: boom",
7405 provider_status: Some(500),
7406 usage: None,
7407 origin: None,
7408 choice: None,
7409 message_ids: Vec::new(),
7410 tool_name: None,
7411 spent: None,
7412 settings: Some(&turn),
7413 retry: RetryNote::default(),
7414 gateway: None,
7415 })
7416 .await
7417 .unwrap();
7418 w.flush().await.unwrap();
7419
7420 let events = read_events(dir.path());
7421 let settings: Vec<(Value, Value, Value, Value)> = events
7422 .iter()
7423 .filter(|e| e["event_type"] == "inference")
7424 .map(|e| {
7425 (
7426 e["decision"].clone(),
7427 e["max_output_tokens"].clone(),
7428 e.get("input_budget")
7429 .cloned()
7430 .expect("input_budget is written, null or not"),
7431 e.get("driver_config")
7432 .cloned()
7433 .expect("driver_config is written, null or not"),
7434 )
7435 })
7436 .collect();
7437 assert_eq!(
7438 settings,
7439 [
7440 (
7441 serde_json::json!("end_turn"),
7442 serde_json::json!(4096),
7443 serde_json::json!(195_904),
7444 serde_json::json!({"reasoning_effort": "high"})
7445 ),
7446 (
7447 serde_json::json!("end_turn"),
7448 serde_json::json!(1024),
7449 Value::Null,
7450 Value::Null
7451 ),
7452 (
7453 serde_json::json!("end_turn"),
7454 serde_json::json!(2000),
7455 serde_json::json!(198_000),
7456 serde_json::json!({"reasoning_effort": "low"})
7457 ),
7458 (
7459 serde_json::json!("error"),
7460 serde_json::json!(4096),
7461 serde_json::json!(195_904),
7462 serde_json::json!({"reasoning_effort": "high"})
7463 ),
7464 ]
7465 );
7466 }
7467
7468 #[tokio::test]
7470 async fn an_inference_line_without_call_settings_writes_neither_key() {
7471 let dir = tempfile::tempdir().unwrap();
7472 let mut w = make_writer(dir.path()).await;
7473 w.write_inference(
7474 0,
7475 Some(10),
7476 Some(5),
7477 "end_turn".to_string(),
7478 None,
7479 None,
7480 None,
7481 None,
7482 Vec::new(),
7483 None,
7484 None,
7485 )
7486 .await
7487 .unwrap();
7488 w.write_failed_inference(FailedInference {
7489 turn: 1,
7490 error_code: INFERENCE_ERROR_HARNESS_AUTH,
7491 error: "bad login",
7492 provider_status: None,
7493 usage: None,
7494 origin: None,
7495 choice: None,
7496 message_ids: Vec::new(),
7497 tool_name: None,
7498 spent: None,
7499 settings: None,
7500 retry: RetryNote::default(),
7501 gateway: None,
7502 })
7503 .await
7504 .unwrap();
7505 w.flush().await.unwrap();
7506
7507 let events = read_events(dir.path());
7508 let lines: Vec<&Value> = events
7509 .iter()
7510 .filter(|e| e["event_type"] == "inference")
7511 .collect();
7512 assert_eq!(lines.len(), 2);
7513 for line in lines {
7514 assert!(line.get("max_output_tokens").is_none(), "{line}");
7515 assert!(line.get("input_budget").is_none(), "{line}");
7516 assert!(line.get("driver_config").is_none(), "{line}");
7517 }
7518 }
7519
7520 fn driver_config_env(value: &str) -> Vec<(String, String)> {
7521 vec![
7522 ("MURMUR_INFERENCE_MODEL".to_string(), "gpt-5".to_string()),
7523 (
7524 "MURMUR_INFERENCE_DRIVER_CONFIG".to_string(),
7525 value.to_string(),
7526 ),
7527 ]
7528 }
7529
7530 fn redact_secret(text: &str) -> String {
7531 text.replace("sk-secret", crate::gateway_credential::REDACTED)
7532 }
7533
7534 #[test]
7535 fn recorded_driver_config_is_none_without_the_variable() {
7536 let env = vec![("MURMUR_INFERENCE_MODEL".to_string(), "gpt-5".to_string())];
7537 assert_eq!(recorded_driver_config(&env, redact_secret), None);
7538 }
7539
7540 #[test]
7541 fn recorded_driver_config_is_the_parsed_object() {
7542 assert_eq!(
7543 recorded_driver_config(
7544 &driver_config_env(r#"{"reasoning_effort":"low"}"#),
7545 redact_secret
7546 ),
7547 Some(serde_json::json!({"reasoning_effort": "low"}))
7548 );
7549 }
7550
7551 #[test]
7552 fn recorded_driver_config_redacts_keys_and_string_leaves_at_any_depth() {
7553 let env = driver_config_env(
7554 r#"{"note":"x-sk-secret-y","nested":{"list":["sk-secret"]},"sk-secret":1}"#,
7555 );
7556 assert_eq!(
7557 recorded_driver_config(&env, redact_secret),
7558 Some(serde_json::json!({
7559 "note": "x-[redacted]-y",
7560 "nested": {"list": ["[redacted]"]},
7561 "[redacted]": 1,
7562 }))
7563 );
7564 }
7565
7566 #[test]
7567 fn recorded_driver_config_replaces_a_number_the_redactor_changes() {
7568 let redact_pin = |text: &str| text.replace("424242", crate::gateway_credential::REDACTED);
7569 let env = driver_config_env(r#"{"pin":424242,"retries":3,"store":false,"seed":null}"#);
7570 assert_eq!(
7571 recorded_driver_config(&env, redact_pin),
7572 Some(serde_json::json!({
7573 "pin": "[redacted]",
7574 "retries": 3,
7575 "store": false,
7576 "seed": null,
7577 }))
7578 );
7579 }
7580
7581 #[test]
7582 fn recorded_driver_config_records_text_that_is_not_json_as_a_redacted_string() {
7583 assert_eq!(
7584 recorded_driver_config(&driver_config_env("effort=sk-secret"), redact_secret),
7585 Some(Value::String("effort=[redacted]".to_string()))
7586 );
7587 }
7588
7589 #[tokio::test]
7590 async fn tool_call_fields() {
7591 let dir = tempfile::tempdir().unwrap();
7592 let mut w = make_writer(dir.path()).await;
7593 w.write_tool_call(
7594 1,
7595 "bash".to_string(),
7596 None,
7597 serde_json::json!({"command": "echo hi"}),
7598 42,
7599 "hi\n",
7600 100,
7601 55,
7602 "ok".to_string(),
7603 None,
7604 None,
7605 None,
7606 )
7607 .await
7608 .unwrap();
7609 w.flush().await.unwrap();
7610
7611 let events = read_events(dir.path());
7612 let e = &events[0];
7613 assert_eq!(e["event_type"], "tool_call");
7614 assert_eq!(e["turn"], 1);
7615 assert_eq!(e["tool_name"], "bash");
7616 assert_eq!(e["input_bytes"], 42);
7617 assert_eq!(e["output_bytes"], 100);
7618 assert_eq!(e["duration_ms"], 55);
7619 assert_eq!(e["status"], "ok");
7620 assert!(
7621 e.get("state_effect").is_none(),
7622 "state_effect must be omitted when the tool declared nothing"
7623 );
7624 }
7625
7626 #[tokio::test]
7627 async fn tool_call_records_declared_state_effect() {
7628 let dir = tempfile::tempdir().unwrap();
7629 let mut w = make_writer(dir.path()).await;
7630 w.write_tool_call(
7631 0,
7632 "murmur-tool-editor".to_string(),
7633 None,
7634 serde_json::json!({"operation": "read_file", "path": "a.txt"}),
7635 10,
7636 "hi\n",
7637 3,
7638 5,
7639 "ok".to_string(),
7640 Some("read".to_string()),
7641 None,
7642 None,
7643 )
7644 .await
7645 .unwrap();
7646 w.flush().await.unwrap();
7647
7648 let events = read_events(dir.path());
7649 assert_eq!(
7650 events[0]["state_effect"], "read",
7651 "declared state_effect must be recorded verbatim on the tool_call event"
7652 );
7653 }
7654
7655 #[tokio::test]
7656 async fn tool_call_records_declared_resource_id() {
7657 let dir = tempfile::tempdir().unwrap();
7658 let mut w = make_writer(dir.path()).await;
7659 w.write_tool_call(
7660 0,
7661 "murmur-tool-code-graph".to_string(),
7662 None,
7663 serde_json::json!({"symbol": "Foo::bar"}),
7664 10,
7665 "hi\n",
7666 3,
7667 5,
7668 "ok".to_string(),
7669 Some("read".to_string()),
7670 Some("sym:Foo".to_string()),
7671 None,
7672 )
7673 .await
7674 .unwrap();
7675 w.flush().await.unwrap();
7676
7677 let events = read_events(dir.path());
7678 assert_eq!(
7679 events[0]["resource_id"], "sym:Foo",
7680 "declared resource_id must reach trace.jsonl verbatim, unparsed"
7681 );
7682 }
7683
7684 #[tokio::test]
7685 async fn tool_call_omits_undeclared_resource_id() {
7686 let dir = tempfile::tempdir().unwrap();
7687 let mut w = make_writer(dir.path()).await;
7688 w.write_tool_call(
7689 0,
7690 "bash".to_string(),
7691 None,
7692 serde_json::json!({"command": "echo hi"}),
7693 10,
7694 "hi\n",
7695 3,
7696 5,
7697 "ok".to_string(),
7698 None,
7699 None,
7700 None,
7701 )
7702 .await
7703 .unwrap();
7704 w.flush().await.unwrap();
7705
7706 let events = read_events(dir.path());
7707 assert!(
7708 events[0].get("resource_id").is_none(),
7709 "resource_id must be omitted entirely (not null) when the tool declared nothing"
7710 );
7711 }
7712
7713 #[tokio::test]
7714 async fn shell_fields() {
7715 let dir = tempfile::tempdir().unwrap();
7716 let mut w = make_writer(dir.path()).await;
7717 w.write_shell(
7718 0,
7719 "/usr/bin/echo".to_string(),
7720 "echo hi".to_string(),
7721 0,
7722 7,
7723 0,
7724 10,
7725 None,
7726 )
7727 .await
7728 .unwrap();
7729 w.flush().await.unwrap();
7730
7731 let events = read_events(dir.path());
7732 let e = &events[0];
7733 assert_eq!(e["event_type"], "shell");
7734 assert_eq!(e["binary"], "/usr/bin/echo");
7737 assert_eq!(e["command"], "echo hi");
7738 assert_eq!(e["exit_code"], 0);
7739 assert_eq!(e["stdout_bytes"], 7);
7740 assert_eq!(e["stderr_bytes"], 0);
7741 assert_eq!(e["duration_ms"], 10);
7742 }
7743
7744 #[tokio::test]
7745 async fn compaction_fields() {
7746 let dir = tempfile::tempdir().unwrap();
7747 let mut w = make_writer(dir.path()).await;
7748 w.write_compaction(3, 80000, OccupancyBasis::Actual, 20000)
7749 .await
7750 .unwrap();
7751 w.write_compaction(4, 30000, OccupancyBasis::Estimate, 10000)
7752 .await
7753 .unwrap();
7754 w.flush().await.unwrap();
7755
7756 let events = read_events(dir.path());
7757 let e = &events[0];
7758 assert_eq!(e["event_type"], "compaction");
7759 assert_eq!(e["turn"], 3);
7760 assert_eq!(e["tokens_before"], 80000);
7761 assert_eq!(e["tokens_before_source"], "actual");
7762 assert_eq!(e["tokens_after"], 20000);
7763 assert_eq!(events[1]["tokens_before_source"], "estimate");
7764 }
7765
7766 #[tokio::test]
7767 async fn hook_dispatch_error_fields() {
7768 let dir = tempfile::tempdir().unwrap();
7769 let mut w = make_writer(dir.path()).await;
7770 w.write_hook_dispatch_error("my-hook", "on-tool-call", "write-manifests")
7771 .await
7772 .unwrap();
7773 w.flush().await.unwrap();
7774
7775 let events = read_events(dir.path());
7776 let e = &events[0];
7777 assert_eq!(e["event_type"], "hook_dispatch_error");
7778 assert_eq!(e["session_id"], "test-session-id");
7779 assert_eq!(e["hook_name"], "my-hook");
7780 assert_eq!(e["event"], "on-tool-call");
7781 assert_eq!(e["arm"], "write-manifests");
7782 assert!(e["timestamp"].as_u64().unwrap() > 0);
7783 assert!(e.get("hookName").is_none(), "must use snake_case");
7785 }
7786
7787 #[tokio::test]
7788 async fn session_end_fields_and_snake_case() {
7789 let dir = tempfile::tempdir().unwrap();
7790 let mut w = make_writer(dir.path()).await;
7791 w.write_inference(
7792 0,
7793 Some(100),
7794 Some(50),
7795 "tool_call".to_string(),
7796 Some("tool_call"),
7797 None,
7798 None,
7799 None,
7800 Vec::new(),
7801 None,
7802 None,
7803 )
7804 .await
7805 .unwrap();
7806 w.write_tool_call(
7807 0,
7808 "bash".to_string(),
7809 None,
7810 serde_json::json!({"command": "ls"}),
7811 10,
7812 "file.txt\n",
7813 20,
7814 5,
7815 "ok".to_string(),
7816 None,
7817 None,
7818 None,
7819 )
7820 .await
7821 .unwrap();
7822 w.write_shell(0, "/bin/ls".to_string(), "ls".to_string(), 0, 3, 0, 2, None)
7823 .await
7824 .unwrap();
7825 w.write_session_end("ok").await.unwrap();
7826 w.flush().await.unwrap();
7827
7828 let events = read_events(dir.path());
7829 let se = events.last().unwrap();
7830 assert_eq!(se["event_type"], "session_end");
7831 assert_eq!(se["total_turns"], 1);
7832 assert_eq!(se["total_input_tokens"], 100);
7833 assert_eq!(se["total_output_tokens"], 50);
7834 assert_eq!(se["total_tool_calls"], 1);
7835 assert_eq!(se["total_shell_calls"], 1);
7836 assert_eq!(se["exit_status"], "ok");
7837 assert!(se["duration_ms"].as_u64().is_some());
7838 assert!(se.get("exitStatus").is_none(), "must use snake_case");
7840 assert!(se.get("totalTurns").is_none(), "must use snake_case");
7841 }
7842
7843 #[tokio::test]
7844 async fn write_session_end_if_not_ended_is_noop_when_already_ended() {
7845 let dir = tempfile::tempdir().unwrap();
7846 let mut w = make_writer(dir.path()).await;
7847 w.write_session_start(Some(10), None, vec![]).await.unwrap();
7848 w.write_session_end("ok").await.unwrap();
7849 w.write_session_end_if_not_ended("failed").await.unwrap();
7850 w.flush().await.unwrap();
7851
7852 let events = read_events(dir.path());
7853 assert_eq!(
7854 events
7855 .iter()
7856 .filter(|e| e["event_type"] == "session_end")
7857 .count(),
7858 1,
7859 "session_end should appear exactly once"
7860 );
7861 }
7862
7863 #[tokio::test]
7864 async fn write_session_end_if_not_ended_writes_when_started_not_ended() {
7865 let dir = tempfile::tempdir().unwrap();
7866 let mut w = make_writer(dir.path()).await;
7867 w.write_session_start(Some(10), None, vec![]).await.unwrap();
7868 w.write_session_end_if_not_ended("failed").await.unwrap();
7870 w.flush().await.unwrap();
7871
7872 let events = read_events(dir.path());
7873 let se = events
7874 .iter()
7875 .find(|e| e["event_type"] == "session_end")
7876 .unwrap();
7877 assert_eq!(se["exit_status"], "failed");
7878 }
7879
7880 #[tokio::test]
7881 async fn no_session_end_written_when_session_never_started() {
7882 let dir = tempfile::tempdir().unwrap();
7883 let mut w = make_writer(dir.path()).await;
7884 w.write_session_end_if_not_ended("failed").await.unwrap();
7886 w.flush().await.unwrap();
7887
7888 let events = read_events(dir.path());
7889 assert!(
7890 events.iter().all(|e| e["event_type"] != "session_end"),
7891 "session_end must not appear when session_start was never written"
7892 );
7893 }
7894
7895 #[tokio::test]
7896 async fn session_id_on_every_event() {
7897 let dir = tempfile::tempdir().unwrap();
7898 let mut w = make_writer(dir.path()).await;
7899 w.write_session_start(Some(10), None, vec!["bash".to_string()])
7900 .await
7901 .unwrap();
7902 w.write_inference(
7903 0,
7904 Some(10),
7905 Some(5),
7906 "end_turn".to_string(),
7907 Some("end_turn"),
7908 None,
7909 None,
7910 None,
7911 Vec::new(),
7912 None,
7913 None,
7914 )
7915 .await
7916 .unwrap();
7917 w.write_session_end("ok").await.unwrap();
7918 w.flush().await.unwrap();
7919
7920 let events = read_events(dir.path());
7921 for e in &events {
7922 assert_eq!(e["session_id"], "test-session-id");
7923 }
7924 }
7925
7926 #[tokio::test]
7927 async fn counts_match_per_event_sum() {
7928 let dir = tempfile::tempdir().unwrap();
7929 let mut w = make_writer(dir.path()).await;
7930 w.write_session_start(Some(10), None, vec![]).await.unwrap();
7931 w.write_inference(
7932 0,
7933 Some(50),
7934 Some(25),
7935 "tool_call".to_string(),
7936 Some("tool_call"),
7937 Some("bash".to_string()),
7938 None,
7939 None,
7940 vec!["msg_0000000000000000000000000000000a".to_string()],
7941 None,
7942 None,
7943 )
7944 .await
7945 .unwrap();
7946 w.write_tool_call(
7947 0,
7948 "bash".to_string(),
7949 None,
7950 serde_json::json!({"command": "echo"}),
7951 5,
7952 "ok",
7953 10,
7954 3,
7955 "ok".to_string(),
7956 None,
7957 None,
7958 None,
7959 )
7960 .await
7961 .unwrap();
7962 w.write_shell(
7963 0,
7964 "/bin/echo".to_string(),
7965 "echo".to_string(),
7966 0,
7967 4,
7968 0,
7969 1,
7970 None,
7971 )
7972 .await
7973 .unwrap();
7974 w.write_inference(
7975 1,
7976 Some(60),
7977 Some(30),
7978 "end_turn".to_string(),
7979 Some("end_turn"),
7980 None,
7981 None,
7982 None,
7983 Vec::new(),
7984 None,
7985 None,
7986 )
7987 .await
7988 .unwrap();
7989 w.write_session_end("ok").await.unwrap();
7990 w.flush().await.unwrap();
7991
7992 let events = read_events(dir.path());
7993 let inference_count = events
7994 .iter()
7995 .filter(|e| e["event_type"] == "inference")
7996 .count();
7997 let tool_count = events
7998 .iter()
7999 .filter(|e| e["event_type"] == "tool_call")
8000 .count();
8001 let shell_count = events.iter().filter(|e| e["event_type"] == "shell").count();
8002
8003 let se = events
8004 .iter()
8005 .find(|e| e["event_type"] == "session_end")
8006 .unwrap();
8007 assert_eq!(
8008 se["total_turns"].as_u64().unwrap() as usize,
8009 inference_count
8010 );
8011 assert_eq!(
8012 se["total_tool_calls"].as_u64().unwrap() as usize,
8013 tool_count
8014 );
8015 assert_eq!(
8016 se["total_shell_calls"].as_u64().unwrap() as usize,
8017 shell_count
8018 );
8019 }
8020
8021 #[tokio::test]
8022 async fn tool_call_input_always_present() {
8023 let dir = tempfile::tempdir().unwrap();
8024 let mut w = make_writer(dir.path()).await; w.write_tool_call(
8026 0,
8027 "bash".to_string(),
8028 None,
8029 serde_json::json!({"command": "ls -la"}),
8030 20,
8031 "total 8\n",
8032 80,
8033 12,
8034 "ok".to_string(),
8035 None,
8036 None,
8037 None,
8038 )
8039 .await
8040 .unwrap();
8041 w.flush().await.unwrap();
8042
8043 let events = read_events(dir.path());
8044 let e = &events[0];
8045 assert_eq!(e["event_type"], "tool_call");
8046 assert_eq!(e["input"]["command"], "ls -la");
8047 assert!(e.get("input").is_some(), "input must always be present");
8048 }
8049
8050 async fn tool_call_with_error(
8052 dir: &std::path::Path,
8053 capture: TraceCapture,
8054 error: Option<&str>,
8055 ) -> Value {
8056 let mut w = make_writer_with_opts(dir, capture).await;
8057 w.write_tool_call(
8058 0,
8059 "tool-fixture".to_string(),
8060 Some("toolu_1".to_string()),
8061 serde_json::json!({}),
8062 2,
8063 "shell execution failed early",
8064 28,
8065 3,
8066 "error".to_string(),
8067 None,
8068 None,
8069 error,
8070 )
8071 .await
8072 .unwrap();
8073 w.flush().await.unwrap();
8074 read_events(dir).remove(0)
8075 }
8076
8077 #[tokio::test]
8078 async fn tool_call_error_is_written_without_output_under_meta() {
8079 let dir = tempfile::tempdir().unwrap();
8080 let text = "sandbox: shell enforcement setup failed before exec: Landlock: \
8081 Too many open files (os error 24)";
8082 let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(text)).await;
8083 assert_eq!(e["error"], text);
8084 assert!(e.get("output").is_none(), "meta stores no output: {e}");
8085 }
8086
8087 #[tokio::test]
8088 async fn tool_call_error_is_written_under_every_capture() {
8089 for capture in [
8090 TraceCapture::None,
8091 TraceCapture::Meta,
8092 TraceCapture::Content,
8093 ] {
8094 let dir = tempfile::tempdir().unwrap();
8095 let e = tool_call_with_error(dir.path(), capture, Some("spawn failed")).await;
8096 assert_eq!(e["error"], "spawn failed", "{capture:?}: {e}");
8097 }
8098 }
8099
8100 #[tokio::test]
8101 async fn tool_call_error_is_capped_on_a_char_boundary() {
8102 let dir = tempfile::tempdir().unwrap();
8103 let long = format!("a{}", "é".repeat(2_999));
8106 assert_eq!(long.chars().count(), 3_000);
8107 let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(&long)).await;
8108 let error = e["error"].as_str().unwrap();
8109 assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
8110 assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
8111 assert!(long.starts_with(error));
8112 }
8113
8114 #[tokio::test]
8115 async fn tool_call_error_redacts_a_peer_handle() {
8116 let dir = tempfile::tempdir().unwrap();
8117 let token = crate::peer_handoff::mint(
8118 &crate::peer_handoff::PeerMintKey::generate().unwrap(),
8119 &crate::peer_handoff::HandlePayload {
8120 v: 1,
8121 iss: "ses_1".to_string(),
8122 p: "report.md".to_string(),
8123 exp: timestamp_ms() + 3_600_000,
8124 n: "0123456789abcdef0123456789abcdef".to_string(),
8125 },
8126 "a@b",
8127 )
8128 .unwrap();
8129 let text = format!("fetch-peer-file {token} failed");
8130 let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(&text)).await;
8131 let id = crate::peer_handoff::handle_id(&token);
8132 assert_eq!(e["error"], format!("fetch-peer-file <handle:{id}> failed"));
8133 }
8134
8135 #[tokio::test]
8136 async fn tool_call_without_an_error_has_no_error_key() {
8137 let dir = tempfile::tempdir().unwrap();
8138 let e = tool_call_with_error(dir.path(), TraceCapture::Meta, None).await;
8139 assert!(e.get("error").is_none(), "{e}");
8140 }
8141
8142 #[tokio::test]
8143 async fn tool_call_output_absent_by_default() {
8144 let dir = tempfile::tempdir().unwrap();
8145 let mut w = make_writer(dir.path()).await; w.write_tool_call(
8147 0,
8148 "bash".to_string(),
8149 None,
8150 serde_json::json!({"command": "ls"}),
8151 10,
8152 "file.txt\n",
8153 90,
8154 8,
8155 "ok".to_string(),
8156 None,
8157 None,
8158 None,
8159 )
8160 .await
8161 .unwrap();
8162 w.flush().await.unwrap();
8163
8164 let events = read_events(dir.path());
8165 let e = &events[0];
8166 assert!(
8167 e.get("output").is_none(),
8168 "output must be absent under a capture mode that stores no bodies"
8169 );
8170 assert_eq!(e["output_bytes"], 90, "output_bytes must still be recorded");
8171 }
8172
8173 #[tokio::test]
8174 async fn skill_call_writes_skill_call_event() {
8175 let dir = tempfile::tempdir().unwrap();
8176 let mut w = make_writer(dir.path()).await;
8177 w.write_skill_call(
8178 2,
8179 "my-skill".to_string(),
8180 &LockOrigin::Operator,
8181 1024,
8182 8,
8183 "ok".to_string(),
8184 )
8185 .await
8186 .unwrap();
8187 w.flush().await.unwrap();
8188
8189 let events = read_events(dir.path());
8190 let e = &events[0];
8191 assert_eq!(e["event_type"], "skill_call");
8192 assert_eq!(e["turn"], 2);
8193 assert_eq!(e["skill_name"], "my-skill");
8194 assert_eq!(e["output_bytes"], 1024);
8195 assert_eq!(e["duration_ms"], 8);
8196 assert_eq!(e["status"], "ok");
8197 }
8198
8199 #[tokio::test]
8200 async fn skill_call_does_not_increment_total_tool_calls() {
8201 let dir = tempfile::tempdir().unwrap();
8202 let mut w = make_writer(dir.path()).await;
8203 w.write_session_start(Some(10), None, vec![]).await.unwrap();
8204 w.write_tool_call(
8205 0,
8206 "real-tool".to_string(),
8207 None,
8208 serde_json::json!({}),
8209 2,
8210 "data",
8211 4,
8212 5,
8213 "ok".to_string(),
8214 None,
8215 None,
8216 None,
8217 )
8218 .await
8219 .unwrap();
8220 w.write_skill_call(
8221 1,
8222 "my-skill".to_string(),
8223 &LockOrigin::Operator,
8224 512,
8225 3,
8226 "ok".to_string(),
8227 )
8228 .await
8229 .unwrap();
8230 w.write_session_end("ok").await.unwrap();
8231 w.flush().await.unwrap();
8232
8233 let events = read_events(dir.path());
8234 let se = events
8235 .iter()
8236 .find(|e| e["event_type"] == "session_end")
8237 .unwrap();
8238 assert_eq!(
8239 se["total_tool_calls"], 1,
8240 "skill_call must not inflate total_tool_calls"
8241 );
8242 }
8243
8244 #[tokio::test]
8245 async fn tool_call_output_present_when_opted_in() {
8246 let dir = tempfile::tempdir().unwrap();
8247 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
8248 w.write_tool_call(
8249 0,
8250 "bash".to_string(),
8251 None,
8252 serde_json::json!({"command": "echo hello"}),
8253 22,
8254 "hello\n",
8255 60,
8256 5,
8257 "ok".to_string(),
8258 None,
8259 None,
8260 None,
8261 )
8262 .await
8263 .unwrap();
8264 w.flush().await.unwrap();
8265
8266 let events = read_events(dir.path());
8267 let e = &events[0];
8268 assert_eq!(
8269 e["output"].as_str().unwrap(),
8270 "hello\n",
8271 "output must be present under capture: content"
8272 );
8273 assert_eq!(e["output_bytes"], 60);
8274 }
8275
8276 #[tokio::test]
8277 async fn task_end_carries_reopen_count() {
8278 let dir = tempfile::tempdir().unwrap();
8279 let mut w = make_writer(dir.path()).await;
8280 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8281 .await
8282 .unwrap();
8283 w.write_task_end("tsk_1", "ok", 2, Vec::new(), None, None)
8284 .await
8285 .unwrap();
8286 w.flush().await.unwrap();
8287
8288 let events = read_events(dir.path());
8289 let end = events
8290 .iter()
8291 .find(|e| e["event_type"] == "task_end")
8292 .unwrap();
8293 assert_eq!(end["task_id"], "tsk_1");
8294 assert_eq!(end["exit_status"], "ok");
8295 assert_eq!(end["reopen_count"], 2);
8296 assert_eq!(
8297 end["capped_turns"], 0,
8298 "always present, 0 when no turn was capped"
8299 );
8300 }
8301
8302 #[tokio::test]
8305 async fn task_end_counts_capped_turns_per_task() {
8306 let dir = tempfile::tempdir().unwrap();
8307 let mut w = make_writer(dir.path()).await;
8308 for task in ["tsk_1", "tsk_2"] {
8309 w.write_task_start(task, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8310 .await
8311 .unwrap();
8312 let stops: &[&str] = if task == "tsk_1" {
8313 &["max_tokens", "tool_call", "max_tokens", "end_turn"]
8314 } else {
8315 &["end_turn"]
8316 };
8317 for (turn, stop) in stops.iter().enumerate() {
8318 let decision = if *stop == "max_tokens" {
8319 "capped"
8320 } else {
8321 stop
8322 };
8323 w.write_turn_inference(
8324 u32::try_from(turn).unwrap(),
8325 Some(10),
8326 Some(5),
8327 decision.to_string(),
8328 Some(stop),
8329 None,
8330 None,
8331 Vec::new(),
8332 None,
8333 None,
8334 None,
8335 RetryNote::default(),
8336 None,
8337 )
8338 .await
8339 .unwrap();
8340 }
8341 w.write_task_end(task, "ok", 0, Vec::new(), None, None)
8342 .await
8343 .unwrap();
8344 }
8345 w.flush().await.unwrap();
8346
8347 let capped: Vec<_> = read_events(dir.path())
8348 .iter()
8349 .filter(|e| e["event_type"] == "task_end")
8350 .map(|e| e["capped_turns"].clone())
8351 .collect();
8352 assert_eq!(capped, vec![serde_json::json!(2), serde_json::json!(0)]);
8353 }
8354
8355 #[tokio::test]
8358 async fn task_end_carries_keep_working_only_when_set() {
8359 let dir = tempfile::tempdir().unwrap();
8360 let mut w = make_writer(dir.path()).await;
8361 for (task_id, keep_working) in [
8362 (
8363 "tsk_1",
8364 Some(KeepWorkingSummary {
8365 used: 2,
8366 limit: 2,
8367 stopped_by: Some("limit"),
8368 }),
8369 ),
8370 (
8371 "tsk_2",
8372 Some(KeepWorkingSummary {
8373 used: 1,
8374 limit: 5,
8375 stopped_by: None,
8376 }),
8377 ),
8378 ("tsk_3", None),
8379 ] {
8380 w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8381 .await
8382 .unwrap();
8383 w.write_task_end(task_id, "ok", 0, Vec::new(), keep_working, None)
8384 .await
8385 .unwrap();
8386 }
8387 w.flush().await.unwrap();
8388
8389 let events = read_events(dir.path());
8390 let ends: Vec<_> = events
8391 .iter()
8392 .filter(|e| e["event_type"] == "task_end")
8393 .collect();
8394 assert_eq!(
8395 ends[0]["keep_working"],
8396 serde_json::json!({"used": 2, "limit": 2, "stopped_by": "limit"})
8397 );
8398 assert_eq!(
8399 ends[1]["keep_working"],
8400 serde_json::json!({"used": 1, "limit": 5, "stopped_by": null})
8401 );
8402 assert!(ends[2].get("keep_working").is_none(), "{}", ends[2]);
8403 }
8404
8405 #[tokio::test]
8408 async fn keep_working_record_carries_its_fields_and_feeds_session_status() {
8409 let dir = tempfile::tempdir().unwrap();
8410 let mut w = make_writer(dir.path()).await;
8411 let status = std::sync::Arc::new(crate::session_status::SessionStatus::new(
8412 std::time::Instant::now(),
8413 false,
8414 None,
8415 None,
8416 0,
8417 Some(3),
8418 None,
8419 ));
8420 w.set_session_status(std::sync::Arc::clone(&status));
8421 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8422 .await
8423 .unwrap();
8424 w.write_keep_working("tsk_1", 1, 3, Some(4), 66)
8425 .await
8426 .unwrap();
8427 w.write_keep_working("tsk_1", 2, 3, None, 66).await.unwrap();
8428 w.flush().await.unwrap();
8429
8430 let events = read_events(dir.path());
8431 let start = events
8432 .iter()
8433 .find(|e| e["event_type"] == "task_start")
8434 .unwrap();
8435 let records: Vec<_> = events
8436 .iter()
8437 .filter(|e| e["event_type"] == "keep_working")
8438 .collect();
8439 assert_eq!(records.len(), 2);
8440 for (record, (number, turns)) in records
8441 .iter()
8442 .zip([(1, serde_json::json!(4)), (2, serde_json::Value::Null)])
8443 {
8444 assert_eq!(record["task_id"], "tsk_1");
8445 assert_eq!(record["number"], number);
8446 assert_eq!(record["limit"], 3);
8447 assert_eq!(record["turns_remaining"], turns);
8448 assert_eq!(record["message_bytes"], 66);
8449 assert_eq!(record["parent_id"], start["event_id"]);
8450 assert!(record["event_id"].is_string());
8451 }
8452 let spend = crate::session_status::SpendReading {
8453 used: 0,
8454 ceiling: None,
8455 machine: None,
8456 };
8457 assert_eq!(
8458 serde_json::to_value(status.snapshot(spend)).unwrap()["keep_working"],
8459 serde_json::json!({"used": 2, "left": 1})
8460 );
8461 }
8462
8463 #[tokio::test]
8465 async fn task_end_carries_done_check_only_when_set() {
8466 let dir = tempfile::tempdir().unwrap();
8467 let mut w = make_writer(dir.path()).await;
8468 for (task_id, done_check) in [
8469 (
8470 "tsk_1",
8471 Some(DoneCheckSummary {
8472 runs: 3,
8473 reopens: 2,
8474 limit: 2,
8475 met: Some(false),
8476 stopped_by: Some("limit"),
8477 }),
8478 ),
8479 (
8480 "tsk_2",
8481 Some(DoneCheckSummary {
8482 runs: 0,
8483 reopens: 0,
8484 limit: 1,
8485 met: None,
8486 stopped_by: None,
8487 }),
8488 ),
8489 ("tsk_3", None),
8490 ] {
8491 w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8492 .await
8493 .unwrap();
8494 w.write_task_end(task_id, "ok", 0, Vec::new(), None, done_check)
8495 .await
8496 .unwrap();
8497 }
8498 w.flush().await.unwrap();
8499
8500 let events = read_events(dir.path());
8501 let ends: Vec<_> = events
8502 .iter()
8503 .filter(|e| e["event_type"] == "task_end")
8504 .collect();
8505 assert_eq!(
8506 ends[0]["done_check"],
8507 serde_json::json!({"runs": 3, "reopens": 2, "limit": 2, "met": false,
8508 "stopped_by": "limit"})
8509 );
8510 assert_eq!(
8511 ends[1]["done_check"],
8512 serde_json::json!({"runs": 0, "reopens": 0, "limit": 1, "met": null,
8513 "stopped_by": null})
8514 );
8515 assert!(ends[2].get("done_check").is_none(), "{}", ends[2]);
8516 }
8517
8518 #[tokio::test]
8521 async fn done_check_record_carries_its_fields_and_feeds_session_status() {
8522 let dir = tempfile::tempdir().unwrap();
8523 let mut w = make_writer(dir.path()).await;
8524 let status = std::sync::Arc::new(crate::session_status::SessionStatus::new(
8525 std::time::Instant::now(),
8526 false,
8527 None,
8528 None,
8529 0,
8530 None,
8531 Some(3),
8532 ));
8533 w.set_session_status(std::sync::Arc::clone(&status));
8534 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8535 .await
8536 .unwrap();
8537 let mut run = DoneCheckRun {
8538 task_id: "tsk_1",
8539 number: 1,
8540 command: "bash -c 'exit 1'",
8541 outcome: "failed",
8542 exit_code: Some(1),
8543 duration_ms: 12,
8544 stdout_bytes: 5,
8545 stderr_bytes: 0,
8546 truncated: false,
8547 resource_limit: None,
8548 reopened: true,
8549 reopens_left: 2,
8550 turns_remaining: Some(4),
8551 };
8552 w.write_done_check(&run, 1).await.unwrap();
8553 run.number = 2;
8554 run.outcome = "timed_out";
8555 run.exit_code = None;
8556 run.reopened = false;
8557 run.turns_remaining = None;
8558 w.write_done_check(&run, 1).await.unwrap();
8559 w.flush().await.unwrap();
8560
8561 let text = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
8562 let line = text
8563 .lines()
8564 .find(|line| line.contains("\"done_check\""))
8565 .unwrap();
8566 let order: Vec<usize> = [
8567 "event_type",
8568 "event_id",
8569 "parent_id",
8570 "session_id",
8571 "timestamp",
8572 "task_id",
8573 "number",
8574 "command",
8575 "outcome",
8576 "exit_code",
8577 "duration_ms",
8578 "stdout_bytes",
8579 "stderr_bytes",
8580 "truncated",
8581 "resource_limit",
8582 "reopened",
8583 "reopens_left",
8584 "turns_remaining",
8585 ]
8586 .iter()
8587 .map(|key| line.find(&format!("\"{key}\":")).unwrap())
8588 .collect();
8589 assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{line}");
8590
8591 let events = read_events(dir.path());
8592 let start = events
8593 .iter()
8594 .find(|e| e["event_type"] == "task_start")
8595 .unwrap();
8596 let records: Vec<_> = events
8597 .iter()
8598 .filter(|e| e["event_type"] == "done_check")
8599 .collect();
8600 assert_eq!(records.len(), 2);
8601 assert_eq!(records[0]["parent_id"], start["event_id"]);
8602 assert_eq!(records[0]["command"], "bash -c 'exit 1'");
8603 assert_eq!(records[0]["exit_code"], 1);
8604 assert_eq!(records[0]["reopened"], true);
8605 assert_eq!(records[0]["turns_remaining"], 4);
8606 assert_eq!(records[1]["outcome"], "timed_out");
8607 assert_eq!(records[1]["exit_code"], serde_json::Value::Null);
8608 assert_eq!(records[1]["resource_limit"], serde_json::Value::Null);
8609 assert_eq!(records[1]["turns_remaining"], serde_json::Value::Null);
8610 let spend = crate::session_status::SpendReading {
8611 used: 0,
8612 ceiling: None,
8613 machine: None,
8614 };
8615 assert_eq!(
8616 serde_json::to_value(status.snapshot(spend)).unwrap()["done_check"],
8617 serde_json::json!({"used": 1, "left": 2})
8618 );
8619 }
8620
8621 #[tokio::test]
8624 async fn task_end_names_the_commands_still_running() {
8625 let dir = tempfile::tempdir().unwrap();
8626 let mut w = make_writer(dir.path()).await;
8627 for (task_id, running) in [
8628 ("tsk_1", vec!["wrk_0a".to_string(), "wrk_0b".to_string()]),
8629 ("tsk_2", Vec::new()),
8630 ] {
8631 w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8632 .await
8633 .unwrap();
8634 w.write_task_end(task_id, "ok", 0, running, None, None)
8635 .await
8636 .unwrap();
8637 }
8638 w.flush().await.unwrap();
8639
8640 let events = read_events(dir.path());
8641 let ends: Vec<_> = events
8642 .iter()
8643 .filter(|e| e["event_type"] == "task_end")
8644 .collect();
8645 assert_eq!(
8646 ends[0]["running_commands"],
8647 serde_json::json!(["wrk_0a", "wrk_0b"])
8648 );
8649 assert!(ends[1].get("running_commands").is_none(), "{}", ends[1]);
8650 }
8651
8652 #[tokio::test]
8656 async fn shell_completed_names_its_delivery_and_exit_time() {
8657 let dir = tempfile::tempdir().unwrap();
8658 let mut w = make_writer(dir.path()).await;
8659 let appender = ResourceTraceAppender::open(
8660 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
8661 "test-session-id".to_string(),
8662 w.session_event_id().to_string(),
8663 )
8664 .await
8665 .unwrap();
8666 let completion = crate::detached::DetachedCompletion {
8667 work_id: "wrk_0a".to_string(),
8668 binary: "/usr/bin/bash".to_string(),
8669 command: "sleep 3".to_string(),
8670 exit_code: 0,
8671 duration_ms: 3_000,
8672 exited_at_ms: 1_750,
8673 output_path: "/records/logs/wrk_0a.log".to_string(),
8674 output_bytes: 10,
8675 resource_limit: None,
8676 output_holders: crate::shell::OutputHolders::Closed,
8677 context_id: "ctx_1".to_string(),
8678 provenance: crate::origin::TaskProvenance::derive(
8679 crate::origin::TaskOrigin::Completion,
8680 None,
8681 ),
8682 error: None,
8683 };
8684 w.write_session_start(None, None, Vec::new()).await.unwrap();
8685 w.flush().await.unwrap();
8686 appender.write_shell_completed(&completion).await;
8687 w.write_shell_completed(&completion, "tsk_2").await.unwrap();
8688 w.flush().await.unwrap();
8689
8690 let events = read_events(dir.path());
8691 let completed: Vec<_> = events
8692 .iter()
8693 .filter(|e| e["event_type"] == "shell_completed")
8694 .collect();
8695 assert_eq!(completed.len(), 2);
8696 assert_eq!(completed[0]["delivered_via"], "background_command");
8697 assert!(
8698 completed[0].get("completion_task_id").is_none(),
8699 "{}",
8700 completed[0]
8701 );
8702 assert_eq!(completed[1]["delivered_via"], "task");
8703 assert_eq!(completed[1]["completion_task_id"], "tsk_2");
8704 for record in &completed {
8705 assert_eq!(record["exited_at_ms"], 1_750);
8706 assert_eq!(record["work_id"], "wrk_0a");
8707 assert_eq!(record["status"], "ok");
8708 assert_eq!(record["duration_ms"], 3_000);
8709 assert_eq!(record["session_id"], "test-session-id");
8710 assert_eq!(record["parent_id"].as_str(), Some(w.session_event_id()));
8711 }
8712 }
8713
8714 #[tokio::test]
8715 async fn task_reopened_names_hook_reason_and_ordinal() {
8716 let dir = tempfile::tempdir().unwrap();
8717 let mut w = make_writer(dir.path()).await;
8718 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8719 .await
8720 .unwrap();
8721 w.write_task_reopened(
8722 "tsk_1",
8723 "gatekeeper",
8724 "tests still fail",
8725 1,
8726 REOPEN_CONTEXT_CONTINUED,
8727 Some(7),
8728 )
8729 .await
8730 .unwrap();
8731 w.flush().await.unwrap();
8732
8733 let events = read_events(dir.path());
8734 let re = events
8735 .iter()
8736 .find(|e| e["event_type"] == "task_reopened")
8737 .unwrap();
8738 assert_eq!(re["task_id"], "tsk_1");
8739 assert_eq!(re["hook_name"], "gatekeeper");
8740 assert_eq!(re["reason"], "tests still fail");
8741 assert_eq!(re["reopen_number"], 1);
8742 assert_eq!(re["attempt_context"], "continued");
8743 assert_eq!(re["turns_remaining"], 7);
8744
8745 w.write_task_reopened(
8746 "tsk_1",
8747 "gatekeeper",
8748 "again",
8749 2,
8750 REOPEN_CONTEXT_CONTINUED,
8751 None,
8752 )
8753 .await
8754 .unwrap();
8755 w.flush().await.unwrap();
8756 let uncapped = read_events(dir.path())
8757 .into_iter()
8758 .filter(|e| e["event_type"] == "task_reopened")
8759 .nth(1)
8760 .unwrap();
8761 assert!(uncapped
8762 .get("turns_remaining")
8763 .is_some_and(serde_json::Value::is_null));
8764 assert!(re["timestamp"].as_u64().unwrap() > 0);
8765 }
8766
8767 #[tokio::test]
8768 async fn task_continued_names_the_round_under_the_task_node() {
8769 let dir = tempfile::tempdir().unwrap();
8770 let mut w = make_writer(dir.path()).await;
8771 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8772 .await
8773 .unwrap();
8774 w.write_task_continued(
8775 "tsk_1",
8776 1,
8777 &["mcl_b".to_string(), "mcl_a".to_string()],
8778 &["dlg_1".to_string()],
8779 1234,
8780 Some(8),
8781 )
8782 .await
8783 .unwrap();
8784 w.flush().await.unwrap();
8785
8786 let events = read_events(dir.path());
8787 let start = events
8788 .iter()
8789 .find(|e| e["event_type"] == "task_start")
8790 .unwrap();
8791 let continued = events
8792 .iter()
8793 .find(|e| e["event_type"] == "task_continued")
8794 .unwrap();
8795 assert_eq!(continued["parent_id"], start["event_id"]);
8796 assert_eq!(continued["task_id"], "tsk_1");
8797 assert_eq!(continued["continuation_number"], 1);
8798 assert_eq!(
8799 continued["member_calls"],
8800 serde_json::json!(["mcl_b", "mcl_a"])
8801 );
8802 assert_eq!(continued["delegations"], serde_json::json!(["dlg_1"]));
8803 assert_eq!(continued["waited_ms"], 1234);
8804 assert_eq!(continued["turns_remaining"], 8);
8805 }
8806
8807 #[tokio::test]
8811 async fn task_turns_accessor_tracks_and_resets() {
8812 let dir = tempfile::tempdir().unwrap();
8813 let mut w = make_writer(dir.path()).await;
8814 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8815 .await
8816 .unwrap();
8817 assert_eq!(w.task_turns(), 0);
8818 w.write_inference(
8819 0,
8820 Some(10),
8821 Some(5),
8822 "end_turn".to_string(),
8823 Some("end_turn"),
8824 None,
8825 None,
8826 None,
8827 Vec::new(),
8828 None,
8829 None,
8830 )
8831 .await
8832 .unwrap();
8833 w.write_inference(
8834 1,
8835 Some(10),
8836 Some(5),
8837 "end_turn".to_string(),
8838 Some("end_turn"),
8839 None,
8840 None,
8841 None,
8842 Vec::new(),
8843 None,
8844 None,
8845 )
8846 .await
8847 .unwrap();
8848 assert_eq!(w.task_turns(), 2);
8849 w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
8851 .await
8852 .unwrap();
8853 assert_eq!(w.task_turns(), 0);
8854 }
8855
8856 #[tokio::test]
8859 async fn task_agent_turns_counts_agent_loop_inference_only() {
8860 let dir = tempfile::tempdir().unwrap();
8861 let mut w = make_writer(dir.path()).await;
8862 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8863 .await
8864 .unwrap();
8865 assert_eq!(w.task_agent_turns(), 0);
8866 let hook = InferenceOrigin {
8867 source: "hook:x".into(),
8868 model: "m".into(),
8869 };
8870 for (turn, origin) in [(0, None), (0, Some(&hook)), (1, None)] {
8871 w.write_inference(
8872 turn,
8873 Some(10),
8874 Some(5),
8875 "end_turn".to_string(),
8876 Some("end_turn"),
8877 None,
8878 origin,
8879 None,
8880 Vec::new(),
8881 None,
8882 None,
8883 )
8884 .await
8885 .unwrap();
8886 }
8887 assert_eq!(w.task_agent_turns(), 2);
8888 assert_eq!(w.task_turns(), 3);
8889 assert_eq!(w.task_hook_inferences(), 1);
8890 w.write_task_end("tsk_1", "completed", 0, Vec::new(), None, None)
8891 .await
8892 .unwrap();
8893 assert_eq!(
8894 w.task_agent_turns(),
8895 0,
8896 "a run outside a task frame starts at 0"
8897 );
8898 w.write_inference(
8899 0,
8900 Some(10),
8901 Some(5),
8902 "end_turn".to_string(),
8903 Some("end_turn"),
8904 None,
8905 None,
8906 None,
8907 Vec::new(),
8908 None,
8909 None,
8910 )
8911 .await
8912 .unwrap();
8913 w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
8914 .await
8915 .unwrap();
8916 assert_eq!(w.task_agent_turns(), 0);
8917 }
8918
8919 #[tokio::test]
8925 async fn every_event_carries_a_unique_id_and_parents_to_an_earlier_event() {
8926 let dir = tempfile::tempdir().unwrap();
8927 let mut w = make_writer(dir.path()).await;
8928 w.write_session_start(Some(10), None, vec!["bash".to_string()])
8929 .await
8930 .unwrap();
8931 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8932 .await
8933 .unwrap();
8934 w.write_inference(
8935 0,
8936 Some(10),
8937 Some(5),
8938 "tool_call".to_string(),
8939 Some("tool_call"),
8940 None,
8941 None,
8942 None,
8943 Vec::new(),
8944 None,
8945 None,
8946 )
8947 .await
8948 .unwrap();
8949 w.write_tool_call(
8950 0,
8951 "bash".to_string(),
8952 Some("toolu_1".to_string()),
8953 serde_json::json!({"command": "echo hi"}),
8954 10,
8955 "hi\n",
8956 3,
8957 5,
8958 "ok".to_string(),
8959 None,
8960 None,
8961 None,
8962 )
8963 .await
8964 .unwrap();
8965 w.write_shell(
8966 0,
8967 "/bin/bash".to_string(),
8968 "echo hi".to_string(),
8969 0,
8970 3,
8971 0,
8972 2,
8973 None,
8974 )
8975 .await
8976 .unwrap();
8977 w.write_artifact_pulled(&pulled(
8978 runtime_summary("pulled-style", ArtifactRuntime::Skill),
8979 "global store",
8980 None,
8981 ))
8982 .await
8983 .unwrap();
8984 w.write_task_end("tsk_1", "ok", 0, Vec::new(), None, None)
8985 .await
8986 .unwrap();
8987 w.write_session_end("ok").await.unwrap();
8988 w.flush().await.unwrap();
8989
8990 let events = read_events(dir.path());
8991 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8992 for (i, event) in events.iter().enumerate() {
8993 let id = event["event_id"].as_str().unwrap();
8994 assert!(id.starts_with("evt_") && id.len() == 36, "event {i}: {id}");
8995 assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()), "event {i}");
8996 assert!(seen.insert(id.to_string()), "event {i} reuses {id}");
8997 assert!(
8998 event.get("parent_id").is_some(),
8999 "event {i} has no parent_id"
9000 );
9001 if let Some(parent) = event["parent_id"].as_str() {
9002 assert!(seen.contains(parent), "event {i} names dangling {parent}");
9003 }
9004 }
9005
9006 let by_type = |ty: &str| {
9007 events
9008 .iter()
9009 .find(|e| e["event_type"] == ty)
9010 .unwrap_or_else(|| panic!("no {ty}"))
9011 .clone()
9012 };
9013 let session = by_type("session_start");
9014 assert!(session["parent_id"].is_null());
9015 assert_eq!(session["event_id"], w.session_event_id());
9016
9017 let task = by_type("task_start");
9018 assert_eq!(task["parent_id"], session["event_id"]);
9019
9020 let turn = by_type("inference");
9021 assert_eq!(turn["parent_id"], task["event_id"]);
9022
9023 assert_eq!(by_type("tool_call")["parent_id"], turn["event_id"]);
9024 assert_eq!(by_type("shell")["parent_id"], turn["event_id"]);
9025 assert_eq!(by_type("task_end")["parent_id"], task["event_id"]);
9026 assert_eq!(by_type("session_end")["parent_id"], session["event_id"]);
9027 assert_eq!(by_type("artifact_pulled")["parent_id"], session["event_id"]);
9028 }
9029
9030 fn runtime_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
9031 InstalledArtifactSummary {
9032 name: name.to_string(),
9033 version: "1.0.0".to_string(),
9034 runtime,
9035 origin: LockOrigin::Runtime {
9036 session: "ses_puller".to_string(),
9037 },
9038 }
9039 }
9040
9041 fn operator_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
9042 InstalledArtifactSummary {
9043 origin: LockOrigin::Operator,
9044 ..runtime_summary(name, runtime)
9045 }
9046 }
9047
9048 fn pulled(
9050 summary: InstalledArtifactSummary,
9051 fetched_from: &str,
9052 owner_id: Option<u64>,
9053 ) -> crate::runtime::PulledArtifact {
9054 crate::runtime::PulledArtifact {
9055 summary,
9056 fetched_from: fetched_from.to_string(),
9057 owner_id,
9058 notes: Vec::new(),
9059 }
9060 }
9061
9062 #[tokio::test]
9065 async fn session_start_instruction_files_is_null_without_the_key() {
9066 let work = tempfile::tempdir().unwrap();
9067 std::fs::write(work.path().join("AGENTS.md"), "x").unwrap();
9068 let read = crate::instruction_files::InstructionFiles::read(
9069 work.path(),
9070 &["AGENTS.md".to_string(), "CLAUDE.md".to_string()],
9071 );
9072 let empty = crate::instruction_files::InstructionFiles::read(work.path(), &[]);
9073 for (files, expected) in [
9074 (None, serde_json::Value::Null),
9075 (Some(&empty), serde_json::json!([])),
9076 (
9077 Some(&read),
9078 serde_json::json!([
9079 {
9080 "path": "AGENTS.md",
9081 "status": "read",
9082 "resolved": "AGENTS.md",
9083 "bytes": 1,
9084 "included_bytes": 1,
9085 "truncated": false,
9086 "sha256": murmur_artifact::sha256_hex(b"x"),
9087 },
9088 {"path": "CLAUDE.md", "status": "missing"},
9089 ]),
9090 ),
9091 ] {
9092 let dir = tempfile::tempdir().unwrap();
9093 let mut w = make_writer(dir.path()).await;
9094 w.set_instruction_files(files);
9095 w.write_session_start(Some(10), None, Vec::new())
9096 .await
9097 .unwrap();
9098 w.flush().await.unwrap();
9099 let events = read_events(dir.path());
9100 assert!(events[0].get("instruction_files").is_some());
9101 assert_eq!(events[0]["instruction_files"], expected);
9102 }
9103 }
9104
9105 fn harness_start_with_prompt(prompt: &str) -> HarnessStart {
9106 HarnessStart {
9107 driver: "local/fixture-process-driver".to_string(),
9108 driver_version: "0.1.0".to_string(),
9109 harness: "fixture".to_string(),
9110 binary: "/bin/true".to_string(),
9111 binary_source: "describe".to_string(),
9112 harness_version: None,
9113 version_tested: false,
9114 harness_session_id: "h".to_string(),
9115 session_mode: "new".to_string(),
9116 args_count: 0,
9117 env_names: Vec::new(),
9118 account_env_unset: Vec::new(),
9119 files: Vec::new(),
9120 bridge_tools: Vec::new(),
9121 stdin_bytes: 0,
9122 keep_stdin_open: false,
9123 system_prompt_sha256: murmur_artifact::sha256_hex(prompt.as_bytes()),
9124 system_prompt: prompt.to_string(),
9125 }
9126 }
9127
9128 #[tokio::test]
9131 async fn harness_start_records_the_system_prompt_hash_and_blob() {
9132 let prompt = "facts\n\nMANIFEST\n\n[Instruction file: AGENTS.md]\nx";
9133 let sha = murmur_artifact::sha256_hex(prompt.as_bytes());
9134 for capture in [TraceCapture::Meta, TraceCapture::Content] {
9135 let dir = tempfile::tempdir().unwrap();
9136 let mut w = make_writer_with_opts(dir.path(), capture).await;
9137 w.write_harness_start(harness_start_with_prompt(prompt))
9138 .await
9139 .unwrap();
9140 w.flush().await.unwrap();
9141 let event = &read_events(dir.path())[0];
9142 assert_eq!(event["event_type"], "harness_start");
9143 assert_eq!(event["system_prompt_sha256"], sha.as_str());
9144 assert!(event.get("system_prompt").is_none(), "{event}");
9145 let blob = dir.path().join(BLOB_DIR_NAME).join(&sha);
9146 if capture == TraceCapture::Content {
9147 assert_eq!(std::fs::read(&blob).unwrap(), prompt.as_bytes());
9148 } else {
9149 assert!(!blob.exists());
9150 }
9151 }
9152 }
9153
9154 #[tokio::test]
9157 async fn runtime_origin_session_start_lists_nothing_for_an_operator_session() {
9158 for set in [false, true] {
9159 let dir = tempfile::tempdir().unwrap();
9160 let mut w = make_writer(dir.path()).await;
9161 if set {
9162 w.set_runtime_artifacts(&[
9163 operator_summary("house-style", ArtifactRuntime::Skill),
9164 operator_summary("fetcher", ArtifactRuntime::Tool),
9165 ]);
9166 }
9167 w.write_session_start(Some(10), None, Vec::new())
9168 .await
9169 .unwrap();
9170 w.flush().await.unwrap();
9171 let events = read_events(dir.path());
9172 assert_eq!(events[0]["runtime_artifacts"], serde_json::json!([]));
9173 }
9174 }
9175
9176 #[tokio::test]
9179 async fn runtime_origin_session_start_lists_runtime_artifacts_in_order() {
9180 let dir = tempfile::tempdir().unwrap();
9181 let mut w = make_writer(dir.path()).await;
9182 w.set_runtime_artifacts(&[
9183 runtime_summary("zeta-tool", ArtifactRuntime::Tool),
9184 operator_summary("house-style", ArtifactRuntime::Skill),
9185 InstalledArtifactSummary {
9186 origin: LockOrigin::Runtime {
9187 session: "ses_other".to_string(),
9188 },
9189 ..runtime_summary("alpha-style", ArtifactRuntime::Skill)
9190 },
9191 ]);
9192 w.write_session_start(Some(10), None, Vec::new())
9193 .await
9194 .unwrap();
9195 w.flush().await.unwrap();
9196
9197 let events = read_events(dir.path());
9198 assert_eq!(
9199 events[0]["runtime_artifacts"],
9200 serde_json::json!([
9201 {"name": "zeta-tool", "version": "1.0.0", "origin": "runtime",
9202 "session": "ses_puller", "trust": "untrusted"},
9203 {"name": "alpha-style", "version": "1.0.0", "origin": "runtime",
9204 "session": "ses_other", "trust": "untrusted"},
9205 ])
9206 );
9207 }
9208
9209 #[tokio::test]
9210 async fn runtime_origin_skill_call_carries_origin_and_trust() {
9211 let dir = tempfile::tempdir().unwrap();
9212 let mut w = make_writer(dir.path()).await;
9213 for origin in [
9214 LockOrigin::Operator,
9215 LockOrigin::Runtime {
9216 session: "ses_puller".to_string(),
9217 },
9218 ] {
9219 w.write_skill_call(0, "a-skill".to_string(), &origin, 1, 1, "ok".to_string())
9220 .await
9221 .unwrap();
9222 }
9223 w.flush().await.unwrap();
9224
9225 let events = read_events(dir.path());
9226 assert_eq!(events[0]["origin"], "operator");
9227 assert_eq!(events[0]["trust"], "trusted");
9228 assert_eq!(events[1]["origin"], "runtime");
9229 assert_eq!(events[1]["trust"], "untrusted");
9230 }
9231
9232 #[tokio::test]
9235 async fn runtime_origin_artifact_pulled_writes_every_key() {
9236 let dir = tempfile::tempdir().unwrap();
9237 let mut w = make_writer(dir.path()).await;
9238 w.write_artifact_pulled(&pulled(
9239 runtime_summary("pulled-style", ArtifactRuntime::Skill),
9240 "github:ana/artifacts",
9241 Some(4242),
9242 ))
9243 .await
9244 .unwrap();
9245 w.write_artifact_pulled(&pulled(
9246 operator_summary("fetcher", ArtifactRuntime::Tool),
9247 "project store",
9248 None,
9249 ))
9250 .await
9251 .unwrap();
9252 w.flush().await.unwrap();
9253
9254 let events = read_events(dir.path());
9255 let runtime = &events[0];
9256 assert_eq!(runtime["event_type"], "artifact_pulled");
9257 assert_eq!(runtime["name"], "pulled-style");
9258 assert_eq!(runtime["version"], "1.0.0");
9259 assert_eq!(runtime["runtime"], "skill");
9260 assert_eq!(runtime["origin"], "runtime");
9261 assert_eq!(runtime["session"], "ses_puller");
9262 assert_eq!(runtime["trust"], "untrusted");
9263 assert_eq!(runtime["fetched_from"], "github:ana/artifacts");
9264 assert_eq!(runtime["owner_id"], 4242);
9265 assert!(
9266 runtime["parent_id"].is_null(),
9267 "no session_start was written"
9268 );
9269
9270 let operator = &events[1];
9271 assert_eq!(operator["runtime"], "tool");
9272 assert_eq!(operator["origin"], "operator");
9273 assert!(operator.get("session").is_some_and(Value::is_null));
9274 assert_eq!(operator["trust"], "trusted");
9275 assert_eq!(operator["fetched_from"], "project store");
9276 assert!(operator.get("owner_id").is_none());
9277 }
9278
9279 #[tokio::test]
9283 async fn hook_origin_inference_hangs_off_the_turn_rather_than_becoming_one() {
9284 let dir = tempfile::tempdir().unwrap();
9285 let mut w = make_writer(dir.path()).await;
9286 w.write_session_start(Some(10), None, Vec::new())
9287 .await
9288 .unwrap();
9289 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9290 .await
9291 .unwrap();
9292 w.write_inference(
9293 0,
9294 Some(10),
9295 Some(5),
9296 "tool_call".to_string(),
9297 Some("tool_call"),
9298 None,
9299 None,
9300 None,
9301 Vec::new(),
9302 None,
9303 None,
9304 )
9305 .await
9306 .unwrap();
9307 let origin = InferenceOrigin {
9308 source: "hook:gatekeeper".to_string(),
9309 model: "claude-test".to_string(),
9310 };
9311 w.write_inference(
9312 0,
9313 Some(1),
9314 Some(1),
9315 "end_turn".to_string(),
9316 None,
9318 None,
9319 Some(&origin),
9320 None,
9321 Vec::new(),
9322 None,
9323 None,
9324 )
9325 .await
9326 .unwrap();
9327 w.flush().await.unwrap();
9328
9329 let events = read_events(dir.path());
9330 let turn = &events[2];
9331 let hook = &events[3];
9332 assert!(turn["origin"].is_null());
9333 assert_eq!(hook["origin"], "hook:gatekeeper");
9334 assert_eq!(hook["parent_id"], turn["event_id"]);
9335 assert!(hook.get("message_ids").is_none(), "{hook}");
9338 }
9339
9340 #[tokio::test]
9342 async fn a2a_send_parents_to_null_without_a_session_frame_and_to_it_with_one() {
9343 let bare = tempfile::tempdir().unwrap();
9344 let mut w = make_writer(bare.path()).await;
9345 w.write_a2a_send(
9346 "http://peer",
9347 "msg_1",
9348 "tsk_1",
9349 "ctx_1",
9350 None,
9351 TrustClass::Untrusted,
9352 )
9353 .await
9354 .unwrap();
9355 w.flush().await.unwrap();
9356 assert!(read_events(bare.path())[0]["parent_id"].is_null());
9357
9358 let framed = tempfile::tempdir().unwrap();
9359 let mut w = make_writer(framed.path()).await;
9360 w.write_session_start(Some(1), None, Vec::new())
9361 .await
9362 .unwrap();
9363 w.write_a2a_send(
9364 "http://peer",
9365 "msg_1",
9366 "tsk_1",
9367 "ctx_1",
9368 None,
9369 TrustClass::Untrusted,
9370 )
9371 .await
9372 .unwrap();
9373 w.flush().await.unwrap();
9374 let events = read_events(framed.path());
9375 assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
9376 }
9377
9378 #[tokio::test]
9380 async fn task_rejected_names_the_task_source_cause_and_reason_under_the_session() {
9381 let dir = tempfile::tempdir().unwrap();
9382 let mut w = make_writer(dir.path()).await;
9383 w.write_session_start(Some(10), None, Vec::new())
9384 .await
9385 .unwrap();
9386 w.write_task_start("tsk_ran", "ctx_ran", "a2a", event_provenance(), 3, &[], &[])
9387 .await
9388 .unwrap();
9389 w.write_task_end("tsk_ran", "ok", 0, Vec::new(), None, None)
9390 .await
9391 .unwrap();
9392 w.write_task_rejected(
9393 "tsk_1",
9394 "ctx_1",
9395 "detached_shell",
9396 TASK_REJECTED_SESSION_ENDED,
9397 "task rejected: the session ended before this task started",
9398 )
9399 .await
9400 .unwrap();
9401 w.flush().await.unwrap();
9402
9403 let events = read_events(dir.path());
9404 let session = events
9405 .iter()
9406 .find(|e| e["event_type"] == "session_start")
9407 .unwrap();
9408 let rejected: Vec<_> = events
9409 .iter()
9410 .filter(|e| e["event_type"] == "task_rejected")
9411 .collect();
9412 assert_eq!(rejected.len(), 1);
9413 let rejected = rejected[0];
9414 assert_eq!(rejected["parent_id"], session["event_id"], "{rejected}");
9415 assert_eq!(rejected["session_id"], session["session_id"]);
9416 assert_eq!(rejected["task_id"], "tsk_1");
9417 assert_eq!(rejected["context_id"], "ctx_1");
9418 assert_eq!(rejected["source"], "detached_shell");
9419 assert_eq!(rejected["cause"], "session_ended");
9420 assert_eq!(
9421 rejected["reason"],
9422 "task rejected: the session ended before this task started"
9423 );
9424 assert!(rejected["event_id"].is_string());
9425 assert!(rejected["timestamp"].is_u64());
9426 assert_eq!(rejected.as_object().unwrap().len(), 10, "{rejected}");
9427 }
9428
9429 #[tokio::test]
9432 async fn task_failed_names_the_task_turn_cause_and_capped_reason() {
9433 let dir = tempfile::tempdir().unwrap();
9434 let mut w = make_writer(dir.path()).await;
9435 w.write_session_start(Some(10), None, Vec::new())
9436 .await
9437 .unwrap();
9438 assert_eq!(w.last_task_failure(), None);
9439 w.write_task_failed(None, TASK_FAILED_RUNTIME_ERROR, "before any task")
9440 .await
9441 .unwrap();
9442 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9443 .await
9444 .unwrap();
9445 let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
9446 w.write_task_failed(Some(2), TASK_FAILED_DRIVER_ERROR, &long)
9447 .await
9448 .unwrap();
9449 w.write_task_end("tsk_1", "failed", 0, Vec::new(), None, None)
9450 .await
9451 .unwrap();
9452 w.flush().await.unwrap();
9453
9454 assert_eq!(w.task_failures_written(), 2);
9455 let events = read_events(dir.path());
9456 let failed: Vec<_> = events
9457 .iter()
9458 .filter(|e| e["event_type"] == "task_failed")
9459 .collect();
9460 assert_eq!(failed.len(), 2);
9461
9462 assert!(failed[0].get("task_id").is_none(), "{}", failed[0]);
9463 assert!(failed[0].get("turn").is_none(), "{}", failed[0]);
9464 assert_eq!(failed[0]["cause"], "runtime_error");
9465 assert_eq!(failed[0]["reason"], "before any task");
9466
9467 let start = events
9468 .iter()
9469 .find(|e| e["event_type"] == "task_start")
9470 .unwrap();
9471 assert_eq!(failed[1]["task_id"], "tsk_1");
9472 assert_eq!(failed[1]["turn"], 2);
9473 assert_eq!(failed[1]["cause"], "driver_error");
9474 assert_eq!(failed[1]["parent_id"], start["event_id"]);
9475 assert_eq!(failed[1]["session_id"], start["session_id"]);
9476 assert!(failed[1]["event_id"].is_string());
9477 assert!(failed[1]["timestamp"].is_u64());
9478 let reason = failed[1]["reason"].as_str().unwrap();
9479 assert!(reason.len() <= MAX_TASK_FAILURE_REASON_BYTES);
9480 assert!(reason.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
9481 assert_eq!(w.last_task_failure(), Some(reason));
9482 }
9483
9484 #[tokio::test]
9487 async fn turn_level_events_carry_the_active_task_id() {
9488 let dir = tempfile::tempdir().unwrap();
9489 let mut w = make_writer(dir.path()).await;
9490 w.write_session_start(Some(10), None, Vec::new())
9491 .await
9492 .unwrap();
9493 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9494 .await
9495 .unwrap();
9496 w.write_inference(
9497 0,
9498 Some(10),
9499 Some(5),
9500 "tool_call".to_string(),
9501 Some("tool_call"),
9502 None,
9503 None,
9504 None,
9505 Vec::new(),
9506 None,
9507 None,
9508 )
9509 .await
9510 .unwrap();
9511 w.write_skill_call(
9512 0,
9513 "house-style".to_string(),
9514 &LockOrigin::Operator,
9515 12,
9516 1,
9517 "ok".to_string(),
9518 )
9519 .await
9520 .unwrap();
9521 w.write_compaction(0, 100, OccupancyBasis::Estimate, 40)
9522 .await
9523 .unwrap();
9524 w.write_task_end("tsk_1", "ok", 0, Vec::new(), None, None)
9525 .await
9526 .unwrap();
9527 w.write_inference(
9528 1,
9529 Some(1),
9530 Some(1),
9531 "end_turn".to_string(),
9532 Some("end_turn"),
9533 None,
9534 None,
9535 None,
9536 Vec::new(),
9537 None,
9538 None,
9539 )
9540 .await
9541 .unwrap();
9542 w.flush().await.unwrap();
9543
9544 let events = read_events(dir.path());
9545 for event in events
9546 .iter()
9547 .filter(|e| {
9548 matches!(
9549 e["event_type"].as_str(),
9550 Some("inference" | "skill_call" | "compaction")
9551 )
9552 })
9553 .take(3)
9554 {
9555 assert_eq!(event["task_id"], "tsk_1", "{event}");
9556 }
9557 let after = events.last().unwrap();
9558 assert_eq!(after["event_type"], "inference");
9559 assert!(
9560 after["task_id"].is_null(),
9561 "an inference outside any task carries a null task_id, not the last task's"
9562 );
9563 assert_eq!(after["parent_id"], events[0]["event_id"]);
9565 }
9566
9567 #[tokio::test]
9570 async fn tool_call_records_the_provider_id_verbatim_and_empty_as_null() {
9571 for (given, expected) in [
9572 (Some("toolu_1".to_string()), Some("toolu_1")),
9573 (Some(String::new()), None),
9574 (None, None),
9575 ] {
9576 let dir = tempfile::tempdir().unwrap();
9577 let mut w = make_writer(dir.path()).await;
9578 w.write_tool_call(
9579 0,
9580 "bash".to_string(),
9581 given.clone(),
9582 serde_json::json!({}),
9583 2,
9584 "",
9585 0,
9586 1,
9587 "ok".to_string(),
9588 None,
9589 None,
9590 None,
9591 )
9592 .await
9593 .unwrap();
9594 w.flush().await.unwrap();
9595 let e = &read_events(dir.path())[0];
9596 match expected {
9597 Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
9598 None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
9599 }
9600 }
9601 }
9602
9603 #[tokio::test]
9606 async fn tool_input_refused_records_its_fields_in_every_capture_mode() {
9607 for capture in [
9608 TraceCapture::None,
9609 TraceCapture::Meta,
9610 TraceCapture::Content,
9611 ] {
9612 for (given, expected) in [
9613 (Some("toolu_1"), Some("toolu_1")),
9614 (Some(""), None),
9615 (None, None),
9616 ] {
9617 let dir = tempfile::tempdir().unwrap();
9618 let mut w = make_writer_with_opts(dir.path(), capture).await;
9619 w.write_tool_input_refused(
9620 2,
9621 "murmur-tool-editor",
9622 given,
9623 &["operation".to_string(), "dest_path".to_string()],
9624 "murmur-tool-editor: missing required fields",
9625 )
9626 .await
9627 .unwrap();
9628 w.flush().await.unwrap();
9629 let e = &read_events(dir.path())[0];
9630 assert_eq!(e["event_type"], "tool_input_refused", "{capture:?}");
9631 assert!(e["event_id"].is_string());
9632 assert!(e["session_id"].is_string());
9633 assert!(e["timestamp"].is_u64());
9634 assert_eq!(e["turn"], 2);
9635 assert_eq!(e["tool_name"], "murmur-tool-editor");
9636 match expected {
9637 Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
9638 None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
9639 }
9640 assert_eq!(e["missing"], serde_json::json!(["operation", "dest_path"]));
9641 assert_eq!(e["reason"], "murmur-tool-editor: missing required fields");
9642 }
9643 }
9644 }
9645
9646 #[tokio::test]
9650 async fn tools_refreshed_event_records_trigger_names_and_task_parent() {
9651 let dir = tempfile::tempdir().unwrap();
9652 let mut w = make_writer(dir.path()).await;
9653 w.set_tool_refresh(Some("immediate"));
9654 w.write_session_start(Some(10), None, Vec::new())
9655 .await
9656 .unwrap();
9657 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9658 .await
9659 .unwrap();
9660 w.write_inference(
9661 0,
9662 Some(10),
9663 Some(5),
9664 "tool_call".to_string(),
9665 Some("tool_call"),
9666 None,
9667 None,
9668 None,
9669 Vec::new(),
9670 None,
9671 None,
9672 )
9673 .await
9674 .unwrap();
9675 w.write_tools_refreshed(
9676 1,
9677 "immediate",
9678 &["aaa-late-skill".to_string()],
9679 &[],
9680 &[
9681 "aaa-late-skill".to_string(),
9682 "zzz-existing-tool".to_string(),
9683 ],
9684 )
9685 .await
9686 .unwrap();
9687 w.flush().await.unwrap();
9688
9689 let events = read_events(dir.path());
9690 assert_eq!(events[0]["tool_refresh"], "immediate");
9691 let task = events
9692 .iter()
9693 .find(|e| e["event_type"] == "task_start")
9694 .unwrap();
9695 let refreshed: Vec<&Value> = events
9696 .iter()
9697 .filter(|e| e["event_type"] == "tools_refreshed")
9698 .collect();
9699 assert_eq!(refreshed.len(), 1);
9700 let r = refreshed[0];
9701 assert!(r["event_id"].as_str().unwrap().starts_with("evt_"));
9702 assert_eq!(r["parent_id"], task["event_id"]);
9703 assert_eq!(r["session_id"], "test-session-id");
9704 assert!(r["timestamp"].as_u64().is_some());
9705 assert_eq!(r["turn"], 1);
9706 assert_eq!(r["task_id"], "tsk_1");
9707 assert_eq!(r["trigger"], "immediate");
9708 assert_eq!(r["added"], serde_json::json!(["aaa-late-skill"]));
9709 assert_eq!(r["removed"], serde_json::json!([]));
9710 assert_eq!(
9711 r["tools"],
9712 serde_json::json!(["aaa-late-skill", "zzz-existing-tool"])
9713 );
9714
9715 let process_dir = tempfile::tempdir().unwrap();
9716 let mut process = make_writer(process_dir.path()).await;
9717 process.set_tool_refresh(None);
9718 process
9719 .write_session_start(Some(10), None, Vec::new())
9720 .await
9721 .unwrap();
9722 process.flush().await.unwrap();
9723 let start = &read_events(process_dir.path())[0];
9724 assert!(start.as_object().unwrap().contains_key("tool_refresh"));
9725 assert!(start["tool_refresh"].is_null());
9726 }
9727
9728 #[tokio::test]
9731 async fn compaction_declined_records_turn_task_tokens_and_reason() {
9732 let dir = tempfile::tempdir().unwrap();
9733 let mut w = make_writer(dir.path()).await;
9734 w.write_session_start(Some(10), None, Vec::new())
9735 .await
9736 .unwrap();
9737 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9738 .await
9739 .unwrap();
9740 w.write_inference(
9741 2,
9742 Some(10),
9743 Some(5),
9744 "tool_call".to_string(),
9745 Some("tool_call"),
9746 None,
9747 None,
9748 None,
9749 Vec::new(),
9750 None,
9751 None,
9752 )
9753 .await
9754 .unwrap();
9755 w.write_compaction_declined(
9756 2,
9757 4321,
9758 OccupancyBasis::Actual,
9759 COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL,
9760 )
9761 .await
9762 .unwrap();
9763 w.flush().await.unwrap();
9764
9765 let events = read_events(dir.path());
9766 let declined: Vec<&Value> = events
9767 .iter()
9768 .filter(|e| e["event_type"] == "compaction_declined")
9769 .collect();
9770 assert_eq!(declined.len(), 1);
9771 let d = declined[0];
9772 assert_eq!(d["reason"], "unresolved_tool_call");
9773 assert_eq!(d["turn"], 2);
9774 assert_eq!(d["task_id"], "tsk_1");
9775 assert_eq!(d["tokens"], 4321);
9776 assert_eq!(d["tokens_source"], "actual");
9777 assert!(d["event_id"].as_str().unwrap().starts_with("evt_"));
9778 assert_eq!(
9779 d["parent_id"],
9780 events
9781 .iter()
9782 .find(|e| e["event_type"] == "inference")
9783 .unwrap()["event_id"]
9784 );
9785 }
9786
9787 #[tokio::test]
9790 async fn event_ids_sort_by_mint_time() {
9791 let dir = tempfile::tempdir().unwrap();
9792 let mut w = make_writer(dir.path()).await;
9793 for turn in 0..5 {
9794 w.write_inference(
9795 turn,
9796 Some(1),
9797 Some(1),
9798 "end_turn".to_string(),
9799 Some("end_turn"),
9800 None,
9801 None,
9802 None,
9803 Vec::new(),
9804 None,
9805 None,
9806 )
9807 .await
9808 .unwrap();
9809 }
9810 w.flush().await.unwrap();
9811
9812 let ids: Vec<String> = read_events(dir.path())
9813 .iter()
9814 .map(|e| e["event_id"].as_str().unwrap().to_string())
9815 .collect();
9816 let mut sorted = ids.clone();
9817 sorted.sort();
9818 assert_eq!(ids, sorted, "evt_ ids must sort into mint order");
9819 }
9820
9821 fn wire_payload(system: &str, messages: usize) -> Value {
9825 let messages: Vec<Value> = (0..messages)
9826 .map(|i| serde_json::json!({"role": "user", "content": format!("m{i}")}))
9827 .collect();
9828 serde_json::json!({
9829 "model": "claude-test",
9830 "max_tokens": 1024,
9831 "messages": messages,
9832 "tools": [{"name": "bash"}],
9833 "params": {},
9834 "system": system,
9835 })
9836 }
9837
9838 async fn write_turn(w: &mut TraceWriter, turn: u32, payload: &Value, response: &str) {
9839 let wire = WireCapture::from_driver_payload(payload, response);
9840 w.write_inference(
9841 turn,
9842 Some(10),
9843 Some(5),
9844 "end_turn".to_string(),
9845 Some("end_turn"),
9846 None,
9847 None,
9848 None,
9849 vec!["msg_0000000000000000000000000000000a".to_string()],
9850 Some(&wire),
9851 None,
9852 )
9853 .await
9854 .unwrap();
9855 }
9856
9857 fn blob_path(dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
9858 dir.join(BLOB_DIR_NAME).join(sha)
9859 }
9860
9861 #[tokio::test]
9864 async fn content_capture_names_blobs_that_exist_and_rehash_to_their_names() {
9865 let dir = tempfile::tempdir().unwrap();
9866 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
9867 let payload = wire_payload("You are a capsule.", 3);
9868 write_turn(&mut w, 0, &payload, r#"{"stop_reason":"end_turn"}"#).await;
9869 w.flush().await.unwrap();
9870
9871 let e = &read_events(dir.path())[0];
9872 let mut named: Vec<String> = ["system_sha", "tools_sha", "response_sha"]
9873 .iter()
9874 .map(|key| {
9875 e[*key]
9876 .as_str()
9877 .unwrap_or_else(|| panic!("{key} missing from {e}"))
9878 .to_string()
9879 })
9880 .collect();
9881 let shas = e["message_shas"].as_array().expect("message_shas");
9882 assert_eq!(shas.len(), 3);
9883 named.extend(shas.iter().map(|s| s.as_str().unwrap().to_string()));
9884
9885 for sha in &named {
9886 let path = blob_path(dir.path(), sha);
9887 let bytes =
9888 std::fs::read(&path).unwrap_or_else(|err| panic!("blob {sha} must exist: {err}"));
9889 assert_eq!(murmur_artifact::sha256_hex(&bytes), *sha);
9890 assert_eq!(sha.len(), 64, "{sha}");
9893 assert!(
9894 sha.chars()
9895 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
9896 "{sha}"
9897 );
9898 }
9899 }
9900
9901 #[tokio::test]
9904 async fn the_system_blob_holds_the_prompt_as_readable_text() {
9905 let dir = tempfile::tempdir().unwrap();
9906 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
9907 let payload = wire_payload("[Capsule] you are cap.\nBe terse.", 1);
9908 write_turn(&mut w, 0, &payload, "{}").await;
9909 w.flush().await.unwrap();
9910
9911 let sha = read_events(dir.path())[0]["system_sha"]
9912 .as_str()
9913 .unwrap()
9914 .to_string();
9915 assert_eq!(
9916 std::fs::read_to_string(blob_path(dir.path(), &sha)).unwrap(),
9917 "[Capsule] you are cap.\nBe terse."
9918 );
9919 }
9920
9921 #[tokio::test]
9924 async fn meta_writes_the_same_hashes_and_no_bodies() {
9925 let payload = wire_payload("You are a capsule.", 2);
9926 let response = r#"{"stop_reason":"end_turn"}"#;
9927
9928 let meta = tempfile::tempdir().unwrap();
9929 let mut w = make_writer_with_opts(meta.path(), TraceCapture::Meta).await;
9930 write_turn(&mut w, 0, &payload, response).await;
9931 w.flush().await.unwrap();
9932
9933 let content = tempfile::tempdir().unwrap();
9934 let mut w = make_writer_with_opts(content.path(), TraceCapture::Content).await;
9935 write_turn(&mut w, 0, &payload, response).await;
9936 w.flush().await.unwrap();
9937
9938 let meta_event = read_events(meta.path()).remove(0);
9939 let content_event = read_events(content.path()).remove(0);
9940 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
9941 assert_eq!(meta_event[key], content_event[key], "{key}");
9942 }
9943 assert!(
9944 !meta.path().join(BLOB_DIR_NAME).exists(),
9945 "meta must not create the blob directory"
9946 );
9947 assert!(content.path().join(BLOB_DIR_NAME).is_dir());
9948 }
9949
9950 #[tokio::test]
9952 async fn the_default_capture_mode_is_meta() {
9953 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
9954 let dir = tempfile::tempdir().unwrap();
9955 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::default(), None, false).await;
9956 write_turn(&mut w, 0, &wire_payload("s", 1), "{}").await;
9957 w.flush().await.unwrap();
9958
9959 let e = &read_events(dir.path())[0];
9960 assert!(e.get("system_sha").is_some());
9961 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
9962 }
9963
9964 #[tokio::test]
9966 async fn none_omits_every_hash_and_leaves_the_rest_of_the_event_intact() {
9967 let payload = wire_payload("You are a capsule.", 2);
9968
9969 let off = tempfile::tempdir().unwrap();
9970 let mut w = make_writer_with_opts(off.path(), TraceCapture::None).await;
9971 write_turn(&mut w, 3, &payload, "{}").await;
9972 w.flush().await.unwrap();
9973
9974 let e = read_events(off.path()).remove(0);
9975 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
9976 assert!(
9977 e.get(key).is_none(),
9978 "{key} must be absent under none, got {e}"
9979 );
9980 }
9981 assert!(!off.path().join(BLOB_DIR_NAME).exists());
9982 for key in [
9983 "event_type",
9984 "event_id",
9985 "parent_id",
9986 "session_id",
9987 "timestamp",
9988 "turn",
9989 "task_id",
9990 "input_tokens",
9991 "output_tokens",
9992 "decision",
9993 "tool_name",
9994 "message_ids",
9995 ] {
9996 assert!(
9997 e.get(key).is_some(),
9998 "{key} must survive under none, got {e}"
9999 );
10000 }
10001 assert_eq!(e["turn"], 3);
10002 assert_eq!(e["message_ids"].as_array().unwrap().len(), 1);
10003 }
10004
10005 #[tokio::test]
10008 async fn an_unchanged_system_prompt_is_stored_once() {
10009 let dir = tempfile::tempdir().unwrap();
10010 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10011 let payload = wire_payload("You are a capsule.", 1);
10012 write_turn(&mut w, 0, &payload, "{}").await;
10013
10014 let sha = {
10015 w.flush().await.unwrap();
10016 read_events(dir.path())[0]["system_sha"]
10017 .as_str()
10018 .unwrap()
10019 .to_string()
10020 };
10021 let path = blob_path(dir.path(), &sha);
10022 std::fs::write(&path, b"sentinel").unwrap();
10023
10024 for turn in 1..10 {
10025 write_turn(&mut w, turn, &payload, "{}").await;
10026 }
10027 w.flush().await.unwrap();
10028
10029 let events = read_events(dir.path());
10030 assert_eq!(events.len(), 10);
10031 for e in &events {
10032 assert_eq!(e["system_sha"], sha.as_str());
10033 }
10034 assert_eq!(
10035 std::fs::read(&path).unwrap(),
10036 b"sentinel",
10037 "an existing blob is never rewritten"
10038 );
10039 let matching = std::fs::read_dir(dir.path().join(BLOB_DIR_NAME))
10040 .unwrap()
10041 .filter(|entry| entry.as_ref().unwrap().file_name() == sha.as_str())
10042 .count();
10043 assert_eq!(matching, 1, "exactly one file bears that name");
10044 }
10045
10046 #[tokio::test]
10051 async fn stop_reason_is_absent_when_no_driver_response_was_parsed() {
10052 let dir = tempfile::tempdir().unwrap();
10053 let mut w = make_writer(dir.path()).await;
10054 w.write_inference(
10055 0,
10056 Some(10),
10057 Some(5),
10058 "end_turn".to_string(),
10059 None,
10060 None,
10061 None,
10062 None,
10063 Vec::new(),
10064 None,
10065 None,
10066 )
10067 .await
10068 .unwrap();
10069 w.flush().await.unwrap();
10070
10071 let event = read_events(dir.path()).remove(0);
10072 assert!(
10073 event.get("stop_reason").is_none(),
10074 "stop_reason must be absent, got {event}"
10075 );
10076 }
10077
10078 #[tokio::test]
10081 async fn a_hook_origin_record_carries_no_hashes() {
10082 let dir = tempfile::tempdir().unwrap();
10083 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10084 let origin = InferenceOrigin {
10085 source: "hook:gatekeeper".to_string(),
10086 model: "claude-test".to_string(),
10087 };
10088 w.write_inference(
10089 0,
10090 Some(1),
10091 Some(1),
10092 "end_turn".to_string(),
10093 None,
10095 None,
10096 Some(&origin),
10097 None,
10098 Vec::new(),
10099 None,
10100 None,
10101 )
10102 .await
10103 .unwrap();
10104 w.flush().await.unwrap();
10105
10106 let e = read_events(dir.path()).remove(0);
10107 assert_eq!(e["origin"], "hook:gatekeeper");
10108 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
10109 assert!(e.get(key).is_none(), "{key} must be absent, got {e}");
10110 }
10111 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
10112 }
10113
10114 #[tokio::test]
10117 async fn message_shas_locate_the_index_two_runs_diverge_at() {
10118 async fn shas_for(payload: &Value) -> Vec<String> {
10119 let dir = tempfile::tempdir().unwrap();
10120 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Meta).await;
10121 write_turn(&mut w, 0, payload, "{}").await;
10122 w.flush().await.unwrap();
10123 read_events(dir.path())[0]["message_shas"]
10124 .as_array()
10125 .unwrap()
10126 .iter()
10127 .map(|s| s.as_str().unwrap().to_string())
10128 .collect()
10129 }
10130
10131 let first = wire_payload("You are a capsule.", 4);
10132 let mut second = first.clone();
10133 second["messages"][2]["content"] = serde_json::json!("diverged");
10134
10135 let a = shas_for(&first).await;
10136 let b = shas_for(&second).await;
10137 assert_eq!(a.len(), 4);
10138 let divergence = a.iter().zip(&b).position(|(x, y)| x != y);
10139 assert_eq!(divergence, Some(2));
10140 assert_eq!(a[3], b[3], "only the changed message changes its own sha");
10141 }
10142
10143 #[tokio::test]
10146 async fn identical_messages_share_one_sha_and_one_blob() {
10147 let dir = tempfile::tempdir().unwrap();
10148 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10149 let payload = serde_json::json!({
10150 "messages": [{"role": "user", "content": "same"}, {"role": "user", "content": "same"}],
10151 "tools": [],
10152 "system": "s",
10153 });
10154 write_turn(&mut w, 0, &payload, "{}").await;
10155 w.flush().await.unwrap();
10156
10157 let shas = read_events(dir.path())[0]["message_shas"]
10158 .as_array()
10159 .unwrap()
10160 .iter()
10161 .map(|s| s.as_str().unwrap().to_string())
10162 .collect::<Vec<_>>();
10163 assert_eq!(shas[0], shas[1]);
10164 assert!(blob_path(dir.path(), &shas[0]).is_file());
10165 }
10166
10167 #[tokio::test]
10170 async fn a_message_blob_is_the_body_verbatim() {
10171 let dir = tempfile::tempdir().unwrap();
10172 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10173 let message = serde_json::json!({"role": "user", "content": "hello"});
10174 let payload = serde_json::json!({
10175 "messages": [message.clone()],
10176 "tools": [],
10177 "system": "s",
10178 });
10179 write_turn(&mut w, 0, &payload, "{}").await;
10180 w.flush().await.unwrap();
10181
10182 let sha = read_events(dir.path())[0]["message_shas"][0]
10183 .as_str()
10184 .unwrap()
10185 .to_string();
10186 assert_eq!(
10187 std::fs::read(blob_path(dir.path(), &sha)).unwrap(),
10188 serde_json::to_vec(&message).unwrap()
10189 );
10190 }
10191
10192 #[tokio::test]
10200 async fn a_plan_written_delegation_record_matches_a_delegate_task_one() {
10201 let plan_dir = tempfile::tempdir().unwrap();
10202 let tool_dir = tempfile::tempdir().unwrap();
10203 let session = "ses_0000000000004000800000000000plan".to_string();
10204 let node = "evt_0000000000004000800000000000node".to_string();
10205
10206 let plan = PlanTraceAppender::open(
10207 &crate::workdir_fs::WorkdirRoot::open(plan_dir.path()).unwrap(),
10208 session.clone(),
10209 node.clone(),
10210 )
10211 .unwrap();
10212 let tool = ResourceTraceAppender::open(
10213 &crate::workdir_fs::WorkdirRoot::open(tool_dir.path()).unwrap(),
10214 session.clone(),
10215 node.clone(),
10216 )
10217 .await
10218 .unwrap();
10219
10220 let snapshot = crate::workdir_snapshot::WorkdirSnapshot {
10221 source: "/home/you/project".to_string(),
10222 files: 2,
10223 dirs: 0,
10224 symlinks: 0,
10225 repointed_symlinks: 0,
10226 bytes: 12,
10227 skipped_count: 0,
10228 skipped: Vec::new(),
10229 };
10230 plan.write_delegation_start(
10231 "dlg_abc123",
10232 "worker",
10233 "0.1.0",
10234 "ses_child",
10235 ".murmur/c",
10236 "/r/ses_child",
10237 Some(&snapshot),
10238 );
10239 tool.write_delegation_start(
10240 "dlg_abc123",
10241 "worker",
10242 "0.1.0",
10243 "ses_child",
10244 ".murmur/c",
10245 "/r/ses_child",
10246 Some(&snapshot),
10247 )
10248 .await;
10249 plan.write_delegation(
10250 "worker",
10251 "0.1.0",
10252 Some("dlg_abc123".to_string()),
10253 Some("ses_child".to_string()),
10254 42,
10255 "completed",
10256 None,
10257 );
10258 tool.write_delegation(
10259 "worker",
10260 "0.1.0",
10261 Some("dlg_abc123".to_string()),
10262 Some("ses_child".to_string()),
10263 42,
10264 "completed",
10265 None,
10266 )
10267 .await;
10268
10269 let read = |dir: &Path| -> Vec<serde_json::Map<String, Value>> {
10270 std::fs::read_to_string(dir.join("trace.jsonl"))
10271 .unwrap()
10272 .lines()
10273 .map(|line| serde_json::from_str::<serde_json::Map<_, _>>(line).unwrap())
10274 .collect()
10275 };
10276 let from_plan = read(plan_dir.path());
10277 let from_tool = read(tool_dir.path());
10278 assert_eq!(from_plan.len(), 2);
10279 assert_eq!(from_tool.len(), 2);
10280 assert_eq!(
10281 from_plan[0]["workdir_snapshot"],
10282 serde_json::to_value(&snapshot).unwrap()
10283 );
10284
10285 for (planned, called, event_type) in [
10286 (&from_plan[0], &from_tool[0], "delegation_start"),
10287 (&from_plan[1], &from_tool[1], "delegation"),
10288 ] {
10289 assert_eq!(planned["event_type"], event_type);
10290 assert_eq!(called["event_type"], event_type);
10291 assert_eq!(
10292 planned.keys().collect::<Vec<_>>(),
10293 called.keys().collect::<Vec<_>>(),
10294 "{planned:?} vs {called:?}"
10295 );
10296 assert_eq!(planned["parent_id"], node.as_str());
10297 assert_eq!(called["parent_id"], node.as_str());
10298 for (key, value) in planned {
10299 if key == "event_id" || key == "timestamp" {
10300 continue;
10301 }
10302 assert_eq!(value, &called[key], "{key}: {planned:?} vs {called:?}");
10303 }
10304 for key in ["plan_id", "step_id", "surface", "kind"] {
10305 assert!(!planned.contains_key(key), "{key} is on {planned:?}");
10306 assert!(!called.contains_key(key), "{key} is on {called:?}");
10307 }
10308 }
10309 }
10310
10311 #[tokio::test]
10315 async fn spawner_ended_names_the_lineage_or_omits_it() {
10316 let dir = tempfile::tempdir().unwrap();
10317 let session = "ses_0000000000004000800000000child".to_string();
10318 let node = "evt_0000000000004000800000000000node".to_string();
10319 let appender = ResourceTraceAppender::open(
10320 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
10321 session.clone(),
10322 node.clone(),
10323 )
10324 .await
10325 .unwrap();
10326
10327 appender
10328 .write_spawner_ended(Some("ses_parent"), Some("dlg_abc123"))
10329 .await;
10330 appender.write_spawner_ended(None, None).await;
10331
10332 let lines: Vec<serde_json::Map<String, Value>> =
10333 std::fs::read_to_string(dir.path().join("trace.jsonl"))
10334 .unwrap()
10335 .lines()
10336 .map(|line| serde_json::from_str(line).unwrap())
10337 .collect();
10338 assert_eq!(lines.len(), 2);
10339 let named = &lines[0];
10340 assert_eq!(named["event_type"], "spawner_ended");
10341 assert_eq!(named["parent_id"], node.as_str());
10342 assert_eq!(named["session_id"], session.as_str());
10343 assert_eq!(named["spawned_by"], "ses_parent");
10344 assert_eq!(named["delegation_id"], "dlg_abc123");
10345 assert_eq!(
10346 named.keys().collect::<Vec<_>>(),
10347 [
10348 "delegation_id",
10349 "event_id",
10350 "event_type",
10351 "parent_id",
10352 "session_id",
10353 "spawned_by",
10354 "timestamp"
10355 ]
10356 );
10357 let bare = &lines[1];
10358 assert_eq!(bare["event_type"], "spawner_ended");
10359 assert!(!bare.contains_key("spawned_by"), "{bare:?}");
10360 assert!(!bare.contains_key("delegation_id"), "{bare:?}");
10361 }
10362}