Codex Handbook
rollout-trace/src/thread.rs 529 lines
//! Thread-scoped rollout trace helpers.//!//! A rollout bundle can contain a root thread plus spawned child threads. This//! context owns the stable identity for one thread inside that bundle. Keeping//! thread-local event methods here avoids repeatedly plumbing `thread_id`//! through session code.use codex_protocol::protocol::AgentStatus;use codex_protocol::protocol::EventMsg;use codex_protocol::protocol::SessionSource;use serde::Serialize;use std::path::Path;use std::path::PathBuf;use std::sync::Arc;use tracing::debug;use tracing::warn;use uuid::Uuid;use crate::AgentThreadId;use crate::CodeCellTraceContext;use crate::CodexTurnId;use crate::CompactionId;use crate::CompactionTraceContext;use crate::InferenceTraceContext;use crate::McpCallTraceContext;use crate::RawPayloadKind;use crate::RawPayloadRef;use crate::RawTraceEventContext;use crate::RawTraceEventPayload;use crate::RolloutStatus;use crate::ToolCallId;use crate::ToolDispatchInvocation;use crate::ToolDispatchTraceContext;use crate::TraceWriter;use crate::protocol_event::codex_turn_trace_event;use crate::protocol_event::tool_runtime_trace_event;use crate::protocol_event::wrapped_protocol_event_type;/// Environment variable that enables local trace-bundle recording.////// The value is a root directory. Each independent root session gets one child/// bundle directory. Spawned child threads share their root session's bundle so/// one reduced `state.json` describes the whole multi-agent rollout tree.pub const CODEX_ROLLOUT_TRACE_ROOT_ENV: &str = "CODEX_ROLLOUT_TRACE_ROOT";/// Metadata captured once at thread/session start.////// This payload is intentionally operational rather than reduced: it is a raw/// payload that later reducers can mine as the reduced thread model evolves.#[derive(Serialize)]pub struct ThreadStartedTraceMetadata {    pub thread_id: String,    pub agent_path: String,    pub task_name: Option<String>,    pub nickname: Option<String>,    pub agent_role: Option<String>,    pub session_source: SessionSource,    pub cwd: std::path::PathBuf,    pub rollout_path: Option<std::path::PathBuf>,    pub model: String,    pub provider_name: String,    pub approval_policy: String,    pub sandbox_policy: String,}/// Trace-only payload for a child completion notification delivered to its parent.#[derive(Serialize)]pub struct AgentResultTracePayload<'a> {    pub child_agent_path: &'a str,    pub message: &'a str,    pub status: &'a AgentStatus,}/// No-op capable trace handle for one thread in a rollout bundle.#[derive(Clone, Debug)]pub struct ThreadTraceContext {    state: ThreadTraceContextState,}#[derive(Clone, Debug)]enum ThreadTraceContextState {    Disabled,    Enabled(EnabledThreadTraceContext),}#[derive(Clone, Debug)]struct EnabledThreadTraceContext {    writer: Arc<TraceWriter>,    root_thread_id: AgentThreadId,    thread_id: AgentThreadId,}impl ThreadTraceContext {    /// Builds a context that accepts trace calls and records nothing.    pub fn disabled() -> Self {        Self {            state: ThreadTraceContextState::Disabled,        }    }    /// Starts a root thread trace from `CODEX_ROLLOUT_TRACE_ROOT`, or disables tracing.    ///    /// Trace startup is best-effort. A tracing failure must not make the Codex    /// session unusable, because traces are diagnostic and can be enabled while    /// debugging unrelated production failures.    pub fn start_root_or_disabled(metadata: ThreadStartedTraceMetadata) -> Self {        let Some(root) = std::env::var_os(CODEX_ROLLOUT_TRACE_ROOT_ENV) else {            return Self::disabled();        };        let root = PathBuf::from(root);        match start_root_in_root(root.as_path(), metadata) {            Ok(context) => context,            Err(err) => {                warn!("failed to initialize rollout trace bundle: {err:#}");                Self::disabled()            }        }    }    /// Starts a root trace in a known directory.    ///    /// This is public for tests that need replayable trace bundles without    /// mutating process environment.    pub fn start_root_in_root_for_test(        root: &Path,        metadata: ThreadStartedTraceMetadata,    ) -> anyhow::Result<Self> {        start_root_in_root(root, metadata)    }    /// Starts one thread lifecycle inside an existing rollout bundle.    pub(crate) fn start(        writer: Arc<TraceWriter>,        root_thread_id: AgentThreadId,        metadata: ThreadStartedTraceMetadata,    ) -> Self {        let context = EnabledThreadTraceContext {            writer,            root_thread_id,            thread_id: metadata.thread_id.clone(),        };        record_thread_started(&context, metadata);        Self {            state: ThreadTraceContextState::Enabled(context),        }    }    /// Returns whether this handle will write trace events.    ///    /// Most methods have their own disabled fast path. Callers should branch on    /// this only when preparing trace payloads would otherwise clone data the    /// production path needs to move elsewhere.    pub fn is_enabled(&self) -> bool {        matches!(self.state, ThreadTraceContextState::Enabled(_))    }    /// Starts a fresh child thread in this context's rollout tree.    ///    /// Callers should use [`ThreadTraceContext::disabled`] for resumed children:    /// reusing the parent trace would emit a duplicate `ThreadStarted` event    /// for an existing thread id and make the bundle unreplayable.    pub fn start_child_thread_trace_or_disabled(        &self,        metadata: ThreadStartedTraceMetadata,    ) -> Self {        match &self.state {            ThreadTraceContextState::Disabled => Self::disabled(),            ThreadTraceContextState::Enabled(context) => Self::start(                Arc::clone(&context.writer),                context.root_thread_id.clone(),                metadata,            ),        }    }    /// Emits terminal trace events for graceful thread shutdown.    ///    /// Spawned child sessions share their root bundle, so only the root    /// thread end closes the rollout. Child thread ends update the child thread    /// execution state without marking the whole bundle complete.    pub fn record_ended(&self, status: RolloutStatus) {        let ThreadTraceContextState::Enabled(context) = &self.state else {            return;        };        context.append_best_effort(RawTraceEventPayload::ThreadEnded {            thread_id: context.thread_id.clone(),            status: status.clone(),        });        if context.thread_id == context.root_thread_id {            context.append_best_effort(RawTraceEventPayload::RolloutEnded { status });        }    }    /// Wraps selected protocol events as raw trace breadcrumbs.    ///    /// High-volume stream deltas stay out of this wrapper; typed inference,    /// tool, terminal, and code-mode hooks provide the canonical runtime data.    pub fn record_protocol_event(&self, event: &EventMsg) {        let ThreadTraceContextState::Enabled(context) = &self.state else {            return;        };        let Some(event_type) = wrapped_protocol_event_type(event) else {            return;        };        let Some(event_payload) =            context.write_json_payload_best_effort(RawPayloadKind::ProtocolEvent, event)        else {            return;        };        context.append_best_effort(RawTraceEventPayload::ProtocolEventObserved {            event_type: event_type.to_string(),            event_payload,        });    }    /// Emits typed Codex turn lifecycle events from protocol lifecycle events.    pub fn record_codex_turn_event(&self, default_turn_id: &str, event: &EventMsg) {        let ThreadTraceContextState::Enabled(context) = &self.state else {            return;        };        let Some(trace_event) =            codex_turn_trace_event(context.thread_id.clone(), default_turn_id, event)        else {            return;        };        context.append_with_context_best_effort(            trace_event.context_turn_id.clone(),            trace_event.payload,        );    }    /// Emits typed runtime tool events from existing protocol lifecycle events.    ///    /// These events are runtime observations on an already-dispatched tool. The    /// dispatch trace records the caller-facing boundary; these payloads explain    /// what Codex did while executing that boundary.    pub fn record_tool_call_event(&self, codex_turn_id: impl Into<CodexTurnId>, event: &EventMsg) {        let ThreadTraceContextState::Enabled(context) = &self.state else {            return;        };        let Some(trace_event) = tool_runtime_trace_event(event) else {            return;        };        let Some(payload) = context.raw_tool_runtime_payload(trace_event) else {            return;        };        context.append_with_context_best_effort(codex_turn_id.into(), payload);    }    /// Emits the v2 child-to-parent completion message as an explicit graph edge.    ///    /// The notification is runtime delivery from a completed child turn into    /// the parent's mailbox, not a tool call executed by the child. Recording it    /// directly preserves timing and source without making the reducer infer    /// the edge from a later parent prompt snapshot.    pub fn record_agent_result_interaction(        &self,        child_codex_turn_id: impl Into<CodexTurnId>,        parent_thread_id: impl Into<AgentThreadId>,        payload: &AgentResultTracePayload<'_>,    ) {        let ThreadTraceContextState::Enabled(context) = &self.state else {            return;        };        let child_codex_turn_id = child_codex_turn_id.into();        let parent_thread_id = parent_thread_id.into();        let carried_payload =            context.write_json_payload_best_effort(RawPayloadKind::AgentResult, payload);        context.append_with_context_best_effort(            child_codex_turn_id.clone(),            RawTraceEventPayload::AgentResultObserved {                edge_id: format!(                    "edge:agent_result:{}:{child_codex_turn_id}:{parent_thread_id}",                    context.thread_id                ),                child_thread_id: context.thread_id.clone(),                child_codex_turn_id,                parent_thread_id,                message: payload.message.to_string(),                carried_payload,            },        );    }    /// Emits a turn-start lifecycle event.    ///    /// Most production turn lifecycle wiring lives outside this PR layer, but    /// trace-focused integration tests need a small explicit hook so reducer    /// inputs remain valid without exercising the full session loop.    pub fn record_codex_turn_started(&self, codex_turn_id: impl Into<CodexTurnId>) {        let ThreadTraceContextState::Enabled(context) = &self.state else {            return;        };        let codex_turn_id = codex_turn_id.into();        context.append_with_context_best_effort(            codex_turn_id.clone(),            RawTraceEventPayload::CodexTurnStarted {                codex_turn_id,                thread_id: context.thread_id.clone(),            },        );    }    /// Starts a first-class code-mode cell lifecycle and returns its trace handle.    pub fn start_code_cell_trace(        &self,        codex_turn_id: impl Into<CodexTurnId>,        runtime_cell_id: impl Into<String>,        model_visible_call_id: impl Into<String>,        source_js: impl Into<String>,    ) -> CodeCellTraceContext {        let context = self.code_cell_trace_context(codex_turn_id, runtime_cell_id);        context.record_started(model_visible_call_id, source_js);        context    }    /// Builds a trace handle for an already-started code-mode runtime cell.    pub fn code_cell_trace_context(        &self,        codex_turn_id: impl Into<CodexTurnId>,        runtime_cell_id: impl Into<String>,    ) -> CodeCellTraceContext {        let ThreadTraceContextState::Enabled(context) = &self.state else {            return CodeCellTraceContext::disabled();        };        CodeCellTraceContext::enabled(            Arc::clone(&context.writer),            context.thread_id.clone(),            codex_turn_id,            runtime_cell_id,        )    }    /// Starts one dispatch-level tool lifecycle and returns its trace handle.    ///    /// `invocation` is lazy because adapting core tool objects into trace-owned    /// payloads can clone large arguments. Disabled tracing should not pay that    /// cost on the hot tool-dispatch path.    pub fn start_tool_dispatch_trace(        &self,        invocation: impl FnOnce() -> Option<ToolDispatchInvocation>,    ) -> ToolDispatchTraceContext {        let ThreadTraceContextState::Enabled(context) = &self.state else {            return ToolDispatchTraceContext::disabled();        };        let Some(invocation) = invocation() else {            return ToolDispatchTraceContext::disabled();        };        ToolDispatchTraceContext::start(Arc::clone(&context.writer), invocation)    }    /// Builds reusable inference trace context for one Codex turn.    ///    /// The returned context is intentionally not "an inference call" yet.    /// Transport code owns retry/fallback attempts and calls `start_attempt`    /// only after it has built the concrete request payload for that attempt.    pub fn inference_trace_context(        &self,        codex_turn_id: impl Into<CodexTurnId>,        model: impl Into<String>,        provider_name: impl Into<String>,    ) -> InferenceTraceContext {        let ThreadTraceContextState::Enabled(context) = &self.state else {            return InferenceTraceContext::disabled();        };        InferenceTraceContext::enabled(            Arc::clone(&context.writer),            context.thread_id.clone(),            codex_turn_id.into(),            model.into(),            provider_name.into(),        )    }    /// Builds remote-compaction trace context for one checkpoint.    ///    /// Rollout tracing currently has a first-class checkpoint model only for remote compaction.    /// The compact endpoint is a model-facing request whose output replaces live history, so it    /// needs both request/response attempt events and a later checkpoint event when processed    /// replacement history is installed.    pub fn compaction_trace_context(        &self,        codex_turn_id: impl Into<CodexTurnId>,        compaction_id: impl Into<CompactionId>,        model: impl Into<String>,        provider_name: impl Into<String>,    ) -> CompactionTraceContext {        let ThreadTraceContextState::Enabled(context) = &self.state else {            return CompactionTraceContext::disabled();        };        CompactionTraceContext::enabled(            Arc::clone(&context.writer),            context.thread_id.clone(),            codex_turn_id.into(),            compaction_id.into(),            model.into(),            provider_name.into(),        )    }    /// Starts bridge correlation for one concrete MCP backend request.    ///    /// Dispatch-level tool IDs remain compact and UI-friendly. This UUID is    /// deliberately separate: it is only for cross-process log joins where a    /// rollout-local counter would collide across samples.    pub fn start_mcp_call_trace(&self, tool_call_id: impl Into<ToolCallId>) -> McpCallTraceContext {        let ThreadTraceContextState::Enabled(context) = &self.state else {            return McpCallTraceContext::disabled();        };        let mcp_call_id = Uuid::new_v4().to_string();        let trace = McpCallTraceContext::enabled(mcp_call_id.clone());        context.append_best_effort(RawTraceEventPayload::McpToolCallCorrelationAssigned {            tool_call_id: tool_call_id.into(),            mcp_call_id,        });        trace    }}fn start_root_in_root(    root: &Path,    metadata: ThreadStartedTraceMetadata,) -> anyhow::Result<ThreadTraceContext> {    let trace_id = Uuid::new_v4().to_string();    let thread_id = metadata.thread_id.clone();    let bundle_dir = root.join(format!("trace-{trace_id}-{thread_id}"));    let writer = TraceWriter::create(        &bundle_dir,        trace_id.clone(),        thread_id.clone(),        thread_id.clone(),    )?;    let writer = Arc::new(writer);    if let Err(err) = writer.append(RawTraceEventPayload::RolloutStarted {        trace_id,        root_thread_id: thread_id.clone(),    }) {        warn!("failed to append rollout trace event: {err:#}");    }    debug!("recording rollout trace at {}", bundle_dir.display());    Ok(ThreadTraceContext::start(writer, thread_id, metadata))}fn record_thread_started(    context: &EnabledThreadTraceContext,    metadata: ThreadStartedTraceMetadata,) {    let metadata_payload =        context.write_json_payload_best_effort(RawPayloadKind::SessionMetadata, &metadata);    context.append_best_effort(RawTraceEventPayload::ThreadStarted {        thread_id: metadata.thread_id,        agent_path: metadata.agent_path,        metadata_payload,    });}impl EnabledThreadTraceContext {    fn write_json_payload_best_effort(        &self,        kind: RawPayloadKind,        payload: &impl Serialize,    ) -> Option<RawPayloadRef> {        match self.writer.write_json_payload(kind, payload) {            Ok(payload_ref) => Some(payload_ref),            Err(err) => {                warn!("failed to write rollout trace payload: {err:#}");                None            }        }    }    fn raw_tool_runtime_payload(        &self,        trace_event: crate::protocol_event::ToolRuntimeTraceEvent<'_>,    ) -> Option<RawTraceEventPayload> {        match trace_event {            crate::protocol_event::ToolRuntimeTraceEvent::Started {                tool_call_id,                payload,            } => {                let runtime_payload = self                    .write_json_payload_best_effort(RawPayloadKind::ToolRuntimeEvent, &payload)?;                Some(RawTraceEventPayload::ToolCallRuntimeStarted {                    tool_call_id: tool_call_id.to_string(),                    runtime_payload,                })            }            crate::protocol_event::ToolRuntimeTraceEvent::Ended {                tool_call_id,                status,                payload,            } => {                let runtime_payload = self                    .write_json_payload_best_effort(RawPayloadKind::ToolRuntimeEvent, &payload)?;                Some(RawTraceEventPayload::ToolCallRuntimeEnded {                    tool_call_id: tool_call_id.to_string(),                    status,                    runtime_payload,                })            }        }    }    fn append_best_effort(&self, payload: RawTraceEventPayload) {        if let Err(err) = self.writer.append(payload) {            warn!("failed to append rollout trace event: {err:#}");        }    }    fn append_with_context_best_effort(        &self,        codex_turn_id: CodexTurnId,        payload: RawTraceEventPayload,    ) {        let event_context = RawTraceEventContext {            thread_id: Some(self.thread_id.clone()),            codex_turn_id: Some(codex_turn_id),        };        if let Err(err) = self.writer.append_with_context(event_context, payload) {            warn!("failed to append rollout trace event: {err:#}");        }    }}#[cfg(test)]#[path = "thread_tests.rs"]mod tests;