diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 02cbe1c6..7cffbe07 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -9,7 +9,8 @@ mod scope; pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer}; pub use config::{ CompactionConfigPartial, FileUploadLimitsPartial, PermissionConfigPartial, PodManifestConfig, - PodMetaConfig, ResolveError, ToolOutputLimitsPartial, WorkerManifestConfig, + PodMetaConfig, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial, + WorkerManifestConfig, }; pub use model::{ AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind, @@ -395,9 +396,10 @@ pub struct ScopeConfig { #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct SessionConfig { - /// Persist every provider stream event directly to `trace.jsonl` next to the - /// segment log. Intended for debugging stalls between stream requests; off - /// by default because it can be verbose. + /// Persist normalized provider stream events and lifecycle diagnostics to a + /// `.trace.jsonl` sidecar next to the segment log. This is not guaranteed to + /// be a byte-for-byte raw SSE capture. Intended for debugging stalls between + /// stream requests; off by default because it can be verbose. #[serde(default)] pub record_event_trace: bool, } diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 1bc3c820..04daa97a 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -898,6 +898,27 @@ mod tests { ); } + #[test] + fn profile_artifact_preserves_session_record_event_trace() { + let mut raw = artifact(); + raw["manifest"]["session"] = serde_json::json!({ "record_event_trace": true }); + + let resolved = resolve_profile_artifact( + ProfileSource::Path { + path: PathBuf::from("/profiles/coder.nix"), + }, + Path::new("/workspace/project"), + raw, + ) + .unwrap(); + + assert!(resolved.manifest.session.record_event_trace); + assert_eq!( + resolved.manifest_snapshot["session"]["record_event_trace"], + serde_json::json!(true) + ); + } + #[test] fn rejects_both_manifest_and_config_fields() { let err = resolve_profile_artifact( diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index 822fda94..8efb66f6 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -502,6 +502,7 @@ where let web_config = pod.manifest().web.clone(); let spawner_name = pod.manifest().pod.name.clone(); let spawner_model = pod.manifest().model.clone(); + let spawner_record_event_trace = pod.manifest().session.record_event_trace; let pod_store = pod.store().clone(); let self_parent_socket = pod.callback_socket().cloned(); @@ -556,6 +557,7 @@ where spawned_registry.clone(), self_parent_socket, spawner_model, + spawner_record_event_trace, scope_handle, )); worker.register_tool(send_to_pod_tool(spawned_registry.clone())); diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index 89c327e1..8c50bc63 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -15,7 +15,7 @@ use async_trait::async_trait; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use manifest::{ ModelManifest, Permission, PodManifestConfig, PodMetaConfig, ScopeConfig, ScopeRule, - SharedScope, WorkerManifestConfig, + SessionConfigPartial, SharedScope, WorkerManifestConfig, }; use serde::Deserialize; use tokio::net::UnixStream; @@ -119,6 +119,9 @@ pub struct SpawnPodTool { /// configuration. Per-spawn override is /// out of scope here (see `tickets/spawn-inherit-provider.md`). spawner_model: ModelManifest, + /// Spawner's session diagnostics policy. Preserved for spawned Pods so + /// opt-in provider event traces continue across delegation. + spawner_record_event_trace: bool, /// Spawner's runtime scope. After a successful spawn, the /// `Permission::Write` rules in the delegated scope are revoked /// from the spawner's in-memory view (a `deny(Write, target)` is @@ -138,6 +141,7 @@ impl SpawnPodTool { registry: Arc, parent_socket: Option, spawner_model: ModelManifest, + spawner_record_event_trace: bool, spawner_scope: SharedScope, ) -> Self { Self { @@ -148,6 +152,7 @@ impl SpawnPodTool { registry, parent_socket, spawner_model, + spawner_record_event_trace, spawner_scope, } } @@ -207,6 +212,7 @@ impl Tool for SpawnPodTool { &instruction, &scope_allow, &self.spawner_model, + self.spawner_record_event_trace, ) { Ok(s) => s, Err(e) => { @@ -384,6 +390,7 @@ fn build_spawn_config_json( instruction: &str, scope_allow: &[ScopeRule], model: &ModelManifest, + record_event_trace: bool, ) -> Result { let config = PodManifestConfig { pod: PodMetaConfig { @@ -399,6 +406,9 @@ fn build_spawn_config_json( allow: scope_allow.to_vec(), deny: Vec::new(), }, + session: record_event_trace.then_some(SessionConfigPartial { + record_event_trace: Some(true), + }), ..Default::default() }; serde_json::to_string(&config) @@ -484,6 +494,7 @@ pub fn spawn_pod_tool( registry: Arc, parent_socket: Option, spawner_model: ModelManifest, + spawner_record_event_trace: bool, spawner_scope: SharedScope, ) -> ToolDefinition { Arc::new(move || { @@ -500,6 +511,7 @@ pub fn spawn_pod_tool( registry.clone(), parent_socket.clone(), spawner_model.clone(), + spawner_record_event_trace, spawner_scope.clone(), )); (meta, tool) @@ -509,7 +521,7 @@ pub fn spawn_pod_tool( #[cfg(test)] mod tests { use super::*; - use manifest::{AuthRef, SchemeKind}; + use manifest::{AuthRef, PodManifest, SchemeKind}; #[test] fn spawn_config_inherits_inline_spawner_model() { @@ -525,7 +537,7 @@ mod tests { }; let config_json = - build_spawn_config_json("child", "$insomnia/default", &[], &model).unwrap(); + build_spawn_config_json("child", "$insomnia/default", &[], &model, false).unwrap(); let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap(); assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic)); @@ -548,11 +560,51 @@ mod tests { ..Default::default() }; let config_json = - build_spawn_config_json("child", "$insomnia/default", &[], &model).unwrap(); + build_spawn_config_json("child", "$insomnia/default", &[], &model, false).unwrap(); let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap(); assert_eq!( parsed.model.ref_.as_deref(), Some("anthropic/claude-sonnet-4-6") ); } + + #[test] + fn spawn_config_preserves_record_event_trace_when_enabled() { + let model = ModelManifest { + ref_: Some("anthropic/claude-sonnet-4-6".into()), + ..Default::default() + }; + let scope = vec![ScopeRule { + target: PathBuf::from("/tmp/child"), + permission: Permission::Read, + recursive: true, + }]; + + let config_json = + build_spawn_config_json("child", "$insomnia/default", &scope, &model, true).unwrap(); + let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap(); + assert_eq!( + parsed.session.as_ref().and_then(|s| s.record_event_trace), + Some(true) + ); + + let manifest: PodManifest = PodManifestConfig::builtin_defaults() + .merge(parsed) + .try_into() + .unwrap(); + assert!(manifest.session.record_event_trace); + } + + #[test] + fn spawn_config_omits_record_event_trace_when_disabled() { + let model = ModelManifest { + ref_: Some("anthropic/claude-sonnet-4-6".into()), + ..Default::default() + }; + let config_json = + build_spawn_config_json("child", "$insomnia/default", &[], &model, false).unwrap(); + let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap(); + + assert!(parsed.session.is_none()); + } } diff --git a/crates/pod/tests/spawn_pod_test.rs b/crates/pod/tests/spawn_pod_test.rs index 7e99e2a3..13f01f01 100644 --- a/crates/pod/tests/spawn_pod_test.rs +++ b/crates/pod/tests/spawn_pod_test.rs @@ -192,6 +192,7 @@ async fn spawn_pod_delegates_scope_and_sends_run() { registry, None, dummy_model(), + false, spawner_scope.clone(), ); let (_meta, tool) = def(); @@ -280,6 +281,7 @@ async fn spawn_pod_rejects_scope_outside_spawner() { registry, None, dummy_model(), + false, spawner_scope.clone(), ); let (_meta, tool) = def(); @@ -351,6 +353,7 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() { registry, None, dummy_model(), + false, spawner_scope.clone(), ); let (_meta, tool) = def(); diff --git a/crates/session-store/src/event_trace.rs b/crates/session-store/src/event_trace.rs index 75445da0..e8226344 100644 --- a/crates/session-store/src/event_trace.rs +++ b/crates/session-store/src/event_trace.rs @@ -1,7 +1,8 @@ //! Debug-only LLM request/stream trace recording. //! -//! [`TraceEntry`] captures stream lifecycle markers and raw provider stream -//! events for debugging stalls. Written to a separate `.trace.jsonl` file, +//! [`TraceEntry`] captures stream lifecycle markers and normalized provider +//! stream events for debugging stalls. It is not a byte-for-byte raw SSE +//! capture. Written to a separate `.trace.jsonl` file, //! completely independent of the segment log used for state restoration. //! //! Disabled by default. Enable via `SessionConfig::record_event_trace`. @@ -10,7 +11,7 @@ use llm_worker::llm_client::event::Event; use serde::{Deserialize, Serialize}; use serde_json::Value; -/// A single trace entry recording either a lifecycle marker or raw stream event. +/// A single trace entry recording either a lifecycle marker or normalized stream event. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TraceEntry { /// Timestamp in milliseconds since Unix epoch.