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)]
915struct ShellAbandonedEvent {
916 event_type: &'static str,
917 event_id: String,
918 parent_id: Option<String>,
919 session_id: String,
920 timestamp: u64,
921 work_id: String,
922 binary: String,
923 command: String,
924 running_ms: u64,
927 #[serde(skip_serializing_if = "Option::is_none")]
930 exit_code: Option<i32>,
931 #[serde(skip_serializing_if = "Option::is_none")]
935 output_path: Option<String>,
936 #[serde(skip_serializing_if = "Option::is_none")]
938 output_bytes: Option<u64>,
939}
940
941#[derive(Serialize)]
953struct ShellLostEvent {
954 event_type: &'static str,
955 event_id: String,
956 #[serde(skip_serializing_if = "Option::is_none")]
959 parent_id: Option<String>,
960 session_id: String,
962 timestamp: u64,
963 work_id: String,
964 binary: String,
965 command: String,
966 detached_at_ms: u64,
968 reconciled_by_session: String,
970 reconciled_task_id: String,
973}
974
975#[derive(Serialize)]
981struct ShellDetachUnrecordedEvent {
982 event_type: &'static str,
983 event_id: String,
984 parent_id: Option<String>,
985 session_id: String,
986 timestamp: u64,
987 turn: u32,
988 task_id: Option<String>,
989 work_id: String,
990 binary: String,
991 reason: String,
993}
994
995#[derive(Serialize)]
999struct SafetyCheckEvent {
1000 event_type: &'static str,
1001 event_id: String,
1002 parent_id: Option<String>,
1003 session_id: String,
1004 timestamp: u64,
1005 turn: u32,
1006 task_id: Option<String>,
1007 tool_call_id: String,
1008 tool: String,
1009 checks: serde_json::Value,
1011 outcome: &'static str,
1013}
1014
1015#[derive(Serialize)]
1016struct CompactionEvent {
1017 event_type: &'static str,
1018 event_id: String,
1019 parent_id: Option<String>,
1020 session_id: String,
1021 timestamp: u64,
1022 turn: u32,
1023 task_id: Option<String>,
1024 tokens_before: u64,
1027 tokens_before_source: OccupancyBasis,
1029 tokens_after: u64,
1032}
1033
1034#[derive(Serialize)]
1038struct CompactionDeclinedEvent {
1039 event_type: &'static str,
1040 event_id: String,
1041 parent_id: Option<String>,
1042 session_id: String,
1043 timestamp: u64,
1044 turn: u32,
1045 task_id: Option<String>,
1046 tokens: u64,
1049 tokens_source: OccupancyBasis,
1051 reason: String,
1054}
1055
1056#[derive(Serialize)]
1063struct ContextSeedEvent {
1064 event_type: &'static str,
1065 event_id: String,
1066 parent_id: Option<String>,
1067 session_id: String,
1068 timestamp: u64,
1069 task_id: Option<String>,
1070 hook_name: String,
1072 tokens: u64,
1075 proposed_tokens: u64,
1078 budget_tokens: u64,
1081 outcome: String,
1084 #[serde(skip_serializing_if = "Option::is_none")]
1087 reason: Option<String>,
1088 message_ids: Vec<String>,
1092}
1093
1094#[derive(Serialize)]
1102struct RetentionEvent {
1103 event_type: &'static str,
1104 event_id: String,
1105 parent_id: Option<String>,
1106 session_id: String,
1107 timestamp: u64,
1108 store: String,
1110 reason: String,
1113 removed: u32,
1117 targets: Vec<String>,
1119 #[serde(skip_serializing_if = "Option::is_none")]
1122 messages_dropped: Option<u64>,
1123}
1124
1125#[derive(Serialize)]
1126struct A2aTaskReceivedEvent {
1127 event_type: &'static str,
1128 event_id: String,
1129 parent_id: Option<String>,
1130 session_id: String,
1131 timestamp: u64,
1132 task_id: String,
1133 context_id: String,
1134 message_id: String,
1135 traceparent_from_caller: Option<String>,
1136 caller: &'static str,
1139 #[serde(skip_serializing_if = "Option::is_none")]
1141 caller_member: Option<String>,
1142 #[serde(skip_serializing_if = "Option::is_none")]
1145 caller_credential: Option<String>,
1146 #[serde(skip_serializing_if = "Option::is_none")]
1149 message_sha256: Option<String>,
1150}
1151
1152pub(crate) const RECEIVED_FROM_OPERATOR: &str = "operator";
1154pub(crate) const RECEIVED_FROM_MEMBER: &str = "member";
1156pub(crate) const RECEIVED_FROM_CREDENTIAL: &str = "credential";
1158pub(crate) const RECEIVED_FROM_PUBLIC: &str = "public";
1160
1161#[derive(Serialize)]
1162struct A2aSendEvent {
1163 event_type: &'static str,
1164 event_id: String,
1165 parent_id: Option<String>,
1166 session_id: String,
1167 timestamp: u64,
1168 peer_url: String,
1169 message_id: String,
1170 task_id: String,
1171 context_id: String,
1172 traceparent: Option<String>,
1173 trust: String,
1176}
1177
1178#[derive(Serialize)]
1181struct ArtifactPulledEvent {
1182 event_type: &'static str,
1183 event_id: String,
1184 parent_id: Option<String>,
1185 session_id: String,
1186 timestamp: u64,
1187 name: String,
1188 version: String,
1189 runtime: &'static str,
1191 origin: &'static str,
1194 session: Option<String>,
1197 trust: &'static str,
1198 fetched_from: String,
1201 #[serde(skip_serializing_if = "Option::is_none")]
1204 owner_id: Option<u64>,
1205}
1206
1207#[derive(Serialize)]
1208struct SessionEndEvent {
1209 event_type: &'static str,
1210 event_id: String,
1211 parent_id: Option<String>,
1212 session_id: String,
1213 timestamp: u64,
1214 total_turns: u32,
1215 total_input_tokens: u64,
1216 total_output_tokens: u64,
1217 total_tool_calls: u32,
1218 total_shell_calls: u32,
1219 duration_ms: u64,
1220 exit_status: String,
1221}
1222
1223#[derive(Serialize)]
1224struct TaskStartEvent {
1225 event_type: &'static str,
1226 event_id: String,
1227 parent_id: Option<String>,
1228 session_id: String,
1229 timestamp: u64,
1230 task_id: String,
1231 context_id: String,
1232 source: String,
1235 origin: String,
1236 trust: String,
1237 lane: String,
1240 message_parts_bytes: u64,
1241 #[serde(skip_serializing_if = "Vec::is_empty")]
1244 part_kinds: Vec<&'static str>,
1245 #[serde(skip_serializing_if = "Vec::is_empty")]
1247 reference_task_ids: Vec<String>,
1248 #[serde(skip_serializing_if = "Vec::is_empty")]
1251 attachments: Vec<AttachmentRecord>,
1252}
1253
1254#[derive(Serialize)]
1255struct TaskEndEvent {
1256 event_type: &'static str,
1257 event_id: String,
1258 parent_id: Option<String>,
1259 session_id: String,
1260 timestamp: u64,
1261 task_id: String,
1262 exit_status: String,
1263 duration_ms: u64,
1264 turns: u32,
1265 input_tokens: u64,
1266 output_tokens: u64,
1267 tool_calls: u32,
1268 shell_calls: u32,
1269 reopen_count: u32,
1272 capped_turns: u32,
1275 #[serde(skip_serializing_if = "Vec::is_empty")]
1278 running_commands: Vec<String>,
1279 #[serde(skip_serializing_if = "Option::is_none")]
1281 keep_working: Option<KeepWorkingSummary>,
1282 #[serde(skip_serializing_if = "Option::is_none")]
1284 done_check: Option<DoneCheckSummary>,
1285}
1286
1287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1290pub(crate) struct DoneCheckSummary {
1291 pub(crate) runs: u32,
1293 pub(crate) reopens: u32,
1295 pub(crate) limit: u32,
1297 pub(crate) met: Option<bool>,
1300 pub(crate) stopped_by: Option<&'static str>,
1303}
1304
1305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1308pub(crate) struct KeepWorkingSummary {
1309 pub(crate) used: u32,
1311 pub(crate) limit: u32,
1313 pub(crate) stopped_by: Option<&'static str>,
1317}
1318
1319#[derive(Serialize)]
1326struct TaskCanceledEvent {
1327 event_type: &'static str,
1328 event_id: String,
1329 parent_id: Option<String>,
1330 session_id: String,
1331 timestamp: u64,
1332 task_id: String,
1333 #[serde(skip_serializing_if = "Option::is_none")]
1335 turn: Option<u32>,
1336 phase: String,
1339 detached_work_ids: Vec<String>,
1342 delegation_ids: Vec<String>,
1344}
1345
1346pub(crate) const TASK_REJECTED_SESSION_ENDED: &str = "session_ended";
1349
1350pub(crate) const TASK_REJECTED_SESSION_STOPPED: &str = "session_stopped";
1352
1353#[derive(Serialize)]
1360struct TaskRejectedEvent {
1361 event_type: &'static str,
1362 event_id: String,
1363 parent_id: Option<String>,
1364 session_id: String,
1365 timestamp: u64,
1366 task_id: String,
1367 context_id: String,
1368 source: String,
1371 cause: String,
1373 reason: String,
1375}
1376
1377pub(crate) const TASK_FAILED_DRIVER_ERROR: &str = "driver_error";
1380
1381pub(crate) const TASK_FAILED_CREDENTIAL_REJECTED: &str = "credential_rejected";
1384
1385pub(crate) const TASK_FAILED_MALFORMED_RESPONSE: &str = "malformed_response";
1388
1389pub(crate) const TASK_FAILED_COMPACTION_HOOK: &str = "compaction_hook";
1391
1392pub(crate) const TASK_FAILED_INPUT_TIMEOUT: &str = "input_timeout";
1394
1395pub(crate) const TASK_FAILED_REOPEN_BUDGET_EXHAUSTED: &str = "reopen_budget_exhausted";
1398
1399pub(crate) const TASK_FAILED_RUNTIME_ERROR: &str = "runtime_error";
1402
1403pub(crate) const TASK_FAILED_OUTPUT_CAP: &str = "output_cap";
1406
1407pub(crate) const TASK_FAILED_WORKDIR_PATH_REFUSED: &str = "workdir_path_refused";
1410
1411pub(crate) const INFERENCE_ERROR_CREDENTIAL_REJECTED: &str = TASK_FAILED_CREDENTIAL_REJECTED;
1415
1416pub(crate) const INFERENCE_ERROR_DRIVER_ERROR: &str = TASK_FAILED_DRIVER_ERROR;
1420
1421pub(crate) const INFERENCE_ERROR_DRIVER_FAILED: &str = "driver_failed";
1424
1425pub(crate) const INFERENCE_ERROR_MALFORMED_RESPONSE: &str = TASK_FAILED_MALFORMED_RESPONSE;
1428
1429pub(crate) const INFERENCE_ERROR_HARNESS_AUTH: &str = "harness_auth";
1431
1432pub(crate) const INFERENCE_ERROR_HARNESS_QUOTA: &str = "harness_quota";
1434
1435pub(crate) const INFERENCE_ERROR_HARNESS_ERROR: &str = "harness_error";
1438
1439pub(crate) const INFERENCE_ERROR_HARNESS_OTHER: &str = "harness_other";
1441
1442pub(crate) const INFERENCE_ERROR_HARNESS_INACTIVE: &str = "harness_inactive";
1444
1445pub(crate) struct FailedInference<'a> {
1447 pub(crate) turn: u32,
1448 pub(crate) error_code: &'static str,
1450 pub(crate) error: &'a str,
1452 pub(crate) provider_status: Option<u16>,
1454 pub(crate) usage: Option<&'a DriverUsage>,
1456 pub(crate) origin: Option<&'a InferenceOrigin>,
1458 pub(crate) choice: Option<&'a crate::driver_choice::DriverChoice>,
1460 pub(crate) message_ids: Vec<String>,
1462 pub(crate) tool_name: Option<String>,
1463 pub(crate) spent: Option<(Option<u64>, Option<u64>)>,
1466 pub(crate) settings: Option<&'a InferenceCallSettings>,
1468 pub(crate) retry: RetryNote,
1470 pub(crate) gateway: Option<GatewayOffsets>,
1472}
1473
1474struct InferenceLine<'a> {
1477 turn: u32,
1478 input_tokens: Option<u64>,
1479 output_tokens: Option<u64>,
1480 decision: String,
1481 stop_reason: Option<&'a str>,
1482 tool_name: Option<String>,
1483 origin: Option<&'a InferenceOrigin>,
1484 usage: Option<&'a DriverUsage>,
1485 message_ids: Vec<String>,
1486 wire: Option<&'a WireCapture>,
1487 choice: Option<&'a crate::driver_choice::DriverChoice>,
1488 failure: Option<(&'static str, String, Option<u16>)>,
1490 counted: bool,
1492 settings: Option<&'a InferenceCallSettings>,
1494 retry: RetryNote,
1495 gateway: Option<GatewayOffsets>,
1498}
1499
1500pub(crate) const MAX_TASK_FAILURE_REASON_BYTES: usize = 2_000;
1503
1504#[derive(Serialize)]
1508struct WorkdirPathRefusedEvent<'a> {
1509 event_type: &'static str,
1510 event_id: String,
1511 parent_id: Option<String>,
1512 session_id: String,
1513 timestamp: u64,
1514 #[serde(skip_serializing_if = "Option::is_none")]
1515 task_id: Option<String>,
1516 path: &'a str,
1518 operation: crate::workdir_fs::WorkdirOperation,
1519 refused_at: &'a str,
1522 reason: crate::workdir_fs::RefusalReason,
1523}
1524
1525#[derive(Serialize)]
1533struct TaskFailedEvent {
1534 event_type: &'static str,
1535 event_id: String,
1536 parent_id: Option<String>,
1537 session_id: String,
1538 timestamp: u64,
1539 #[serde(skip_serializing_if = "Option::is_none")]
1541 task_id: Option<String>,
1542 #[serde(skip_serializing_if = "Option::is_none")]
1545 turn: Option<u32>,
1546 cause: String,
1547 reason: String,
1549}
1550
1551pub(crate) const REOPEN_CONTEXT_CONTINUED: &str = "continued";
1554
1555pub(crate) const REOPEN_CONTEXT_RESTARTED: &str = "restarted";
1558
1559#[derive(Serialize)]
1564struct TaskReopenedEvent {
1565 event_type: &'static str,
1566 event_id: String,
1567 parent_id: Option<String>,
1568 session_id: String,
1569 timestamp: u64,
1570 task_id: String,
1571 hook_name: String,
1573 reason: String,
1575 reopen_number: u32,
1577 attempt_context: &'static str,
1579 turns_remaining: Option<u32>,
1583}
1584
1585#[derive(Serialize)]
1590struct TaskContinuedEvent<'a> {
1591 event_type: &'static str,
1592 event_id: String,
1593 parent_id: Option<String>,
1594 session_id: String,
1595 timestamp: u64,
1596 task_id: String,
1597 continuation_number: u32,
1599 member_calls: &'a [String],
1601 delegations: &'a [String],
1604 waited_ms: u64,
1606 turns_remaining: Option<u32>,
1610}
1611
1612#[derive(Serialize)]
1615struct KeepWorkingEvent {
1616 event_type: &'static str,
1617 event_id: String,
1618 parent_id: Option<String>,
1619 session_id: String,
1620 timestamp: u64,
1621 task_id: String,
1622 number: u32,
1624 limit: u32,
1626 turns_remaining: Option<u32>,
1629 message_bytes: u64,
1631}
1632
1633#[derive(Serialize)]
1637struct DoneCheckEvent<'a> {
1638 event_type: &'static str,
1639 event_id: String,
1640 parent_id: Option<String>,
1641 session_id: String,
1642 timestamp: u64,
1643 #[serde(flatten)]
1644 run: &'a DoneCheckRun<'a>,
1645}
1646
1647#[derive(Debug, Serialize)]
1649pub(crate) struct DoneCheckRun<'a> {
1650 pub(crate) task_id: &'a str,
1651 pub(crate) number: u32,
1653 pub(crate) command: &'a str,
1655 pub(crate) outcome: &'static str,
1657 pub(crate) exit_code: Option<i32>,
1659 pub(crate) duration_ms: u64,
1660 pub(crate) stdout_bytes: u64,
1662 pub(crate) stderr_bytes: u64,
1663 pub(crate) truncated: bool,
1664 pub(crate) resource_limit: Option<&'a str>,
1666 pub(crate) reopened: bool,
1668 pub(crate) reopens_left: u32,
1670 pub(crate) turns_remaining: Option<u32>,
1672}
1673
1674#[derive(Serialize)]
1678struct CallDeadlineEvent {
1679 event_type: &'static str,
1680 event_id: String,
1681 parent_id: Option<String>,
1682 session_id: String,
1683 timestamp: u64,
1684 #[serde(skip_serializing_if = "Option::is_none")]
1685 task_id: Option<String>,
1686 component: String,
1688 role: &'static str,
1690 call: String,
1692 deadline_seconds: u64,
1693 elapsed_ms: u64,
1695}
1696
1697#[derive(Serialize)]
1700struct InferenceRetryEvent<'a> {
1701 event_type: &'static str,
1702 event_id: String,
1703 parent_id: Option<String>,
1704 session_id: String,
1705 timestamp: u64,
1706 turn: u32,
1707 task_id: Option<String>,
1708 attempt: u32,
1709 max_retries: u32,
1710 reason: &'static str,
1711 #[serde(skip_serializing_if = "Option::is_none")]
1712 provider_status: Option<u16>,
1713 error: &'a str,
1714 delay_ms: u64,
1715 #[serde(skip_serializing_if = "Option::is_none")]
1716 retry_after_ms: Option<u64>,
1717 charged_tokens: u64,
1718 #[serde(skip_serializing_if = "Option::is_none")]
1719 origin: Option<&'a str>,
1720}
1721
1722#[derive(Serialize)]
1725struct SpendCeilingReachedEvent<'a> {
1726 event_type: &'static str,
1727 event_id: String,
1728 parent_id: Option<String>,
1729 session_id: String,
1730 timestamp: u64,
1731 turn: u32,
1732 task_id: Option<String>,
1733 #[serde(flatten)]
1734 refusal: &'a crate::spend::SpendRefusal,
1735 #[serde(skip_serializing_if = "Option::is_none")]
1737 origin: Option<&'a str>,
1738}
1739
1740#[derive(Serialize)]
1745pub(crate) struct HarnessStart {
1746 pub driver: String,
1748 pub driver_version: String,
1749 pub harness: String,
1751 pub binary: String,
1753 pub binary_source: String,
1755 pub harness_version: Option<String>,
1757 pub version_tested: bool,
1759 pub harness_session_id: String,
1761 pub session_mode: String,
1762 pub args_count: usize,
1765 pub env_names: Vec<String>,
1767 pub account_env_unset: Vec<String>,
1770 pub files: Vec<String>,
1772 pub bridge_tools: Vec<String>,
1774 pub stdin_bytes: usize,
1776 pub keep_stdin_open: bool,
1777 pub system_prompt_sha256: String,
1781 #[serde(skip)]
1784 pub system_prompt: String,
1785}
1786
1787#[derive(Serialize)]
1788struct HarnessStartEvent {
1789 event_type: &'static str,
1790 event_id: String,
1791 parent_id: Option<String>,
1792 session_id: String,
1793 timestamp: u64,
1794 task_id: Option<String>,
1795 #[serde(flatten)]
1796 start: HarnessStart,
1797}
1798
1799#[derive(Serialize)]
1802struct HarnessWarningEvent {
1803 event_type: &'static str,
1804 event_id: String,
1805 parent_id: Option<String>,
1806 session_id: String,
1807 timestamp: u64,
1808 task_id: Option<String>,
1809 code: String,
1810 message: String,
1811}
1812
1813#[derive(Serialize)]
1816struct HarnessSessionEvent {
1817 event_type: &'static str,
1818 event_id: String,
1819 parent_id: Option<String>,
1820 session_id: String,
1821 timestamp: u64,
1822 task_id: Option<String>,
1823 harness_session_id: String,
1824 auth: String,
1826 model: Option<String>,
1828}
1829
1830#[derive(Serialize)]
1834struct HarnessSessionForgottenEvent {
1835 event_type: &'static str,
1836 event_id: String,
1837 parent_id: Option<String>,
1838 session_id: String,
1839 timestamp: u64,
1840 task_id: Option<String>,
1841 context_id: String,
1843 harness_session_id: String,
1845 requested_by: String,
1847}
1848
1849#[derive(Serialize)]
1850struct HarnessRetryEvent {
1851 event_type: &'static str,
1852 event_id: String,
1853 parent_id: Option<String>,
1854 session_id: String,
1855 timestamp: u64,
1856 task_id: Option<String>,
1857 attempt: u32,
1858 reason: String,
1859}
1860
1861#[derive(Serialize)]
1864struct HarnessTextEvent {
1865 event_type: &'static str,
1866 event_id: String,
1867 parent_id: Option<String>,
1868 session_id: String,
1869 timestamp: u64,
1870 task_id: Option<String>,
1871 text: String,
1872}
1873
1874#[derive(Serialize)]
1877struct HarnessQuotaWindow<'a> {
1878 name: &'a str,
1879 #[serde(skip_serializing_if = "Option::is_none")]
1880 used: Option<f64>,
1881 #[serde(skip_serializing_if = "Option::is_none")]
1882 resets_at_ms: Option<u64>,
1883 #[serde(skip_serializing_if = "Option::is_none")]
1884 window_minutes: Option<u64>,
1885 #[serde(skip_serializing_if = "Option::is_none")]
1886 state: Option<&'static str>,
1887}
1888
1889#[derive(Serialize)]
1890struct HarnessQuotaEvent<'a> {
1891 event_type: &'static str,
1892 event_id: String,
1893 parent_id: Option<String>,
1894 session_id: String,
1895 timestamp: u64,
1896 task_id: Option<String>,
1897 windows: Vec<HarnessQuotaWindow<'a>>,
1898}
1899
1900#[derive(Serialize)]
1901struct HarnessFailedEvent {
1902 event_type: &'static str,
1903 event_id: String,
1904 parent_id: Option<String>,
1905 session_id: String,
1906 timestamp: u64,
1907 task_id: Option<String>,
1908 kind: String,
1910 message: String,
1911 source: String,
1913}
1914
1915#[derive(Serialize)]
1917struct HarnessInterruptEvent {
1918 event_type: &'static str,
1919 event_id: String,
1920 parent_id: Option<String>,
1921 session_id: String,
1922 timestamp: u64,
1923 task_id: Option<String>,
1924 method: String,
1926 delivered: bool,
1928 grace_ms: u64,
1931}
1932
1933#[derive(Serialize)]
1935pub(crate) struct HarnessExit {
1936 pub code: Option<i32>,
1938 pub signal: Option<i32>,
1940 pub cause: String,
1943 pub killed: bool,
1945 pub duration_ms: u64,
1947}
1948
1949#[derive(Serialize)]
1950struct HarnessExitEvent {
1951 event_type: &'static str,
1952 event_id: String,
1953 parent_id: Option<String>,
1954 session_id: String,
1955 timestamp: u64,
1956 task_id: Option<String>,
1957 #[serde(flatten)]
1958 exit: HarnessExit,
1959}
1960
1961#[derive(Serialize)]
1965struct CallDeniedEvent {
1966 event_type: &'static str,
1967 event_id: String,
1968 parent_id: Option<String>,
1969 session_id: String,
1970 timestamp: u64,
1971 turn: u32,
1972 event: String,
1975 hook_name: String,
1977 target: String,
1980 reason: String,
1982}
1983
1984#[derive(Serialize)]
1988struct ToolInputRefusedEvent {
1989 event_type: &'static str,
1990 event_id: String,
1991 parent_id: Option<String>,
1992 session_id: String,
1993 timestamp: u64,
1994 turn: u32,
1995 tool_name: String,
1996 tool_call_id: Option<String>,
1999 missing: Vec<String>,
2001 reason: String,
2003}
2004
2005#[derive(Serialize)]
2010struct ProtectedPathDeniedEvent {
2011 event_type: &'static str,
2012 event_id: String,
2013 parent_id: Option<String>,
2014 session_id: String,
2015 timestamp: u64,
2016 turn: u32,
2017 call: String,
2019 target: String,
2021 path: String,
2024 rule: String,
2026 signal: String,
2029 reason: String,
2031}
2032
2033#[derive(Serialize)]
2034struct HookDispatchErrorEvent {
2035 event_type: &'static str,
2036 event_id: String,
2037 parent_id: Option<String>,
2038 session_id: String,
2039 timestamp: u64,
2040 hook_name: String,
2041 event: String,
2042 arm: String,
2043}
2044
2045#[derive(Serialize)]
2049struct FormationEndedEvent {
2050 event_type: &'static str,
2051 event_id: String,
2052 parent_id: Option<String>,
2053 session_id: String,
2054 timestamp: u64,
2055 formation_id: String,
2056}
2057
2058#[derive(Serialize)]
2060struct SessionStatusEvent<'a> {
2061 event_type: &'static str,
2062 event_id: String,
2063 parent_id: Option<String>,
2064 session_id: String,
2065 timestamp: u64,
2066 #[serde(skip_serializing_if = "Option::is_none")]
2068 task_id: Option<String>,
2069 status: &'a crate::session_status::StatusReport,
2070}
2071
2072#[derive(Serialize)]
2074struct DeadlineNoticeEvent<'a> {
2075 event_type: &'static str,
2076 event_id: String,
2077 parent_id: Option<String>,
2078 session_id: String,
2079 timestamp: u64,
2080 #[serde(skip_serializing_if = "Option::is_none")]
2082 task_id: Option<String>,
2083 tool_name: &'a str,
2085 deadline_at: &'a str,
2087 left_secs: u64,
2089 notice_secs: u64,
2091}
2092
2093#[derive(Serialize)]
2096struct DeadlineReachedEvent {
2097 event_type: &'static str,
2098 event_id: String,
2099 parent_id: Option<String>,
2100 session_id: String,
2101 timestamp: u64,
2102 deadline_at: String,
2104 deadline_secs: u64,
2106 source: &'static str,
2108}
2109
2110#[derive(Serialize)]
2117struct SpawnerEndedEvent {
2118 event_type: &'static str,
2119 event_id: String,
2120 parent_id: Option<String>,
2121 session_id: String,
2122 timestamp: u64,
2123 #[serde(skip_serializing_if = "Option::is_none")]
2124 spawned_by: Option<String>,
2125 #[serde(skip_serializing_if = "Option::is_none")]
2126 delegation_id: Option<String>,
2127}
2128
2129#[derive(Serialize)]
2130struct ResourceListEvent {
2131 event_type: &'static str,
2132 event_id: String,
2133 parent_id: Option<String>,
2134 session_id: String,
2135 timestamp: u64,
2136 root: String,
2139 entry_count: usize,
2140 total_bytes: u64,
2141 generation: u64,
2143 containment_achieved: ContainmentClass,
2144 outcome: String,
2146 reason: Option<String>,
2148}
2149
2150#[derive(Serialize)]
2151struct ResourceReadEvent {
2152 event_type: &'static str,
2153 event_id: String,
2154 parent_id: Option<String>,
2155 session_id: String,
2156 timestamp: u64,
2157 path: String,
2160 outcome: String,
2161 bytes: Option<u64>,
2163 sha256: Option<String>,
2165 generation: u64,
2166 containment_achieved: ContainmentClass,
2167 reason: Option<String>,
2168}
2169
2170#[derive(Serialize)]
2172struct PeerHandleMintEvent {
2173 event_type: &'static str,
2174 event_id: String,
2175 parent_id: Option<String>,
2176 session_id: String,
2177 timestamp: u64,
2178 handle_id: Option<String>,
2181 path: String,
2184 audience: String,
2185 expires_at_ms: Option<u64>,
2187 outcome: String,
2188 reason: Option<String>,
2189}
2190
2191#[derive(Serialize)]
2194struct PeerHandleRedeemEvent {
2195 event_type: &'static str,
2196 event_id: String,
2197 parent_id: Option<String>,
2198 session_id: String,
2199 timestamp: u64,
2200 handle_id: String,
2201 path: Option<String>,
2204 generation: u64,
2207 audience_asserted: Option<String>,
2209 bytes: Option<u64>,
2211 sha256: Option<String>,
2213 outcome: String,
2214 reason: Option<String>,
2215}
2216
2217#[derive(Serialize)]
2219struct PeerFileFetchEvent {
2220 event_type: &'static str,
2221 event_id: String,
2222 parent_id: Option<String>,
2223 session_id: String,
2224 timestamp: u64,
2225 peer: String,
2226 handle_id: String,
2227 stored_path: Option<String>,
2230 bytes: Option<u64>,
2231 sha256: Option<String>,
2232 outcome: String,
2233 reason: Option<String>,
2234}
2235
2236#[derive(Serialize)]
2247struct DelegationStartEvent {
2248 event_type: &'static str,
2249 event_id: String,
2250 parent_id: Option<String>,
2251 session_id: String,
2252 timestamp: u64,
2253 delegation_id: String,
2256 capsule: String,
2257 version: String,
2258 child_session_id: String,
2260 child_workdir: String,
2262 child_records_dir: String,
2265 workdir_snapshot: Option<crate::workdir_snapshot::WorkdirSnapshot>,
2269}
2270
2271impl DelegationStartEvent {
2272 #[allow(clippy::too_many_arguments)]
2275 fn new(
2276 session_id: &str,
2277 session_event_id: &str,
2278 delegation_id: &str,
2279 capsule: &str,
2280 version: &str,
2281 child_session_id: &str,
2282 child_workdir: &str,
2283 child_records_dir: &str,
2284 workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
2285 ) -> Self {
2286 Self {
2287 event_type: "delegation_start",
2288 event_id: new_event_id(),
2289 parent_id: Some(session_event_id.to_string()),
2290 session_id: session_id.to_string(),
2291 timestamp: timestamp_ms(),
2292 delegation_id: delegation_id.to_string(),
2293 capsule: capsule.to_string(),
2294 version: version.to_string(),
2295 child_session_id: child_session_id.to_string(),
2296 child_workdir: child_workdir.to_string(),
2297 child_records_dir: child_records_dir.to_string(),
2298 workdir_snapshot: workdir_snapshot.cloned(),
2299 }
2300 }
2301}
2302
2303#[derive(Serialize)]
2309struct MemberCallStartEvent {
2310 event_type: &'static str,
2311 event_id: String,
2312 parent_id: Option<String>,
2313 session_id: String,
2314 timestamp: u64,
2315 task_id: String,
2317 call_id: String,
2319 member: String,
2321 member_task_id: String,
2323}
2324
2325#[derive(Serialize)]
2331struct MemberCallBusyEvent {
2332 event_type: &'static str,
2333 event_id: String,
2334 parent_id: Option<String>,
2335 session_id: String,
2336 timestamp: u64,
2337 task_id: String,
2339 call_id: String,
2341 member: String,
2343 offer: u32,
2345 waited_ms: u64,
2347 message: String,
2349}
2350
2351#[derive(Serialize)]
2357struct MemberCallEvent {
2358 event_type: &'static str,
2359 event_id: String,
2360 parent_id: Option<String>,
2361 session_id: String,
2362 timestamp: u64,
2363 task_id: String,
2364 call_id: String,
2365 member: String,
2366 #[serde(skip_serializing_if = "Option::is_none")]
2368 member_task_id: Option<String>,
2369 status: String,
2372 duration_ms: u64,
2373 output: String,
2375 truncated: bool,
2376 delivered: bool,
2379 #[serde(skip_serializing_if = "Vec::is_empty")]
2382 no_answer_below: Vec<NoAnswerBelow>,
2383}
2384
2385#[derive(Serialize)]
2387struct NoAnswerBelow {
2388 member: String,
2389 status: String,
2391}
2392
2393#[derive(Serialize)]
2404struct DelegationEvent {
2405 event_type: &'static str,
2406 event_id: String,
2407 parent_id: Option<String>,
2408 session_id: String,
2409 timestamp: u64,
2410 capsule: String,
2412 version: String,
2413 delegation_id: Option<String>,
2415 child_session_id: Option<String>,
2417 duration_ms: u64,
2420 outcome: String,
2430 reason: Option<String>,
2433}
2434
2435impl DelegationEvent {
2436 #[allow(clippy::too_many_arguments)]
2439 fn new(
2440 session_id: &str,
2441 session_event_id: &str,
2442 capsule: &str,
2443 version: &str,
2444 delegation_id: Option<String>,
2445 child_session_id: Option<String>,
2446 duration_ms: u64,
2447 outcome: &str,
2448 reason: Option<String>,
2449 ) -> Self {
2450 Self {
2451 event_type: "delegation",
2452 event_id: new_event_id(),
2453 parent_id: Some(session_event_id.to_string()),
2454 session_id: session_id.to_string(),
2455 timestamp: timestamp_ms(),
2456 capsule: capsule.to_string(),
2457 version: version.to_string(),
2458 delegation_id,
2459 child_session_id,
2460 duration_ms,
2461 outcome: outcome.to_string(),
2462 reason,
2463 }
2464 }
2465}
2466
2467impl TraceWriter {
2470 #[allow(clippy::too_many_arguments)]
2471 pub(crate) async fn open(
2472 records_root: &WorkdirRoot,
2473 session_id: String,
2474 capsule_name: String,
2475 capsule_version: String,
2476 model: String,
2477 capabilities: Vec<String>,
2478 effective_grants: ScopeReport,
2479 capture: TraceCapture,
2480 system_prompt: Option<String>,
2481 system_prompt_overridden: bool,
2482 resumed_from: Option<String>,
2483 context_id: Option<String>,
2484 spawned_by: Option<String>,
2485 spawn_delegation_id: Option<String>,
2486 ) -> std::io::Result<Self> {
2487 let file = File::from_std(records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?);
2488 let system_prompt_source = match (system_prompt_overridden, system_prompt.is_some()) {
2491 (true, _) => "cli",
2492 (false, true) => "manifest",
2493 (false, false) => "none",
2494 };
2495 let system_prompt_sha256 = system_prompt
2496 .as_ref()
2497 .map(|prompt| murmur_artifact::sha256_hex(prompt.as_bytes()));
2498 let blobs = BlobStore::new(records_root);
2499 if let (true, Some(prompt), Some(sha)) = (
2503 capture.captures_content(),
2504 system_prompt.as_ref(),
2505 system_prompt_sha256.as_deref(),
2506 ) {
2507 blobs.put(sha, prompt.as_bytes()).await?;
2508 }
2509 Ok(Self {
2510 writer: BufWriter::new(file),
2511 session_id,
2512 capsule_name,
2513 capsule_version,
2514 model,
2515 capabilities,
2516 effective_grants,
2517 capture,
2518 blobs,
2519 system_prompt_source,
2520 system_prompt_sha256,
2521 credential_source: "none",
2522 gateways: Vec::new(),
2523 control: None,
2524 inference_choices: Vec::new(),
2525 tool_refresh: None,
2526 runtime_artifacts: Vec::new(),
2527 host_facts: Vec::new(),
2528 instruction_files: None,
2529 resumed_from,
2530 context_id,
2531 spawned_by,
2532 spawn_delegation_id,
2533 formation_id: None,
2534 formation_member: None,
2535 session_start_time: Instant::now(),
2536 session_event_id: new_event_id(),
2537 session_started: false,
2538 session_ended: false,
2539 task_event_id: None,
2540 turn_event_id: None,
2541 total_turns: 0,
2542 total_input_tokens: 0,
2543 total_output_tokens: 0,
2544 total_tool_calls: 0,
2545 total_shell_calls: 0,
2546 task_turns: 0,
2547 task_input_tokens: 0,
2548 task_output_tokens: 0,
2549 task_tool_calls: 0,
2550 task_shell_calls: 0,
2551 task_agent_turns: 0,
2552 task_hook_inferences: 0,
2553 task_capped_turns: 0,
2554 task_start_instant: None,
2555 active_task_id: None,
2556 task_failures_written: 0,
2557 last_task_failure: None,
2558 max_session_tokens: None,
2559 machine_tokens_per_day: None,
2560 session_status: None,
2561 call_deadlines: None,
2562 })
2563 }
2564
2565 pub(crate) fn set_call_deadlines(&mut self, deadlines: crate::limits::CallDeadlines) {
2568 self.call_deadlines = Some(deadlines);
2569 }
2570
2571 pub(crate) fn set_session_status(
2574 &mut self,
2575 status: std::sync::Arc<crate::session_status::SessionStatus>,
2576 ) {
2577 self.session_status = Some(status);
2578 }
2579
2580 pub(crate) fn set_spend_ceilings(
2582 &mut self,
2583 max_session_tokens: Option<u64>,
2584 machine_tokens_per_day: Option<u64>,
2585 ) {
2586 self.max_session_tokens = max_session_tokens;
2587 self.machine_tokens_per_day = machine_tokens_per_day;
2588 }
2589
2590 pub(crate) fn session_event_id(&self) -> &str {
2594 &self.session_event_id
2595 }
2596
2597 pub(crate) fn captures_wire(&self) -> bool {
2601 self.capture.captures_hashes()
2602 }
2603
2604 fn session_parent(&self) -> Option<String> {
2608 self.session_started.then(|| self.session_event_id.clone())
2609 }
2610
2611 fn task_parent(&self) -> Option<String> {
2614 self.task_event_id.clone().or_else(|| self.session_parent())
2615 }
2616
2617 fn turn_parent(&self) -> Option<String> {
2621 self.turn_event_id.clone().or_else(|| self.task_parent())
2622 }
2623
2624 pub(crate) fn set_credential_source(&mut self, source: &'static str) {
2627 self.credential_source = source;
2628 }
2629
2630 pub(crate) fn set_gateways(&mut self, gateways: Vec<SessionGateway>) {
2632 self.gateways = gateways;
2633 }
2634
2635 pub(crate) fn set_control(&mut self, control: Option<SessionControl>) {
2637 self.control = control;
2638 }
2639
2640 pub(crate) fn set_inference_choices(&mut self, choices: Vec<SessionInferenceChoice>) {
2643 self.inference_choices = choices;
2644 }
2645
2646 pub(crate) fn set_runtime_artifacts(&mut self, installed: &[InstalledArtifactSummary]) {
2649 self.runtime_artifacts = installed
2650 .iter()
2651 .filter_map(|artifact| match &artifact.origin {
2652 LockOrigin::Runtime { session } => Some(RuntimeArtifact {
2653 name: artifact.name.clone(),
2654 version: artifact.version.clone(),
2655 origin: artifact.origin.as_str(),
2656 session: session.clone(),
2657 trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
2658 }),
2659 LockOrigin::Operator => None,
2660 })
2661 .collect();
2662 }
2663
2664 pub(crate) fn set_host_facts(&mut self, uses: &[crate::host_facts::HostFactsUse]) {
2666 self.host_facts = uses.iter().map(HostFactsRecord::of).collect();
2667 }
2668
2669 pub(crate) async fn write_control_applied(
2676 &mut self,
2677 turn: u32,
2678 name: &str,
2679 value: Value,
2680 change_id: &str,
2681 ) -> std::io::Result<()> {
2682 let event = ControlAppliedEvent {
2683 event_type: "control_applied",
2684 event_id: new_event_id(),
2685 parent_id: self.task_parent(),
2686 session_id: self.session_id.clone(),
2687 timestamp: timestamp_ms(),
2688 turn,
2689 task_id: self.active_task_id.clone(),
2690 name,
2691 value,
2692 change_id,
2693 };
2694 self.write_event(&event).await
2695 }
2696
2697 pub(crate) fn set_formation_id(&mut self, formation_id: Option<&FormationId>) {
2699 self.formation_id = formation_id.cloned();
2700 }
2701
2702 pub(crate) fn set_formation_member(
2705 &mut self,
2706 member: Option<&crate::formation_credentials::FormationMember>,
2707 ) {
2708 self.formation_member = member.map(|member| {
2709 (
2710 member.name().to_string(),
2711 member.callees().map(str::to_string).collect(),
2712 )
2713 });
2714 }
2715
2716 pub(crate) fn set_instruction_files(
2719 &mut self,
2720 files: Option<&crate::instruction_files::InstructionFiles>,
2721 ) {
2722 self.instruction_files =
2723 files.map(crate::instruction_files::InstructionFiles::trace_records);
2724 }
2725
2726 pub(crate) fn set_tool_refresh(&mut self, tool_refresh: Option<&'static str>) {
2729 self.tool_refresh = tool_refresh;
2730 }
2731
2732 pub(crate) async fn write_tools_refreshed(
2740 &mut self,
2741 turn: u32,
2742 trigger: &str,
2743 added: &[String],
2744 removed: &[String],
2745 tools: &[String],
2746 ) -> std::io::Result<()> {
2747 let event = ToolsRefreshedEvent {
2748 event_type: "tools_refreshed",
2749 event_id: new_event_id(),
2750 parent_id: self.task_parent(),
2751 session_id: self.session_id.clone(),
2752 timestamp: timestamp_ms(),
2753 turn,
2754 task_id: self.active_task_id.clone(),
2755 trigger,
2756 added,
2757 removed,
2758 tools,
2759 };
2760 self.write_event(&event).await
2761 }
2762
2763 pub(crate) async fn write_session_start(
2764 &mut self,
2765 max_turns: Option<u32>,
2766 model_limits: Option<crate::model_limits::ResolvedModelLimits>,
2767 tools_declared: Vec<String>,
2768 ) -> std::io::Result<()> {
2769 let event = SessionStartEvent {
2770 event_type: "session_start",
2771 event_id: self.session_event_id.clone(),
2772 parent_id: None,
2773 session_id: self.session_id.clone(),
2774 timestamp: timestamp_ms(),
2775 capsule_name: self.capsule_name.clone(),
2776 capsule_version: self.capsule_version.clone(),
2777 model: self.model.clone(),
2778 max_turns,
2779 model_limits,
2780 max_session_tokens: self.max_session_tokens,
2781 machine_tokens_per_day: self.machine_tokens_per_day,
2782 deadline: self.session_status.as_ref().and_then(|status| {
2783 status.deadline().map(|deadline| SessionStartDeadline {
2784 at: deadline.at_rfc3339(),
2785 secs: deadline.secs(),
2786 notice_secs: status.notice_secs(),
2787 source: deadline.source().as_str(),
2788 })
2789 }),
2790 capabilities: self.capabilities.clone(),
2791 tools_declared,
2792 containment_declared: self.effective_grants.declared_containment,
2793 containment_achieved: self.effective_grants.achieved_containment,
2794 workdir_exec: self.effective_grants.workdir_exec,
2795 userns_grant: self.effective_grants.userns_grant,
2796 effective_grants: self.effective_grants.clone(),
2797 system_prompt_source: self.system_prompt_source,
2798 system_prompt_sha256: self.system_prompt_sha256.clone(),
2799 credential_source: self.credential_source,
2800 gateways: self.gateways.clone(),
2801 control: self.control.clone(),
2802 inference_choices: self.inference_choices.clone(),
2803 tool_refresh: self.tool_refresh,
2804 runtime_artifacts: self.runtime_artifacts.clone(),
2805 host_facts: self.host_facts.clone(),
2806 instruction_files: self.instruction_files.clone(),
2807 resumed_from: self.resumed_from.clone(),
2808 context_id: self.context_id.clone(),
2809 spawned_by: self.spawned_by.clone(),
2810 delegation_id: self.spawn_delegation_id.clone(),
2811 formation_id: self.formation_id.clone(),
2812 formation_member: self.formation_member.as_ref().map(|(name, _)| name.clone()),
2813 formation_callees: self
2814 .formation_member
2815 .as_ref()
2816 .map(|(_, callees)| callees.clone()),
2817 };
2818 self.write_event(&event).await?;
2819 self.session_started = true;
2820 self.write_call_deadlines().await
2822 }
2823
2824 #[allow(clippy::too_many_arguments)]
2837 pub(crate) async fn write_inference(
2838 &mut self,
2839 turn: u32,
2840 input_tokens: Option<u64>,
2841 output_tokens: Option<u64>,
2842 decision: String,
2843 stop_reason: Option<&str>,
2844 tool_name: Option<String>,
2845 origin: Option<&InferenceOrigin>,
2846 usage: Option<&DriverUsage>,
2847 message_ids: Vec<String>,
2848 wire: Option<&WireCapture>,
2849 settings: Option<&InferenceCallSettings>,
2850 ) -> std::io::Result<()> {
2851 self.write_inference_event(InferenceLine {
2852 turn,
2853 input_tokens,
2854 output_tokens,
2855 decision,
2856 stop_reason,
2857 tool_name,
2858 origin,
2859 usage,
2860 message_ids,
2861 wire,
2862 choice: None,
2863 failure: None,
2864 counted: true,
2865 settings,
2866 retry: RetryNote::default(),
2867 gateway: None,
2868 })
2869 .await
2870 }
2871
2872 #[allow(clippy::too_many_arguments)]
2875 pub(crate) async fn write_hook_inference(
2876 &mut self,
2877 turn: u32,
2878 input_tokens: Option<u64>,
2879 output_tokens: Option<u64>,
2880 decision: String,
2881 origin: &InferenceOrigin,
2882 usage: Option<&DriverUsage>,
2883 settings: Option<&InferenceCallSettings>,
2884 retry: RetryNote,
2885 gateway: Option<GatewayOffsets>,
2886 ) -> std::io::Result<()> {
2887 self.write_inference_event(InferenceLine {
2888 turn,
2889 input_tokens,
2890 output_tokens,
2891 decision,
2892 stop_reason: None,
2893 tool_name: None,
2894 origin: Some(origin),
2895 usage,
2896 message_ids: Vec::new(),
2897 wire: None,
2898 choice: None,
2899 failure: None,
2900 counted: true,
2901 settings,
2902 retry,
2903 gateway,
2904 })
2905 .await
2906 }
2907
2908 #[allow(clippy::too_many_arguments)]
2912 pub(crate) async fn write_turn_inference(
2913 &mut self,
2914 turn: u32,
2915 input_tokens: Option<u64>,
2916 output_tokens: Option<u64>,
2917 decision: String,
2918 stop_reason: Option<&str>,
2919 tool_name: Option<String>,
2920 usage: Option<&DriverUsage>,
2921 message_ids: Vec<String>,
2922 wire: Option<&WireCapture>,
2923 choice: Option<&crate::driver_choice::DriverChoice>,
2924 settings: Option<&InferenceCallSettings>,
2925 retry: RetryNote,
2926 gateway: Option<GatewayOffsets>,
2927 ) -> std::io::Result<()> {
2928 if stop_reason == Some("max_tokens") {
2929 self.task_capped_turns = self.task_capped_turns.saturating_add(1);
2930 }
2931 self.write_inference_event(InferenceLine {
2932 turn,
2933 input_tokens,
2934 output_tokens,
2935 decision,
2936 stop_reason,
2937 tool_name,
2938 origin: None,
2939 usage,
2940 message_ids,
2941 wire,
2942 choice,
2943 failure: None,
2944 counted: true,
2945 settings,
2946 retry,
2947 gateway,
2948 })
2949 .await
2950 }
2951
2952 pub(crate) async fn write_failed_inference(
2960 &mut self,
2961 failed: FailedInference<'_>,
2962 ) -> std::io::Result<()> {
2963 let error =
2964 crate::agent::truncate_on_char_boundary(failed.error, MAX_TASK_FAILURE_REASON_BYTES)
2965 .to_string();
2966 let (input_tokens, output_tokens) = failed.spent.unwrap_or((None, None));
2967 self.write_inference_event(InferenceLine {
2968 turn: failed.turn,
2969 input_tokens,
2970 output_tokens,
2971 decision: "error".to_string(),
2972 stop_reason: Some("error"),
2973 tool_name: failed.tool_name,
2974 origin: failed.origin,
2975 usage: failed.usage,
2976 message_ids: failed.message_ids,
2977 wire: None,
2978 choice: failed.choice,
2979 failure: Some((failed.error_code, error, failed.provider_status)),
2980 counted: failed.spent.is_some(),
2981 settings: failed.settings,
2982 retry: failed.retry,
2983 gateway: failed.gateway,
2984 })
2985 .await
2986 }
2987
2988 async fn write_inference_event(&mut self, line: InferenceLine<'_>) -> std::io::Result<()> {
2989 let InferenceLine {
2990 turn,
2991 input_tokens,
2992 output_tokens,
2993 decision,
2994 stop_reason,
2995 tool_name,
2996 origin,
2997 usage,
2998 message_ids,
2999 wire,
3000 choice,
3001 failure,
3002 counted,
3003 settings,
3004 retry,
3005 gateway,
3006 } = line;
3007 let (error_code, error, provider_status) = match failure {
3008 Some((code, error, status)) => (Some(code.to_string()), Some(error), status),
3009 None => (None, None, None),
3010 };
3011 self.write_call_deadlines().await?;
3013 let event_id = new_event_id();
3014 let parent_id = if origin.is_none() {
3018 let parent = self.task_parent();
3019 self.turn_event_id = Some(event_id.clone());
3020 parent
3021 } else {
3022 self.turn_parent()
3023 };
3024 let (system_sha, tools_sha, response_sha, message_shas) =
3025 match wire.filter(|_| self.capture.captures_hashes()) {
3026 Some(wire) => self.record_wire(wire).await?,
3027 None => (None, None, None, Vec::new()),
3028 };
3029 let event = InferenceEvent {
3030 event_type: "inference",
3031 event_id,
3032 parent_id,
3033 session_id: self.session_id.clone(),
3034 timestamp: timestamp_ms(),
3035 turn,
3036 task_id: self.active_task_id.clone(),
3037 input_tokens,
3038 output_tokens,
3039 decision,
3040 stop_reason: stop_reason.map(str::to_string),
3041 tool_name,
3042 input_tokens_actual: usage.and_then(|u| u.input_tokens),
3043 output_tokens_actual: usage.and_then(|u| u.output_tokens),
3044 cached_tokens: usage.and_then(|u| u.cached_tokens),
3045 cache_write_tokens: usage.and_then(|u| u.cache_write_tokens),
3046 thinking_tokens: usage.and_then(|u| u.thinking_tokens),
3047 origin: origin.map(|o| o.source.clone()),
3048 driver_choice: choice.map(|choice| choice.name.clone()),
3049 model: origin
3050 .map(|o| o.model.clone())
3051 .or_else(|| choice.map(|choice| choice.model.clone())),
3052 max_output_tokens: settings.map(|settings| settings.max_output_tokens),
3053 input_budget: settings.map(|settings| settings.input_budget),
3054 driver_config: settings.map(|settings| settings.driver_config.clone()),
3055 message_ids,
3056 system_sha,
3057 tools_sha,
3058 response_sha,
3059 message_shas,
3060 error_code,
3061 error,
3062 provider_status,
3063 gateway_reached_ms: gateway.map(|gateway| gateway.reached_ms),
3064 upstream_responded_ms: gateway.map(|gateway| gateway.responded_ms),
3065 attempts: retry.attempts,
3066 retry_stopped: retry.stopped.map(RetryStop::wire_name),
3067 };
3068 self.write_event(&event).await?;
3069 if origin.is_none() {
3070 self.task_agent_turns = self.task_agent_turns.saturating_add(1);
3071 }
3072 if counted {
3073 let (input, output) = (input_tokens.unwrap_or(0), output_tokens.unwrap_or(0));
3077 self.total_turns = self.total_turns.saturating_add(1);
3078 self.total_input_tokens = self.total_input_tokens.saturating_add(input);
3079 self.total_output_tokens = self.total_output_tokens.saturating_add(output);
3080 self.task_turns = self.task_turns.saturating_add(1);
3081 if origin.is_some() {
3082 self.task_hook_inferences = self.task_hook_inferences.saturating_add(1);
3083 }
3084 self.task_input_tokens = self.task_input_tokens.saturating_add(input);
3085 self.task_output_tokens = self.task_output_tokens.saturating_add(output);
3086 }
3087 if let Some(status) = &self.session_status {
3090 status.record_inference(self.task_turns, input_tokens, output_tokens, usage);
3091 }
3092 Ok(())
3093 }
3094
3095 async fn record_wire(
3102 &mut self,
3103 wire: &WireCapture,
3104 ) -> std::io::Result<(Option<String>, Option<String>, Option<String>, Vec<String>)> {
3105 let system_sha = murmur_artifact::sha256_hex(&wire.system);
3106 let tools_sha = murmur_artifact::sha256_hex(&wire.tools);
3107 let response_sha = murmur_artifact::sha256_hex(&wire.response);
3108 let message_shas: Vec<String> = wire
3109 .messages
3110 .iter()
3111 .map(|message| murmur_artifact::sha256_hex(message))
3112 .collect();
3113
3114 if self.capture.captures_content() {
3115 self.blobs.put(&system_sha, &wire.system).await?;
3119 self.blobs.put(&tools_sha, &wire.tools).await?;
3120 self.blobs.put(&response_sha, &wire.response).await?;
3121 for (sha, body) in message_shas.iter().zip(&wire.messages) {
3122 self.blobs.put(sha, body).await?;
3123 }
3124 }
3125
3126 Ok((
3127 Some(system_sha),
3128 Some(tools_sha),
3129 Some(response_sha),
3130 message_shas,
3131 ))
3132 }
3133
3134 #[allow(clippy::too_many_arguments)]
3135 pub(crate) async fn write_tool_call(
3136 &mut self,
3137 turn: u32,
3138 tool_name: String,
3139 tool_call_id: Option<String>,
3140 input: Value,
3141 input_bytes: u64,
3142 output: &str,
3143 output_bytes: u64,
3144 duration_ms: u64,
3145 status: String,
3146 state_effect: Option<String>,
3147 resource_id: Option<String>,
3148 error: Option<&str>,
3149 ) -> std::io::Result<()> {
3150 self.write_tool_call_with_attachments(
3151 turn,
3152 tool_name,
3153 tool_call_id,
3154 input,
3155 input_bytes,
3156 output,
3157 output_bytes,
3158 duration_ms,
3159 status,
3160 state_effect,
3161 resource_id,
3162 error,
3163 Vec::new(),
3164 )
3165 .await
3166 }
3167
3168 #[allow(clippy::too_many_arguments)]
3171 pub(crate) async fn write_tool_call_with_attachments(
3172 &mut self,
3173 turn: u32,
3174 tool_name: String,
3175 tool_call_id: Option<String>,
3176 mut input: Value,
3177 input_bytes: u64,
3178 output: &str,
3179 output_bytes: u64,
3180 duration_ms: u64,
3181 status: String,
3182 state_effect: Option<String>,
3183 resource_id: Option<String>,
3184 error: Option<&str>,
3185 attachments: Vec<AttachmentRecord>,
3186 ) -> std::io::Result<()> {
3187 crate::peer_handoff::redact_handles_in_json(&mut input);
3192 let event = ToolCallEvent {
3193 event_type: "tool_call",
3194 event_id: new_event_id(),
3195 parent_id: self.turn_parent(),
3196 session_id: self.session_id.clone(),
3197 timestamp: timestamp_ms(),
3198 turn,
3199 task_id: self.active_task_id.clone(),
3200 tool_name,
3201 tool_call_id: tool_call_id.filter(|id| !id.is_empty()),
3204 input,
3205 input_bytes,
3206 output: self
3207 .capture
3208 .captures_content()
3209 .then(|| crate::peer_handoff::redact_handle_tokens(output).into_owned()),
3210 output_bytes,
3211 duration_ms,
3212 status,
3213 error: error.map(|error| {
3214 let redacted = crate::peer_handoff::redact_handle_tokens(error);
3215 crate::agent::truncate_on_char_boundary(&redacted, MAX_TASK_FAILURE_REASON_BYTES)
3216 .to_string()
3217 }),
3218 state_effect,
3219 resource_id,
3220 attachments,
3221 };
3222 self.write_event(&event).await?;
3223 self.total_tool_calls = self.total_tool_calls.saturating_add(1);
3224 self.task_tool_calls = self.task_tool_calls.saturating_add(1);
3225 Ok(())
3226 }
3227
3228 pub(crate) async fn write_skill_call(
3234 &mut self,
3235 turn: u32,
3236 skill_name: String,
3237 origin: &LockOrigin,
3238 output_bytes: u64,
3239 duration_ms: u64,
3240 status: String,
3241 ) -> std::io::Result<()> {
3242 let event = SkillCallEvent {
3243 event_type: "skill_call",
3244 event_id: new_event_id(),
3245 parent_id: self.turn_parent(),
3246 session_id: self.session_id.clone(),
3247 timestamp: timestamp_ms(),
3248 turn,
3249 task_id: self.active_task_id.clone(),
3250 skill_name,
3251 output_bytes,
3252 duration_ms,
3253 status,
3254 origin: origin.as_str(),
3255 trust: crate::origin::artifact_trust(origin).as_str(),
3256 };
3257 self.write_event(&event).await
3258 }
3260
3261 #[allow(clippy::too_many_arguments)]
3264 pub(crate) async fn write_shell(
3265 &mut self,
3266 turn: u32,
3267 binary: String,
3268 command: String,
3269 exit_code: i32,
3270 stdout_bytes: u64,
3271 stderr_bytes: u64,
3272 duration_ms: u64,
3273 resource_limit: Option<String>,
3274 ) -> std::io::Result<()> {
3275 let event = ShellEvent {
3276 event_type: "shell",
3277 event_id: new_event_id(),
3278 parent_id: self.turn_parent(),
3279 session_id: self.session_id.clone(),
3280 timestamp: timestamp_ms(),
3281 turn,
3282 task_id: self.active_task_id.clone(),
3283 binary,
3284 command,
3285 exit_code,
3286 stdout_bytes,
3287 stderr_bytes,
3288 duration_ms,
3289 resource_limit,
3290 };
3291 self.write_event(&event).await?;
3292 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
3293 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
3294 Ok(())
3295 }
3296
3297 pub(crate) async fn write_shell_detached(
3304 &mut self,
3305 turn: u32,
3306 work_id: &str,
3307 binary: &str,
3308 command: &str,
3309 grace_ms: Option<u64>,
3310 ) -> std::io::Result<()> {
3311 let event = ShellDetachedEvent {
3312 event_type: "shell_detached",
3313 event_id: new_event_id(),
3314 parent_id: self.turn_parent(),
3315 session_id: self.session_id.clone(),
3316 timestamp: timestamp_ms(),
3317 turn,
3318 task_id: self.active_task_id.clone(),
3319 work_id: work_id.to_string(),
3320 binary: binary.to_string(),
3321 command: command.to_string(),
3322 grace_ms,
3323 };
3324 self.write_event(&event).await?;
3325 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
3326 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
3327 Ok(())
3328 }
3329
3330 pub(crate) async fn write_shell_completed(
3333 &mut self,
3334 completion: &crate::detached::DetachedCompletion,
3335 completion_task_id: &str,
3336 ) -> std::io::Result<()> {
3337 let event = ShellCompletedEvent::new(
3338 self.session_id.clone(),
3339 self.session_parent(),
3340 completion,
3341 crate::detached::Delivery::Task,
3342 Some(completion_task_id),
3343 );
3344 self.write_event(&event).await
3345 }
3346
3347 pub(crate) async fn write_shell_abandoned(
3354 &mut self,
3355 work_id: &str,
3356 binary: &str,
3357 command: &str,
3358 running_ms: u64,
3359 recovered: Option<(i32, &str, u64)>,
3360 ) -> std::io::Result<()> {
3361 let event = ShellAbandonedEvent {
3362 event_type: "shell_abandoned",
3363 event_id: new_event_id(),
3364 parent_id: self.session_parent(),
3365 session_id: self.session_id.clone(),
3366 timestamp: timestamp_ms(),
3367 work_id: work_id.to_string(),
3368 binary: binary.to_string(),
3369 command: command.to_string(),
3370 running_ms,
3371 exit_code: recovered.map(|(exit_code, _, _)| exit_code),
3372 output_path: recovered.map(|(_, path, _)| path.to_string()),
3373 output_bytes: recovered.map(|(_, _, bytes)| bytes),
3374 };
3375 self.write_event(&event).await
3376 }
3377
3378 pub(crate) async fn write_shell_detach_unrecorded(
3380 &mut self,
3381 turn: u32,
3382 work_id: &str,
3383 binary: &str,
3384 reason: &str,
3385 ) -> std::io::Result<()> {
3386 let event = ShellDetachUnrecordedEvent {
3387 event_type: "shell_detach_unrecorded",
3388 event_id: new_event_id(),
3389 parent_id: self.turn_parent(),
3390 session_id: self.session_id.clone(),
3391 timestamp: timestamp_ms(),
3392 turn,
3393 task_id: self.active_task_id.clone(),
3394 work_id: work_id.to_string(),
3395 binary: binary.to_string(),
3396 reason: reason.to_string(),
3397 };
3398 self.write_event(&event).await
3399 }
3400
3401 pub(crate) async fn write_safety_check(
3403 &mut self,
3404 turn: u32,
3405 tool_call_id: &str,
3406 tool: &str,
3407 checks: &serde_json::Value,
3408 outcome: &'static str,
3409 ) -> std::io::Result<()> {
3410 let event = SafetyCheckEvent {
3411 event_type: "safety_check",
3412 event_id: new_event_id(),
3413 parent_id: self.turn_parent(),
3414 session_id: self.session_id.clone(),
3415 timestamp: timestamp_ms(),
3416 turn,
3417 task_id: self.active_task_id.clone(),
3418 tool_call_id: tool_call_id.to_string(),
3419 tool: tool.to_string(),
3420 checks: checks.clone(),
3421 outcome,
3422 };
3423 self.write_event(&event).await
3424 }
3425
3426 #[allow(clippy::too_many_arguments)]
3431 pub(crate) async fn write_a2a_task_received(
3432 &mut self,
3433 task_id: &str,
3434 context_id: &str,
3435 message_id: &str,
3436 traceparent_from_caller: Option<&str>,
3437 caller: &crate::identity::TaskCaller,
3438 caller_member: Option<&str>,
3439 message_text: &str,
3440 ) -> std::io::Result<()> {
3441 use crate::identity::TaskCaller;
3442 let (received_from, caller_credential) = match (caller_member, caller) {
3443 (Some(_), _) => (RECEIVED_FROM_MEMBER, None),
3444 (None, TaskCaller::Operator) => (RECEIVED_FROM_OPERATOR, None),
3445 (None, TaskCaller::Public) => (RECEIVED_FROM_PUBLIC, None),
3446 (None, TaskCaller::Credential(name)) => {
3447 (RECEIVED_FROM_CREDENTIAL, Some(name.to_string()))
3448 }
3449 };
3450 let message_sha256 = if self.capture.captures_hashes() {
3451 let sha = murmur_artifact::sha256_hex(message_text.as_bytes());
3452 if self.capture.captures_content() {
3453 self.blobs.put(&sha, message_text.as_bytes()).await?;
3454 }
3455 Some(sha)
3456 } else {
3457 None
3458 };
3459 let event = A2aTaskReceivedEvent {
3460 event_type: "a2a_task_received",
3461 event_id: new_event_id(),
3462 parent_id: self.session_parent(),
3463 session_id: self.session_id.clone(),
3464 timestamp: timestamp_ms(),
3465 task_id: task_id.to_string(),
3466 context_id: context_id.to_string(),
3467 message_id: message_id.to_string(),
3468 traceparent_from_caller: traceparent_from_caller.map(str::to_string),
3469 caller: received_from,
3470 caller_member: caller_member.map(str::to_string),
3471 caller_credential,
3472 message_sha256,
3473 };
3474 self.write_event(&event).await
3475 }
3476
3477 pub(crate) async fn write_a2a_send(
3478 &mut self,
3479 peer_url: &str,
3480 message_id: &str,
3481 task_id: &str,
3482 context_id: &str,
3483 traceparent: Option<&str>,
3484 trust: TrustClass,
3485 ) -> std::io::Result<()> {
3486 let event = A2aSendEvent {
3487 event_type: "a2a_send",
3488 event_id: new_event_id(),
3489 parent_id: self.session_parent(),
3490 session_id: self.session_id.clone(),
3491 timestamp: timestamp_ms(),
3492 peer_url: peer_url.to_string(),
3493 message_id: message_id.to_string(),
3494 task_id: task_id.to_string(),
3495 context_id: context_id.to_string(),
3496 traceparent: traceparent.map(str::to_string),
3497 trust: trust.as_str().to_string(),
3498 };
3499 self.write_event(&event).await
3500 }
3501
3502 pub(crate) async fn write_artifact_pulled(
3505 &mut self,
3506 pulled: &crate::runtime::PulledArtifact,
3507 ) -> std::io::Result<()> {
3508 let artifact = &pulled.summary;
3509 let session = match &artifact.origin {
3510 LockOrigin::Runtime { session } => Some(session.clone()),
3511 LockOrigin::Operator => None,
3512 };
3513 let event = ArtifactPulledEvent {
3514 event_type: "artifact_pulled",
3515 event_id: new_event_id(),
3516 parent_id: self.session_parent(),
3517 session_id: self.session_id.clone(),
3518 timestamp: timestamp_ms(),
3519 name: artifact.name.clone(),
3520 version: artifact.version.clone(),
3521 runtime: artifact.runtime.as_str(),
3522 origin: artifact.origin.as_str(),
3523 session,
3524 trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
3525 fetched_from: pulled.fetched_from.clone(),
3526 owner_id: pulled.owner_id,
3527 };
3528 self.write_event(&event).await
3529 }
3530
3531 #[cfg(test)]
3534 #[allow(clippy::too_many_arguments)]
3535 pub(crate) async fn write_task_start(
3536 &mut self,
3537 task_id: &str,
3538 context_id: &str,
3539 source: &str,
3540 provenance: TaskProvenance,
3541 message_parts_bytes: u64,
3542 part_kinds: &[&'static str],
3543 reference_task_ids: &[String],
3544 ) -> std::io::Result<()> {
3545 self.write_task_start_with_attachments(
3546 task_id,
3547 context_id,
3548 source,
3549 provenance,
3550 message_parts_bytes,
3551 part_kinds,
3552 reference_task_ids,
3553 Vec::new(),
3554 )
3555 .await
3556 }
3557
3558 #[allow(clippy::too_many_arguments)]
3561 pub(crate) async fn write_task_start_with_attachments(
3562 &mut self,
3563 task_id: &str,
3564 context_id: &str,
3565 source: &str,
3566 provenance: TaskProvenance,
3567 message_parts_bytes: u64,
3568 part_kinds: &[&'static str],
3569 reference_task_ids: &[String],
3570 attachments: Vec<AttachmentRecord>,
3571 ) -> std::io::Result<()> {
3572 self.write_call_deadlines().await?;
3574 self.task_turns = 0;
3576 self.task_input_tokens = 0;
3577 self.task_output_tokens = 0;
3578 self.task_tool_calls = 0;
3579 self.task_shell_calls = 0;
3580 self.task_agent_turns = 0;
3581 self.task_hook_inferences = 0;
3582 self.task_capped_turns = 0;
3583 let started = Instant::now();
3584 self.task_start_instant = Some(started);
3585 self.active_task_id = Some(task_id.to_string());
3586 if let Some(status) = &self.session_status {
3587 status.task_started(task_id, started);
3588 }
3589
3590 let event_id = new_event_id();
3591 self.task_event_id = Some(event_id.clone());
3592 self.turn_event_id = None;
3593
3594 let event = TaskStartEvent {
3595 event_type: "task_start",
3596 event_id: event_id.clone(),
3597 parent_id: self.session_parent(),
3598 session_id: self.session_id.clone(),
3599 timestamp: timestamp_ms(),
3600 task_id: task_id.to_string(),
3601 context_id: context_id.to_string(),
3602 source: source.to_string(),
3603 origin: provenance.origin().as_str().to_string(),
3604 trust: provenance.trust().as_str().to_string(),
3605 lane: TaskLane::for_origin(provenance.origin())
3608 .as_str()
3609 .to_string(),
3610 message_parts_bytes,
3611 part_kinds: part_kinds.to_vec(),
3612 reference_task_ids: reference_task_ids.to_vec(),
3613 attachments,
3614 };
3615 self.write_event(&event).await
3616 }
3617
3618 pub(crate) async fn write_task_end(
3619 &mut self,
3620 task_id: &str,
3621 exit_status: &str,
3622 reopen_count: u32,
3623 running_commands: Vec<String>,
3624 keep_working: Option<KeepWorkingSummary>,
3625 done_check: Option<DoneCheckSummary>,
3626 ) -> std::io::Result<()> {
3627 self.write_call_deadlines().await?;
3629 let duration_ms = self
3630 .task_start_instant
3631 .take()
3632 .map(|t| t.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
3633 .unwrap_or(0);
3634
3635 let event = TaskEndEvent {
3636 event_type: "task_end",
3637 event_id: new_event_id(),
3638 parent_id: self.task_parent(),
3639 session_id: self.session_id.clone(),
3640 timestamp: timestamp_ms(),
3641 task_id: task_id.to_string(),
3642 exit_status: exit_status.to_string(),
3643 duration_ms,
3644 turns: self.task_turns,
3645 input_tokens: self.task_input_tokens,
3646 output_tokens: self.task_output_tokens,
3647 tool_calls: self.task_tool_calls,
3648 shell_calls: self.task_shell_calls,
3649 reopen_count,
3650 capped_turns: self.task_capped_turns,
3651 running_commands,
3652 keep_working,
3653 done_check,
3654 };
3655 self.active_task_id = None;
3656 self.task_event_id = None;
3657 self.turn_event_id = None;
3658 self.task_agent_turns = 0;
3659 if let Some(status) = &self.session_status {
3660 status.task_ended();
3661 }
3662 self.write_event(&event).await
3663 }
3664
3665 pub(crate) async fn write_task_canceled(
3671 &mut self,
3672 task_id: &str,
3673 turn: Option<u32>,
3674 phase: &str,
3675 detached_work_ids: Vec<String>,
3676 delegation_ids: Vec<String>,
3677 ) -> std::io::Result<()> {
3678 let event = TaskCanceledEvent {
3679 event_type: "task_canceled",
3680 event_id: new_event_id(),
3681 parent_id: self.task_parent(),
3682 session_id: self.session_id.clone(),
3683 timestamp: timestamp_ms(),
3684 task_id: task_id.to_string(),
3685 turn,
3686 phase: phase.to_string(),
3687 detached_work_ids,
3688 delegation_ids,
3689 };
3690 self.write_event(&event).await
3691 }
3692
3693 pub(crate) async fn write_task_rejected(
3699 &mut self,
3700 task_id: &str,
3701 context_id: &str,
3702 source: &str,
3703 cause: &str,
3704 reason: &str,
3705 ) -> std::io::Result<()> {
3706 let event = TaskRejectedEvent {
3707 event_type: "task_rejected",
3708 event_id: new_event_id(),
3709 parent_id: self.task_parent(),
3710 session_id: self.session_id.clone(),
3711 timestamp: timestamp_ms(),
3712 task_id: task_id.to_string(),
3713 context_id: context_id.to_string(),
3714 source: source.to_string(),
3715 cause: cause.to_string(),
3716 reason: reason.to_string(),
3717 };
3718 self.write_event(&event).await
3719 }
3720
3721 pub(crate) async fn write_task_failed(
3727 &mut self,
3728 turn: Option<u32>,
3729 cause: &str,
3730 reason: &str,
3731 ) -> std::io::Result<()> {
3732 let reason = crate::agent::truncate_on_char_boundary(reason, MAX_TASK_FAILURE_REASON_BYTES)
3733 .to_string();
3734 let event = TaskFailedEvent {
3735 event_type: "task_failed",
3736 event_id: new_event_id(),
3737 parent_id: self.task_parent(),
3738 session_id: self.session_id.clone(),
3739 timestamp: timestamp_ms(),
3740 task_id: self.active_task_id.clone(),
3741 turn,
3742 cause: cause.to_string(),
3743 reason: reason.clone(),
3744 };
3745 self.task_failures_written += 1;
3746 self.last_task_failure = Some(reason);
3747 self.write_event(&event).await
3748 }
3749
3750 pub(crate) async fn write_workdir_path_refused(
3752 &mut self,
3753 refusal: &crate::workdir_fs::WorkdirRefusal,
3754 ) -> std::io::Result<()> {
3755 let event = WorkdirPathRefusedEvent {
3756 event_type: "workdir_path_refused",
3757 event_id: new_event_id(),
3758 parent_id: self.task_parent(),
3759 session_id: self.session_id.clone(),
3760 timestamp: timestamp_ms(),
3761 task_id: self.active_task_id.clone(),
3762 path: &refusal.path,
3763 operation: refusal.operation,
3764 refused_at: &refusal.refused_at,
3765 reason: refusal.reason,
3766 };
3767 self.write_event(&event).await
3768 }
3769
3770 pub(crate) fn last_task_failure(&self) -> Option<&str> {
3772 self.last_task_failure.as_deref()
3773 }
3774
3775 pub(crate) fn task_failures_written(&self) -> u64 {
3778 self.task_failures_written
3779 }
3780
3781 pub(crate) async fn write_task_reopened(
3785 &mut self,
3786 task_id: &str,
3787 hook_name: &str,
3788 reason: &str,
3789 reopen_number: u32,
3790 attempt_context: &'static str,
3791 turns_remaining: Option<u32>,
3792 ) -> std::io::Result<()> {
3793 let event = TaskReopenedEvent {
3794 event_type: "task_reopened",
3795 event_id: new_event_id(),
3796 parent_id: self.task_parent(),
3797 session_id: self.session_id.clone(),
3798 timestamp: timestamp_ms(),
3799 task_id: task_id.to_string(),
3800 hook_name: hook_name.to_string(),
3801 reason: reason.to_string(),
3802 reopen_number,
3803 attempt_context,
3804 turns_remaining,
3805 };
3806 self.write_event(&event).await
3807 }
3808
3809 pub(crate) async fn write_task_continued(
3813 &mut self,
3814 task_id: &str,
3815 continuation_number: u32,
3816 member_calls: &[String],
3817 delegations: &[String],
3818 waited_ms: u64,
3819 turns_remaining: Option<u32>,
3820 ) -> std::io::Result<()> {
3821 let event = TaskContinuedEvent {
3822 event_type: "task_continued",
3823 event_id: new_event_id(),
3824 parent_id: self.task_parent(),
3825 session_id: self.session_id.clone(),
3826 timestamp: timestamp_ms(),
3827 task_id: task_id.to_string(),
3828 continuation_number,
3829 member_calls,
3830 delegations,
3831 waited_ms,
3832 turns_remaining,
3833 };
3834 self.write_event(&event).await
3835 }
3836
3837 pub(crate) async fn write_keep_working(
3840 &mut self,
3841 task_id: &str,
3842 number: u32,
3843 limit: u32,
3844 turns_remaining: Option<u32>,
3845 message_bytes: u64,
3846 ) -> std::io::Result<()> {
3847 let event = KeepWorkingEvent {
3848 event_type: "keep_working",
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: task_id.to_string(),
3854 number,
3855 limit,
3856 turns_remaining,
3857 message_bytes,
3858 };
3859 if let Some(status) = &self.session_status {
3860 status.keep_working_sent(number);
3861 }
3862 self.write_event(&event).await
3863 }
3864
3865 pub(crate) async fn write_done_check(
3868 &mut self,
3869 run: &DoneCheckRun<'_>,
3870 reopens: u32,
3871 ) -> std::io::Result<()> {
3872 let event = DoneCheckEvent {
3873 event_type: "done_check",
3874 event_id: new_event_id(),
3875 parent_id: self.task_parent(),
3876 session_id: self.session_id.clone(),
3877 timestamp: timestamp_ms(),
3878 run,
3879 };
3880 if run.reopened {
3881 if let Some(status) = &self.session_status {
3882 status.done_check_reopened(reopens);
3883 }
3884 }
3885 self.write_event(&event).await
3886 }
3887
3888 pub(crate) fn task_turns(&self) -> u32 {
3898 self.task_turns
3899 }
3900
3901 pub(crate) fn task_hook_inferences(&self) -> u32 {
3903 self.task_hook_inferences
3904 }
3905
3906 pub(crate) fn task_agent_turns(&self) -> u32 {
3910 self.task_agent_turns
3911 }
3912
3913 pub(crate) async fn write_compaction(
3914 &mut self,
3915 turn: u32,
3916 tokens_before: u64,
3917 tokens_before_source: OccupancyBasis,
3918 tokens_after: u64,
3919 ) -> std::io::Result<()> {
3920 let event = CompactionEvent {
3921 event_type: "compaction",
3922 event_id: new_event_id(),
3923 parent_id: self.turn_parent(),
3924 session_id: self.session_id.clone(),
3925 timestamp: timestamp_ms(),
3926 turn,
3927 task_id: self.active_task_id.clone(),
3928 tokens_before,
3929 tokens_before_source,
3930 tokens_after,
3931 };
3932 self.write_event(&event).await
3933 }
3934
3935 pub(crate) async fn write_compaction_declined(
3941 &mut self,
3942 turn: u32,
3943 tokens: u64,
3944 tokens_source: OccupancyBasis,
3945 reason: &str,
3946 ) -> std::io::Result<()> {
3947 let event = CompactionDeclinedEvent {
3948 event_type: "compaction_declined",
3949 event_id: new_event_id(),
3950 parent_id: self.turn_parent(),
3951 session_id: self.session_id.clone(),
3952 timestamp: timestamp_ms(),
3953 turn,
3954 task_id: self.active_task_id.clone(),
3955 tokens,
3956 tokens_source,
3957 reason: reason.to_string(),
3958 };
3959 self.write_event(&event).await
3960 }
3961
3962 #[allow(clippy::too_many_arguments)]
3969 pub(crate) async fn write_context_seed(
3970 &mut self,
3971 hook_name: &str,
3972 tokens: u64,
3973 proposed_tokens: u64,
3974 budget_tokens: u64,
3975 outcome: &str,
3976 reason: Option<&str>,
3977 message_ids: Vec<String>,
3978 ) -> std::io::Result<()> {
3979 let event = ContextSeedEvent {
3980 event_type: "context_seed",
3981 event_id: new_event_id(),
3982 parent_id: self.task_parent(),
3983 session_id: self.session_id.clone(),
3984 timestamp: timestamp_ms(),
3985 task_id: self.active_task_id.clone(),
3986 hook_name: hook_name.to_string(),
3987 tokens,
3988 proposed_tokens,
3989 budget_tokens,
3990 outcome: outcome.to_string(),
3991 reason: reason.map(str::to_string),
3992 message_ids,
3993 };
3994 self.write_event(&event).await
3995 }
3996
3997 pub(crate) async fn write_retention(
4003 &mut self,
4004 store: &str,
4005 reason: &str,
4006 targets: Vec<String>,
4007 messages_dropped: Option<u64>,
4008 ) -> std::io::Result<()> {
4009 let event = RetentionEvent {
4010 event_type: "retention",
4011 event_id: new_event_id(),
4012 parent_id: self.session_parent(),
4013 session_id: self.session_id.clone(),
4014 timestamp: timestamp_ms(),
4015 store: store.to_string(),
4016 reason: reason.to_string(),
4017 removed: u32::try_from(targets.len()).unwrap_or(u32::MAX),
4018 targets,
4019 messages_dropped,
4020 };
4021 self.write_event(&event).await
4022 }
4023
4024 pub(crate) async fn write_call_denied(
4028 &mut self,
4029 turn: u32,
4030 event: &str,
4031 hook_name: &str,
4032 target: &str,
4033 reason: &str,
4034 ) -> std::io::Result<()> {
4035 let event = CallDeniedEvent {
4036 event_type: "call_denied",
4037 event_id: new_event_id(),
4038 parent_id: self.turn_parent(),
4039 session_id: self.session_id.clone(),
4040 timestamp: timestamp_ms(),
4041 turn,
4042 event: event.to_string(),
4043 hook_name: hook_name.to_string(),
4044 target: target.to_string(),
4045 reason: reason.to_string(),
4046 };
4047 self.write_event(&event).await
4048 }
4049
4050 pub(crate) async fn write_tool_input_refused(
4055 &mut self,
4056 turn: u32,
4057 tool_name: &str,
4058 tool_call_id: Option<&str>,
4059 missing: &[String],
4060 reason: &str,
4061 ) -> std::io::Result<()> {
4062 let event = ToolInputRefusedEvent {
4063 event_type: "tool_input_refused",
4064 event_id: new_event_id(),
4065 parent_id: self.turn_parent(),
4066 session_id: self.session_id.clone(),
4067 timestamp: timestamp_ms(),
4068 turn,
4069 tool_name: tool_name.to_string(),
4070 tool_call_id: tool_call_id.filter(|id| !id.is_empty()).map(str::to_string),
4071 missing: missing.to_vec(),
4072 reason: reason.to_string(),
4073 };
4074 self.write_event(&event).await
4075 }
4076
4077 pub(crate) async fn write_inference_retry(
4082 &mut self,
4083 turn: u32,
4084 retry: &InferenceRetryRecord,
4085 ) -> std::io::Result<()> {
4086 let event = InferenceRetryEvent {
4087 event_type: "inference_retry",
4088 event_id: new_event_id(),
4089 parent_id: self.turn_parent(),
4090 session_id: self.session_id.clone(),
4091 timestamp: timestamp_ms(),
4092 turn,
4093 task_id: self.active_task_id.clone(),
4094 attempt: retry.attempt,
4095 max_retries: retry.max_retries,
4096 reason: retry.reason.wire_name(),
4097 provider_status: retry.provider_status,
4098 error: crate::agent::truncate_on_char_boundary(
4099 &retry.error,
4100 MAX_TASK_FAILURE_REASON_BYTES,
4101 ),
4102 delay_ms: retry.delay_ms,
4103 retry_after_ms: retry.retry_after_ms,
4104 charged_tokens: retry.charged_tokens,
4105 origin: retry.origin.as_deref(),
4106 };
4107 self.write_event(&event).await
4108 }
4109
4110 pub(crate) async fn write_spend_ceiling_reached(
4113 &mut self,
4114 turn: u32,
4115 refusal: &crate::spend::SpendRefusal,
4116 origin: Option<&str>,
4117 ) -> std::io::Result<()> {
4118 let event = SpendCeilingReachedEvent {
4119 event_type: "spend_ceiling_reached",
4120 event_id: new_event_id(),
4121 parent_id: self.turn_parent(),
4122 session_id: self.session_id.clone(),
4123 timestamp: timestamp_ms(),
4124 turn,
4125 task_id: self.active_task_id.clone(),
4126 refusal,
4127 origin,
4128 };
4129 self.write_event(&event).await
4130 }
4131
4132 #[allow(clippy::too_many_arguments)]
4136 pub(crate) async fn write_protected_path_denied(
4137 &mut self,
4138 turn: u32,
4139 call: &str,
4140 target: &str,
4141 path: &str,
4142 rule: &str,
4143 signal: &str,
4144 reason: &str,
4145 ) -> std::io::Result<()> {
4146 let event = ProtectedPathDeniedEvent {
4147 event_type: "protected_path_denied",
4148 event_id: new_event_id(),
4149 parent_id: self.turn_parent(),
4150 session_id: self.session_id.clone(),
4151 timestamp: timestamp_ms(),
4152 turn,
4153 call: call.to_string(),
4154 target: target.to_string(),
4155 path: path.to_string(),
4156 rule: rule.to_string(),
4157 signal: signal.to_string(),
4158 reason: reason.to_string(),
4159 };
4160 self.write_event(&event).await
4161 }
4162
4163 pub(crate) async fn write_hook_dispatch_error(
4169 &mut self,
4170 hook_name: &str,
4171 event: &str,
4172 arm: &str,
4173 ) -> std::io::Result<()> {
4174 let event = HookDispatchErrorEvent {
4175 event_type: "hook_dispatch_error",
4176 event_id: new_event_id(),
4177 parent_id: self.session_parent(),
4178 session_id: self.session_id.clone(),
4179 timestamp: timestamp_ms(),
4180 hook_name: hook_name.to_string(),
4181 event: event.to_string(),
4182 arm: arm.to_string(),
4183 };
4184 self.write_event(&event).await
4185 }
4186
4187 pub(crate) async fn write_harness_start(&mut self, start: HarnessStart) -> std::io::Result<()> {
4197 if self.capture.captures_content() {
4198 self.blobs
4199 .put(&start.system_prompt_sha256, start.system_prompt.as_bytes())
4200 .await?;
4201 }
4202 let event = HarnessStartEvent {
4203 event_type: "harness_start",
4204 event_id: new_event_id(),
4205 parent_id: self.task_parent(),
4206 session_id: self.session_id.clone(),
4207 timestamp: timestamp_ms(),
4208 task_id: self.active_task_id.clone(),
4209 start,
4210 };
4211 self.write_event(&event).await
4212 }
4213
4214 pub(crate) async fn write_harness_warning(
4216 &mut self,
4217 code: &str,
4218 message: &str,
4219 ) -> std::io::Result<()> {
4220 let event = HarnessWarningEvent {
4221 event_type: "harness_warning",
4222 event_id: new_event_id(),
4223 parent_id: self.task_parent(),
4224 session_id: self.session_id.clone(),
4225 timestamp: timestamp_ms(),
4226 task_id: self.active_task_id.clone(),
4227 code: code.to_string(),
4228 message: message.to_string(),
4229 };
4230 self.write_event(&event).await
4231 }
4232
4233 pub(crate) async fn write_harness_session(
4235 &mut self,
4236 harness_session_id: &str,
4237 auth: &str,
4238 model: Option<&str>,
4239 ) -> std::io::Result<()> {
4240 let event = HarnessSessionEvent {
4241 event_type: "harness_session",
4242 event_id: new_event_id(),
4243 parent_id: self.task_parent(),
4244 session_id: self.session_id.clone(),
4245 timestamp: timestamp_ms(),
4246 task_id: self.active_task_id.clone(),
4247 harness_session_id: harness_session_id.to_string(),
4248 auth: auth.to_string(),
4249 model: model.map(str::to_string),
4250 };
4251 self.write_event(&event).await
4252 }
4253
4254 pub(crate) async fn write_harness_session_forgotten(
4256 &mut self,
4257 context_id: &str,
4258 harness_session_id: &str,
4259 requested_by: &str,
4260 ) -> std::io::Result<()> {
4261 let event = HarnessSessionForgottenEvent {
4262 event_type: "harness_session_forgotten",
4263 event_id: new_event_id(),
4264 parent_id: self.task_parent(),
4265 session_id: self.session_id.clone(),
4266 timestamp: timestamp_ms(),
4267 task_id: self.active_task_id.clone(),
4268 context_id: context_id.to_string(),
4269 harness_session_id: harness_session_id.to_string(),
4270 requested_by: requested_by.to_string(),
4271 };
4272 self.write_event(&event).await
4273 }
4274
4275 pub(crate) async fn write_harness_retry(
4277 &mut self,
4278 attempt: u32,
4279 reason: &str,
4280 ) -> std::io::Result<()> {
4281 let event = HarnessRetryEvent {
4282 event_type: "harness_retry",
4283 event_id: new_event_id(),
4284 parent_id: self.task_parent(),
4285 session_id: self.session_id.clone(),
4286 timestamp: timestamp_ms(),
4287 task_id: self.active_task_id.clone(),
4288 attempt,
4289 reason: reason.to_string(),
4290 };
4291 self.write_event(&event).await
4292 }
4293
4294 pub(crate) async fn write_harness_note(&mut self, text: &str) -> std::io::Result<()> {
4297 let event = HarnessTextEvent {
4298 event_type: "harness_note",
4299 event_id: new_event_id(),
4300 parent_id: self.task_parent(),
4301 session_id: self.session_id.clone(),
4302 timestamp: timestamp_ms(),
4303 task_id: self.active_task_id.clone(),
4304 text: text.to_string(),
4305 };
4306 self.write_event(&event).await
4307 }
4308
4309 pub(crate) async fn write_harness_quota(
4312 &mut self,
4313 windows: &[crate::process_driver::QuotaWindow],
4314 ) -> std::io::Result<()> {
4315 if let Some(status) = &self.session_status {
4316 status.record_quota(windows);
4317 }
4318 let event = HarnessQuotaEvent {
4319 event_type: "harness_quota",
4320 event_id: new_event_id(),
4321 parent_id: self.task_parent(),
4322 session_id: self.session_id.clone(),
4323 timestamp: timestamp_ms(),
4324 task_id: self.active_task_id.clone(),
4325 windows: windows
4326 .iter()
4327 .map(|window| HarnessQuotaWindow {
4328 name: &window.name,
4329 used: window.used,
4330 resets_at_ms: window.resets_at,
4331 window_minutes: window.window_minutes,
4332 state: window
4333 .state
4334 .map(crate::process_driver::QuotaState::wire_name),
4335 })
4336 .collect(),
4337 };
4338 self.write_event(&event).await
4339 }
4340
4341 pub(crate) async fn write_harness_suggestion(&mut self, text: &str) -> std::io::Result<()> {
4343 let event = HarnessTextEvent {
4344 event_type: "harness_suggestion",
4345 event_id: new_event_id(),
4346 parent_id: self.task_parent(),
4347 session_id: self.session_id.clone(),
4348 timestamp: timestamp_ms(),
4349 task_id: self.active_task_id.clone(),
4350 text: text.to_string(),
4351 };
4352 self.write_event(&event).await
4353 }
4354
4355 pub(crate) async fn write_harness_failed(
4358 &mut self,
4359 kind: &str,
4360 message: &str,
4361 source: &str,
4362 ) -> std::io::Result<()> {
4363 let event = HarnessFailedEvent {
4364 event_type: "harness_failed",
4365 event_id: new_event_id(),
4366 parent_id: self.task_parent(),
4367 session_id: self.session_id.clone(),
4368 timestamp: timestamp_ms(),
4369 task_id: self.active_task_id.clone(),
4370 kind: kind.to_string(),
4371 message: message.to_string(),
4372 source: source.to_string(),
4373 };
4374 self.write_event(&event).await
4375 }
4376
4377 pub(crate) async fn write_harness_interrupt(
4380 &mut self,
4381 method: &str,
4382 delivered: bool,
4383 grace_ms: u64,
4384 ) -> std::io::Result<()> {
4385 let event = HarnessInterruptEvent {
4386 event_type: "harness_interrupt",
4387 event_id: new_event_id(),
4388 parent_id: self.task_parent(),
4389 session_id: self.session_id.clone(),
4390 timestamp: timestamp_ms(),
4391 task_id: self.active_task_id.clone(),
4392 method: method.to_string(),
4393 delivered,
4394 grace_ms,
4395 };
4396 self.write_event(&event).await
4397 }
4398
4399 pub(crate) async fn write_harness_exit(&mut self, exit: HarnessExit) -> std::io::Result<()> {
4401 let event = HarnessExitEvent {
4402 event_type: "harness_exit",
4403 event_id: new_event_id(),
4404 parent_id: self.task_parent(),
4405 session_id: self.session_id.clone(),
4406 timestamp: timestamp_ms(),
4407 task_id: self.active_task_id.clone(),
4408 exit,
4409 };
4410 self.write_event(&event).await
4411 }
4412
4413 pub(crate) async fn write_session_end(&mut self, exit_status: &str) -> std::io::Result<()> {
4414 let duration_ms = self
4415 .session_start_time
4416 .elapsed()
4417 .as_millis()
4418 .try_into()
4419 .unwrap_or(u64::MAX);
4420 let event = SessionEndEvent {
4421 event_type: "session_end",
4422 event_id: new_event_id(),
4423 parent_id: self.session_parent(),
4424 session_id: self.session_id.clone(),
4425 timestamp: timestamp_ms(),
4426 total_turns: self.total_turns,
4427 total_input_tokens: self.total_input_tokens,
4428 total_output_tokens: self.total_output_tokens,
4429 total_tool_calls: self.total_tool_calls,
4430 total_shell_calls: self.total_shell_calls,
4431 duration_ms,
4432 exit_status: exit_status.to_string(),
4433 };
4434 self.write_event(&event).await?;
4435 self.session_ended = true;
4436 Ok(())
4437 }
4438
4439 pub(crate) async fn write_session_end_if_not_ended(
4442 &mut self,
4443 exit_status: &str,
4444 ) -> std::io::Result<()> {
4445 if self.session_started && !self.session_ended {
4446 self.write_session_end(exit_status).await?;
4447 }
4448 Ok(())
4449 }
4450
4451 pub(crate) async fn flush(&mut self) -> std::io::Result<()> {
4452 self.writer.flush().await
4453 }
4454
4455 async fn write_event(&mut self, event: &impl Serialize) -> std::io::Result<()> {
4459 self.write_call_deadlines().await?;
4460 self.write_line(event).await
4461 }
4462
4463 async fn write_call_deadlines(&mut self) -> std::io::Result<()> {
4466 let Some(deadlines) = self
4467 .call_deadlines
4468 .as_ref()
4469 .filter(|_| self.session_started)
4470 else {
4471 return Ok(());
4472 };
4473 for cut in deadlines.take() {
4474 let event = CallDeadlineEvent {
4475 event_type: "call_deadline",
4476 event_id: new_event_id(),
4477 parent_id: self.turn_parent(),
4478 session_id: self.session_id.clone(),
4479 timestamp: timestamp_ms(),
4480 task_id: self.active_task_id.clone(),
4481 component: cut.component,
4482 role: cut.role.wire_name(),
4483 call: cut.call,
4484 deadline_seconds: cut.deadline_seconds,
4485 elapsed_ms: cut.elapsed_ms,
4486 };
4487 self.write_line(&event).await?;
4488 }
4489 Ok(())
4490 }
4491
4492 async fn write_line(&mut self, event: &impl Serialize) -> std::io::Result<()> {
4500 let mut line = serde_json::to_string(event).map_err(std::io::Error::other)?;
4501 line.push('\n');
4502 self.writer.write_all(line.as_bytes()).await?;
4503 self.writer.flush().await
4504 }
4505}
4506
4507#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4509pub(crate) struct SessionGateway {
4510 pub(crate) artifact: String,
4512 pub(crate) host: String,
4514 pub(crate) credential_source: &'static str,
4516 pub(crate) metered: bool,
4519}
4520
4521#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4524pub(crate) struct RuntimeArtifact {
4525 name: String,
4526 version: String,
4527 origin: &'static str,
4529 session: String,
4531 trust: &'static str,
4533}
4534
4535#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4537pub(crate) struct HostFactsRecord {
4538 binary: String,
4540 source: &'static str,
4542 #[serde(skip_serializing_if = "Option::is_none")]
4545 reason: Option<&'static str>,
4546 #[serde(skip_serializing_if = "Option::is_none")]
4548 changed_path: Option<String>,
4549}
4550
4551impl HostFactsRecord {
4552 fn of(used: &crate::host_facts::HostFactsUse) -> Self {
4553 use crate::host_facts::HostFactsSource;
4554
4555 let binary = used.binary.display().to_string();
4556 match &used.source {
4557 HostFactsSource::Kept => Self {
4558 binary,
4559 source: "kept",
4560 reason: None,
4561 changed_path: None,
4562 },
4563 HostFactsSource::Derived {
4564 reason,
4565 changed_path,
4566 } => Self {
4567 binary,
4568 source: "derived",
4569 reason: Some(reason.wire_name()),
4570 changed_path: changed_path.as_ref().map(|path| path.display().to_string()),
4571 },
4572 }
4573 }
4574}
4575
4576#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4578pub(crate) struct SessionControl {
4579 pub(crate) settings: Vec<&'static str>,
4581 pub(crate) secrets: Vec<String>,
4583 #[serde(skip_serializing_if = "Vec::is_empty")]
4586 pub(crate) agent_settings: Vec<&'static str>,
4587}
4588
4589#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4592pub(crate) struct SessionInferenceChoice {
4593 pub(crate) name: String,
4594 pub(crate) driver: String,
4595 pub(crate) model: String,
4596 pub(crate) credential_source: &'static str,
4599 pub(crate) available: bool,
4602 pub(crate) model_limits: Option<crate::model_limits::ResolvedModelLimits>,
4604}
4605
4606pub(crate) struct ControlChange<'a> {
4610 pub(crate) event_id: String,
4611 pub(crate) principal: crate::control_plane::Principal,
4613 pub(crate) token_id: Option<&'a str>,
4616 pub(crate) kind: &'static str,
4618 pub(crate) name: &'a str,
4619 pub(crate) action: &'static str,
4621 pub(crate) previous: Option<Value>,
4623 pub(crate) value: Option<Value>,
4624 pub(crate) applies_from: Option<&'static str>,
4626 pub(crate) replaced: Option<bool>,
4628}
4629
4630#[derive(Serialize)]
4633struct ControlChangeEvent<'a> {
4634 event_type: &'static str,
4635 event_id: String,
4636 parent_id: Option<String>,
4637 session_id: String,
4638 timestamp: u64,
4639 principal: &'static str,
4640 #[serde(skip_serializing_if = "Option::is_none")]
4641 token_id: Option<&'a str>,
4642 kind: &'static str,
4643 name: &'a str,
4644 action: &'static str,
4645 #[serde(skip_serializing_if = "Option::is_none")]
4646 previous: Option<Value>,
4647 #[serde(skip_serializing_if = "Option::is_none")]
4648 value: Option<Value>,
4649 #[serde(skip_serializing_if = "Option::is_none")]
4650 applies_from: Option<&'static str>,
4651 #[serde(skip_serializing_if = "Option::is_none")]
4652 replaced: Option<bool>,
4653}
4654
4655#[derive(Serialize)]
4659struct ControlRefusedEvent<'a> {
4660 event_type: &'static str,
4661 event_id: String,
4662 parent_id: Option<String>,
4663 session_id: String,
4664 timestamp: u64,
4665 principal: &'static str,
4666 status: u16,
4667 reason: &'static str,
4668 #[serde(skip_serializing_if = "Option::is_none")]
4669 kind: Option<&'static str>,
4670 #[serde(skip_serializing_if = "Option::is_none")]
4671 name: Option<&'a str>,
4672 #[serde(skip_serializing_if = "Option::is_none")]
4673 token_id: Option<&'a str>,
4674}
4675
4676#[derive(Serialize)]
4678struct ControlAppliedEvent<'a> {
4679 event_type: &'static str,
4680 event_id: String,
4681 parent_id: Option<String>,
4682 session_id: String,
4683 timestamp: u64,
4684 turn: u32,
4685 task_id: Option<String>,
4686 name: &'a str,
4687 value: Value,
4688 change_id: &'a str,
4689}
4690
4691#[derive(Serialize)]
4693struct ToolsRefreshedEvent<'a> {
4694 event_type: &'static str,
4695 event_id: String,
4696 parent_id: Option<String>,
4697 session_id: String,
4698 timestamp: u64,
4699 turn: u32,
4700 task_id: Option<String>,
4701 trigger: &'a str,
4702 added: &'a [String],
4703 removed: &'a [String],
4704 tools: &'a [String],
4705}
4706
4707#[derive(Serialize)]
4710struct GatewayCredentialEvent<'a> {
4711 event_type: &'static str,
4712 event_id: String,
4713 parent_id: Option<String>,
4714 session_id: String,
4715 timestamp: u64,
4716 artifact: &'a str,
4717 source: &'static str,
4718 #[serde(skip_serializing_if = "Option::is_none")]
4719 credential: Option<&'a str>,
4720 change: &'static str,
4721 #[serde(skip_serializing_if = "Option::is_none")]
4722 trigger: Option<&'static str>,
4723 #[serde(skip_serializing_if = "Option::is_none")]
4724 status: Option<u16>,
4725 #[serde(skip_serializing_if = "Option::is_none")]
4726 retried: Option<bool>,
4727 #[serde(skip_serializing_if = "Option::is_none")]
4728 reason: Option<&'static str>,
4729}
4730
4731#[derive(Serialize)]
4735struct InferenceCredentialEvent<'a> {
4736 event_type: &'static str,
4737 event_id: String,
4738 parent_id: Option<String>,
4739 session_id: String,
4740 timestamp: u64,
4741 source: &'static str,
4742 #[serde(skip_serializing_if = "Option::is_none")]
4743 credential: Option<&'a str>,
4744 change: &'static str,
4745 #[serde(skip_serializing_if = "Option::is_none")]
4746 trigger: Option<&'static str>,
4747 #[serde(skip_serializing_if = "Option::is_none")]
4748 status: Option<u16>,
4749 #[serde(skip_serializing_if = "Option::is_none")]
4750 retried: Option<bool>,
4751 #[serde(skip_serializing_if = "Option::is_none")]
4752 reason: Option<&'static str>,
4753}
4754
4755#[allow(clippy::type_complexity)]
4758fn credential_change_fields(
4759 change: CredentialChange,
4760) -> (
4761 &'static str,
4762 Option<&'static str>,
4763 Option<u16>,
4764 Option<bool>,
4765 Option<&'static str>,
4766) {
4767 match change {
4768 CredentialChange::Rotated { trigger } => ("rotated", Some(trigger), None, None, None),
4769 CredentialChange::Rejected { status, retried } => {
4770 ("rejected", None, Some(status), Some(retried), None)
4771 }
4772 CredentialChange::Unreadable { reason } => ("unreadable", None, None, None, Some(reason)),
4773 }
4774}
4775
4776pub struct ResourceTraceAppender {
4791 file: tokio::sync::Mutex<File>,
4793 session_id: String,
4794 session_event_id: String,
4799}
4800
4801impl ResourceTraceAppender {
4802 pub async fn open(
4803 records_root: &WorkdirRoot,
4804 session_id: String,
4805 session_event_id: String,
4806 ) -> std::io::Result<Self> {
4807 let file = File::from_std(records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?);
4808 Ok(Self {
4809 file: tokio::sync::Mutex::new(file),
4810 session_id,
4811 session_event_id,
4812 })
4813 }
4814
4815 #[allow(clippy::too_many_arguments)]
4819 pub(crate) async fn write_resource_list(
4820 &self,
4821 root: &str,
4822 entry_count: usize,
4823 total_bytes: u64,
4824 generation: u64,
4825 containment_achieved: ContainmentClass,
4826 outcome: &str,
4827 reason: Option<String>,
4828 ) {
4829 let event = ResourceListEvent {
4830 event_type: "resource_list",
4831 event_id: new_event_id(),
4832 parent_id: Some(self.session_event_id.clone()),
4833 session_id: self.session_id.clone(),
4834 timestamp: timestamp_ms(),
4835 root: root.to_string(),
4836 entry_count,
4837 total_bytes,
4838 generation,
4839 containment_achieved,
4840 outcome: outcome.to_string(),
4841 reason,
4842 };
4843 self.append(&event).await;
4844 }
4845
4846 #[allow(clippy::too_many_arguments)]
4849 pub(crate) async fn write_resource_read(
4850 &self,
4851 path: &str,
4852 outcome: &str,
4853 bytes: Option<u64>,
4854 sha256: Option<String>,
4855 generation: u64,
4856 containment_achieved: ContainmentClass,
4857 reason: Option<String>,
4858 ) {
4859 let event = ResourceReadEvent {
4860 event_type: "resource_read",
4861 event_id: new_event_id(),
4862 parent_id: Some(self.session_event_id.clone()),
4863 session_id: self.session_id.clone(),
4864 timestamp: timestamp_ms(),
4865 path: path.to_string(),
4866 outcome: outcome.to_string(),
4867 bytes,
4868 sha256,
4869 generation,
4870 containment_achieved,
4871 reason,
4872 };
4873 self.append(&event).await;
4874 }
4875
4876 pub(crate) async fn write_formation_ended(&self, formation_id: &crate::FormationId) {
4879 let event = FormationEndedEvent {
4880 event_type: "formation_ended",
4881 event_id: new_event_id(),
4882 parent_id: Some(self.session_event_id.clone()),
4883 session_id: self.session_id.clone(),
4884 timestamp: timestamp_ms(),
4885 formation_id: formation_id.as_str().to_string(),
4886 };
4887 self.append(&event).await;
4888 }
4889
4890 pub(crate) async fn write_session_status(
4893 &self,
4894 task_id: Option<String>,
4895 status: &crate::session_status::StatusReport,
4896 ) {
4897 let event = SessionStatusEvent {
4898 event_type: "session_status",
4899 event_id: new_event_id(),
4900 parent_id: Some(self.session_event_id.clone()),
4901 session_id: self.session_id.clone(),
4902 timestamp: timestamp_ms(),
4903 task_id,
4904 status,
4905 };
4906 self.append(&event).await;
4907 }
4908
4909 #[allow(clippy::too_many_arguments)]
4912 pub(crate) async fn write_background_command(
4913 &self,
4914 task_id: Option<String>,
4915 work_id: &str,
4916 operation: &'static str,
4917 state: &'static str,
4918 exit_code: Option<i32>,
4919 waited_ms: Option<u64>,
4920 output_bytes: u64,
4921 ) {
4922 let event = BackgroundCommandEvent {
4923 event_type: "background_command",
4924 event_id: new_event_id(),
4925 parent_id: Some(self.session_event_id.clone()),
4926 session_id: self.session_id.clone(),
4927 timestamp: timestamp_ms(),
4928 task_id,
4929 work_id,
4930 operation,
4931 state,
4932 exit_code,
4933 waited_ms,
4934 output_bytes,
4935 };
4936 self.append(&event).await;
4937 }
4938
4939 pub(crate) async fn write_shell_completed(
4943 &self,
4944 completion: &crate::detached::DetachedCompletion,
4945 ) {
4946 let event = ShellCompletedEvent::new(
4947 self.session_id.clone(),
4948 Some(self.session_event_id.clone()),
4949 completion,
4950 crate::detached::Delivery::BackgroundCommand,
4951 None,
4952 );
4953 self.append(&event).await;
4954 }
4955
4956 pub(crate) async fn write_deadline_notice(
4958 &self,
4959 task_id: Option<String>,
4960 tool_name: &str,
4961 notice: &crate::session_status::DeadlineNotice,
4962 ) {
4963 let event = DeadlineNoticeEvent {
4964 event_type: "deadline_notice",
4965 event_id: new_event_id(),
4966 parent_id: Some(self.session_event_id.clone()),
4967 session_id: self.session_id.clone(),
4968 timestamp: timestamp_ms(),
4969 task_id,
4970 tool_name,
4971 deadline_at: ¬ice.at,
4972 left_secs: notice.left_secs,
4973 notice_secs: notice.notice_secs,
4974 };
4975 self.append(&event).await;
4976 }
4977
4978 pub(crate) async fn write_deadline_reached(
4981 &self,
4982 deadline: &crate::session_status::SessionDeadline,
4983 ) {
4984 let event = DeadlineReachedEvent {
4985 event_type: "deadline_reached",
4986 event_id: new_event_id(),
4987 parent_id: Some(self.session_event_id.clone()),
4988 session_id: self.session_id.clone(),
4989 timestamp: timestamp_ms(),
4990 deadline_at: deadline.at_rfc3339(),
4991 deadline_secs: deadline.secs(),
4992 source: deadline.source().as_str(),
4993 };
4994 self.append(&event).await;
4995 }
4996
4997 pub(crate) async fn write_spawner_ended(
5001 &self,
5002 spawned_by: Option<&str>,
5003 delegation_id: Option<&str>,
5004 ) {
5005 let event = SpawnerEndedEvent {
5006 event_type: "spawner_ended",
5007 event_id: new_event_id(),
5008 parent_id: Some(self.session_event_id.clone()),
5009 session_id: self.session_id.clone(),
5010 timestamp: timestamp_ms(),
5011 spawned_by: spawned_by.map(str::to_string),
5012 delegation_id: delegation_id.map(str::to_string),
5013 };
5014 self.append(&event).await;
5015 }
5016
5017 #[allow(clippy::too_many_arguments)]
5020 pub(crate) async fn write_peer_handle_mint(
5021 &self,
5022 handle_id: Option<String>,
5023 path: &str,
5024 audience: &str,
5025 expires_at_ms: Option<u64>,
5026 outcome: &str,
5027 reason: Option<String>,
5028 ) {
5029 let event = PeerHandleMintEvent {
5030 event_type: "peer_handle_mint",
5031 event_id: new_event_id(),
5032 parent_id: Some(self.session_event_id.clone()),
5033 session_id: self.session_id.clone(),
5034 timestamp: timestamp_ms(),
5035 handle_id,
5036 path: path.to_string(),
5037 audience: audience.to_string(),
5038 expires_at_ms,
5039 outcome: outcome.to_string(),
5040 reason,
5041 };
5042 self.append(&event).await;
5043 }
5044
5045 #[allow(clippy::too_many_arguments)]
5048 pub(crate) async fn write_peer_handle_redeem(
5049 &self,
5050 handle_id: &str,
5051 path: Option<String>,
5052 generation: u64,
5053 audience_asserted: Option<String>,
5054 bytes: Option<u64>,
5055 sha256: Option<String>,
5056 outcome: &str,
5057 reason: Option<String>,
5058 ) {
5059 let event = PeerHandleRedeemEvent {
5060 event_type: "peer_handle_redeem",
5061 event_id: new_event_id(),
5062 parent_id: Some(self.session_event_id.clone()),
5063 session_id: self.session_id.clone(),
5064 timestamp: timestamp_ms(),
5065 handle_id: handle_id.to_string(),
5066 path,
5067 generation,
5068 audience_asserted,
5069 bytes,
5070 sha256,
5071 outcome: outcome.to_string(),
5072 reason,
5073 };
5074 self.append(&event).await;
5075 }
5076
5077 #[allow(clippy::too_many_arguments)]
5079 pub(crate) async fn write_peer_file_fetch(
5080 &self,
5081 peer: &str,
5082 handle_id: &str,
5083 stored_path: Option<String>,
5084 bytes: Option<u64>,
5085 sha256: Option<String>,
5086 outcome: &str,
5087 reason: Option<String>,
5088 ) {
5089 let event = PeerFileFetchEvent {
5090 event_type: "peer_file_fetch",
5091 event_id: new_event_id(),
5092 parent_id: Some(self.session_event_id.clone()),
5093 session_id: self.session_id.clone(),
5094 timestamp: timestamp_ms(),
5095 peer: peer.to_string(),
5096 handle_id: handle_id.to_string(),
5097 stored_path,
5098 bytes,
5099 sha256,
5100 outcome: outcome.to_string(),
5101 reason,
5102 };
5103 self.append(&event).await;
5104 }
5105
5106 #[allow(clippy::too_many_arguments)]
5111 pub(crate) async fn write_delegation_start(
5112 &self,
5113 delegation_id: &str,
5114 capsule: &str,
5115 version: &str,
5116 child_session_id: &str,
5117 child_workdir: &str,
5118 child_records_dir: &str,
5119 workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
5120 ) {
5121 let event = DelegationStartEvent::new(
5122 &self.session_id,
5123 &self.session_event_id,
5124 delegation_id,
5125 capsule,
5126 version,
5127 child_session_id,
5128 child_workdir,
5129 child_records_dir,
5130 workdir_snapshot,
5131 );
5132 self.append(&event).await;
5133 }
5134
5135 #[allow(clippy::too_many_arguments)]
5139 pub(crate) async fn write_delegation(
5140 &self,
5141 capsule: &str,
5142 version: &str,
5143 delegation_id: Option<String>,
5144 child_session_id: Option<String>,
5145 duration_ms: u64,
5146 outcome: &str,
5147 reason: Option<String>,
5148 ) {
5149 let event = DelegationEvent::new(
5150 &self.session_id,
5151 &self.session_event_id,
5152 capsule,
5153 version,
5154 delegation_id,
5155 child_session_id,
5156 duration_ms,
5157 outcome,
5158 reason,
5159 );
5160 self.append(&event).await;
5161 }
5162
5163 pub(crate) async fn write_member_call_start(
5165 &self,
5166 task_id: &str,
5167 call_id: &str,
5168 member: &str,
5169 member_task_id: &str,
5170 ) {
5171 let event = self.member_call_start(task_id, call_id, member, member_task_id);
5172 self.append(&event).await;
5173 }
5174
5175 pub(crate) async fn write_member_call_start_if(
5178 &self,
5179 task_id: &str,
5180 call_id: &str,
5181 member: &str,
5182 member_task_id: &str,
5183 admit: impl FnOnce() -> bool,
5184 ) -> bool {
5185 let event = self.member_call_start(task_id, call_id, member, member_task_id);
5186 self.append_if(&event, admit).await
5187 }
5188
5189 fn member_call_start(
5190 &self,
5191 task_id: &str,
5192 call_id: &str,
5193 member: &str,
5194 member_task_id: &str,
5195 ) -> MemberCallStartEvent {
5196 MemberCallStartEvent {
5197 event_type: "member_call_start",
5198 event_id: new_event_id(),
5199 parent_id: Some(self.session_event_id.clone()),
5200 session_id: self.session_id.clone(),
5201 timestamp: timestamp_ms(),
5202 task_id: task_id.to_string(),
5203 call_id: call_id.to_string(),
5204 member: member.to_string(),
5205 member_task_id: member_task_id.to_string(),
5206 }
5207 }
5208
5209 pub(crate) async fn write_member_call_busy(
5212 &self,
5213 task_id: &str,
5214 call_id: &str,
5215 member: &str,
5216 offer: u32,
5217 waited_ms: u64,
5218 message: &str,
5219 ) {
5220 let event = MemberCallBusyEvent {
5221 event_type: "member_call_busy",
5222 event_id: new_event_id(),
5223 parent_id: Some(self.session_event_id.clone()),
5224 session_id: self.session_id.clone(),
5225 timestamp: timestamp_ms(),
5226 task_id: task_id.to_string(),
5227 call_id: call_id.to_string(),
5228 member: member.to_string(),
5229 offer,
5230 waited_ms,
5231 message: message.to_string(),
5232 };
5233 self.append(&event).await;
5234 }
5235
5236 pub(crate) async fn write_member_call(
5238 &self,
5239 task_id: &str,
5240 outcome: &crate::member_call::MemberCallOutcome,
5241 delivered: bool,
5242 ) {
5243 let event = MemberCallEvent {
5244 event_type: "member_call",
5245 event_id: new_event_id(),
5246 parent_id: Some(self.session_event_id.clone()),
5247 session_id: self.session_id.clone(),
5248 timestamp: timestamp_ms(),
5249 task_id: task_id.to_string(),
5250 call_id: outcome.call_id.clone(),
5251 member: outcome.member.clone(),
5252 member_task_id: outcome.member_task_id.clone(),
5253 status: outcome.status.as_str().to_string(),
5254 duration_ms: outcome.duration_ms,
5255 output: outcome.output.clone(),
5256 truncated: outcome.truncated,
5257 delivered,
5258 no_answer_below: outcome
5259 .below
5260 .iter()
5261 .map(|(member, status)| NoAnswerBelow {
5262 member: member.clone(),
5263 status: status.as_str().to_string(),
5264 })
5265 .collect(),
5266 };
5267 self.append(&event).await;
5268 }
5269
5270 pub(crate) async fn write_inference_credential(
5273 &self,
5274 source: &'static str,
5275 credential: Option<&str>,
5276 change: CredentialChange,
5277 ) {
5278 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
5279 let event = InferenceCredentialEvent {
5280 event_type: "inference_credential",
5281 event_id: new_event_id(),
5282 parent_id: Some(self.session_event_id.clone()),
5283 session_id: self.session_id.clone(),
5284 timestamp: timestamp_ms(),
5285 source,
5286 credential,
5287 change: change_name,
5288 trigger,
5289 status,
5290 retried,
5291 reason,
5292 };
5293 self.append(&event).await;
5294 }
5295
5296 pub(crate) async fn write_gateway_credential(
5299 &self,
5300 artifact: &str,
5301 source: &'static str,
5302 credential: Option<&str>,
5303 change: CredentialChange,
5304 ) {
5305 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
5306 let event = GatewayCredentialEvent {
5307 event_type: "gateway_credential",
5308 event_id: new_event_id(),
5309 parent_id: Some(self.session_event_id.clone()),
5310 session_id: self.session_id.clone(),
5311 timestamp: timestamp_ms(),
5312 artifact,
5313 source,
5314 credential,
5315 change: change_name,
5316 trigger,
5317 status,
5318 retried,
5319 reason,
5320 };
5321 self.append(&event).await;
5322 }
5323
5324 pub(crate) async fn write_control_change(&self, change: ControlChange<'_>) {
5326 let event = ControlChangeEvent {
5327 event_type: "control_change",
5328 event_id: change.event_id,
5329 parent_id: Some(self.session_event_id.clone()),
5330 session_id: self.session_id.clone(),
5331 timestamp: timestamp_ms(),
5332 principal: change.principal.as_str(),
5333 token_id: change.token_id,
5334 kind: change.kind,
5335 name: change.name,
5336 action: change.action,
5337 previous: change.previous,
5338 value: change.value,
5339 applies_from: change.applies_from,
5340 replaced: change.replaced,
5341 };
5342 self.append(&event).await;
5343 }
5344
5345 pub(crate) async fn write_control_refused(
5348 &self,
5349 principal: crate::control_plane::Principal,
5350 status: u16,
5351 reason: &'static str,
5352 kind: Option<&'static str>,
5353 name: Option<&str>,
5354 token_id: Option<&str>,
5355 ) {
5356 let event = ControlRefusedEvent {
5357 event_type: "control_refused",
5358 event_id: new_event_id(),
5359 parent_id: Some(self.session_event_id.clone()),
5360 session_id: self.session_id.clone(),
5361 timestamp: timestamp_ms(),
5362 principal: principal.as_str(),
5363 status,
5364 reason,
5365 kind,
5366 name,
5367 token_id,
5368 };
5369 self.append(&event).await;
5370 }
5371
5372 async fn append(&self, event: &impl Serialize) {
5373 self.append_if(event, || true).await;
5374 }
5375
5376 async fn append_if(&self, event: &impl Serialize, admit: impl FnOnce() -> bool) -> bool {
5379 let Ok(mut line) = serde_json::to_string(event) else {
5380 return admit();
5381 };
5382 line.push('\n');
5383 let mut file = self.file.lock().await;
5384 if !admit() {
5385 return false;
5386 }
5387 if file.write_all(line.as_bytes()).await.is_ok() {
5388 let _ = file.flush().await;
5389 }
5390 true
5391 }
5392}
5393
5394pub(crate) struct PriorSessionTraceAppender {
5409 file: File,
5410 session_id: String,
5412 session_event_id: Option<String>,
5414 terminate_torn_tail: bool,
5418}
5419
5420impl PriorSessionTraceAppender {
5421 pub(crate) async fn open(
5425 trace_dir: &WorkdirRoot,
5426 trace: &str,
5427 session_id: String,
5428 session_event_id: Option<String>,
5429 ) -> std::io::Result<Self> {
5430 let contents = trace_dir
5431 .read_file(trace, u64::MAX)?
5432 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))?;
5433 let terminate_torn_tail = contents.last().is_some_and(|last| *last != b'\n');
5435 let file = File::from_std(trace_dir.open_append(trace, DEFAULT_FILE_MODE)?);
5436 Ok(Self {
5437 file,
5438 session_id,
5439 session_event_id,
5440 terminate_torn_tail,
5441 })
5442 }
5443
5444 pub(crate) async fn write_shell_lost(
5450 &mut self,
5451 work: &crate::detached::LostWork,
5452 reconciled_by_session: &str,
5453 reconciled_task_id: &str,
5454 ) -> std::io::Result<()> {
5455 let event = ShellLostEvent {
5456 event_type: "shell_lost",
5457 event_id: new_event_id(),
5458 parent_id: self.session_event_id.clone(),
5459 session_id: self.session_id.clone(),
5460 timestamp: timestamp_ms(),
5461 work_id: work.work_id.clone(),
5462 binary: work.binary.clone(),
5463 command: work.command.clone(),
5464 detached_at_ms: work.detached_at_ms,
5465 reconciled_by_session: reconciled_by_session.to_string(),
5466 reconciled_task_id: reconciled_task_id.to_string(),
5467 };
5468 let mut line = if self.terminate_torn_tail {
5469 "\n".to_string()
5470 } else {
5471 String::new()
5472 };
5473 line.push_str(&serde_json::to_string(&event).map_err(std::io::Error::other)?);
5474 line.push('\n');
5475 self.file.write_all(line.as_bytes()).await?;
5476 self.file.flush().await?;
5477 self.terminate_torn_tail = false;
5478 Ok(())
5479 }
5480}
5481
5482pub(crate) fn timestamp_ms() -> u64 {
5483 SystemTime::now()
5484 .duration_since(UNIX_EPOCH)
5485 .unwrap_or_default()
5486 .as_millis() as u64
5487}
5488
5489#[derive(Serialize, Clone)]
5496pub(crate) struct PlanStepShape {
5497 pub(crate) step_id: String,
5498 pub(crate) kind: &'static str,
5501 pub(crate) depends_on: Vec<String>,
5502 pub(crate) has_condition: bool,
5504}
5505
5506#[derive(Serialize)]
5509struct PlanStartEvent {
5510 event_type: &'static str,
5511 event_id: String,
5512 parent_id: Option<String>,
5513 session_id: String,
5514 timestamp: u64,
5515 plan_id: String,
5516 step_count: usize,
5517 steps: Vec<PlanStepShape>,
5518}
5519
5520#[derive(Serialize)]
5526struct PlanStepStartEvent {
5527 event_type: &'static str,
5528 event_id: String,
5529 parent_id: Option<String>,
5530 session_id: String,
5531 timestamp: u64,
5532 plan_id: String,
5533 step_id: String,
5534 kind: &'static str,
5535 depends_on: Vec<String>,
5536}
5537
5538#[derive(Serialize)]
5542struct PlanStepEvent {
5543 event_type: &'static str,
5544 event_id: String,
5545 parent_id: Option<String>,
5546 session_id: String,
5547 timestamp: u64,
5548 plan_id: String,
5549 step_id: String,
5550 kind: &'static str,
5551 status: &'static str,
5552 attempts: u32,
5553 duration_ms: u64,
5554 #[serde(skip_serializing_if = "Option::is_none")]
5555 error: Option<String>,
5556 #[serde(skip_serializing_if = "Option::is_none")]
5559 input: Option<Value>,
5560 #[serde(skip_serializing_if = "Option::is_none")]
5564 state_effect: Option<String>,
5565 #[serde(skip_serializing_if = "Option::is_none")]
5567 resource_id: Option<String>,
5568}
5569
5570#[derive(Serialize)]
5572struct PlanEndEvent {
5573 event_type: &'static str,
5574 event_id: String,
5575 parent_id: Option<String>,
5576 session_id: String,
5577 timestamp: u64,
5578 plan_id: String,
5579 outcome: &'static str,
5580 #[serde(skip_serializing_if = "Option::is_none")]
5581 failed_step: Option<String>,
5582 steps_total: usize,
5583 steps_succeeded: usize,
5584 steps_failed: usize,
5585 steps_skipped: usize,
5586 duration_ms: u64,
5587 #[serde(skip_serializing_if = "Option::is_none")]
5590 reason: Option<String>,
5591}
5592
5593pub(crate) struct PlanStepRecord {
5595 pub(crate) plan_id: String,
5596 pub(crate) step_id: String,
5597 pub(crate) kind: &'static str,
5598 pub(crate) status: &'static str,
5601 pub(crate) attempts: u32,
5603 pub(crate) duration_ms: u64,
5604 pub(crate) error: Option<String>,
5605 pub(crate) input: Option<Value>,
5606 pub(crate) state_effect: Option<String>,
5607 pub(crate) resource_id: Option<String>,
5608}
5609
5610pub(crate) struct PlanEndRecord {
5612 pub(crate) plan_id: String,
5613 pub(crate) outcome: &'static str,
5614 pub(crate) failed_step: Option<String>,
5615 pub(crate) steps_total: usize,
5616 pub(crate) steps_succeeded: usize,
5617 pub(crate) steps_failed: usize,
5618 pub(crate) steps_skipped: usize,
5619 pub(crate) duration_ms: u64,
5620 pub(crate) reason: Option<String>,
5621}
5622
5623pub struct PlanTraceAppender {
5634 file: std::sync::Mutex<std::fs::File>,
5635 session_id: String,
5636 session_event_id: String,
5639}
5640
5641impl PlanTraceAppender {
5642 pub fn open(
5643 records_root: &WorkdirRoot,
5644 session_id: String,
5645 session_event_id: String,
5646 ) -> std::io::Result<Self> {
5647 let file = records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?;
5648 Ok(Self {
5649 file: std::sync::Mutex::new(file),
5650 session_id,
5651 session_event_id,
5652 })
5653 }
5654
5655 pub(crate) fn write_plan_start(&self, plan_id: &str, steps: Vec<PlanStepShape>) -> String {
5657 let event_id = new_event_id();
5658 let event = PlanStartEvent {
5659 event_type: "plan_start",
5660 event_id: event_id.clone(),
5661 parent_id: Some(self.session_event_id.clone()),
5662 session_id: self.session_id.clone(),
5663 timestamp: timestamp_ms(),
5664 plan_id: plan_id.to_string(),
5665 step_count: steps.len(),
5666 steps,
5667 };
5668 self.append(&event);
5669 event_id
5670 }
5671
5672 pub(crate) fn write_plan_step_start(
5673 &self,
5674 plan_node: &str,
5675 plan_id: &str,
5676 step_id: &str,
5677 kind: &'static str,
5678 depends_on: Vec<String>,
5679 ) {
5680 let event = PlanStepStartEvent {
5681 event_type: "plan_step_start",
5682 event_id: new_event_id(),
5683 parent_id: Some(plan_node.to_string()),
5684 session_id: self.session_id.clone(),
5685 timestamp: timestamp_ms(),
5686 plan_id: plan_id.to_string(),
5687 step_id: step_id.to_string(),
5688 kind,
5689 depends_on,
5690 };
5691 self.append(&event);
5692 }
5693
5694 pub(crate) fn write_plan_step(&self, plan_node: &str, record: PlanStepRecord) {
5695 let input = record.input.map(|mut value| {
5698 crate::peer_handoff::redact_handles_in_json(&mut value);
5699 value
5700 });
5701 let event = PlanStepEvent {
5702 event_type: "plan_step",
5703 event_id: new_event_id(),
5704 parent_id: Some(plan_node.to_string()),
5705 session_id: self.session_id.clone(),
5706 timestamp: timestamp_ms(),
5707 plan_id: record.plan_id,
5708 step_id: record.step_id,
5709 kind: record.kind,
5710 status: record.status,
5711 attempts: record.attempts,
5712 duration_ms: record.duration_ms,
5713 error: record.error,
5714 input,
5715 state_effect: record.state_effect,
5716 resource_id: record.resource_id,
5717 };
5718 self.append(&event);
5719 }
5720
5721 pub(crate) fn write_plan_end(&self, plan_node: Option<&str>, record: PlanEndRecord) {
5724 let event = PlanEndEvent {
5725 event_type: "plan_end",
5726 event_id: new_event_id(),
5727 parent_id: Some(
5728 plan_node
5729 .map(str::to_string)
5730 .unwrap_or_else(|| self.session_event_id.clone()),
5731 ),
5732 session_id: self.session_id.clone(),
5733 timestamp: timestamp_ms(),
5734 plan_id: record.plan_id,
5735 outcome: record.outcome,
5736 failed_step: record.failed_step,
5737 steps_total: record.steps_total,
5738 steps_succeeded: record.steps_succeeded,
5739 steps_failed: record.steps_failed,
5740 steps_skipped: record.steps_skipped,
5741 duration_ms: record.duration_ms,
5742 reason: record.reason,
5743 };
5744 self.append(&event);
5745 }
5746
5747 #[allow(clippy::too_many_arguments)]
5753 pub(crate) fn write_delegation_start(
5754 &self,
5755 delegation_id: &str,
5756 capsule: &str,
5757 version: &str,
5758 child_session_id: &str,
5759 child_workdir: &str,
5760 child_records_dir: &str,
5761 workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
5762 ) {
5763 let event = DelegationStartEvent::new(
5764 &self.session_id,
5765 &self.session_event_id,
5766 delegation_id,
5767 capsule,
5768 version,
5769 child_session_id,
5770 child_workdir,
5771 child_records_dir,
5772 workdir_snapshot,
5773 );
5774 self.append(&event);
5775 }
5776
5777 #[allow(clippy::too_many_arguments)]
5785 pub(crate) fn write_delegation(
5786 &self,
5787 capsule: &str,
5788 version: &str,
5789 delegation_id: Option<String>,
5790 child_session_id: Option<String>,
5791 duration_ms: u64,
5792 outcome: &str,
5793 reason: Option<String>,
5794 ) {
5795 let event = DelegationEvent::new(
5796 &self.session_id,
5797 &self.session_event_id,
5798 capsule,
5799 version,
5800 delegation_id,
5801 child_session_id,
5802 duration_ms,
5803 outcome,
5804 reason,
5805 );
5806 self.append(&event);
5807 }
5808
5809 fn append(&self, event: &impl Serialize) {
5813 use std::io::Write as _;
5814
5815 let Ok(mut line) = serde_json::to_string(event) else {
5816 return;
5817 };
5818 line.push('\n');
5819 let Ok(mut file) = self.file.lock() else {
5820 return;
5821 };
5822 if file.write_all(line.as_bytes()).is_ok() {
5823 let _ = file.flush();
5824 }
5825 }
5826}
5827
5828#[cfg(test)]
5831mod tests {
5832 use super::*;
5833 use crate::{
5834 sandbox::EnforcementTier, sealed::UsernsGrant, trace_blobs::BLOB_DIR_NAME,
5835 types::CapabilityPolicy,
5836 };
5837 use murmur_artifact::{ArtifactRuntime, InterpreterRuntimeDir, InterpreterRuntimeGrant};
5838 use serde_json::Value;
5839 use std::path::Path;
5840
5841 use crate::origin::TaskOrigin;
5842
5843 fn event_provenance() -> TaskProvenance {
5845 TaskProvenance::derive(TaskOrigin::Event, None)
5846 }
5847
5848 fn report_for(
5852 declared: ContainmentClass,
5853 tier: EnforcementTier,
5854 workdir_exec: bool,
5855 ) -> ScopeReport {
5856 crate::containment::report_for(
5857 &CapabilityPolicy {
5858 workdir_exec_allowed: workdir_exec,
5859 ..CapabilityPolicy::default()
5860 },
5861 declared,
5862 tier,
5863 None,
5864 None,
5865 None,
5866 None,
5867 Vec::new(),
5868 Vec::new(),
5869 Vec::new(),
5870 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5871 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(tier, declared)),
5872 crate::cgroup::IoMaxReport::default(),
5873 )
5874 }
5875
5876 fn demotion_info() -> crate::detached::DetachedDispatchInfo {
5877 crate::detached::DetachedDispatchInfo {
5878 work_id: "wrk_0a1b2c3d4e5f6a7b".to_string(),
5879 binary: "/usr/bin/bash".to_string(),
5880 command: "make -j8".to_string(),
5881 grace_ms: Some(1_000),
5882 }
5883 }
5884
5885 #[tokio::test]
5888 async fn a_demotion_marker_that_cannot_be_written_does_not_fail_the_demotion() {
5889 let dir = tempfile::tempdir().unwrap();
5890 let mut writer = make_writer(dir.path()).await;
5894 let full = std::fs::OpenOptions::new()
5895 .write(true)
5896 .open("/dev/full")
5897 .unwrap();
5898 writer.writer = BufWriter::new(File::from_std(full));
5899 let info = demotion_info();
5900
5901 assert!(
5902 writer
5903 .write_shell_detached(1, &info.work_id, &info.binary, &info.command, info.grace_ms)
5904 .await
5905 .is_err(),
5906 "the sink under this writer must be unwritable for the claim below to mean anything"
5907 );
5908 crate::agent::record_demotion(&mut writer, 1, &info).await;
5909 }
5910
5911 #[tokio::test]
5913 async fn a_recorded_demotion_writes_only_its_own_marker() {
5914 let dir = tempfile::tempdir().unwrap();
5915 let mut writer = make_writer(dir.path()).await;
5916 crate::agent::record_demotion(&mut writer, 3, &demotion_info()).await;
5917
5918 let events = read_events(dir.path());
5919 assert_eq!(events.len(), 1);
5920 assert_eq!(events[0]["event_type"], "shell_detached");
5921 assert_eq!(events[0]["work_id"], "wrk_0a1b2c3d4e5f6a7b");
5922 assert_eq!(events[0]["turn"], 3);
5923 assert_eq!(events[0]["grace_ms"], 1_000);
5924 }
5925
5926 #[tokio::test]
5928 async fn a_demotion_with_no_grace_writes_no_grace_ms() {
5929 let dir = tempfile::tempdir().unwrap();
5930 let mut writer = make_writer(dir.path()).await;
5931 let info = crate::detached::DetachedDispatchInfo {
5932 grace_ms: None,
5933 ..demotion_info()
5934 };
5935 crate::agent::record_demotion(&mut writer, 3, &info).await;
5936
5937 let events = read_events(dir.path());
5938 assert_eq!(events.len(), 1);
5939 assert_eq!(events[0]["event_type"], "shell_detached");
5940 assert!(events[0].get("grace_ms").is_none(), "{}", events[0]);
5941 }
5942
5943 #[tokio::test]
5944 async fn a_safety_check_event_carries_the_checks_verbatim_and_its_outcome() {
5945 let dir = tempfile::tempdir().unwrap();
5946 let mut writer = make_writer(dir.path()).await;
5947 let checks = serde_json::json!([{"id": "sc_1", "code": "c", "message": "m", "x": 1}]);
5948 writer
5949 .write_safety_check(2, "call_1_0", "computer", &checks, "declined")
5950 .await
5951 .unwrap();
5952
5953 let events = read_events(dir.path());
5954 assert_eq!(events.len(), 1);
5955 assert_eq!(events[0]["event_type"], "safety_check");
5956 assert_eq!(events[0]["turn"], 2);
5957 assert_eq!(events[0]["tool_call_id"], "call_1_0");
5958 assert_eq!(events[0]["tool"], "computer");
5959 assert_eq!(events[0]["checks"], checks);
5960 assert_eq!(events[0]["outcome"], "declined");
5961 assert!(events[0]["event_id"].is_string() && events[0]["session_id"].is_string());
5962 }
5963
5964 #[tokio::test]
5965 async fn an_unrecorded_demotion_names_the_work_id_and_the_write_error() {
5966 let dir = tempfile::tempdir().unwrap();
5967 let mut writer = make_writer(dir.path()).await;
5968 writer
5969 .write_shell_detach_unrecorded(2, "wrk_0a1b", "/usr/bin/bash", "No space left")
5970 .await
5971 .unwrap();
5972
5973 let events = read_events(dir.path());
5974 assert_eq!(events.len(), 1);
5975 assert_eq!(events[0]["event_type"], "shell_detach_unrecorded");
5976 assert_eq!(events[0]["turn"], 2);
5977 assert_eq!(events[0]["work_id"], "wrk_0a1b");
5978 assert_eq!(events[0]["binary"], "/usr/bin/bash");
5979 assert_eq!(events[0]["reason"], "No space left");
5980 }
5981
5982 #[tokio::test]
5986 async fn an_abandoned_command_still_running_omits_the_fields_it_cannot_know() {
5987 let dir = tempfile::tempdir().unwrap();
5988 let mut writer = make_writer(dir.path()).await;
5989 writer
5990 .write_shell_abandoned(
5991 "wrk_0a1b2c3d4e5f6a7b",
5992 "/usr/bin/bash",
5993 "sleep 45; echo done",
5994 45_012,
5995 None,
5996 )
5997 .await
5998 .unwrap();
5999
6000 let events = read_events(dir.path());
6001 assert_eq!(events.len(), 1);
6002 let event = &events[0];
6003 assert_eq!(event["event_type"], "shell_abandoned");
6004 assert_eq!(event["work_id"], "wrk_0a1b2c3d4e5f6a7b");
6005 assert_eq!(event["binary"], "/usr/bin/bash");
6006 assert_eq!(event["command"], "sleep 45; echo done");
6007 assert_eq!(event["running_ms"], 45_012);
6008 for absent in ["exit_code", "output_path", "output_bytes"] {
6009 assert!(
6010 event.get(absent).is_none(),
6011 "{absent} must be omitted, not written as null: {event}"
6012 );
6013 }
6014 }
6015
6016 #[tokio::test]
6020 async fn an_abandoned_command_that_finished_carries_its_result() {
6021 let dir = tempfile::tempdir().unwrap();
6022 let mut writer = make_writer(dir.path()).await;
6023 writer
6024 .write_shell_abandoned(
6025 "wrk_0a1b2c3d4e5f6a7b",
6026 "/usr/bin/bash",
6027 "sleep 2; echo done",
6028 2_014,
6029 Some((
6030 0,
6031 "/home/test/.murmur/records/work/ses_1/logs/wrk_0a1b2c3d4e5f6a7b.log",
6032 91,
6033 )),
6034 )
6035 .await
6036 .unwrap();
6037
6038 let event = &read_events(dir.path())[0];
6039 assert_eq!(event["event_type"], "shell_abandoned");
6040 assert_eq!(event["running_ms"], 2_014);
6041 assert_eq!(event["exit_code"], 0);
6042 assert_eq!(
6043 event["output_path"],
6044 "/home/test/.murmur/records/work/ses_1/logs/wrk_0a1b2c3d4e5f6a7b.log"
6045 );
6046 assert_eq!(event["output_bytes"], 91);
6047 }
6048
6049 async fn make_writer(dir: &std::path::Path) -> TraceWriter {
6050 make_writer_with_opts(dir, TraceCapture::Meta).await
6051 }
6052
6053 async fn make_writer_with_opts(dir: &std::path::Path, capture: TraceCapture) -> TraceWriter {
6054 make_writer_with_prompt(dir, capture, None, false).await
6055 }
6056
6057 async fn make_writer_with_prompt(
6058 dir: &std::path::Path,
6059 capture: TraceCapture,
6060 system_prompt: Option<&str>,
6061 system_prompt_overridden: bool,
6062 ) -> TraceWriter {
6063 TraceWriter::open(
6064 &crate::workdir_fs::WorkdirRoot::open(dir).unwrap(),
6065 "test-session-id".to_string(),
6066 "test-capsule".to_string(),
6067 "1.0.0".to_string(),
6068 "claude-test".to_string(),
6069 vec!["shell".to_string()],
6070 report_for(
6071 ContainmentClass::Advisory,
6072 EnforcementTier::EnvironmentOnly,
6073 false,
6074 ),
6075 capture,
6076 system_prompt.map(str::to_string),
6077 system_prompt_overridden,
6078 None,
6079 None,
6080 None,
6081 None,
6082 )
6083 .await
6084 .unwrap()
6085 }
6086
6087 fn read_events(dir: &std::path::Path) -> Vec<Value> {
6088 let content = std::fs::read_to_string(dir.join("trace.jsonl")).unwrap();
6089 content
6090 .lines()
6091 .filter(|l| !l.is_empty())
6092 .map(|l| serde_json::from_str(l).unwrap())
6093 .collect()
6094 }
6095
6096 #[tokio::test]
6097 async fn session_start_fields() {
6098 let dir = tempfile::tempdir().unwrap();
6099 let mut w = make_writer(dir.path()).await;
6100 w.write_session_start(Some(10), None, vec!["bash".to_string()])
6101 .await
6102 .unwrap();
6103 w.flush().await.unwrap();
6104
6105 let events = read_events(dir.path());
6106 assert_eq!(events.len(), 1);
6107 let e = &events[0];
6108 assert_eq!(e["event_type"], "session_start");
6109 assert_eq!(e["session_id"], "test-session-id");
6110 assert_eq!(e["capsule_name"], "test-capsule");
6111 assert_eq!(e["capsule_version"], "1.0.0");
6112 assert_eq!(e["model"], "claude-test");
6113 assert_eq!(e["max_turns"], 10);
6114 assert_eq!(e["capabilities"], serde_json::json!(["shell"]));
6115 assert_eq!(e["tools_declared"], serde_json::json!(["bash"]));
6116 assert_eq!(e["containment_declared"], "advisory");
6117 assert_eq!(e["containment_achieved"], "advisory");
6118 assert_eq!(e["workdir_exec"], false);
6119 assert!(e["timestamp"].as_u64().unwrap() > 0);
6120 assert_eq!(e["model_limits"], serde_json::Value::Null);
6121 }
6122
6123 #[tokio::test]
6125 async fn session_start_writes_no_turn_cap_and_the_model_limits() {
6126 let dir = tempfile::tempdir().unwrap();
6127 let mut w = make_writer(dir.path()).await;
6128 let limits = crate::model_limits::ResolvedModelLimits {
6129 max_output_tokens: 65536,
6130 max_output_tokens_source: crate::model_limits::LimitSource::Driver,
6131 context_window: None,
6132 context_window_source: crate::model_limits::LimitSource::None,
6133 max_input_tokens: None,
6134 max_input_tokens_source: crate::model_limits::LimitSource::None,
6135 input_budget: None,
6136 input_budget_basis: crate::model_limits::BudgetBasis::None,
6137 };
6138 w.write_session_start(None, Some(limits), Vec::new())
6139 .await
6140 .unwrap();
6141 w.flush().await.unwrap();
6142
6143 let e = &read_events(dir.path())[0];
6144 assert!(e.get("max_turns").is_some_and(serde_json::Value::is_null));
6145 assert_eq!(
6146 e["model_limits"],
6147 serde_json::json!({
6148 "max_output_tokens": 65536,
6149 "max_output_tokens_source": "driver",
6150 "context_window": null,
6151 "context_window_source": "none",
6152 "max_input_tokens": null,
6153 "max_input_tokens_source": "none",
6154 "input_budget": null,
6155 "input_budget_basis": "none",
6156 })
6157 );
6158 }
6159
6160 #[tokio::test]
6163 async fn session_start_is_the_first_line_and_carries_a_members_formation() {
6164 let formation = FormationId::mint();
6165 for member in [Some(&formation), None] {
6166 let dir = tempfile::tempdir().unwrap();
6167 let mut w = make_writer(dir.path()).await;
6168 w.set_formation_id(member);
6169 w.write_session_start(Some(10), None, Vec::new())
6170 .await
6171 .unwrap();
6172 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6173 .await
6174 .unwrap();
6175 w.flush().await.unwrap();
6176
6177 let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6178 let first_line = content.lines().next().unwrap();
6179 let first: Value = serde_json::from_str(first_line).unwrap();
6180 assert_eq!(first["event_type"], "session_start");
6181 match member {
6182 Some(id) => {
6183 assert_eq!(first["formation_id"], id.as_str());
6184 assert!(
6185 first_line.contains(&format!("\"formation_id\":\"{id}\"")),
6186 "{first_line}"
6187 );
6188 }
6189 None => assert!(!first_line.contains("formation_id"), "{first_line}"),
6190 }
6191 assert_eq!(
6193 content.matches("formation_id").count(),
6194 usize::from(member.is_some())
6195 );
6196 }
6197 }
6198
6199 #[tokio::test]
6203 async fn a_members_trace_names_it_its_callees_and_its_callers() {
6204 let authority = crate::formation_credentials::FormationAuthority::for_test();
6205 let cases = [
6206 (Some(("coder", vec!["reviewer"])), Some("planner")),
6207 (Some(("reviewer", vec![])), None),
6208 (None, None),
6209 ];
6210 for (member, caller) in cases {
6211 let member = member.map(|(name, callees)| {
6212 crate::formation_credentials::FormationMember::from_bundle(
6213 authority.member_bundle(name, &callees),
6214 )
6215 });
6216 let dir = tempfile::tempdir().unwrap();
6217 let mut w = make_writer(dir.path()).await;
6218 w.set_formation_member(member.as_ref());
6219 w.write_session_start(Some(10), None, Vec::new())
6220 .await
6221 .unwrap();
6222 let door_caller = match caller {
6223 Some(caller) => crate::identity::TaskCaller::Credential(format!("member:{caller}")),
6224 None => crate::identity::TaskCaller::Operator,
6225 };
6226 w.write_a2a_task_received("tsk_1", "ctx_1", "msg_1", None, &door_caller, caller, "hi")
6227 .await
6228 .unwrap();
6229 w.flush().await.unwrap();
6230 let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6231 let lines: Vec<Value> = content
6232 .lines()
6233 .map(|line| serde_json::from_str(line).unwrap())
6234 .collect();
6235 let start = &lines[0];
6236 let received = lines
6237 .iter()
6238 .find(|line| line["event_type"] == "a2a_task_received")
6239 .unwrap();
6240 match &member {
6241 Some(member) => {
6242 assert_eq!(start["formation_member"], member.name());
6243 assert_eq!(
6244 start["formation_callees"],
6245 serde_json::json!(member.callees().collect::<Vec<_>>())
6246 );
6247 }
6248 None => {
6249 assert!(start.get("formation_member").is_none(), "{start}");
6250 assert!(start.get("formation_callees").is_none(), "{start}");
6251 }
6252 }
6253 match caller {
6254 Some(caller) => {
6255 assert_eq!(received["caller"], "member");
6256 assert_eq!(received["caller_member"], caller);
6257 }
6258 None => {
6259 assert_eq!(received["caller"], "operator");
6260 assert!(received.get("caller_member").is_none(), "{received}");
6261 }
6262 }
6263 assert!(received.get("caller_credential").is_none(), "{received}");
6264 assert!(!content.contains("mft1."), "{content}");
6265 }
6266 }
6267
6268 #[tokio::test]
6272 async fn a_received_task_names_who_the_door_let_it_in_as() {
6273 use crate::identity::TaskCaller;
6274 let cases = [
6275 (TaskCaller::Operator, None, "operator", None),
6276 (TaskCaller::Public, None, "public", None),
6277 (
6278 TaskCaller::Credential("alice".to_string()),
6279 None,
6280 "credential",
6281 Some("alice"),
6282 ),
6283 (
6284 TaskCaller::Credential("member:planner".to_string()),
6285 Some("planner"),
6286 "member",
6287 None,
6288 ),
6289 ];
6290 for (door_caller, member, caller, credential) in cases {
6291 let dir = tempfile::tempdir().unwrap();
6292 let mut w = make_writer(dir.path()).await;
6293 w.write_session_start(Some(10), None, Vec::new())
6294 .await
6295 .unwrap();
6296 w.write_a2a_task_received("tsk_1", "ctx_1", "msg_1", None, &door_caller, member, "hi")
6297 .await
6298 .unwrap();
6299 w.flush().await.unwrap();
6300 let received = read_events(dir.path())
6301 .into_iter()
6302 .find(|line| line["event_type"] == "a2a_task_received")
6303 .unwrap();
6304 assert_eq!(received["caller"], caller, "{received}");
6305 match member {
6306 Some(member) => assert_eq!(received["caller_member"], member, "{received}"),
6307 None => assert!(received.get("caller_member").is_none(), "{received}"),
6308 }
6309 match credential {
6310 Some(name) => assert_eq!(received["caller_credential"], name, "{received}"),
6311 None => assert!(received.get("caller_credential").is_none(), "{received}"),
6312 }
6313 }
6314 }
6315
6316 #[tokio::test]
6319 async fn a_received_message_is_hashed_and_stored_by_capture_mode() {
6320 let text = "Name three colours, please.";
6321 let sha = murmur_artifact::sha256_hex(text.as_bytes());
6322 for capture in [
6323 TraceCapture::None,
6324 TraceCapture::Meta,
6325 TraceCapture::Content,
6326 ] {
6327 let dir = tempfile::tempdir().unwrap();
6328 let mut w = make_writer_with_opts(dir.path(), capture).await;
6329 w.write_a2a_task_received(
6330 "tsk_1",
6331 "ctx_1",
6332 "msg_1",
6333 None,
6334 &crate::identity::TaskCaller::Operator,
6335 None,
6336 text,
6337 )
6338 .await
6339 .unwrap();
6340 w.flush().await.unwrap();
6341 let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6342 assert!(!content.contains(text), "{content}");
6343 let received = read_events(dir.path())
6344 .into_iter()
6345 .find(|line| line["event_type"] == "a2a_task_received")
6346 .unwrap();
6347 let blob = std::fs::read(blob_path(dir.path(), &sha)).ok();
6348 match capture {
6349 TraceCapture::None => {
6350 assert!(received.get("message_sha256").is_none(), "{received}");
6351 assert_eq!(blob, None);
6352 }
6353 TraceCapture::Meta => {
6354 assert_eq!(received["message_sha256"], sha.as_str(), "{received}");
6355 assert_eq!(blob, None);
6356 }
6357 TraceCapture::Content => {
6358 assert_eq!(received["message_sha256"], sha.as_str(), "{received}");
6359 assert_eq!(blob.as_deref(), Some(text.as_bytes()));
6360 }
6361 }
6362 }
6363 }
6364
6365 #[tokio::test]
6370 async fn session_start_records_system_prompt_source_and_hash() {
6371 let expected_sha = murmur_artifact::sha256_hex(b"Be terse.");
6372
6373 for (prompt, overridden, source, sha) in [
6374 (Some("Be terse."), false, "manifest", Some(&expected_sha)),
6375 (Some("Be terse."), true, "cli", Some(&expected_sha)),
6376 (None, false, "none", None),
6377 (None, true, "cli", None),
6380 ] {
6381 let dir = tempfile::tempdir().unwrap();
6382 let mut w =
6383 make_writer_with_prompt(dir.path(), TraceCapture::Meta, prompt, overridden).await;
6384 w.write_session_start(Some(1), None, Vec::new())
6385 .await
6386 .unwrap();
6387 w.flush().await.unwrap();
6388
6389 let e = &read_events(dir.path())[0];
6390 assert_eq!(e["system_prompt_source"], source, "prompt={prompt:?}");
6391 match sha {
6392 Some(sha) => assert_eq!(e["system_prompt_sha256"], sha.as_str()),
6393 None => assert!(
6394 e["system_prompt_sha256"].is_null(),
6395 "no prompt in effect must hash to null, got {}",
6396 e["system_prompt_sha256"]
6397 ),
6398 }
6399 }
6400 }
6401
6402 #[tokio::test]
6406 async fn session_start_records_the_prompt_by_hash_and_stores_it_only_under_content() {
6407 let sha = murmur_artifact::sha256_hex(b"Be terse.");
6408
6409 let dir = tempfile::tempdir().unwrap();
6410 let mut w =
6411 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), false).await;
6412 w.write_session_start(Some(1), None, Vec::new())
6413 .await
6414 .unwrap();
6415 w.flush().await.unwrap();
6416
6417 let e = &read_events(dir.path())[0];
6418 assert!(
6419 e.get("system_prompt").is_none(),
6420 "the verbatim prompt is not a session_start field, got {e}"
6421 );
6422 assert_eq!(e["system_prompt_sha256"], sha.as_str());
6423 assert!(
6424 !dir.path().join(BLOB_DIR_NAME).exists(),
6425 "meta stores no bodies"
6426 );
6427
6428 let opted_in = tempfile::tempdir().unwrap();
6429 let mut w = make_writer_with_prompt(
6430 opted_in.path(),
6431 TraceCapture::Content,
6432 Some("Be terse."),
6433 false,
6434 )
6435 .await;
6436 w.write_session_start(Some(1), None, Vec::new())
6437 .await
6438 .unwrap();
6439 w.flush().await.unwrap();
6440
6441 let event = &read_events(opted_in.path())[0];
6442 assert!(event.get("system_prompt").is_none());
6443 assert_eq!(event["system_prompt_sha256"], sha.as_str());
6444 let blob = opted_in.path().join(BLOB_DIR_NAME).join(&sha);
6445 assert_eq!(std::fs::read(&blob).unwrap(), b"Be terse.");
6446 }
6447
6448 #[tokio::test]
6451 async fn content_capture_stores_no_prompt_blob_when_there_is_no_prompt() {
6452 let dir = tempfile::tempdir().unwrap();
6453 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::Content, None, false).await;
6454 w.write_session_start(Some(1), None, Vec::new())
6455 .await
6456 .unwrap();
6457 w.flush().await.unwrap();
6458
6459 let e = &read_events(dir.path())[0];
6460 assert!(e.get("system_prompt").is_none());
6461 assert!(e["system_prompt_sha256"].is_null());
6462 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
6463 }
6464
6465 #[tokio::test]
6467 async fn every_task_session_start_repeats_the_same_system_prompt_record() {
6468 let dir = tempfile::tempdir().unwrap();
6469 let mut w =
6470 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), true).await;
6471 w.write_session_start(Some(1), None, Vec::new())
6472 .await
6473 .unwrap();
6474 w.write_session_start(Some(1), None, Vec::new())
6475 .await
6476 .unwrap();
6477 w.flush().await.unwrap();
6478
6479 let events = read_events(dir.path());
6480 assert_eq!(events.len(), 2);
6481 assert_eq!(
6482 events[0]["system_prompt_source"],
6483 events[1]["system_prompt_source"]
6484 );
6485 assert_eq!(
6486 events[0]["system_prompt_sha256"],
6487 events[1]["system_prompt_sha256"]
6488 );
6489 assert_eq!(events[1]["system_prompt_source"], "cli");
6490 }
6491
6492 #[tokio::test]
6497 async fn session_start_records_workdir_exec_next_to_the_class_it_forced() {
6498 let dir = tempfile::tempdir().unwrap();
6499 let mut w = TraceWriter::open(
6500 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6501 "test-session-id".to_string(),
6502 "test-capsule".to_string(),
6503 "1.0.0".to_string(),
6504 "claude-test".to_string(),
6505 Vec::new(),
6506 report_for(
6508 ContainmentClass::Advisory,
6509 EnforcementTier::KernelFull,
6510 true,
6511 ),
6512 TraceCapture::Meta,
6513 None,
6514 false,
6515 None,
6516 None,
6517 None,
6518 None,
6519 )
6520 .await
6521 .unwrap();
6522 w.write_session_start(Some(1), None, Vec::new())
6523 .await
6524 .unwrap();
6525 w.flush().await.unwrap();
6526
6527 let events = read_events(dir.path());
6528 assert_eq!(events[0]["workdir_exec"], true);
6529 assert_eq!(events[0]["containment_achieved"], "advisory");
6530 }
6531
6532 #[tokio::test]
6535 async fn session_start_records_declared_and_achieved_containment_separately() {
6536 let dir = tempfile::tempdir().unwrap();
6537 let mut w = TraceWriter::open(
6538 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6539 "test-session-id".to_string(),
6540 "test-capsule".to_string(),
6541 "1.0.0".to_string(),
6542 "claude-test".to_string(),
6543 Vec::new(),
6544 report_for(ContainmentClass::Sealed, EnforcementTier::KernelFull, false),
6545 TraceCapture::Meta,
6546 None,
6547 false,
6548 None,
6549 None,
6550 None,
6551 None,
6552 )
6553 .await
6554 .unwrap();
6555 w.write_session_start(Some(1), None, Vec::new())
6556 .await
6557 .unwrap();
6558 w.flush().await.unwrap();
6559
6560 let events = read_events(dir.path());
6561 assert_eq!(events[0]["containment_declared"], "sealed");
6562 assert_eq!(events[0]["containment_achieved"], "scoped");
6563 }
6564
6565 #[tokio::test]
6573 async fn session_start_distinguishes_two_hosts_with_the_same_achieved_class() {
6574 async fn event_for(grant: UsernsGrant) -> Value {
6575 let report = crate::containment::report_for(
6576 &CapabilityPolicy::default(),
6577 ContainmentClass::Sealed,
6578 EnforcementTier::KernelSealed,
6579 None,
6580 Some(grant),
6581 None,
6582 None,
6583 Vec::new(),
6584 Vec::new(),
6585 Vec::new(),
6586 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6587 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6588 EnforcementTier::KernelSealed,
6589 ContainmentClass::Sealed,
6590 )),
6591 crate::cgroup::IoMaxReport::default(),
6592 );
6593 let dir = tempfile::tempdir().unwrap();
6594 let mut w = TraceWriter::open(
6595 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6596 "test-session-id".to_string(),
6597 "test-capsule".to_string(),
6598 "1.0.0".to_string(),
6599 "claude-test".to_string(),
6600 Vec::new(),
6601 report,
6602 TraceCapture::Meta,
6603 None,
6604 false,
6605 None,
6606 None,
6607 None,
6608 None,
6609 )
6610 .await
6611 .unwrap();
6612 w.write_session_start(Some(1), None, Vec::new())
6613 .await
6614 .unwrap();
6615 w.flush().await.unwrap();
6616 let mut event = read_events(dir.path()).remove(0);
6617 event.as_object_mut().unwrap().remove("timestamp");
6620 event
6621 }
6622
6623 let through_profile = event_for(UsernsGrant::ProfileConfining).await;
6624 let host_wide = event_for(UsernsGrant::RestrictionDisabledHostWide).await;
6625
6626 assert_eq!(through_profile["containment_achieved"], "sealed");
6627 assert_eq!(host_wide["containment_achieved"], "sealed");
6628 assert_eq!(through_profile["userns_grant"], "profile_confining");
6629 assert_eq!(host_wide["userns_grant"], "restriction_disabled_host_wide");
6630 assert_ne!(
6631 through_profile, host_wide,
6632 "two hosts granting the user namespace by different mechanisms must not write the \
6633 same session_start record"
6634 );
6635
6636 let unprobed = crate::containment::report_for(
6639 &CapabilityPolicy::default(),
6640 ContainmentClass::Advisory,
6641 EnforcementTier::EnvironmentOnly,
6642 None,
6643 None,
6644 None,
6645 None,
6646 Vec::new(),
6647 Vec::new(),
6648 Vec::new(),
6649 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6650 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6651 EnforcementTier::EnvironmentOnly,
6652 ContainmentClass::Advisory,
6653 )),
6654 crate::cgroup::IoMaxReport::default(),
6655 );
6656 assert!(
6657 serde_json::to_value(&unprobed).unwrap()["userns_grant"].is_null(),
6658 "off Linux the key is present and null, not absent"
6659 );
6660 }
6661
6662 #[tokio::test]
6670 async fn session_start_records_the_whole_scope_report_as_effective_grants() {
6671 let policy = CapabilityPolicy {
6672 network_allow: vec!["https://api.example.com".to_string()],
6673 unix_sockets_allowed: false,
6674 filesystem_scope: Some("workdir".to_string()),
6675 shell_allow: vec!["python3".to_string()],
6676 spawn_allow: vec!["helper".to_string()],
6677 env_allow: vec!["TZ".to_string()],
6678 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6679 binary: "python3".to_string(),
6680 dirs: vec![InterpreterRuntimeDir {
6681 path: "/usr/lib/python3.11".to_string(),
6682 list_dir: true,
6683 }],
6684 }],
6685 ..CapabilityPolicy::default()
6686 };
6687 let expected = crate::containment::report_for(
6688 &policy,
6689 ContainmentClass::Scoped,
6690 EnforcementTier::KernelFull,
6691 None,
6692 None,
6693 Some(8),
6695 None,
6696 vec![crate::containment::StateStoreReport {
6699 artifact: "notes-tool".to_string(),
6700 store: "shey".to_string(),
6701 host_path: "/home/dev/.murmur/state/shey".to_string(),
6702 }],
6703 vec!["config-echo".to_string()],
6705 vec![crate::containment::PreopenReport {
6706 artifact: "notes-tool".to_string(),
6707 role: "tool".to_string(),
6708 scope: None,
6709 surface: crate::containment::PreopenSurface::WholeWorkdir,
6710 runtime_dir_reach: Some(crate::containment::RuntimeDirReach::MurmurRuntime),
6711 extra_roots: vec![crate::containment::ExtraRootReport {
6713 guest_path: "murmur-host/home/dev/.cargo".to_string(),
6714 path: "/home/dev/.cargo".to_string(),
6715 access: crate::host_paths::HostPathAccess::Read,
6716 source: crate::guest_roots::GuestRootSource::HostPaths,
6717 }],
6718 }],
6719 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6720 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6721 EnforcementTier::KernelFull,
6722 ContainmentClass::Scoped,
6723 )),
6724 crate::cgroup::IoMaxReport::default(),
6725 );
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!["network".to_string(), "shell".to_string()],
6735 expected.clone(),
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
6751 let events = read_events(dir.path());
6752 assert_eq!(
6753 events[0]["effective_grants"],
6754 serde_json::to_value(&expected).unwrap()
6755 );
6756
6757 assert_eq!(
6760 events[0]["capabilities"],
6761 serde_json::json!(["network", "shell"])
6762 );
6763 assert_eq!(
6764 events[0]["effective_grants"]["state_stores"],
6765 serde_json::json!([{
6766 "artifact": "notes-tool",
6767 "store": "shey",
6768 "host_path": "/home/dev/.murmur/state/shey",
6769 }])
6770 );
6771 assert_eq!(
6774 events[0]["effective_grants"]["configured_artifacts"],
6775 serde_json::json!(["config-echo"])
6776 );
6777 assert_eq!(
6781 events[0]["effective_grants"]["preopens"],
6782 serde_json::json!([{
6783 "artifact": "notes-tool",
6784 "role": "tool",
6785 "scope": null,
6786 "surface": "whole-workdir",
6787 "runtime_dir_reach": "murmur-runtime",
6788 "extra_roots": [{
6789 "guest_path": "murmur-host/home/dev/.cargo",
6790 "path": "/home/dev/.cargo",
6791 "access": "read",
6792 "source": "host_paths",
6793 }],
6794 }])
6795 );
6796 assert_eq!(events[0]["containment_declared"], "scoped");
6797 assert_eq!(events[0]["containment_achieved"], "scoped");
6798 assert_eq!(events[0]["workdir_exec"], false);
6799 assert_eq!(
6800 events[0]["effective_grants"]["network_allow"],
6801 serde_json::json!(["https://api.example.com"])
6802 );
6803 assert_eq!(
6804 events[0]["effective_grants"]["interpreter_runtime_grants"],
6805 serde_json::json!(["python3: /usr/lib/python3.11 (list_dir)"])
6806 );
6807 }
6808
6809 #[tokio::test]
6810 async fn inference_fields_and_snake_case() {
6811 let dir = tempfile::tempdir().unwrap();
6812 let mut w = make_writer(dir.path()).await;
6813 w.write_inference(
6814 0,
6815 Some(100),
6816 Some(50),
6817 "tool_call".to_string(),
6818 Some("tool_call"),
6819 Some("bash".to_string()),
6820 None,
6821 None,
6822 vec!["msg_0000000000000000000000000000000a".to_string()],
6823 None,
6824 None,
6825 )
6826 .await
6827 .unwrap();
6828 w.flush().await.unwrap();
6829
6830 let events = read_events(dir.path());
6831 let e = &events[0];
6832 assert_eq!(e["event_type"], "inference");
6833 assert_eq!(
6834 e["message_ids"],
6835 serde_json::json!(["msg_0000000000000000000000000000000a"]),
6836 "an agent-loop turn names the messages its request embedded"
6837 );
6838 assert_eq!(e["turn"], 0);
6839 assert_eq!(e["input_tokens"], 100);
6840 assert_eq!(e["output_tokens"], 50);
6841 assert_eq!(e["decision"], "tool_call");
6842 assert_eq!(e["tool_name"], "bash");
6843 assert!(e.get("inputTokens").is_none(), "must use snake_case");
6845 assert!(e.get("toolName").is_none(), "must use snake_case");
6846 for key in [
6848 "input_tokens_actual",
6849 "output_tokens_actual",
6850 "cached_tokens",
6851 "cache_write_tokens",
6852 ] {
6853 assert!(e.get(key).is_none(), "{key} must be omitted, not null or 0");
6854 }
6855 }
6856
6857 #[tokio::test]
6860 async fn inference_writes_only_the_reported_usage_members() {
6861 let dir = tempfile::tempdir().unwrap();
6862 let mut w = make_writer(dir.path()).await;
6863 let usage = DriverUsage {
6864 input_tokens: Some(12043),
6865 output_tokens: None,
6866 cached_tokens: Some(0),
6867 cache_write_tokens: None,
6868 thinking_tokens: None,
6869 };
6870 w.write_inference(
6871 0,
6872 Some(100),
6873 Some(50),
6874 "end_turn".to_string(),
6875 Some("end_turn"),
6876 None,
6877 None,
6878 Some(&usage),
6879 Vec::new(),
6880 None,
6881 None,
6882 )
6883 .await
6884 .unwrap();
6885 w.flush().await.unwrap();
6886
6887 let events = read_events(dir.path());
6888 let e = &events[0];
6889 assert_eq!(e["input_tokens"], 100, "the estimate is untouched");
6890 assert_eq!(e["input_tokens_actual"], 12043);
6891 assert_eq!(e["cached_tokens"], 0, "a reported zero is written");
6892 assert!(e.get("output_tokens_actual").is_none());
6893 assert!(e.get("cache_write_tokens").is_none());
6894 }
6895
6896 #[tokio::test]
6897 async fn failed_inference_writes_its_keys_and_counts_only_what_it_spent() {
6898 let dir = tempfile::tempdir().unwrap();
6899 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
6900 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6901 .await
6902 .unwrap();
6903 let usage = DriverUsage {
6904 input_tokens: Some(7),
6905 ..DriverUsage::default()
6906 };
6907 let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
6908 w.write_failed_inference(FailedInference {
6909 turn: 0,
6910 error_code: INFERENCE_ERROR_CREDENTIAL_REJECTED,
6911 error: &long,
6912 provider_status: Some(401),
6913 usage: Some(&usage),
6914 origin: None,
6915 choice: None,
6916 message_ids: vec!["msg_1".to_string()],
6917 tool_name: None,
6918 spent: None,
6919 settings: None,
6920 retry: RetryNote::default(),
6921 gateway: None,
6922 })
6923 .await
6924 .unwrap();
6925 assert_eq!(w.task_turns(), 0, "an uncounted record moves no counter");
6926 assert_eq!((w.total_turns, w.total_input_tokens), (0, 0));
6927 assert_eq!((w.task_input_tokens, w.task_output_tokens), (0, 0));
6928
6929 w.write_failed_inference(FailedInference {
6930 turn: 1,
6931 error_code: INFERENCE_ERROR_HARNESS_AUTH,
6932 error: "bad login",
6933 provider_status: None,
6934 usage: None,
6935 origin: None,
6936 choice: None,
6937 message_ids: Vec::new(),
6938 tool_name: None,
6939 spent: Some((Some(10), Some(5))),
6940 settings: None,
6941 retry: RetryNote::default(),
6942 gateway: None,
6943 })
6944 .await
6945 .unwrap();
6946 assert_eq!(w.task_turns(), 1);
6947 assert_eq!(w.total_turns, 1);
6948 assert_eq!((w.total_input_tokens, w.total_output_tokens), (10, 5));
6949 assert_eq!((w.task_input_tokens, w.task_output_tokens), (10, 5));
6950 w.flush().await.unwrap();
6951
6952 let events = read_events(dir.path());
6953 let failed: Vec<&Value> = events
6954 .iter()
6955 .filter(|e| e["event_type"] == "inference")
6956 .collect();
6957 let keys = |e: &Value| {
6958 let mut keys: Vec<String> = e.as_object().unwrap().keys().cloned().collect();
6959 keys.sort();
6960 keys
6961 };
6962 assert_eq!(
6963 keys(failed[0]),
6964 [
6965 "decision",
6966 "error",
6967 "error_code",
6968 "event_id",
6969 "event_type",
6970 "input_tokens_actual",
6971 "message_ids",
6972 "parent_id",
6973 "provider_status",
6974 "session_id",
6975 "stop_reason",
6976 "task_id",
6977 "timestamp",
6978 "tool_name",
6979 "turn",
6980 ]
6981 );
6982 assert_eq!(failed[0]["decision"], "error");
6983 assert_eq!(failed[0]["stop_reason"], "error");
6984 assert_eq!(failed[0]["error_code"], "credential_rejected");
6985 assert_eq!(failed[0]["provider_status"], 401);
6986 assert_eq!(failed[0]["input_tokens_actual"], 7);
6987 let error = failed[0]["error"].as_str().unwrap();
6988 assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
6989 assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
6990
6991 assert_eq!(failed[1]["error_code"], "harness_auth");
6992 assert_eq!(failed[1]["error"], "bad login");
6993 assert_eq!(failed[1]["input_tokens"], 10);
6994 assert_eq!(failed[1]["output_tokens"], 5);
6995 assert!(failed[1].get("provider_status").is_none());
6996 assert!(
6997 !dir.path().join("blobs").exists(),
6998 "a failed record stores no blob, even under trace.capture: content"
6999 );
7000 let task_start = events
7002 .iter()
7003 .find(|e| e["event_type"] == "task_start")
7004 .unwrap();
7005 assert_eq!(failed[0]["parent_id"], task_start["event_id"]);
7006 }
7007
7008 #[tokio::test]
7011 async fn gateway_timing_keys_follow_the_gateway() {
7012 let dir = tempfile::tempdir().unwrap();
7013 let mut w = make_writer(dir.path()).await;
7014 let failed = |turn, gateway| FailedInference {
7015 turn,
7016 error_code: INFERENCE_ERROR_DRIVER_FAILED,
7017 error: "stopped",
7018 provider_status: None,
7019 usage: None,
7020 origin: None,
7021 choice: None,
7022 message_ids: Vec::new(),
7023 tool_name: None,
7024 spent: None,
7025 settings: None,
7026 retry: RetryNote::default(),
7027 gateway,
7028 };
7029 w.write_failed_inference(failed(0, None)).await.unwrap();
7030 w.write_failed_inference(failed(1, Some(GatewayOffsets::default())))
7031 .await
7032 .unwrap();
7033 let answered = GatewayOffsets {
7034 reached_ms: Some(4),
7035 responded_ms: Some(90),
7036 };
7037 w.write_failed_inference(failed(2, Some(answered)))
7038 .await
7039 .unwrap();
7040 w.flush().await.unwrap();
7041
7042 let events = read_events(dir.path());
7043 assert!(events[0].get("gateway_reached_ms").is_none());
7044 assert!(events[0].get("upstream_responded_ms").is_none());
7045 assert_eq!(events[1]["gateway_reached_ms"], Value::Null);
7046 assert_eq!(events[1]["upstream_responded_ms"], Value::Null);
7047 assert!(events[1]
7048 .as_object()
7049 .unwrap()
7050 .contains_key("gateway_reached_ms"));
7051 assert!(events[1]
7052 .as_object()
7053 .unwrap()
7054 .contains_key("upstream_responded_ms"));
7055 assert_eq!(events[2]["gateway_reached_ms"], 4);
7056 assert_eq!(events[2]["upstream_responded_ms"], 90);
7057 }
7058
7059 #[tokio::test]
7063 async fn call_deadlines_are_drained_ahead_of_the_next_line() {
7064 let dir = tempfile::tempdir().unwrap();
7065 let mut w = make_writer(dir.path()).await;
7066 let deadlines = crate::limits::CallDeadlines::default();
7067 w.set_call_deadlines(deadlines.clone());
7068 let staged = |component: &str, call: &str| crate::limits::CallDeadline {
7069 component: component.to_string(),
7070 role: crate::limits::ComponentRole::Hook,
7071 call: call.to_string(),
7072 deadline_seconds: 2,
7073 elapsed_ms: 4_000,
7074 };
7075 deadlines.push_for_test(staged("stager", "on-stage"));
7076 w.write_session_start(None, None, Vec::new()).await.unwrap();
7077 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
7078 .await
7079 .unwrap();
7080 deadlines.push_for_test(crate::limits::CallDeadline {
7081 component: "stall-driver".to_string(),
7082 role: crate::limits::ComponentRole::Driver,
7083 call: "run".to_string(),
7084 deadline_seconds: 3,
7085 elapsed_ms: 5_001,
7086 });
7087 w.write_failed_inference(FailedInference {
7088 turn: 0,
7089 error_code: INFERENCE_ERROR_DRIVER_FAILED,
7090 error: "stopped",
7091 provider_status: None,
7092 usage: None,
7093 origin: None,
7094 choice: None,
7095 message_ids: Vec::new(),
7096 tool_name: None,
7097 spent: None,
7098 settings: None,
7099 retry: RetryNote::default(),
7100 gateway: Some(GatewayOffsets::default()),
7101 })
7102 .await
7103 .unwrap();
7104 w.write_session_end("ok").await.unwrap();
7105 w.flush().await.unwrap();
7106
7107 let events = read_events(dir.path());
7108 let types: Vec<&str> = events
7109 .iter()
7110 .map(|e| e["event_type"].as_str().unwrap())
7111 .collect();
7112 assert_eq!(
7113 types,
7114 [
7115 "session_start",
7116 "call_deadline",
7117 "task_start",
7118 "call_deadline",
7119 "inference",
7120 "session_end"
7121 ]
7122 );
7123 assert_eq!(events[1]["call"], "on-stage");
7124 assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
7125 assert!(events[1].get("task_id").is_none(), "outside a task");
7126 let cut = &events[3];
7127 assert_eq!(cut["component"], "stall-driver");
7128 assert_eq!(cut["role"], "driver");
7129 assert_eq!(cut["call"], "run");
7130 assert_eq!(cut["deadline_seconds"], 3);
7131 assert_eq!(cut["elapsed_ms"], 5_001);
7132 assert_eq!(cut["task_id"], "tsk_1");
7133 assert_eq!(cut["parent_id"], events[2]["event_id"]);
7134 assert!(cut["event_id"].as_str().is_some());
7135 assert!(cut["timestamp"].as_u64().is_some());
7136 }
7137
7138 #[tokio::test]
7139 async fn a_failed_hook_record_hangs_off_the_turn_and_keeps_hook_accounting() {
7140 let dir = tempfile::tempdir().unwrap();
7141 let mut w = make_writer(dir.path()).await;
7142 w.write_inference(
7143 0,
7144 Some(10),
7145 Some(5),
7146 "end_turn".to_string(),
7147 Some("end_turn"),
7148 None,
7149 None,
7150 None,
7151 Vec::new(),
7152 None,
7153 None,
7154 )
7155 .await
7156 .unwrap();
7157 let origin = InferenceOrigin {
7158 source: "hook:compactor".to_string(),
7159 model: "m".to_string(),
7160 };
7161 w.write_failed_inference(FailedInference {
7162 turn: 0,
7163 error_code: INFERENCE_ERROR_DRIVER_ERROR,
7164 error: "HTTP 500: boom",
7165 provider_status: Some(500),
7166 usage: None,
7167 origin: Some(&origin),
7168 choice: None,
7169 message_ids: Vec::new(),
7170 tool_name: None,
7171 spent: Some((Some(40), None)),
7172 settings: None,
7173 retry: RetryNote::default(),
7174 gateway: None,
7175 })
7176 .await
7177 .unwrap();
7178 assert_eq!(w.total_turns, 2);
7179 assert_eq!((w.total_input_tokens, w.total_output_tokens), (50, 5));
7180 w.flush().await.unwrap();
7181
7182 let events = read_events(dir.path());
7183 let turn = events.iter().find(|e| e["decision"] == "end_turn").unwrap();
7184 let hook = events.iter().find(|e| e["decision"] == "error").unwrap();
7185 assert_eq!(hook["parent_id"], turn["event_id"]);
7186 assert_eq!(hook["origin"], "hook:compactor");
7187 assert_eq!(hook["model"], "m");
7188 assert_eq!(hook["input_tokens"], 40);
7189 assert!(hook.get("output_tokens").is_none());
7190 }
7191
7192 #[tokio::test]
7196 async fn inference_call_settings_are_written_on_every_kind_of_http_line() {
7197 let dir = tempfile::tempdir().unwrap();
7198 let mut w = make_writer(dir.path()).await;
7199 let turn = InferenceCallSettings {
7200 max_output_tokens: 4096,
7201 input_budget: Some(195_904),
7202 driver_config: Some(serde_json::json!({"reasoning_effort": "high"})),
7203 };
7204 let unconfigured = InferenceCallSettings {
7205 max_output_tokens: 1024,
7206 input_budget: None,
7207 driver_config: None,
7208 };
7209 let hook = InferenceCallSettings {
7210 max_output_tokens: 2000,
7211 input_budget: Some(198_000),
7212 driver_config: Some(serde_json::json!({"reasoning_effort": "low"})),
7213 };
7214 let origin = InferenceOrigin {
7215 source: "hook:compactor".to_string(),
7216 model: "m".to_string(),
7217 };
7218 w.write_turn_inference(
7219 0,
7220 Some(10),
7221 Some(5),
7222 "end_turn".to_string(),
7223 Some("end_turn"),
7224 None,
7225 None,
7226 Vec::new(),
7227 None,
7228 None,
7229 Some(&turn),
7230 RetryNote::default(),
7231 None,
7232 )
7233 .await
7234 .unwrap();
7235 w.write_turn_inference(
7236 1,
7237 Some(10),
7238 Some(5),
7239 "end_turn".to_string(),
7240 Some("end_turn"),
7241 None,
7242 None,
7243 Vec::new(),
7244 None,
7245 None,
7246 Some(&unconfigured),
7247 RetryNote::default(),
7248 None,
7249 )
7250 .await
7251 .unwrap();
7252 w.write_inference(
7253 1,
7254 Some(30),
7255 Some(8),
7256 "end_turn".to_string(),
7257 None,
7258 None,
7259 Some(&origin),
7260 None,
7261 Vec::new(),
7262 None,
7263 Some(&hook),
7264 )
7265 .await
7266 .unwrap();
7267 w.write_failed_inference(FailedInference {
7268 turn: 2,
7269 error_code: INFERENCE_ERROR_DRIVER_ERROR,
7270 error: "HTTP 500: boom",
7271 provider_status: Some(500),
7272 usage: None,
7273 origin: None,
7274 choice: None,
7275 message_ids: Vec::new(),
7276 tool_name: None,
7277 spent: None,
7278 settings: Some(&turn),
7279 retry: RetryNote::default(),
7280 gateway: None,
7281 })
7282 .await
7283 .unwrap();
7284 w.flush().await.unwrap();
7285
7286 let events = read_events(dir.path());
7287 let settings: Vec<(Value, Value, Value, Value)> = events
7288 .iter()
7289 .filter(|e| e["event_type"] == "inference")
7290 .map(|e| {
7291 (
7292 e["decision"].clone(),
7293 e["max_output_tokens"].clone(),
7294 e.get("input_budget")
7295 .cloned()
7296 .expect("input_budget is written, null or not"),
7297 e.get("driver_config")
7298 .cloned()
7299 .expect("driver_config is written, null or not"),
7300 )
7301 })
7302 .collect();
7303 assert_eq!(
7304 settings,
7305 [
7306 (
7307 serde_json::json!("end_turn"),
7308 serde_json::json!(4096),
7309 serde_json::json!(195_904),
7310 serde_json::json!({"reasoning_effort": "high"})
7311 ),
7312 (
7313 serde_json::json!("end_turn"),
7314 serde_json::json!(1024),
7315 Value::Null,
7316 Value::Null
7317 ),
7318 (
7319 serde_json::json!("end_turn"),
7320 serde_json::json!(2000),
7321 serde_json::json!(198_000),
7322 serde_json::json!({"reasoning_effort": "low"})
7323 ),
7324 (
7325 serde_json::json!("error"),
7326 serde_json::json!(4096),
7327 serde_json::json!(195_904),
7328 serde_json::json!({"reasoning_effort": "high"})
7329 ),
7330 ]
7331 );
7332 }
7333
7334 #[tokio::test]
7336 async fn an_inference_line_without_call_settings_writes_neither_key() {
7337 let dir = tempfile::tempdir().unwrap();
7338 let mut w = make_writer(dir.path()).await;
7339 w.write_inference(
7340 0,
7341 Some(10),
7342 Some(5),
7343 "end_turn".to_string(),
7344 None,
7345 None,
7346 None,
7347 None,
7348 Vec::new(),
7349 None,
7350 None,
7351 )
7352 .await
7353 .unwrap();
7354 w.write_failed_inference(FailedInference {
7355 turn: 1,
7356 error_code: INFERENCE_ERROR_HARNESS_AUTH,
7357 error: "bad login",
7358 provider_status: None,
7359 usage: None,
7360 origin: None,
7361 choice: None,
7362 message_ids: Vec::new(),
7363 tool_name: None,
7364 spent: None,
7365 settings: None,
7366 retry: RetryNote::default(),
7367 gateway: None,
7368 })
7369 .await
7370 .unwrap();
7371 w.flush().await.unwrap();
7372
7373 let events = read_events(dir.path());
7374 let lines: Vec<&Value> = events
7375 .iter()
7376 .filter(|e| e["event_type"] == "inference")
7377 .collect();
7378 assert_eq!(lines.len(), 2);
7379 for line in lines {
7380 assert!(line.get("max_output_tokens").is_none(), "{line}");
7381 assert!(line.get("input_budget").is_none(), "{line}");
7382 assert!(line.get("driver_config").is_none(), "{line}");
7383 }
7384 }
7385
7386 fn driver_config_env(value: &str) -> Vec<(String, String)> {
7387 vec![
7388 ("MURMUR_INFERENCE_MODEL".to_string(), "gpt-5".to_string()),
7389 (
7390 "MURMUR_INFERENCE_DRIVER_CONFIG".to_string(),
7391 value.to_string(),
7392 ),
7393 ]
7394 }
7395
7396 fn redact_secret(text: &str) -> String {
7397 text.replace("sk-secret", crate::gateway_credential::REDACTED)
7398 }
7399
7400 #[test]
7401 fn recorded_driver_config_is_none_without_the_variable() {
7402 let env = vec![("MURMUR_INFERENCE_MODEL".to_string(), "gpt-5".to_string())];
7403 assert_eq!(recorded_driver_config(&env, redact_secret), None);
7404 }
7405
7406 #[test]
7407 fn recorded_driver_config_is_the_parsed_object() {
7408 assert_eq!(
7409 recorded_driver_config(
7410 &driver_config_env(r#"{"reasoning_effort":"low"}"#),
7411 redact_secret
7412 ),
7413 Some(serde_json::json!({"reasoning_effort": "low"}))
7414 );
7415 }
7416
7417 #[test]
7418 fn recorded_driver_config_redacts_keys_and_string_leaves_at_any_depth() {
7419 let env = driver_config_env(
7420 r#"{"note":"x-sk-secret-y","nested":{"list":["sk-secret"]},"sk-secret":1}"#,
7421 );
7422 assert_eq!(
7423 recorded_driver_config(&env, redact_secret),
7424 Some(serde_json::json!({
7425 "note": "x-[redacted]-y",
7426 "nested": {"list": ["[redacted]"]},
7427 "[redacted]": 1,
7428 }))
7429 );
7430 }
7431
7432 #[test]
7433 fn recorded_driver_config_replaces_a_number_the_redactor_changes() {
7434 let redact_pin = |text: &str| text.replace("424242", crate::gateway_credential::REDACTED);
7435 let env = driver_config_env(r#"{"pin":424242,"retries":3,"store":false,"seed":null}"#);
7436 assert_eq!(
7437 recorded_driver_config(&env, redact_pin),
7438 Some(serde_json::json!({
7439 "pin": "[redacted]",
7440 "retries": 3,
7441 "store": false,
7442 "seed": null,
7443 }))
7444 );
7445 }
7446
7447 #[test]
7448 fn recorded_driver_config_records_text_that_is_not_json_as_a_redacted_string() {
7449 assert_eq!(
7450 recorded_driver_config(&driver_config_env("effort=sk-secret"), redact_secret),
7451 Some(Value::String("effort=[redacted]".to_string()))
7452 );
7453 }
7454
7455 #[tokio::test]
7456 async fn tool_call_fields() {
7457 let dir = tempfile::tempdir().unwrap();
7458 let mut w = make_writer(dir.path()).await;
7459 w.write_tool_call(
7460 1,
7461 "bash".to_string(),
7462 None,
7463 serde_json::json!({"command": "echo hi"}),
7464 42,
7465 "hi\n",
7466 100,
7467 55,
7468 "ok".to_string(),
7469 None,
7470 None,
7471 None,
7472 )
7473 .await
7474 .unwrap();
7475 w.flush().await.unwrap();
7476
7477 let events = read_events(dir.path());
7478 let e = &events[0];
7479 assert_eq!(e["event_type"], "tool_call");
7480 assert_eq!(e["turn"], 1);
7481 assert_eq!(e["tool_name"], "bash");
7482 assert_eq!(e["input_bytes"], 42);
7483 assert_eq!(e["output_bytes"], 100);
7484 assert_eq!(e["duration_ms"], 55);
7485 assert_eq!(e["status"], "ok");
7486 assert!(
7487 e.get("state_effect").is_none(),
7488 "state_effect must be omitted when the tool declared nothing"
7489 );
7490 }
7491
7492 #[tokio::test]
7493 async fn tool_call_records_declared_state_effect() {
7494 let dir = tempfile::tempdir().unwrap();
7495 let mut w = make_writer(dir.path()).await;
7496 w.write_tool_call(
7497 0,
7498 "murmur-tool-editor".to_string(),
7499 None,
7500 serde_json::json!({"operation": "read_file", "path": "a.txt"}),
7501 10,
7502 "hi\n",
7503 3,
7504 5,
7505 "ok".to_string(),
7506 Some("read".to_string()),
7507 None,
7508 None,
7509 )
7510 .await
7511 .unwrap();
7512 w.flush().await.unwrap();
7513
7514 let events = read_events(dir.path());
7515 assert_eq!(
7516 events[0]["state_effect"], "read",
7517 "declared state_effect must be recorded verbatim on the tool_call event"
7518 );
7519 }
7520
7521 #[tokio::test]
7522 async fn tool_call_records_declared_resource_id() {
7523 let dir = tempfile::tempdir().unwrap();
7524 let mut w = make_writer(dir.path()).await;
7525 w.write_tool_call(
7526 0,
7527 "murmur-tool-code-graph".to_string(),
7528 None,
7529 serde_json::json!({"symbol": "Foo::bar"}),
7530 10,
7531 "hi\n",
7532 3,
7533 5,
7534 "ok".to_string(),
7535 Some("read".to_string()),
7536 Some("sym:Foo".to_string()),
7537 None,
7538 )
7539 .await
7540 .unwrap();
7541 w.flush().await.unwrap();
7542
7543 let events = read_events(dir.path());
7544 assert_eq!(
7545 events[0]["resource_id"], "sym:Foo",
7546 "declared resource_id must reach trace.jsonl verbatim, unparsed"
7547 );
7548 }
7549
7550 #[tokio::test]
7551 async fn tool_call_omits_undeclared_resource_id() {
7552 let dir = tempfile::tempdir().unwrap();
7553 let mut w = make_writer(dir.path()).await;
7554 w.write_tool_call(
7555 0,
7556 "bash".to_string(),
7557 None,
7558 serde_json::json!({"command": "echo hi"}),
7559 10,
7560 "hi\n",
7561 3,
7562 5,
7563 "ok".to_string(),
7564 None,
7565 None,
7566 None,
7567 )
7568 .await
7569 .unwrap();
7570 w.flush().await.unwrap();
7571
7572 let events = read_events(dir.path());
7573 assert!(
7574 events[0].get("resource_id").is_none(),
7575 "resource_id must be omitted entirely (not null) when the tool declared nothing"
7576 );
7577 }
7578
7579 #[tokio::test]
7580 async fn shell_fields() {
7581 let dir = tempfile::tempdir().unwrap();
7582 let mut w = make_writer(dir.path()).await;
7583 w.write_shell(
7584 0,
7585 "/usr/bin/echo".to_string(),
7586 "echo hi".to_string(),
7587 0,
7588 7,
7589 0,
7590 10,
7591 None,
7592 )
7593 .await
7594 .unwrap();
7595 w.flush().await.unwrap();
7596
7597 let events = read_events(dir.path());
7598 let e = &events[0];
7599 assert_eq!(e["event_type"], "shell");
7600 assert_eq!(e["binary"], "/usr/bin/echo");
7603 assert_eq!(e["command"], "echo hi");
7604 assert_eq!(e["exit_code"], 0);
7605 assert_eq!(e["stdout_bytes"], 7);
7606 assert_eq!(e["stderr_bytes"], 0);
7607 assert_eq!(e["duration_ms"], 10);
7608 }
7609
7610 #[tokio::test]
7611 async fn compaction_fields() {
7612 let dir = tempfile::tempdir().unwrap();
7613 let mut w = make_writer(dir.path()).await;
7614 w.write_compaction(3, 80000, OccupancyBasis::Actual, 20000)
7615 .await
7616 .unwrap();
7617 w.write_compaction(4, 30000, OccupancyBasis::Estimate, 10000)
7618 .await
7619 .unwrap();
7620 w.flush().await.unwrap();
7621
7622 let events = read_events(dir.path());
7623 let e = &events[0];
7624 assert_eq!(e["event_type"], "compaction");
7625 assert_eq!(e["turn"], 3);
7626 assert_eq!(e["tokens_before"], 80000);
7627 assert_eq!(e["tokens_before_source"], "actual");
7628 assert_eq!(e["tokens_after"], 20000);
7629 assert_eq!(events[1]["tokens_before_source"], "estimate");
7630 }
7631
7632 #[tokio::test]
7633 async fn hook_dispatch_error_fields() {
7634 let dir = tempfile::tempdir().unwrap();
7635 let mut w = make_writer(dir.path()).await;
7636 w.write_hook_dispatch_error("my-hook", "on-tool-call", "write-manifests")
7637 .await
7638 .unwrap();
7639 w.flush().await.unwrap();
7640
7641 let events = read_events(dir.path());
7642 let e = &events[0];
7643 assert_eq!(e["event_type"], "hook_dispatch_error");
7644 assert_eq!(e["session_id"], "test-session-id");
7645 assert_eq!(e["hook_name"], "my-hook");
7646 assert_eq!(e["event"], "on-tool-call");
7647 assert_eq!(e["arm"], "write-manifests");
7648 assert!(e["timestamp"].as_u64().unwrap() > 0);
7649 assert!(e.get("hookName").is_none(), "must use snake_case");
7651 }
7652
7653 #[tokio::test]
7654 async fn session_end_fields_and_snake_case() {
7655 let dir = tempfile::tempdir().unwrap();
7656 let mut w = make_writer(dir.path()).await;
7657 w.write_inference(
7658 0,
7659 Some(100),
7660 Some(50),
7661 "tool_call".to_string(),
7662 Some("tool_call"),
7663 None,
7664 None,
7665 None,
7666 Vec::new(),
7667 None,
7668 None,
7669 )
7670 .await
7671 .unwrap();
7672 w.write_tool_call(
7673 0,
7674 "bash".to_string(),
7675 None,
7676 serde_json::json!({"command": "ls"}),
7677 10,
7678 "file.txt\n",
7679 20,
7680 5,
7681 "ok".to_string(),
7682 None,
7683 None,
7684 None,
7685 )
7686 .await
7687 .unwrap();
7688 w.write_shell(0, "/bin/ls".to_string(), "ls".to_string(), 0, 3, 0, 2, None)
7689 .await
7690 .unwrap();
7691 w.write_session_end("ok").await.unwrap();
7692 w.flush().await.unwrap();
7693
7694 let events = read_events(dir.path());
7695 let se = events.last().unwrap();
7696 assert_eq!(se["event_type"], "session_end");
7697 assert_eq!(se["total_turns"], 1);
7698 assert_eq!(se["total_input_tokens"], 100);
7699 assert_eq!(se["total_output_tokens"], 50);
7700 assert_eq!(se["total_tool_calls"], 1);
7701 assert_eq!(se["total_shell_calls"], 1);
7702 assert_eq!(se["exit_status"], "ok");
7703 assert!(se["duration_ms"].as_u64().is_some());
7704 assert!(se.get("exitStatus").is_none(), "must use snake_case");
7706 assert!(se.get("totalTurns").is_none(), "must use snake_case");
7707 }
7708
7709 #[tokio::test]
7710 async fn write_session_end_if_not_ended_is_noop_when_already_ended() {
7711 let dir = tempfile::tempdir().unwrap();
7712 let mut w = make_writer(dir.path()).await;
7713 w.write_session_start(Some(10), None, vec![]).await.unwrap();
7714 w.write_session_end("ok").await.unwrap();
7715 w.write_session_end_if_not_ended("failed").await.unwrap();
7716 w.flush().await.unwrap();
7717
7718 let events = read_events(dir.path());
7719 assert_eq!(
7720 events
7721 .iter()
7722 .filter(|e| e["event_type"] == "session_end")
7723 .count(),
7724 1,
7725 "session_end should appear exactly once"
7726 );
7727 }
7728
7729 #[tokio::test]
7730 async fn write_session_end_if_not_ended_writes_when_started_not_ended() {
7731 let dir = tempfile::tempdir().unwrap();
7732 let mut w = make_writer(dir.path()).await;
7733 w.write_session_start(Some(10), None, vec![]).await.unwrap();
7734 w.write_session_end_if_not_ended("failed").await.unwrap();
7736 w.flush().await.unwrap();
7737
7738 let events = read_events(dir.path());
7739 let se = events
7740 .iter()
7741 .find(|e| e["event_type"] == "session_end")
7742 .unwrap();
7743 assert_eq!(se["exit_status"], "failed");
7744 }
7745
7746 #[tokio::test]
7747 async fn no_session_end_written_when_session_never_started() {
7748 let dir = tempfile::tempdir().unwrap();
7749 let mut w = make_writer(dir.path()).await;
7750 w.write_session_end_if_not_ended("failed").await.unwrap();
7752 w.flush().await.unwrap();
7753
7754 let events = read_events(dir.path());
7755 assert!(
7756 events.iter().all(|e| e["event_type"] != "session_end"),
7757 "session_end must not appear when session_start was never written"
7758 );
7759 }
7760
7761 #[tokio::test]
7762 async fn session_id_on_every_event() {
7763 let dir = tempfile::tempdir().unwrap();
7764 let mut w = make_writer(dir.path()).await;
7765 w.write_session_start(Some(10), None, vec!["bash".to_string()])
7766 .await
7767 .unwrap();
7768 w.write_inference(
7769 0,
7770 Some(10),
7771 Some(5),
7772 "end_turn".to_string(),
7773 Some("end_turn"),
7774 None,
7775 None,
7776 None,
7777 Vec::new(),
7778 None,
7779 None,
7780 )
7781 .await
7782 .unwrap();
7783 w.write_session_end("ok").await.unwrap();
7784 w.flush().await.unwrap();
7785
7786 let events = read_events(dir.path());
7787 for e in &events {
7788 assert_eq!(e["session_id"], "test-session-id");
7789 }
7790 }
7791
7792 #[tokio::test]
7793 async fn counts_match_per_event_sum() {
7794 let dir = tempfile::tempdir().unwrap();
7795 let mut w = make_writer(dir.path()).await;
7796 w.write_session_start(Some(10), None, vec![]).await.unwrap();
7797 w.write_inference(
7798 0,
7799 Some(50),
7800 Some(25),
7801 "tool_call".to_string(),
7802 Some("tool_call"),
7803 Some("bash".to_string()),
7804 None,
7805 None,
7806 vec!["msg_0000000000000000000000000000000a".to_string()],
7807 None,
7808 None,
7809 )
7810 .await
7811 .unwrap();
7812 w.write_tool_call(
7813 0,
7814 "bash".to_string(),
7815 None,
7816 serde_json::json!({"command": "echo"}),
7817 5,
7818 "ok",
7819 10,
7820 3,
7821 "ok".to_string(),
7822 None,
7823 None,
7824 None,
7825 )
7826 .await
7827 .unwrap();
7828 w.write_shell(
7829 0,
7830 "/bin/echo".to_string(),
7831 "echo".to_string(),
7832 0,
7833 4,
7834 0,
7835 1,
7836 None,
7837 )
7838 .await
7839 .unwrap();
7840 w.write_inference(
7841 1,
7842 Some(60),
7843 Some(30),
7844 "end_turn".to_string(),
7845 Some("end_turn"),
7846 None,
7847 None,
7848 None,
7849 Vec::new(),
7850 None,
7851 None,
7852 )
7853 .await
7854 .unwrap();
7855 w.write_session_end("ok").await.unwrap();
7856 w.flush().await.unwrap();
7857
7858 let events = read_events(dir.path());
7859 let inference_count = events
7860 .iter()
7861 .filter(|e| e["event_type"] == "inference")
7862 .count();
7863 let tool_count = events
7864 .iter()
7865 .filter(|e| e["event_type"] == "tool_call")
7866 .count();
7867 let shell_count = events.iter().filter(|e| e["event_type"] == "shell").count();
7868
7869 let se = events
7870 .iter()
7871 .find(|e| e["event_type"] == "session_end")
7872 .unwrap();
7873 assert_eq!(
7874 se["total_turns"].as_u64().unwrap() as usize,
7875 inference_count
7876 );
7877 assert_eq!(
7878 se["total_tool_calls"].as_u64().unwrap() as usize,
7879 tool_count
7880 );
7881 assert_eq!(
7882 se["total_shell_calls"].as_u64().unwrap() as usize,
7883 shell_count
7884 );
7885 }
7886
7887 #[tokio::test]
7888 async fn tool_call_input_always_present() {
7889 let dir = tempfile::tempdir().unwrap();
7890 let mut w = make_writer(dir.path()).await; w.write_tool_call(
7892 0,
7893 "bash".to_string(),
7894 None,
7895 serde_json::json!({"command": "ls -la"}),
7896 20,
7897 "total 8\n",
7898 80,
7899 12,
7900 "ok".to_string(),
7901 None,
7902 None,
7903 None,
7904 )
7905 .await
7906 .unwrap();
7907 w.flush().await.unwrap();
7908
7909 let events = read_events(dir.path());
7910 let e = &events[0];
7911 assert_eq!(e["event_type"], "tool_call");
7912 assert_eq!(e["input"]["command"], "ls -la");
7913 assert!(e.get("input").is_some(), "input must always be present");
7914 }
7915
7916 async fn tool_call_with_error(
7918 dir: &std::path::Path,
7919 capture: TraceCapture,
7920 error: Option<&str>,
7921 ) -> Value {
7922 let mut w = make_writer_with_opts(dir, capture).await;
7923 w.write_tool_call(
7924 0,
7925 "tool-fixture".to_string(),
7926 Some("toolu_1".to_string()),
7927 serde_json::json!({}),
7928 2,
7929 "native tool execution failed",
7930 28,
7931 3,
7932 "error".to_string(),
7933 None,
7934 None,
7935 error,
7936 )
7937 .await
7938 .unwrap();
7939 w.flush().await.unwrap();
7940 read_events(dir).remove(0)
7941 }
7942
7943 #[tokio::test]
7944 async fn tool_call_error_is_written_without_output_under_meta() {
7945 let dir = tempfile::tempdir().unwrap();
7946 let text = "sandbox: native tool enforcement setup failed before exec: Landlock: \
7947 Too many open files (os error 24)";
7948 let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(text)).await;
7949 assert_eq!(e["error"], text);
7950 assert!(e.get("output").is_none(), "meta stores no output: {e}");
7951 }
7952
7953 #[tokio::test]
7954 async fn tool_call_error_is_written_under_every_capture() {
7955 for capture in [
7956 TraceCapture::None,
7957 TraceCapture::Meta,
7958 TraceCapture::Content,
7959 ] {
7960 let dir = tempfile::tempdir().unwrap();
7961 let e = tool_call_with_error(dir.path(), capture, Some("spawn failed")).await;
7962 assert_eq!(e["error"], "spawn failed", "{capture:?}: {e}");
7963 }
7964 }
7965
7966 #[tokio::test]
7967 async fn tool_call_error_is_capped_on_a_char_boundary() {
7968 let dir = tempfile::tempdir().unwrap();
7969 let long = format!("a{}", "é".repeat(2_999));
7972 assert_eq!(long.chars().count(), 3_000);
7973 let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(&long)).await;
7974 let error = e["error"].as_str().unwrap();
7975 assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
7976 assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
7977 assert!(long.starts_with(error));
7978 }
7979
7980 #[tokio::test]
7981 async fn tool_call_error_redacts_a_peer_handle() {
7982 let dir = tempfile::tempdir().unwrap();
7983 let token = crate::peer_handoff::mint(
7984 &crate::peer_handoff::PeerMintKey::generate().unwrap(),
7985 &crate::peer_handoff::HandlePayload {
7986 v: 1,
7987 iss: "ses_1".to_string(),
7988 p: "report.md".to_string(),
7989 exp: timestamp_ms() + 3_600_000,
7990 n: "0123456789abcdef0123456789abcdef".to_string(),
7991 },
7992 "a@b",
7993 )
7994 .unwrap();
7995 let text = format!("fetch-peer-file {token} failed");
7996 let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(&text)).await;
7997 let id = crate::peer_handoff::handle_id(&token);
7998 assert_eq!(e["error"], format!("fetch-peer-file <handle:{id}> failed"));
7999 }
8000
8001 #[tokio::test]
8002 async fn tool_call_without_an_error_has_no_error_key() {
8003 let dir = tempfile::tempdir().unwrap();
8004 let e = tool_call_with_error(dir.path(), TraceCapture::Meta, None).await;
8005 assert!(e.get("error").is_none(), "{e}");
8006 }
8007
8008 #[tokio::test]
8009 async fn tool_call_output_absent_by_default() {
8010 let dir = tempfile::tempdir().unwrap();
8011 let mut w = make_writer(dir.path()).await; w.write_tool_call(
8013 0,
8014 "bash".to_string(),
8015 None,
8016 serde_json::json!({"command": "ls"}),
8017 10,
8018 "file.txt\n",
8019 90,
8020 8,
8021 "ok".to_string(),
8022 None,
8023 None,
8024 None,
8025 )
8026 .await
8027 .unwrap();
8028 w.flush().await.unwrap();
8029
8030 let events = read_events(dir.path());
8031 let e = &events[0];
8032 assert!(
8033 e.get("output").is_none(),
8034 "output must be absent under a capture mode that stores no bodies"
8035 );
8036 assert_eq!(e["output_bytes"], 90, "output_bytes must still be recorded");
8037 }
8038
8039 #[tokio::test]
8040 async fn skill_call_writes_skill_call_event() {
8041 let dir = tempfile::tempdir().unwrap();
8042 let mut w = make_writer(dir.path()).await;
8043 w.write_skill_call(
8044 2,
8045 "my-skill".to_string(),
8046 &LockOrigin::Operator,
8047 1024,
8048 8,
8049 "ok".to_string(),
8050 )
8051 .await
8052 .unwrap();
8053 w.flush().await.unwrap();
8054
8055 let events = read_events(dir.path());
8056 let e = &events[0];
8057 assert_eq!(e["event_type"], "skill_call");
8058 assert_eq!(e["turn"], 2);
8059 assert_eq!(e["skill_name"], "my-skill");
8060 assert_eq!(e["output_bytes"], 1024);
8061 assert_eq!(e["duration_ms"], 8);
8062 assert_eq!(e["status"], "ok");
8063 }
8064
8065 #[tokio::test]
8066 async fn skill_call_does_not_increment_total_tool_calls() {
8067 let dir = tempfile::tempdir().unwrap();
8068 let mut w = make_writer(dir.path()).await;
8069 w.write_session_start(Some(10), None, vec![]).await.unwrap();
8070 w.write_tool_call(
8071 0,
8072 "real-tool".to_string(),
8073 None,
8074 serde_json::json!({}),
8075 2,
8076 "data",
8077 4,
8078 5,
8079 "ok".to_string(),
8080 None,
8081 None,
8082 None,
8083 )
8084 .await
8085 .unwrap();
8086 w.write_skill_call(
8087 1,
8088 "my-skill".to_string(),
8089 &LockOrigin::Operator,
8090 512,
8091 3,
8092 "ok".to_string(),
8093 )
8094 .await
8095 .unwrap();
8096 w.write_session_end("ok").await.unwrap();
8097 w.flush().await.unwrap();
8098
8099 let events = read_events(dir.path());
8100 let se = events
8101 .iter()
8102 .find(|e| e["event_type"] == "session_end")
8103 .unwrap();
8104 assert_eq!(
8105 se["total_tool_calls"], 1,
8106 "skill_call must not inflate total_tool_calls"
8107 );
8108 }
8109
8110 #[tokio::test]
8111 async fn tool_call_output_present_when_opted_in() {
8112 let dir = tempfile::tempdir().unwrap();
8113 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
8114 w.write_tool_call(
8115 0,
8116 "bash".to_string(),
8117 None,
8118 serde_json::json!({"command": "echo hello"}),
8119 22,
8120 "hello\n",
8121 60,
8122 5,
8123 "ok".to_string(),
8124 None,
8125 None,
8126 None,
8127 )
8128 .await
8129 .unwrap();
8130 w.flush().await.unwrap();
8131
8132 let events = read_events(dir.path());
8133 let e = &events[0];
8134 assert_eq!(
8135 e["output"].as_str().unwrap(),
8136 "hello\n",
8137 "output must be present under capture: content"
8138 );
8139 assert_eq!(e["output_bytes"], 60);
8140 }
8141
8142 #[tokio::test]
8143 async fn task_end_carries_reopen_count() {
8144 let dir = tempfile::tempdir().unwrap();
8145 let mut w = make_writer(dir.path()).await;
8146 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8147 .await
8148 .unwrap();
8149 w.write_task_end("tsk_1", "ok", 2, Vec::new(), None, None)
8150 .await
8151 .unwrap();
8152 w.flush().await.unwrap();
8153
8154 let events = read_events(dir.path());
8155 let end = events
8156 .iter()
8157 .find(|e| e["event_type"] == "task_end")
8158 .unwrap();
8159 assert_eq!(end["task_id"], "tsk_1");
8160 assert_eq!(end["exit_status"], "ok");
8161 assert_eq!(end["reopen_count"], 2);
8162 assert_eq!(
8163 end["capped_turns"], 0,
8164 "always present, 0 when no turn was capped"
8165 );
8166 }
8167
8168 #[tokio::test]
8171 async fn task_end_counts_capped_turns_per_task() {
8172 let dir = tempfile::tempdir().unwrap();
8173 let mut w = make_writer(dir.path()).await;
8174 for task in ["tsk_1", "tsk_2"] {
8175 w.write_task_start(task, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8176 .await
8177 .unwrap();
8178 let stops: &[&str] = if task == "tsk_1" {
8179 &["max_tokens", "tool_call", "max_tokens", "end_turn"]
8180 } else {
8181 &["end_turn"]
8182 };
8183 for (turn, stop) in stops.iter().enumerate() {
8184 let decision = if *stop == "max_tokens" {
8185 "capped"
8186 } else {
8187 stop
8188 };
8189 w.write_turn_inference(
8190 u32::try_from(turn).unwrap(),
8191 Some(10),
8192 Some(5),
8193 decision.to_string(),
8194 Some(stop),
8195 None,
8196 None,
8197 Vec::new(),
8198 None,
8199 None,
8200 None,
8201 RetryNote::default(),
8202 None,
8203 )
8204 .await
8205 .unwrap();
8206 }
8207 w.write_task_end(task, "ok", 0, Vec::new(), None, None)
8208 .await
8209 .unwrap();
8210 }
8211 w.flush().await.unwrap();
8212
8213 let capped: Vec<_> = read_events(dir.path())
8214 .iter()
8215 .filter(|e| e["event_type"] == "task_end")
8216 .map(|e| e["capped_turns"].clone())
8217 .collect();
8218 assert_eq!(capped, vec![serde_json::json!(2), serde_json::json!(0)]);
8219 }
8220
8221 #[tokio::test]
8224 async fn task_end_carries_keep_working_only_when_set() {
8225 let dir = tempfile::tempdir().unwrap();
8226 let mut w = make_writer(dir.path()).await;
8227 for (task_id, keep_working) in [
8228 (
8229 "tsk_1",
8230 Some(KeepWorkingSummary {
8231 used: 2,
8232 limit: 2,
8233 stopped_by: Some("limit"),
8234 }),
8235 ),
8236 (
8237 "tsk_2",
8238 Some(KeepWorkingSummary {
8239 used: 1,
8240 limit: 5,
8241 stopped_by: None,
8242 }),
8243 ),
8244 ("tsk_3", None),
8245 ] {
8246 w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8247 .await
8248 .unwrap();
8249 w.write_task_end(task_id, "ok", 0, Vec::new(), keep_working, None)
8250 .await
8251 .unwrap();
8252 }
8253 w.flush().await.unwrap();
8254
8255 let events = read_events(dir.path());
8256 let ends: Vec<_> = events
8257 .iter()
8258 .filter(|e| e["event_type"] == "task_end")
8259 .collect();
8260 assert_eq!(
8261 ends[0]["keep_working"],
8262 serde_json::json!({"used": 2, "limit": 2, "stopped_by": "limit"})
8263 );
8264 assert_eq!(
8265 ends[1]["keep_working"],
8266 serde_json::json!({"used": 1, "limit": 5, "stopped_by": null})
8267 );
8268 assert!(ends[2].get("keep_working").is_none(), "{}", ends[2]);
8269 }
8270
8271 #[tokio::test]
8274 async fn keep_working_record_carries_its_fields_and_feeds_session_status() {
8275 let dir = tempfile::tempdir().unwrap();
8276 let mut w = make_writer(dir.path()).await;
8277 let status = std::sync::Arc::new(crate::session_status::SessionStatus::new(
8278 std::time::Instant::now(),
8279 false,
8280 None,
8281 None,
8282 0,
8283 Some(3),
8284 None,
8285 ));
8286 w.set_session_status(std::sync::Arc::clone(&status));
8287 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8288 .await
8289 .unwrap();
8290 w.write_keep_working("tsk_1", 1, 3, Some(4), 66)
8291 .await
8292 .unwrap();
8293 w.write_keep_working("tsk_1", 2, 3, None, 66).await.unwrap();
8294 w.flush().await.unwrap();
8295
8296 let events = read_events(dir.path());
8297 let start = events
8298 .iter()
8299 .find(|e| e["event_type"] == "task_start")
8300 .unwrap();
8301 let records: Vec<_> = events
8302 .iter()
8303 .filter(|e| e["event_type"] == "keep_working")
8304 .collect();
8305 assert_eq!(records.len(), 2);
8306 for (record, (number, turns)) in records
8307 .iter()
8308 .zip([(1, serde_json::json!(4)), (2, serde_json::Value::Null)])
8309 {
8310 assert_eq!(record["task_id"], "tsk_1");
8311 assert_eq!(record["number"], number);
8312 assert_eq!(record["limit"], 3);
8313 assert_eq!(record["turns_remaining"], turns);
8314 assert_eq!(record["message_bytes"], 66);
8315 assert_eq!(record["parent_id"], start["event_id"]);
8316 assert!(record["event_id"].is_string());
8317 }
8318 let spend = crate::session_status::SpendReading {
8319 used: 0,
8320 ceiling: None,
8321 machine: None,
8322 };
8323 assert_eq!(
8324 serde_json::to_value(status.snapshot(spend)).unwrap()["keep_working"],
8325 serde_json::json!({"used": 2, "left": 1})
8326 );
8327 }
8328
8329 #[tokio::test]
8331 async fn task_end_carries_done_check_only_when_set() {
8332 let dir = tempfile::tempdir().unwrap();
8333 let mut w = make_writer(dir.path()).await;
8334 for (task_id, done_check) in [
8335 (
8336 "tsk_1",
8337 Some(DoneCheckSummary {
8338 runs: 3,
8339 reopens: 2,
8340 limit: 2,
8341 met: Some(false),
8342 stopped_by: Some("limit"),
8343 }),
8344 ),
8345 (
8346 "tsk_2",
8347 Some(DoneCheckSummary {
8348 runs: 0,
8349 reopens: 0,
8350 limit: 1,
8351 met: None,
8352 stopped_by: None,
8353 }),
8354 ),
8355 ("tsk_3", None),
8356 ] {
8357 w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8358 .await
8359 .unwrap();
8360 w.write_task_end(task_id, "ok", 0, Vec::new(), None, done_check)
8361 .await
8362 .unwrap();
8363 }
8364 w.flush().await.unwrap();
8365
8366 let events = read_events(dir.path());
8367 let ends: Vec<_> = events
8368 .iter()
8369 .filter(|e| e["event_type"] == "task_end")
8370 .collect();
8371 assert_eq!(
8372 ends[0]["done_check"],
8373 serde_json::json!({"runs": 3, "reopens": 2, "limit": 2, "met": false,
8374 "stopped_by": "limit"})
8375 );
8376 assert_eq!(
8377 ends[1]["done_check"],
8378 serde_json::json!({"runs": 0, "reopens": 0, "limit": 1, "met": null,
8379 "stopped_by": null})
8380 );
8381 assert!(ends[2].get("done_check").is_none(), "{}", ends[2]);
8382 }
8383
8384 #[tokio::test]
8387 async fn done_check_record_carries_its_fields_and_feeds_session_status() {
8388 let dir = tempfile::tempdir().unwrap();
8389 let mut w = make_writer(dir.path()).await;
8390 let status = std::sync::Arc::new(crate::session_status::SessionStatus::new(
8391 std::time::Instant::now(),
8392 false,
8393 None,
8394 None,
8395 0,
8396 None,
8397 Some(3),
8398 ));
8399 w.set_session_status(std::sync::Arc::clone(&status));
8400 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8401 .await
8402 .unwrap();
8403 let mut run = DoneCheckRun {
8404 task_id: "tsk_1",
8405 number: 1,
8406 command: "bash -c 'exit 1'",
8407 outcome: "failed",
8408 exit_code: Some(1),
8409 duration_ms: 12,
8410 stdout_bytes: 5,
8411 stderr_bytes: 0,
8412 truncated: false,
8413 resource_limit: None,
8414 reopened: true,
8415 reopens_left: 2,
8416 turns_remaining: Some(4),
8417 };
8418 w.write_done_check(&run, 1).await.unwrap();
8419 run.number = 2;
8420 run.outcome = "timed_out";
8421 run.exit_code = None;
8422 run.reopened = false;
8423 run.turns_remaining = None;
8424 w.write_done_check(&run, 1).await.unwrap();
8425 w.flush().await.unwrap();
8426
8427 let text = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
8428 let line = text
8429 .lines()
8430 .find(|line| line.contains("\"done_check\""))
8431 .unwrap();
8432 let order: Vec<usize> = [
8433 "event_type",
8434 "event_id",
8435 "parent_id",
8436 "session_id",
8437 "timestamp",
8438 "task_id",
8439 "number",
8440 "command",
8441 "outcome",
8442 "exit_code",
8443 "duration_ms",
8444 "stdout_bytes",
8445 "stderr_bytes",
8446 "truncated",
8447 "resource_limit",
8448 "reopened",
8449 "reopens_left",
8450 "turns_remaining",
8451 ]
8452 .iter()
8453 .map(|key| line.find(&format!("\"{key}\":")).unwrap())
8454 .collect();
8455 assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{line}");
8456
8457 let events = read_events(dir.path());
8458 let start = events
8459 .iter()
8460 .find(|e| e["event_type"] == "task_start")
8461 .unwrap();
8462 let records: Vec<_> = events
8463 .iter()
8464 .filter(|e| e["event_type"] == "done_check")
8465 .collect();
8466 assert_eq!(records.len(), 2);
8467 assert_eq!(records[0]["parent_id"], start["event_id"]);
8468 assert_eq!(records[0]["command"], "bash -c 'exit 1'");
8469 assert_eq!(records[0]["exit_code"], 1);
8470 assert_eq!(records[0]["reopened"], true);
8471 assert_eq!(records[0]["turns_remaining"], 4);
8472 assert_eq!(records[1]["outcome"], "timed_out");
8473 assert_eq!(records[1]["exit_code"], serde_json::Value::Null);
8474 assert_eq!(records[1]["resource_limit"], serde_json::Value::Null);
8475 assert_eq!(records[1]["turns_remaining"], serde_json::Value::Null);
8476 let spend = crate::session_status::SpendReading {
8477 used: 0,
8478 ceiling: None,
8479 machine: None,
8480 };
8481 assert_eq!(
8482 serde_json::to_value(status.snapshot(spend)).unwrap()["done_check"],
8483 serde_json::json!({"used": 1, "left": 2})
8484 );
8485 }
8486
8487 #[tokio::test]
8490 async fn task_end_names_the_commands_still_running() {
8491 let dir = tempfile::tempdir().unwrap();
8492 let mut w = make_writer(dir.path()).await;
8493 for (task_id, running) in [
8494 ("tsk_1", vec!["wrk_0a".to_string(), "wrk_0b".to_string()]),
8495 ("tsk_2", Vec::new()),
8496 ] {
8497 w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8498 .await
8499 .unwrap();
8500 w.write_task_end(task_id, "ok", 0, running, None, None)
8501 .await
8502 .unwrap();
8503 }
8504 w.flush().await.unwrap();
8505
8506 let events = read_events(dir.path());
8507 let ends: Vec<_> = events
8508 .iter()
8509 .filter(|e| e["event_type"] == "task_end")
8510 .collect();
8511 assert_eq!(
8512 ends[0]["running_commands"],
8513 serde_json::json!(["wrk_0a", "wrk_0b"])
8514 );
8515 assert!(ends[1].get("running_commands").is_none(), "{}", ends[1]);
8516 }
8517
8518 #[tokio::test]
8522 async fn shell_completed_names_its_delivery_and_exit_time() {
8523 let dir = tempfile::tempdir().unwrap();
8524 let mut w = make_writer(dir.path()).await;
8525 let appender = ResourceTraceAppender::open(
8526 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
8527 "test-session-id".to_string(),
8528 w.session_event_id().to_string(),
8529 )
8530 .await
8531 .unwrap();
8532 let completion = crate::detached::DetachedCompletion {
8533 work_id: "wrk_0a".to_string(),
8534 binary: "/usr/bin/bash".to_string(),
8535 command: "sleep 3".to_string(),
8536 exit_code: 0,
8537 duration_ms: 3_000,
8538 exited_at_ms: 1_750,
8539 output_path: "/records/logs/wrk_0a.log".to_string(),
8540 output_bytes: 10,
8541 resource_limit: None,
8542 output_holders: crate::shell::OutputHolders::Closed,
8543 context_id: "ctx_1".to_string(),
8544 provenance: crate::origin::TaskProvenance::derive(
8545 crate::origin::TaskOrigin::Completion,
8546 None,
8547 ),
8548 error: None,
8549 };
8550 w.write_session_start(None, None, Vec::new()).await.unwrap();
8551 w.flush().await.unwrap();
8552 appender.write_shell_completed(&completion).await;
8553 w.write_shell_completed(&completion, "tsk_2").await.unwrap();
8554 w.flush().await.unwrap();
8555
8556 let events = read_events(dir.path());
8557 let completed: Vec<_> = events
8558 .iter()
8559 .filter(|e| e["event_type"] == "shell_completed")
8560 .collect();
8561 assert_eq!(completed.len(), 2);
8562 assert_eq!(completed[0]["delivered_via"], "background_command");
8563 assert!(
8564 completed[0].get("completion_task_id").is_none(),
8565 "{}",
8566 completed[0]
8567 );
8568 assert_eq!(completed[1]["delivered_via"], "task");
8569 assert_eq!(completed[1]["completion_task_id"], "tsk_2");
8570 for record in &completed {
8571 assert_eq!(record["exited_at_ms"], 1_750);
8572 assert_eq!(record["work_id"], "wrk_0a");
8573 assert_eq!(record["status"], "ok");
8574 assert_eq!(record["duration_ms"], 3_000);
8575 assert_eq!(record["session_id"], "test-session-id");
8576 assert_eq!(record["parent_id"].as_str(), Some(w.session_event_id()));
8577 }
8578 }
8579
8580 #[tokio::test]
8581 async fn task_reopened_names_hook_reason_and_ordinal() {
8582 let dir = tempfile::tempdir().unwrap();
8583 let mut w = make_writer(dir.path()).await;
8584 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8585 .await
8586 .unwrap();
8587 w.write_task_reopened(
8588 "tsk_1",
8589 "gatekeeper",
8590 "tests still fail",
8591 1,
8592 REOPEN_CONTEXT_CONTINUED,
8593 Some(7),
8594 )
8595 .await
8596 .unwrap();
8597 w.flush().await.unwrap();
8598
8599 let events = read_events(dir.path());
8600 let re = events
8601 .iter()
8602 .find(|e| e["event_type"] == "task_reopened")
8603 .unwrap();
8604 assert_eq!(re["task_id"], "tsk_1");
8605 assert_eq!(re["hook_name"], "gatekeeper");
8606 assert_eq!(re["reason"], "tests still fail");
8607 assert_eq!(re["reopen_number"], 1);
8608 assert_eq!(re["attempt_context"], "continued");
8609 assert_eq!(re["turns_remaining"], 7);
8610
8611 w.write_task_reopened(
8612 "tsk_1",
8613 "gatekeeper",
8614 "again",
8615 2,
8616 REOPEN_CONTEXT_CONTINUED,
8617 None,
8618 )
8619 .await
8620 .unwrap();
8621 w.flush().await.unwrap();
8622 let uncapped = read_events(dir.path())
8623 .into_iter()
8624 .filter(|e| e["event_type"] == "task_reopened")
8625 .nth(1)
8626 .unwrap();
8627 assert!(uncapped
8628 .get("turns_remaining")
8629 .is_some_and(serde_json::Value::is_null));
8630 assert!(re["timestamp"].as_u64().unwrap() > 0);
8631 }
8632
8633 #[tokio::test]
8634 async fn task_continued_names_the_round_under_the_task_node() {
8635 let dir = tempfile::tempdir().unwrap();
8636 let mut w = make_writer(dir.path()).await;
8637 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8638 .await
8639 .unwrap();
8640 w.write_task_continued(
8641 "tsk_1",
8642 1,
8643 &["mcl_b".to_string(), "mcl_a".to_string()],
8644 &["dlg_1".to_string()],
8645 1234,
8646 Some(8),
8647 )
8648 .await
8649 .unwrap();
8650 w.flush().await.unwrap();
8651
8652 let events = read_events(dir.path());
8653 let start = events
8654 .iter()
8655 .find(|e| e["event_type"] == "task_start")
8656 .unwrap();
8657 let continued = events
8658 .iter()
8659 .find(|e| e["event_type"] == "task_continued")
8660 .unwrap();
8661 assert_eq!(continued["parent_id"], start["event_id"]);
8662 assert_eq!(continued["task_id"], "tsk_1");
8663 assert_eq!(continued["continuation_number"], 1);
8664 assert_eq!(
8665 continued["member_calls"],
8666 serde_json::json!(["mcl_b", "mcl_a"])
8667 );
8668 assert_eq!(continued["delegations"], serde_json::json!(["dlg_1"]));
8669 assert_eq!(continued["waited_ms"], 1234);
8670 assert_eq!(continued["turns_remaining"], 8);
8671 }
8672
8673 #[tokio::test]
8677 async fn task_turns_accessor_tracks_and_resets() {
8678 let dir = tempfile::tempdir().unwrap();
8679 let mut w = make_writer(dir.path()).await;
8680 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8681 .await
8682 .unwrap();
8683 assert_eq!(w.task_turns(), 0);
8684 w.write_inference(
8685 0,
8686 Some(10),
8687 Some(5),
8688 "end_turn".to_string(),
8689 Some("end_turn"),
8690 None,
8691 None,
8692 None,
8693 Vec::new(),
8694 None,
8695 None,
8696 )
8697 .await
8698 .unwrap();
8699 w.write_inference(
8700 1,
8701 Some(10),
8702 Some(5),
8703 "end_turn".to_string(),
8704 Some("end_turn"),
8705 None,
8706 None,
8707 None,
8708 Vec::new(),
8709 None,
8710 None,
8711 )
8712 .await
8713 .unwrap();
8714 assert_eq!(w.task_turns(), 2);
8715 w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
8717 .await
8718 .unwrap();
8719 assert_eq!(w.task_turns(), 0);
8720 }
8721
8722 #[tokio::test]
8725 async fn task_agent_turns_counts_agent_loop_inference_only() {
8726 let dir = tempfile::tempdir().unwrap();
8727 let mut w = make_writer(dir.path()).await;
8728 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8729 .await
8730 .unwrap();
8731 assert_eq!(w.task_agent_turns(), 0);
8732 let hook = InferenceOrigin {
8733 source: "hook:x".into(),
8734 model: "m".into(),
8735 };
8736 for (turn, origin) in [(0, None), (0, Some(&hook)), (1, None)] {
8737 w.write_inference(
8738 turn,
8739 Some(10),
8740 Some(5),
8741 "end_turn".to_string(),
8742 Some("end_turn"),
8743 None,
8744 origin,
8745 None,
8746 Vec::new(),
8747 None,
8748 None,
8749 )
8750 .await
8751 .unwrap();
8752 }
8753 assert_eq!(w.task_agent_turns(), 2);
8754 assert_eq!(w.task_turns(), 3);
8755 assert_eq!(w.task_hook_inferences(), 1);
8756 w.write_task_end("tsk_1", "completed", 0, Vec::new(), None, None)
8757 .await
8758 .unwrap();
8759 assert_eq!(
8760 w.task_agent_turns(),
8761 0,
8762 "a run outside a task frame starts at 0"
8763 );
8764 w.write_inference(
8765 0,
8766 Some(10),
8767 Some(5),
8768 "end_turn".to_string(),
8769 Some("end_turn"),
8770 None,
8771 None,
8772 None,
8773 Vec::new(),
8774 None,
8775 None,
8776 )
8777 .await
8778 .unwrap();
8779 w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
8780 .await
8781 .unwrap();
8782 assert_eq!(w.task_agent_turns(), 0);
8783 }
8784
8785 #[tokio::test]
8791 async fn every_event_carries_a_unique_id_and_parents_to_an_earlier_event() {
8792 let dir = tempfile::tempdir().unwrap();
8793 let mut w = make_writer(dir.path()).await;
8794 w.write_session_start(Some(10), None, vec!["bash".to_string()])
8795 .await
8796 .unwrap();
8797 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8798 .await
8799 .unwrap();
8800 w.write_inference(
8801 0,
8802 Some(10),
8803 Some(5),
8804 "tool_call".to_string(),
8805 Some("tool_call"),
8806 None,
8807 None,
8808 None,
8809 Vec::new(),
8810 None,
8811 None,
8812 )
8813 .await
8814 .unwrap();
8815 w.write_tool_call(
8816 0,
8817 "bash".to_string(),
8818 Some("toolu_1".to_string()),
8819 serde_json::json!({"command": "echo hi"}),
8820 10,
8821 "hi\n",
8822 3,
8823 5,
8824 "ok".to_string(),
8825 None,
8826 None,
8827 None,
8828 )
8829 .await
8830 .unwrap();
8831 w.write_shell(
8832 0,
8833 "/bin/bash".to_string(),
8834 "echo hi".to_string(),
8835 0,
8836 3,
8837 0,
8838 2,
8839 None,
8840 )
8841 .await
8842 .unwrap();
8843 w.write_artifact_pulled(&pulled(
8844 runtime_summary("pulled-style", ArtifactRuntime::Skill),
8845 "global store",
8846 None,
8847 ))
8848 .await
8849 .unwrap();
8850 w.write_task_end("tsk_1", "ok", 0, Vec::new(), None, None)
8851 .await
8852 .unwrap();
8853 w.write_session_end("ok").await.unwrap();
8854 w.flush().await.unwrap();
8855
8856 let events = read_events(dir.path());
8857 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8858 for (i, event) in events.iter().enumerate() {
8859 let id = event["event_id"].as_str().unwrap();
8860 assert!(id.starts_with("evt_") && id.len() == 36, "event {i}: {id}");
8861 assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()), "event {i}");
8862 assert!(seen.insert(id.to_string()), "event {i} reuses {id}");
8863 assert!(
8864 event.get("parent_id").is_some(),
8865 "event {i} has no parent_id"
8866 );
8867 if let Some(parent) = event["parent_id"].as_str() {
8868 assert!(seen.contains(parent), "event {i} names dangling {parent}");
8869 }
8870 }
8871
8872 let by_type = |ty: &str| {
8873 events
8874 .iter()
8875 .find(|e| e["event_type"] == ty)
8876 .unwrap_or_else(|| panic!("no {ty}"))
8877 .clone()
8878 };
8879 let session = by_type("session_start");
8880 assert!(session["parent_id"].is_null());
8881 assert_eq!(session["event_id"], w.session_event_id());
8882
8883 let task = by_type("task_start");
8884 assert_eq!(task["parent_id"], session["event_id"]);
8885
8886 let turn = by_type("inference");
8887 assert_eq!(turn["parent_id"], task["event_id"]);
8888
8889 assert_eq!(by_type("tool_call")["parent_id"], turn["event_id"]);
8890 assert_eq!(by_type("shell")["parent_id"], turn["event_id"]);
8891 assert_eq!(by_type("task_end")["parent_id"], task["event_id"]);
8892 assert_eq!(by_type("session_end")["parent_id"], session["event_id"]);
8893 assert_eq!(by_type("artifact_pulled")["parent_id"], session["event_id"]);
8894 }
8895
8896 fn runtime_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
8897 InstalledArtifactSummary {
8898 name: name.to_string(),
8899 version: "1.0.0".to_string(),
8900 runtime,
8901 implementation: None,
8902 origin: LockOrigin::Runtime {
8903 session: "ses_puller".to_string(),
8904 },
8905 }
8906 }
8907
8908 fn operator_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
8909 InstalledArtifactSummary {
8910 origin: LockOrigin::Operator,
8911 ..runtime_summary(name, runtime)
8912 }
8913 }
8914
8915 fn pulled(
8917 summary: InstalledArtifactSummary,
8918 fetched_from: &str,
8919 owner_id: Option<u64>,
8920 ) -> crate::runtime::PulledArtifact {
8921 crate::runtime::PulledArtifact {
8922 summary,
8923 fetched_from: fetched_from.to_string(),
8924 owner_id,
8925 notes: Vec::new(),
8926 }
8927 }
8928
8929 #[tokio::test]
8932 async fn session_start_instruction_files_is_null_without_the_key() {
8933 let work = tempfile::tempdir().unwrap();
8934 std::fs::write(work.path().join("AGENTS.md"), "x").unwrap();
8935 let read = crate::instruction_files::InstructionFiles::read(
8936 work.path(),
8937 &["AGENTS.md".to_string(), "CLAUDE.md".to_string()],
8938 );
8939 let empty = crate::instruction_files::InstructionFiles::read(work.path(), &[]);
8940 for (files, expected) in [
8941 (None, serde_json::Value::Null),
8942 (Some(&empty), serde_json::json!([])),
8943 (
8944 Some(&read),
8945 serde_json::json!([
8946 {
8947 "path": "AGENTS.md",
8948 "status": "read",
8949 "resolved": "AGENTS.md",
8950 "bytes": 1,
8951 "included_bytes": 1,
8952 "truncated": false,
8953 "sha256": murmur_artifact::sha256_hex(b"x"),
8954 },
8955 {"path": "CLAUDE.md", "status": "missing"},
8956 ]),
8957 ),
8958 ] {
8959 let dir = tempfile::tempdir().unwrap();
8960 let mut w = make_writer(dir.path()).await;
8961 w.set_instruction_files(files);
8962 w.write_session_start(Some(10), None, Vec::new())
8963 .await
8964 .unwrap();
8965 w.flush().await.unwrap();
8966 let events = read_events(dir.path());
8967 assert!(events[0].get("instruction_files").is_some());
8968 assert_eq!(events[0]["instruction_files"], expected);
8969 }
8970 }
8971
8972 fn harness_start_with_prompt(prompt: &str) -> HarnessStart {
8973 HarnessStart {
8974 driver: "local/fixture-process-driver".to_string(),
8975 driver_version: "0.1.0".to_string(),
8976 harness: "fixture".to_string(),
8977 binary: "/bin/true".to_string(),
8978 binary_source: "describe".to_string(),
8979 harness_version: None,
8980 version_tested: false,
8981 harness_session_id: "h".to_string(),
8982 session_mode: "new".to_string(),
8983 args_count: 0,
8984 env_names: Vec::new(),
8985 account_env_unset: Vec::new(),
8986 files: Vec::new(),
8987 bridge_tools: Vec::new(),
8988 stdin_bytes: 0,
8989 keep_stdin_open: false,
8990 system_prompt_sha256: murmur_artifact::sha256_hex(prompt.as_bytes()),
8991 system_prompt: prompt.to_string(),
8992 }
8993 }
8994
8995 #[tokio::test]
8998 async fn harness_start_records_the_system_prompt_hash_and_blob() {
8999 let prompt = "facts\n\nMANIFEST\n\n[Instruction file: AGENTS.md]\nx";
9000 let sha = murmur_artifact::sha256_hex(prompt.as_bytes());
9001 for capture in [TraceCapture::Meta, TraceCapture::Content] {
9002 let dir = tempfile::tempdir().unwrap();
9003 let mut w = make_writer_with_opts(dir.path(), capture).await;
9004 w.write_harness_start(harness_start_with_prompt(prompt))
9005 .await
9006 .unwrap();
9007 w.flush().await.unwrap();
9008 let event = &read_events(dir.path())[0];
9009 assert_eq!(event["event_type"], "harness_start");
9010 assert_eq!(event["system_prompt_sha256"], sha.as_str());
9011 assert!(event.get("system_prompt").is_none(), "{event}");
9012 let blob = dir.path().join(BLOB_DIR_NAME).join(&sha);
9013 if capture == TraceCapture::Content {
9014 assert_eq!(std::fs::read(&blob).unwrap(), prompt.as_bytes());
9015 } else {
9016 assert!(!blob.exists());
9017 }
9018 }
9019 }
9020
9021 #[tokio::test]
9024 async fn runtime_origin_session_start_lists_nothing_for_an_operator_session() {
9025 for set in [false, true] {
9026 let dir = tempfile::tempdir().unwrap();
9027 let mut w = make_writer(dir.path()).await;
9028 if set {
9029 w.set_runtime_artifacts(&[
9030 operator_summary("house-style", ArtifactRuntime::Skill),
9031 operator_summary("fetcher", ArtifactRuntime::Tool),
9032 ]);
9033 }
9034 w.write_session_start(Some(10), None, Vec::new())
9035 .await
9036 .unwrap();
9037 w.flush().await.unwrap();
9038 let events = read_events(dir.path());
9039 assert_eq!(events[0]["runtime_artifacts"], serde_json::json!([]));
9040 }
9041 }
9042
9043 #[tokio::test]
9046 async fn runtime_origin_session_start_lists_runtime_artifacts_in_order() {
9047 let dir = tempfile::tempdir().unwrap();
9048 let mut w = make_writer(dir.path()).await;
9049 w.set_runtime_artifacts(&[
9050 runtime_summary("zeta-tool", ArtifactRuntime::Tool),
9051 operator_summary("house-style", ArtifactRuntime::Skill),
9052 InstalledArtifactSummary {
9053 origin: LockOrigin::Runtime {
9054 session: "ses_other".to_string(),
9055 },
9056 ..runtime_summary("alpha-style", ArtifactRuntime::Skill)
9057 },
9058 ]);
9059 w.write_session_start(Some(10), None, Vec::new())
9060 .await
9061 .unwrap();
9062 w.flush().await.unwrap();
9063
9064 let events = read_events(dir.path());
9065 assert_eq!(
9066 events[0]["runtime_artifacts"],
9067 serde_json::json!([
9068 {"name": "zeta-tool", "version": "1.0.0", "origin": "runtime",
9069 "session": "ses_puller", "trust": "untrusted"},
9070 {"name": "alpha-style", "version": "1.0.0", "origin": "runtime",
9071 "session": "ses_other", "trust": "untrusted"},
9072 ])
9073 );
9074 }
9075
9076 #[tokio::test]
9077 async fn runtime_origin_skill_call_carries_origin_and_trust() {
9078 let dir = tempfile::tempdir().unwrap();
9079 let mut w = make_writer(dir.path()).await;
9080 for origin in [
9081 LockOrigin::Operator,
9082 LockOrigin::Runtime {
9083 session: "ses_puller".to_string(),
9084 },
9085 ] {
9086 w.write_skill_call(0, "a-skill".to_string(), &origin, 1, 1, "ok".to_string())
9087 .await
9088 .unwrap();
9089 }
9090 w.flush().await.unwrap();
9091
9092 let events = read_events(dir.path());
9093 assert_eq!(events[0]["origin"], "operator");
9094 assert_eq!(events[0]["trust"], "trusted");
9095 assert_eq!(events[1]["origin"], "runtime");
9096 assert_eq!(events[1]["trust"], "untrusted");
9097 }
9098
9099 #[tokio::test]
9102 async fn runtime_origin_artifact_pulled_writes_every_key() {
9103 let dir = tempfile::tempdir().unwrap();
9104 let mut w = make_writer(dir.path()).await;
9105 w.write_artifact_pulled(&pulled(
9106 runtime_summary("pulled-style", ArtifactRuntime::Skill),
9107 "github:ana/artifacts",
9108 Some(4242),
9109 ))
9110 .await
9111 .unwrap();
9112 w.write_artifact_pulled(&pulled(
9113 operator_summary("fetcher", ArtifactRuntime::Tool),
9114 "project store",
9115 None,
9116 ))
9117 .await
9118 .unwrap();
9119 w.flush().await.unwrap();
9120
9121 let events = read_events(dir.path());
9122 let runtime = &events[0];
9123 assert_eq!(runtime["event_type"], "artifact_pulled");
9124 assert_eq!(runtime["name"], "pulled-style");
9125 assert_eq!(runtime["version"], "1.0.0");
9126 assert_eq!(runtime["runtime"], "skill");
9127 assert_eq!(runtime["origin"], "runtime");
9128 assert_eq!(runtime["session"], "ses_puller");
9129 assert_eq!(runtime["trust"], "untrusted");
9130 assert_eq!(runtime["fetched_from"], "github:ana/artifacts");
9131 assert_eq!(runtime["owner_id"], 4242);
9132 assert!(
9133 runtime["parent_id"].is_null(),
9134 "no session_start was written"
9135 );
9136
9137 let operator = &events[1];
9138 assert_eq!(operator["runtime"], "tool");
9139 assert_eq!(operator["origin"], "operator");
9140 assert!(operator.get("session").is_some_and(Value::is_null));
9141 assert_eq!(operator["trust"], "trusted");
9142 assert_eq!(operator["fetched_from"], "project store");
9143 assert!(operator.get("owner_id").is_none());
9144 }
9145
9146 #[tokio::test]
9150 async fn hook_origin_inference_hangs_off_the_turn_rather_than_becoming_one() {
9151 let dir = tempfile::tempdir().unwrap();
9152 let mut w = make_writer(dir.path()).await;
9153 w.write_session_start(Some(10), None, Vec::new())
9154 .await
9155 .unwrap();
9156 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9157 .await
9158 .unwrap();
9159 w.write_inference(
9160 0,
9161 Some(10),
9162 Some(5),
9163 "tool_call".to_string(),
9164 Some("tool_call"),
9165 None,
9166 None,
9167 None,
9168 Vec::new(),
9169 None,
9170 None,
9171 )
9172 .await
9173 .unwrap();
9174 let origin = InferenceOrigin {
9175 source: "hook:gatekeeper".to_string(),
9176 model: "claude-test".to_string(),
9177 };
9178 w.write_inference(
9179 0,
9180 Some(1),
9181 Some(1),
9182 "end_turn".to_string(),
9183 None,
9185 None,
9186 Some(&origin),
9187 None,
9188 Vec::new(),
9189 None,
9190 None,
9191 )
9192 .await
9193 .unwrap();
9194 w.flush().await.unwrap();
9195
9196 let events = read_events(dir.path());
9197 let turn = &events[2];
9198 let hook = &events[3];
9199 assert!(turn["origin"].is_null());
9200 assert_eq!(hook["origin"], "hook:gatekeeper");
9201 assert_eq!(hook["parent_id"], turn["event_id"]);
9202 assert!(hook.get("message_ids").is_none(), "{hook}");
9205 }
9206
9207 #[tokio::test]
9209 async fn a2a_send_parents_to_null_without_a_session_frame_and_to_it_with_one() {
9210 let bare = tempfile::tempdir().unwrap();
9211 let mut w = make_writer(bare.path()).await;
9212 w.write_a2a_send(
9213 "http://peer",
9214 "msg_1",
9215 "tsk_1",
9216 "ctx_1",
9217 None,
9218 TrustClass::Untrusted,
9219 )
9220 .await
9221 .unwrap();
9222 w.flush().await.unwrap();
9223 assert!(read_events(bare.path())[0]["parent_id"].is_null());
9224
9225 let framed = tempfile::tempdir().unwrap();
9226 let mut w = make_writer(framed.path()).await;
9227 w.write_session_start(Some(1), None, Vec::new())
9228 .await
9229 .unwrap();
9230 w.write_a2a_send(
9231 "http://peer",
9232 "msg_1",
9233 "tsk_1",
9234 "ctx_1",
9235 None,
9236 TrustClass::Untrusted,
9237 )
9238 .await
9239 .unwrap();
9240 w.flush().await.unwrap();
9241 let events = read_events(framed.path());
9242 assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
9243 }
9244
9245 #[tokio::test]
9247 async fn task_rejected_names_the_task_source_cause_and_reason_under_the_session() {
9248 let dir = tempfile::tempdir().unwrap();
9249 let mut w = make_writer(dir.path()).await;
9250 w.write_session_start(Some(10), None, Vec::new())
9251 .await
9252 .unwrap();
9253 w.write_task_start("tsk_ran", "ctx_ran", "a2a", event_provenance(), 3, &[], &[])
9254 .await
9255 .unwrap();
9256 w.write_task_end("tsk_ran", "ok", 0, Vec::new(), None, None)
9257 .await
9258 .unwrap();
9259 w.write_task_rejected(
9260 "tsk_1",
9261 "ctx_1",
9262 "detached_shell",
9263 TASK_REJECTED_SESSION_ENDED,
9264 "task rejected: the session ended before this task started",
9265 )
9266 .await
9267 .unwrap();
9268 w.flush().await.unwrap();
9269
9270 let events = read_events(dir.path());
9271 let session = events
9272 .iter()
9273 .find(|e| e["event_type"] == "session_start")
9274 .unwrap();
9275 let rejected: Vec<_> = events
9276 .iter()
9277 .filter(|e| e["event_type"] == "task_rejected")
9278 .collect();
9279 assert_eq!(rejected.len(), 1);
9280 let rejected = rejected[0];
9281 assert_eq!(rejected["parent_id"], session["event_id"], "{rejected}");
9282 assert_eq!(rejected["session_id"], session["session_id"]);
9283 assert_eq!(rejected["task_id"], "tsk_1");
9284 assert_eq!(rejected["context_id"], "ctx_1");
9285 assert_eq!(rejected["source"], "detached_shell");
9286 assert_eq!(rejected["cause"], "session_ended");
9287 assert_eq!(
9288 rejected["reason"],
9289 "task rejected: the session ended before this task started"
9290 );
9291 assert!(rejected["event_id"].is_string());
9292 assert!(rejected["timestamp"].is_u64());
9293 assert_eq!(rejected.as_object().unwrap().len(), 10, "{rejected}");
9294 }
9295
9296 #[tokio::test]
9299 async fn task_failed_names_the_task_turn_cause_and_capped_reason() {
9300 let dir = tempfile::tempdir().unwrap();
9301 let mut w = make_writer(dir.path()).await;
9302 w.write_session_start(Some(10), None, Vec::new())
9303 .await
9304 .unwrap();
9305 assert_eq!(w.last_task_failure(), None);
9306 w.write_task_failed(None, TASK_FAILED_RUNTIME_ERROR, "before any task")
9307 .await
9308 .unwrap();
9309 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9310 .await
9311 .unwrap();
9312 let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
9313 w.write_task_failed(Some(2), TASK_FAILED_DRIVER_ERROR, &long)
9314 .await
9315 .unwrap();
9316 w.write_task_end("tsk_1", "failed", 0, Vec::new(), None, None)
9317 .await
9318 .unwrap();
9319 w.flush().await.unwrap();
9320
9321 assert_eq!(w.task_failures_written(), 2);
9322 let events = read_events(dir.path());
9323 let failed: Vec<_> = events
9324 .iter()
9325 .filter(|e| e["event_type"] == "task_failed")
9326 .collect();
9327 assert_eq!(failed.len(), 2);
9328
9329 assert!(failed[0].get("task_id").is_none(), "{}", failed[0]);
9330 assert!(failed[0].get("turn").is_none(), "{}", failed[0]);
9331 assert_eq!(failed[0]["cause"], "runtime_error");
9332 assert_eq!(failed[0]["reason"], "before any task");
9333
9334 let start = events
9335 .iter()
9336 .find(|e| e["event_type"] == "task_start")
9337 .unwrap();
9338 assert_eq!(failed[1]["task_id"], "tsk_1");
9339 assert_eq!(failed[1]["turn"], 2);
9340 assert_eq!(failed[1]["cause"], "driver_error");
9341 assert_eq!(failed[1]["parent_id"], start["event_id"]);
9342 assert_eq!(failed[1]["session_id"], start["session_id"]);
9343 assert!(failed[1]["event_id"].is_string());
9344 assert!(failed[1]["timestamp"].is_u64());
9345 let reason = failed[1]["reason"].as_str().unwrap();
9346 assert!(reason.len() <= MAX_TASK_FAILURE_REASON_BYTES);
9347 assert!(reason.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
9348 assert_eq!(w.last_task_failure(), Some(reason));
9349 }
9350
9351 #[tokio::test]
9354 async fn turn_level_events_carry_the_active_task_id() {
9355 let dir = tempfile::tempdir().unwrap();
9356 let mut w = make_writer(dir.path()).await;
9357 w.write_session_start(Some(10), None, Vec::new())
9358 .await
9359 .unwrap();
9360 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9361 .await
9362 .unwrap();
9363 w.write_inference(
9364 0,
9365 Some(10),
9366 Some(5),
9367 "tool_call".to_string(),
9368 Some("tool_call"),
9369 None,
9370 None,
9371 None,
9372 Vec::new(),
9373 None,
9374 None,
9375 )
9376 .await
9377 .unwrap();
9378 w.write_skill_call(
9379 0,
9380 "house-style".to_string(),
9381 &LockOrigin::Operator,
9382 12,
9383 1,
9384 "ok".to_string(),
9385 )
9386 .await
9387 .unwrap();
9388 w.write_compaction(0, 100, OccupancyBasis::Estimate, 40)
9389 .await
9390 .unwrap();
9391 w.write_task_end("tsk_1", "ok", 0, Vec::new(), None, None)
9392 .await
9393 .unwrap();
9394 w.write_inference(
9395 1,
9396 Some(1),
9397 Some(1),
9398 "end_turn".to_string(),
9399 Some("end_turn"),
9400 None,
9401 None,
9402 None,
9403 Vec::new(),
9404 None,
9405 None,
9406 )
9407 .await
9408 .unwrap();
9409 w.flush().await.unwrap();
9410
9411 let events = read_events(dir.path());
9412 for event in events
9413 .iter()
9414 .filter(|e| {
9415 matches!(
9416 e["event_type"].as_str(),
9417 Some("inference" | "skill_call" | "compaction")
9418 )
9419 })
9420 .take(3)
9421 {
9422 assert_eq!(event["task_id"], "tsk_1", "{event}");
9423 }
9424 let after = events.last().unwrap();
9425 assert_eq!(after["event_type"], "inference");
9426 assert!(
9427 after["task_id"].is_null(),
9428 "an inference outside any task carries a null task_id, not the last task's"
9429 );
9430 assert_eq!(after["parent_id"], events[0]["event_id"]);
9432 }
9433
9434 #[tokio::test]
9437 async fn tool_call_records_the_provider_id_verbatim_and_empty_as_null() {
9438 for (given, expected) in [
9439 (Some("toolu_1".to_string()), Some("toolu_1")),
9440 (Some(String::new()), None),
9441 (None, None),
9442 ] {
9443 let dir = tempfile::tempdir().unwrap();
9444 let mut w = make_writer(dir.path()).await;
9445 w.write_tool_call(
9446 0,
9447 "bash".to_string(),
9448 given.clone(),
9449 serde_json::json!({}),
9450 2,
9451 "",
9452 0,
9453 1,
9454 "ok".to_string(),
9455 None,
9456 None,
9457 None,
9458 )
9459 .await
9460 .unwrap();
9461 w.flush().await.unwrap();
9462 let e = &read_events(dir.path())[0];
9463 match expected {
9464 Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
9465 None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
9466 }
9467 }
9468 }
9469
9470 #[tokio::test]
9473 async fn tool_input_refused_records_its_fields_in_every_capture_mode() {
9474 for capture in [
9475 TraceCapture::None,
9476 TraceCapture::Meta,
9477 TraceCapture::Content,
9478 ] {
9479 for (given, expected) in [
9480 (Some("toolu_1"), Some("toolu_1")),
9481 (Some(""), None),
9482 (None, None),
9483 ] {
9484 let dir = tempfile::tempdir().unwrap();
9485 let mut w = make_writer_with_opts(dir.path(), capture).await;
9486 w.write_tool_input_refused(
9487 2,
9488 "murmur-tool-editor",
9489 given,
9490 &["operation".to_string(), "dest_path".to_string()],
9491 "murmur-tool-editor: missing required fields",
9492 )
9493 .await
9494 .unwrap();
9495 w.flush().await.unwrap();
9496 let e = &read_events(dir.path())[0];
9497 assert_eq!(e["event_type"], "tool_input_refused", "{capture:?}");
9498 assert!(e["event_id"].is_string());
9499 assert!(e["session_id"].is_string());
9500 assert!(e["timestamp"].is_u64());
9501 assert_eq!(e["turn"], 2);
9502 assert_eq!(e["tool_name"], "murmur-tool-editor");
9503 match expected {
9504 Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
9505 None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
9506 }
9507 assert_eq!(e["missing"], serde_json::json!(["operation", "dest_path"]));
9508 assert_eq!(e["reason"], "murmur-tool-editor: missing required fields");
9509 }
9510 }
9511 }
9512
9513 #[tokio::test]
9517 async fn tools_refreshed_event_records_trigger_names_and_task_parent() {
9518 let dir = tempfile::tempdir().unwrap();
9519 let mut w = make_writer(dir.path()).await;
9520 w.set_tool_refresh(Some("immediate"));
9521 w.write_session_start(Some(10), None, Vec::new())
9522 .await
9523 .unwrap();
9524 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9525 .await
9526 .unwrap();
9527 w.write_inference(
9528 0,
9529 Some(10),
9530 Some(5),
9531 "tool_call".to_string(),
9532 Some("tool_call"),
9533 None,
9534 None,
9535 None,
9536 Vec::new(),
9537 None,
9538 None,
9539 )
9540 .await
9541 .unwrap();
9542 w.write_tools_refreshed(
9543 1,
9544 "immediate",
9545 &["aaa-late-skill".to_string()],
9546 &[],
9547 &[
9548 "aaa-late-skill".to_string(),
9549 "zzz-existing-tool".to_string(),
9550 ],
9551 )
9552 .await
9553 .unwrap();
9554 w.flush().await.unwrap();
9555
9556 let events = read_events(dir.path());
9557 assert_eq!(events[0]["tool_refresh"], "immediate");
9558 let task = events
9559 .iter()
9560 .find(|e| e["event_type"] == "task_start")
9561 .unwrap();
9562 let refreshed: Vec<&Value> = events
9563 .iter()
9564 .filter(|e| e["event_type"] == "tools_refreshed")
9565 .collect();
9566 assert_eq!(refreshed.len(), 1);
9567 let r = refreshed[0];
9568 assert!(r["event_id"].as_str().unwrap().starts_with("evt_"));
9569 assert_eq!(r["parent_id"], task["event_id"]);
9570 assert_eq!(r["session_id"], "test-session-id");
9571 assert!(r["timestamp"].as_u64().is_some());
9572 assert_eq!(r["turn"], 1);
9573 assert_eq!(r["task_id"], "tsk_1");
9574 assert_eq!(r["trigger"], "immediate");
9575 assert_eq!(r["added"], serde_json::json!(["aaa-late-skill"]));
9576 assert_eq!(r["removed"], serde_json::json!([]));
9577 assert_eq!(
9578 r["tools"],
9579 serde_json::json!(["aaa-late-skill", "zzz-existing-tool"])
9580 );
9581
9582 let process_dir = tempfile::tempdir().unwrap();
9583 let mut process = make_writer(process_dir.path()).await;
9584 process.set_tool_refresh(None);
9585 process
9586 .write_session_start(Some(10), None, Vec::new())
9587 .await
9588 .unwrap();
9589 process.flush().await.unwrap();
9590 let start = &read_events(process_dir.path())[0];
9591 assert!(start.as_object().unwrap().contains_key("tool_refresh"));
9592 assert!(start["tool_refresh"].is_null());
9593 }
9594
9595 #[tokio::test]
9598 async fn compaction_declined_records_turn_task_tokens_and_reason() {
9599 let dir = tempfile::tempdir().unwrap();
9600 let mut w = make_writer(dir.path()).await;
9601 w.write_session_start(Some(10), None, Vec::new())
9602 .await
9603 .unwrap();
9604 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9605 .await
9606 .unwrap();
9607 w.write_inference(
9608 2,
9609 Some(10),
9610 Some(5),
9611 "tool_call".to_string(),
9612 Some("tool_call"),
9613 None,
9614 None,
9615 None,
9616 Vec::new(),
9617 None,
9618 None,
9619 )
9620 .await
9621 .unwrap();
9622 w.write_compaction_declined(
9623 2,
9624 4321,
9625 OccupancyBasis::Actual,
9626 COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL,
9627 )
9628 .await
9629 .unwrap();
9630 w.flush().await.unwrap();
9631
9632 let events = read_events(dir.path());
9633 let declined: Vec<&Value> = events
9634 .iter()
9635 .filter(|e| e["event_type"] == "compaction_declined")
9636 .collect();
9637 assert_eq!(declined.len(), 1);
9638 let d = declined[0];
9639 assert_eq!(d["reason"], "unresolved_tool_call");
9640 assert_eq!(d["turn"], 2);
9641 assert_eq!(d["task_id"], "tsk_1");
9642 assert_eq!(d["tokens"], 4321);
9643 assert_eq!(d["tokens_source"], "actual");
9644 assert!(d["event_id"].as_str().unwrap().starts_with("evt_"));
9645 assert_eq!(
9646 d["parent_id"],
9647 events
9648 .iter()
9649 .find(|e| e["event_type"] == "inference")
9650 .unwrap()["event_id"]
9651 );
9652 }
9653
9654 #[tokio::test]
9657 async fn event_ids_sort_by_mint_time() {
9658 let dir = tempfile::tempdir().unwrap();
9659 let mut w = make_writer(dir.path()).await;
9660 for turn in 0..5 {
9661 w.write_inference(
9662 turn,
9663 Some(1),
9664 Some(1),
9665 "end_turn".to_string(),
9666 Some("end_turn"),
9667 None,
9668 None,
9669 None,
9670 Vec::new(),
9671 None,
9672 None,
9673 )
9674 .await
9675 .unwrap();
9676 }
9677 w.flush().await.unwrap();
9678
9679 let ids: Vec<String> = read_events(dir.path())
9680 .iter()
9681 .map(|e| e["event_id"].as_str().unwrap().to_string())
9682 .collect();
9683 let mut sorted = ids.clone();
9684 sorted.sort();
9685 assert_eq!(ids, sorted, "evt_ ids must sort into mint order");
9686 }
9687
9688 fn wire_payload(system: &str, messages: usize) -> Value {
9692 let messages: Vec<Value> = (0..messages)
9693 .map(|i| serde_json::json!({"role": "user", "content": format!("m{i}")}))
9694 .collect();
9695 serde_json::json!({
9696 "model": "claude-test",
9697 "max_tokens": 1024,
9698 "messages": messages,
9699 "tools": [{"name": "bash"}],
9700 "params": {},
9701 "system": system,
9702 })
9703 }
9704
9705 async fn write_turn(w: &mut TraceWriter, turn: u32, payload: &Value, response: &str) {
9706 let wire = WireCapture::from_driver_payload(payload, response);
9707 w.write_inference(
9708 turn,
9709 Some(10),
9710 Some(5),
9711 "end_turn".to_string(),
9712 Some("end_turn"),
9713 None,
9714 None,
9715 None,
9716 vec!["msg_0000000000000000000000000000000a".to_string()],
9717 Some(&wire),
9718 None,
9719 )
9720 .await
9721 .unwrap();
9722 }
9723
9724 fn blob_path(dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
9725 dir.join(BLOB_DIR_NAME).join(sha)
9726 }
9727
9728 #[tokio::test]
9731 async fn content_capture_names_blobs_that_exist_and_rehash_to_their_names() {
9732 let dir = tempfile::tempdir().unwrap();
9733 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
9734 let payload = wire_payload("You are a capsule.", 3);
9735 write_turn(&mut w, 0, &payload, r#"{"stop_reason":"end_turn"}"#).await;
9736 w.flush().await.unwrap();
9737
9738 let e = &read_events(dir.path())[0];
9739 let mut named: Vec<String> = ["system_sha", "tools_sha", "response_sha"]
9740 .iter()
9741 .map(|key| {
9742 e[*key]
9743 .as_str()
9744 .unwrap_or_else(|| panic!("{key} missing from {e}"))
9745 .to_string()
9746 })
9747 .collect();
9748 let shas = e["message_shas"].as_array().expect("message_shas");
9749 assert_eq!(shas.len(), 3);
9750 named.extend(shas.iter().map(|s| s.as_str().unwrap().to_string()));
9751
9752 for sha in &named {
9753 let path = blob_path(dir.path(), sha);
9754 let bytes =
9755 std::fs::read(&path).unwrap_or_else(|err| panic!("blob {sha} must exist: {err}"));
9756 assert_eq!(murmur_artifact::sha256_hex(&bytes), *sha);
9757 assert_eq!(sha.len(), 64, "{sha}");
9760 assert!(
9761 sha.chars()
9762 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
9763 "{sha}"
9764 );
9765 }
9766 }
9767
9768 #[tokio::test]
9771 async fn the_system_blob_holds_the_prompt_as_readable_text() {
9772 let dir = tempfile::tempdir().unwrap();
9773 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
9774 let payload = wire_payload("[Capsule] you are cap.\nBe terse.", 1);
9775 write_turn(&mut w, 0, &payload, "{}").await;
9776 w.flush().await.unwrap();
9777
9778 let sha = read_events(dir.path())[0]["system_sha"]
9779 .as_str()
9780 .unwrap()
9781 .to_string();
9782 assert_eq!(
9783 std::fs::read_to_string(blob_path(dir.path(), &sha)).unwrap(),
9784 "[Capsule] you are cap.\nBe terse."
9785 );
9786 }
9787
9788 #[tokio::test]
9791 async fn meta_writes_the_same_hashes_and_no_bodies() {
9792 let payload = wire_payload("You are a capsule.", 2);
9793 let response = r#"{"stop_reason":"end_turn"}"#;
9794
9795 let meta = tempfile::tempdir().unwrap();
9796 let mut w = make_writer_with_opts(meta.path(), TraceCapture::Meta).await;
9797 write_turn(&mut w, 0, &payload, response).await;
9798 w.flush().await.unwrap();
9799
9800 let content = tempfile::tempdir().unwrap();
9801 let mut w = make_writer_with_opts(content.path(), TraceCapture::Content).await;
9802 write_turn(&mut w, 0, &payload, response).await;
9803 w.flush().await.unwrap();
9804
9805 let meta_event = read_events(meta.path()).remove(0);
9806 let content_event = read_events(content.path()).remove(0);
9807 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
9808 assert_eq!(meta_event[key], content_event[key], "{key}");
9809 }
9810 assert!(
9811 !meta.path().join(BLOB_DIR_NAME).exists(),
9812 "meta must not create the blob directory"
9813 );
9814 assert!(content.path().join(BLOB_DIR_NAME).is_dir());
9815 }
9816
9817 #[tokio::test]
9819 async fn the_default_capture_mode_is_meta() {
9820 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
9821 let dir = tempfile::tempdir().unwrap();
9822 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::default(), None, false).await;
9823 write_turn(&mut w, 0, &wire_payload("s", 1), "{}").await;
9824 w.flush().await.unwrap();
9825
9826 let e = &read_events(dir.path())[0];
9827 assert!(e.get("system_sha").is_some());
9828 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
9829 }
9830
9831 #[tokio::test]
9833 async fn none_omits_every_hash_and_leaves_the_rest_of_the_event_intact() {
9834 let payload = wire_payload("You are a capsule.", 2);
9835
9836 let off = tempfile::tempdir().unwrap();
9837 let mut w = make_writer_with_opts(off.path(), TraceCapture::None).await;
9838 write_turn(&mut w, 3, &payload, "{}").await;
9839 w.flush().await.unwrap();
9840
9841 let e = read_events(off.path()).remove(0);
9842 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
9843 assert!(
9844 e.get(key).is_none(),
9845 "{key} must be absent under none, got {e}"
9846 );
9847 }
9848 assert!(!off.path().join(BLOB_DIR_NAME).exists());
9849 for key in [
9850 "event_type",
9851 "event_id",
9852 "parent_id",
9853 "session_id",
9854 "timestamp",
9855 "turn",
9856 "task_id",
9857 "input_tokens",
9858 "output_tokens",
9859 "decision",
9860 "tool_name",
9861 "message_ids",
9862 ] {
9863 assert!(
9864 e.get(key).is_some(),
9865 "{key} must survive under none, got {e}"
9866 );
9867 }
9868 assert_eq!(e["turn"], 3);
9869 assert_eq!(e["message_ids"].as_array().unwrap().len(), 1);
9870 }
9871
9872 #[tokio::test]
9875 async fn an_unchanged_system_prompt_is_stored_once() {
9876 let dir = tempfile::tempdir().unwrap();
9877 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
9878 let payload = wire_payload("You are a capsule.", 1);
9879 write_turn(&mut w, 0, &payload, "{}").await;
9880
9881 let sha = {
9882 w.flush().await.unwrap();
9883 read_events(dir.path())[0]["system_sha"]
9884 .as_str()
9885 .unwrap()
9886 .to_string()
9887 };
9888 let path = blob_path(dir.path(), &sha);
9889 std::fs::write(&path, b"sentinel").unwrap();
9890
9891 for turn in 1..10 {
9892 write_turn(&mut w, turn, &payload, "{}").await;
9893 }
9894 w.flush().await.unwrap();
9895
9896 let events = read_events(dir.path());
9897 assert_eq!(events.len(), 10);
9898 for e in &events {
9899 assert_eq!(e["system_sha"], sha.as_str());
9900 }
9901 assert_eq!(
9902 std::fs::read(&path).unwrap(),
9903 b"sentinel",
9904 "an existing blob is never rewritten"
9905 );
9906 let matching = std::fs::read_dir(dir.path().join(BLOB_DIR_NAME))
9907 .unwrap()
9908 .filter(|entry| entry.as_ref().unwrap().file_name() == sha.as_str())
9909 .count();
9910 assert_eq!(matching, 1, "exactly one file bears that name");
9911 }
9912
9913 #[tokio::test]
9918 async fn stop_reason_is_absent_when_no_driver_response_was_parsed() {
9919 let dir = tempfile::tempdir().unwrap();
9920 let mut w = make_writer(dir.path()).await;
9921 w.write_inference(
9922 0,
9923 Some(10),
9924 Some(5),
9925 "end_turn".to_string(),
9926 None,
9927 None,
9928 None,
9929 None,
9930 Vec::new(),
9931 None,
9932 None,
9933 )
9934 .await
9935 .unwrap();
9936 w.flush().await.unwrap();
9937
9938 let event = read_events(dir.path()).remove(0);
9939 assert!(
9940 event.get("stop_reason").is_none(),
9941 "stop_reason must be absent, got {event}"
9942 );
9943 }
9944
9945 #[tokio::test]
9948 async fn a_hook_origin_record_carries_no_hashes() {
9949 let dir = tempfile::tempdir().unwrap();
9950 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
9951 let origin = InferenceOrigin {
9952 source: "hook:gatekeeper".to_string(),
9953 model: "claude-test".to_string(),
9954 };
9955 w.write_inference(
9956 0,
9957 Some(1),
9958 Some(1),
9959 "end_turn".to_string(),
9960 None,
9962 None,
9963 Some(&origin),
9964 None,
9965 Vec::new(),
9966 None,
9967 None,
9968 )
9969 .await
9970 .unwrap();
9971 w.flush().await.unwrap();
9972
9973 let e = read_events(dir.path()).remove(0);
9974 assert_eq!(e["origin"], "hook:gatekeeper");
9975 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
9976 assert!(e.get(key).is_none(), "{key} must be absent, got {e}");
9977 }
9978 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
9979 }
9980
9981 #[tokio::test]
9984 async fn message_shas_locate_the_index_two_runs_diverge_at() {
9985 async fn shas_for(payload: &Value) -> Vec<String> {
9986 let dir = tempfile::tempdir().unwrap();
9987 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Meta).await;
9988 write_turn(&mut w, 0, payload, "{}").await;
9989 w.flush().await.unwrap();
9990 read_events(dir.path())[0]["message_shas"]
9991 .as_array()
9992 .unwrap()
9993 .iter()
9994 .map(|s| s.as_str().unwrap().to_string())
9995 .collect()
9996 }
9997
9998 let first = wire_payload("You are a capsule.", 4);
9999 let mut second = first.clone();
10000 second["messages"][2]["content"] = serde_json::json!("diverged");
10001
10002 let a = shas_for(&first).await;
10003 let b = shas_for(&second).await;
10004 assert_eq!(a.len(), 4);
10005 let divergence = a.iter().zip(&b).position(|(x, y)| x != y);
10006 assert_eq!(divergence, Some(2));
10007 assert_eq!(a[3], b[3], "only the changed message changes its own sha");
10008 }
10009
10010 #[tokio::test]
10013 async fn identical_messages_share_one_sha_and_one_blob() {
10014 let dir = tempfile::tempdir().unwrap();
10015 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10016 let payload = serde_json::json!({
10017 "messages": [{"role": "user", "content": "same"}, {"role": "user", "content": "same"}],
10018 "tools": [],
10019 "system": "s",
10020 });
10021 write_turn(&mut w, 0, &payload, "{}").await;
10022 w.flush().await.unwrap();
10023
10024 let shas = read_events(dir.path())[0]["message_shas"]
10025 .as_array()
10026 .unwrap()
10027 .iter()
10028 .map(|s| s.as_str().unwrap().to_string())
10029 .collect::<Vec<_>>();
10030 assert_eq!(shas[0], shas[1]);
10031 assert!(blob_path(dir.path(), &shas[0]).is_file());
10032 }
10033
10034 #[tokio::test]
10037 async fn a_message_blob_is_the_body_verbatim() {
10038 let dir = tempfile::tempdir().unwrap();
10039 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10040 let message = serde_json::json!({"role": "user", "content": "hello"});
10041 let payload = serde_json::json!({
10042 "messages": [message.clone()],
10043 "tools": [],
10044 "system": "s",
10045 });
10046 write_turn(&mut w, 0, &payload, "{}").await;
10047 w.flush().await.unwrap();
10048
10049 let sha = read_events(dir.path())[0]["message_shas"][0]
10050 .as_str()
10051 .unwrap()
10052 .to_string();
10053 assert_eq!(
10054 std::fs::read(blob_path(dir.path(), &sha)).unwrap(),
10055 serde_json::to_vec(&message).unwrap()
10056 );
10057 }
10058
10059 #[tokio::test]
10067 async fn a_plan_written_delegation_record_matches_a_delegate_task_one() {
10068 let plan_dir = tempfile::tempdir().unwrap();
10069 let tool_dir = tempfile::tempdir().unwrap();
10070 let session = "ses_0000000000004000800000000000plan".to_string();
10071 let node = "evt_0000000000004000800000000000node".to_string();
10072
10073 let plan = PlanTraceAppender::open(
10074 &crate::workdir_fs::WorkdirRoot::open(plan_dir.path()).unwrap(),
10075 session.clone(),
10076 node.clone(),
10077 )
10078 .unwrap();
10079 let tool = ResourceTraceAppender::open(
10080 &crate::workdir_fs::WorkdirRoot::open(tool_dir.path()).unwrap(),
10081 session.clone(),
10082 node.clone(),
10083 )
10084 .await
10085 .unwrap();
10086
10087 let snapshot = crate::workdir_snapshot::WorkdirSnapshot {
10088 source: "/home/you/project".to_string(),
10089 files: 2,
10090 dirs: 0,
10091 symlinks: 0,
10092 repointed_symlinks: 0,
10093 bytes: 12,
10094 skipped_count: 0,
10095 skipped: Vec::new(),
10096 };
10097 plan.write_delegation_start(
10098 "dlg_abc123",
10099 "worker",
10100 "0.1.0",
10101 "ses_child",
10102 ".murmur/c",
10103 "/r/ses_child",
10104 Some(&snapshot),
10105 );
10106 tool.write_delegation_start(
10107 "dlg_abc123",
10108 "worker",
10109 "0.1.0",
10110 "ses_child",
10111 ".murmur/c",
10112 "/r/ses_child",
10113 Some(&snapshot),
10114 )
10115 .await;
10116 plan.write_delegation(
10117 "worker",
10118 "0.1.0",
10119 Some("dlg_abc123".to_string()),
10120 Some("ses_child".to_string()),
10121 42,
10122 "completed",
10123 None,
10124 );
10125 tool.write_delegation(
10126 "worker",
10127 "0.1.0",
10128 Some("dlg_abc123".to_string()),
10129 Some("ses_child".to_string()),
10130 42,
10131 "completed",
10132 None,
10133 )
10134 .await;
10135
10136 let read = |dir: &Path| -> Vec<serde_json::Map<String, Value>> {
10137 std::fs::read_to_string(dir.join("trace.jsonl"))
10138 .unwrap()
10139 .lines()
10140 .map(|line| serde_json::from_str::<serde_json::Map<_, _>>(line).unwrap())
10141 .collect()
10142 };
10143 let from_plan = read(plan_dir.path());
10144 let from_tool = read(tool_dir.path());
10145 assert_eq!(from_plan.len(), 2);
10146 assert_eq!(from_tool.len(), 2);
10147 assert_eq!(
10148 from_plan[0]["workdir_snapshot"],
10149 serde_json::to_value(&snapshot).unwrap()
10150 );
10151
10152 for (planned, called, event_type) in [
10153 (&from_plan[0], &from_tool[0], "delegation_start"),
10154 (&from_plan[1], &from_tool[1], "delegation"),
10155 ] {
10156 assert_eq!(planned["event_type"], event_type);
10157 assert_eq!(called["event_type"], event_type);
10158 assert_eq!(
10159 planned.keys().collect::<Vec<_>>(),
10160 called.keys().collect::<Vec<_>>(),
10161 "{planned:?} vs {called:?}"
10162 );
10163 assert_eq!(planned["parent_id"], node.as_str());
10164 assert_eq!(called["parent_id"], node.as_str());
10165 for (key, value) in planned {
10166 if key == "event_id" || key == "timestamp" {
10167 continue;
10168 }
10169 assert_eq!(value, &called[key], "{key}: {planned:?} vs {called:?}");
10170 }
10171 for key in ["plan_id", "step_id", "surface", "kind"] {
10172 assert!(!planned.contains_key(key), "{key} is on {planned:?}");
10173 assert!(!called.contains_key(key), "{key} is on {called:?}");
10174 }
10175 }
10176 }
10177
10178 #[tokio::test]
10182 async fn spawner_ended_names_the_lineage_or_omits_it() {
10183 let dir = tempfile::tempdir().unwrap();
10184 let session = "ses_0000000000004000800000000child".to_string();
10185 let node = "evt_0000000000004000800000000000node".to_string();
10186 let appender = ResourceTraceAppender::open(
10187 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
10188 session.clone(),
10189 node.clone(),
10190 )
10191 .await
10192 .unwrap();
10193
10194 appender
10195 .write_spawner_ended(Some("ses_parent"), Some("dlg_abc123"))
10196 .await;
10197 appender.write_spawner_ended(None, None).await;
10198
10199 let lines: Vec<serde_json::Map<String, Value>> =
10200 std::fs::read_to_string(dir.path().join("trace.jsonl"))
10201 .unwrap()
10202 .lines()
10203 .map(|line| serde_json::from_str(line).unwrap())
10204 .collect();
10205 assert_eq!(lines.len(), 2);
10206 let named = &lines[0];
10207 assert_eq!(named["event_type"], "spawner_ended");
10208 assert_eq!(named["parent_id"], node.as_str());
10209 assert_eq!(named["session_id"], session.as_str());
10210 assert_eq!(named["spawned_by"], "ses_parent");
10211 assert_eq!(named["delegation_id"], "dlg_abc123");
10212 assert_eq!(
10213 named.keys().collect::<Vec<_>>(),
10214 [
10215 "delegation_id",
10216 "event_id",
10217 "event_type",
10218 "parent_id",
10219 "session_id",
10220 "spawned_by",
10221 "timestamp"
10222 ]
10223 );
10224 let bare = &lines[1];
10225 assert_eq!(bare["event_type"], "spawner_ended");
10226 assert!(!bare.contains_key("spawned_by"), "{bare:?}");
10227 assert!(!bare.contains_key("delegation_id"), "{bare:?}");
10228 }
10229}